• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

bpatrik / pigallery2 / 20007421682

07 Dec 2025 04:58PM UTC coverage: 68.821% (-0.008%) from 68.829%
20007421682

push

github

bpatrik
Improve album cache filling error handling.

1452 of 2365 branches covered (61.4%)

Branch coverage included in aggregate %.

4 of 6 new or added lines in 1 file covered. (66.67%)

5289 of 7430 relevant lines covered (71.18%)

4154.92 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.72
/src/backend/model/database/ProjectedCacheManager.ts
1
import {Brackets, Connection} from 'typeorm';
1✔
2
import {SQLConnection} from './SQLConnection';
1✔
3
import {ProjectedDirectoryCacheEntity} from './enitites/ProjectedDirectoryCacheEntity';
1✔
4
import {ProjectedAlbumCacheEntity} from './enitites/album/ProjectedAlbumCacheEntity';
1✔
5
import {DirectoryEntity} from './enitites/DirectoryEntity';
1✔
6
import {ObjectManagers} from '../ObjectManagers';
1✔
7
import {UserEntity} from './enitites/UserEntity';
1✔
8
import {SharingEntity} from './enitites/SharingEntity';
1✔
9
import {IObjectManager} from './IObjectManager';
10
import {ParentDirectoryDTO} from '../../../common/entities/DirectoryDTO';
11
import {DiskManager} from '../fileaccess/DiskManager';
1✔
12
import {ExtensionDecorator} from '../extension/ExtensionDecorator';
1✔
13
import {Logger} from '../../Logger';
1✔
14
import {SessionManager} from './SessionManager';
1✔
15
import * as path from 'path';
1✔
16
import {SessionContext} from '../SessionContext';
1✔
17
import {MediaEntity} from './enitites/MediaEntity';
1✔
18
import {Config} from '../../../common/config/private/Config';
1✔
19
import {DatabaseType} from '../../../common/config/private/PrivateConfig';
1✔
20
import {ProjectedPersonCacheEntity} from './enitites/person/ProjectedPersonCacheEntity';
1✔
21
import {NotificationManager} from '../NotifocationManager';
1✔
22

23
const LOG_TAG = '[ProjectedCacheManager]';
1✔
24

25
export class ProjectedCacheManager implements IObjectManager {
1✔
26

27
  async init(): Promise<void> {
28
    // Cleanup at startup to avoid stale growth
29
    await this.cleanupNonExistingProjections();
255✔
30
  }
31

32
  public async onNewDataVersion(changedDir?: ParentDirectoryDTO): Promise<void> {
33
    if (!changedDir) {
108!
34
      return;
×
35
    }
36
    await this.invalidateDirectoryCache(changedDir);
108✔
37
  }
38

39
  /**
40
   * Includes all possible projection keys, including the default one and sharing keys too.
41
   */
42
  public async getAllProjections(): Promise<string[]> {
43
    const connection = await SQLConnection.getConnection();
255✔
44
    const activeKeys = new Set<string>();
255✔
45

46
    // Always include default projection key
47
    activeKeys.add(SessionManager.NO_PROJECTION_KEY);
255✔
48

49
    // Users
50
    const users = await connection.getRepository(UserEntity).find();
255✔
51
    for (const user of users) {
255✔
52
      const ctx = await ObjectManagers.getInstance().SessionManager.buildContext(user);
145✔
53
      if (ctx?.user?.projectionKey) {
145✔
54
        activeKeys.add(ctx.user.projectionKey);
145✔
55
      }
56
    }
57

58
    // Sharings
59
    const shares = await connection.getRepository(SharingEntity)
255✔
60
      .createQueryBuilder('share')
61
      .leftJoinAndSelect('share.creator', 'creator')
62
      .getMany();
63
    for (const s of shares) {
255✔
64
      const q = ObjectManagers.getInstance().SessionManager.buildAllowListForSharing(s);
×
65
      const key = ObjectManagers.getInstance().SessionManager.createProjectionKey(q);
×
66
      activeKeys.add(key);
×
67
    }
68

69
    return Array.from(activeKeys);
255✔
70
  }
71

72
  public async cleanupNonExistingProjections(): Promise<void> {
73
    const connection = await SQLConnection.getConnection();
255✔
74
    const keys = await this.getAllProjections();
255✔
75
    Logger.debug(LOG_TAG, 'Cleanup non existing projections, known number of keys: ' + keys.length);
255✔
76
    if (keys.length === 0) {
255!
77
      // No known projections; nothing to prune safely
78
      return;
×
79
    }
80

81
    await connection.getRepository(ProjectedDirectoryCacheEntity)
255✔
82
      .createQueryBuilder()
83
      .delete()
84
      .where('projectionKey NOT IN (:...keys)', {keys})
85
      .execute();
86

87
    await connection.getRepository(ProjectedAlbumCacheEntity)
255✔
88
      .createQueryBuilder()
89
      .delete()
90
      .where('projectionKey NOT IN (:...keys)', {keys})
91
      .execute();
92

93
    await connection.getRepository(ProjectedPersonCacheEntity)
255✔
94
      .createQueryBuilder()
95
      .delete()
96
      .where('projectionKey NOT IN (:...keys)', {keys})
97
      .execute();
98
  }
99

100

101
  @ExtensionDecorator(e => e.gallery.ProjectedCacheManager.getCacheForDirectory)
270✔
102
  public async getCacheForDirectory(connection: Connection, session: SessionContext, dir: {
1✔
103
    id: number,
104
    name: string,
105
    path: string
106
  }): Promise<ProjectedDirectoryCacheEntity> {
107
    // Compute aggregates under the current projection (if any)
108
    const mediaRepo = connection.getRepository(MediaEntity);
270✔
109
    const baseQb = mediaRepo
270✔
110
      .createQueryBuilder('media')
111
      .innerJoin('media.directory', 'directory')
112
      .where('directory.id = :dir', {dir: dir.id});
113

114
    if (session.projectionQuery) {
270✔
115
      baseQb.andWhere(session.projectionQuery);
26✔
116
    }
117

118
    const agg = await baseQb
270✔
119
      .select([
120
        'COUNT(*) as mediaCount',
121
        'MIN(media.metadata.creationDate) as oldest',
122
        'MAX(media.metadata.creationDate) as youngest',
123
      ])
124
      .getRawOne();
125

126
    const mediaCount: number = agg?.mediaCount != null ? parseInt(agg.mediaCount as any, 10) : 0;
270!
127
    const oldestMedia: number = agg?.oldest != null ? parseInt(agg.oldest as any, 10) : null;
270✔
128
    const youngestMedia: number = agg?.youngest != null ? parseInt(agg.youngest as any, 10) : null;
270✔
129

130
    // Compute recursive media count under projection (includes children) using single SQL query
131
    const recQb = mediaRepo
270✔
132
      .createQueryBuilder('media')
133
      .innerJoin('media.directory', 'directory')
134
      .where(
135
        new Brackets(q => {
136
          q.where('directory.id = :dir', {dir: dir.id});
270✔
137
          if (Config.Database.type === DatabaseType.mysql) {
270✔
138
            q.orWhere('directory.path like :path || \'%\'', {path: DiskManager.pathFromParent(dir)});
135✔
139
          } else {
140
            q.orWhere('directory.path GLOB :path', {
135✔
141
              path: DiskManager.pathFromParent(dir).replaceAll('[', '[[]') + '*',
142
            });
143
          }
144
        })
145
      );
146

147
    if (session.projectionQuery) {
270✔
148
      recQb.andWhere(session.projectionQuery);
26✔
149
    }
150
    const aggRec = await recQb.select(['COUNT(*) as cnt']).getRawOne();
270✔
151
    const recursiveMediaCount = aggRec?.cnt != null ? parseInt(aggRec.cnt as any, 10) : 0;
270!
152

153

154
    // Compute cover respecting projection
155
    const coverMedia = await ObjectManagers.getInstance().CoverManager.getCoverForDirectory(session, dir);
270✔
156

157
    const cacheRepo = connection.getRepository(ProjectedDirectoryCacheEntity);
270✔
158

159
    // Find existing cache row by (projectionKey, directory)
160
    const projectionKey = session?.user?.projectionKey;
270✔
161

162
    let row = await cacheRepo
270✔
163
      .createQueryBuilder('pdc')
164
      .leftJoin('pdc.directory', 'd')
165
      .where('pdc.projectionKey = :pk AND d.id = :dir', {pk: projectionKey, dir: dir.id})
166
      .getOne();
167

168
    if (!row) {
270✔
169
      row = new ProjectedDirectoryCacheEntity();
230✔
170
      row.projectionKey = projectionKey;
230✔
171
      // Avoid fetching the full directory graph; assign relation by id only
172
      row.directory = {id: dir.id} as any;
230✔
173
    }
174

175
    row.mediaCount = mediaCount || 0;
270✔
176
    row.recursiveMediaCount = recursiveMediaCount || 0;
270✔
177
    row.oldestMedia = oldestMedia ?? null;
270✔
178
    row.youngestMedia = youngestMedia ?? null;
270✔
179
    row.cover = coverMedia as any;
270✔
180
    row.valid = true;
270✔
181

182
    return row;
270✔
183
  }
184

185
  public async setAndGetCacheForDirectory(connection: Connection, session: SessionContext, dir: {
186
    id: number,
187
    name: string,
188
    path: string
189
  }): Promise<ProjectedDirectoryCacheEntity> {
190
    const cacheRepo = connection.getRepository(ProjectedDirectoryCacheEntity);
270✔
191
    const row = await this.getCacheForDirectory(connection, session, dir);
270✔
192
    const ret = await cacheRepo.save(row);
270✔
193
    // we would not select these either
194
    delete ret.projectionKey;
270✔
195
    delete ret.directory;
270✔
196
    delete ret.id;
270✔
197
    if (ret.cover) {
270✔
198
      delete ret.cover.id;
241✔
199
    }
200
    return ret;
270✔
201
  }
202

203
  public async setAndGetCacheForAlbum(connection: Connection, session: SessionContext, album: {
204
    name: string,
205
    id: number,
206
    searchQuery: any
207
  }): Promise<ProjectedAlbumCacheEntity> {
208

209

210
    const cacheRepo = connection.getRepository(ProjectedAlbumCacheEntity);
34✔
211

212
    // Find existing cache row by (projectionKey, album)
213
    const projectionKey = session?.user?.projectionKey;
34✔
214

215
    let row = await cacheRepo
34✔
216
      .createQueryBuilder('pac')
217
      .leftJoin('pac.album', 'a')
218
      .where('pac.projectionKey = :pk AND a.id = :albumId', {pk: projectionKey, albumId: album.id})
219
      .getOne();
220

221
    if (row && row.valid === true) {
34!
222
      return row;
×
223
    }
224

225
    // Compute aggregates under the current projection (if any)
226
    const mediaRepo = connection.getRepository(MediaEntity);
34✔
227

228
    // Build base query from album's search query
229
    const baseQb = mediaRepo
34✔
230
      .createQueryBuilder('media')
231
      .leftJoin('media.directory', 'directory');
232

233
    // Apply album search query constraints
234
    const albumWhereQuery = await ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(
34✔
235
      album.searchQuery
236
    );
237
    baseQb.andWhere(albumWhereQuery);
34✔
238

239
    // Apply projection constraints if any
240
    if (session.projectionQuery) {
34✔
241
      baseQb.andWhere(session.projectionQuery);
14✔
242
    }
243

244
    let coverMedia, agg;
245
    try {
34✔
246
      agg = await baseQb
34✔
247
        .select([
248
          'COUNT(*) as itemCount',
249
          'MIN(media.metadata.creationDate) as oldest',
250
          'MAX(media.metadata.creationDate) as youngest',
251
        ])
252
        .getRawOne();
253

254

255
      // Compute cover respecting projection
256
      coverMedia = await ObjectManagers.getInstance().CoverManager.getCoverForAlbum(session, album as any);
34✔
257
    } catch (e) {
NEW
258
      Logger.error(LOG_TAG, `Error computing album cache for album "${album.name}": ` + e);
×
NEW
259
      NotificationManager.warning(`Error computing album cache for album "${album.name}"`);
×
260
    }
261

262
    const itemCount: number = agg?.itemCount != null ? parseInt(agg.itemCount as any, 10) : 0;
34!
263
    const oldestMedia: number = agg?.oldest != null ? parseInt(agg.oldest as any, 10) : null;
34✔
264
    const youngestMedia: number = agg?.youngest != null ? parseInt(agg.youngest as any, 10) : null;
34✔
265

266
    if (!row) {
34✔
267
      row = new ProjectedAlbumCacheEntity();
32✔
268
      row.projectionKey = projectionKey;
32✔
269
      // Avoid fetching the full album graph; assign relation by id only
270
      row.album = {id: album.id} as any;
32✔
271
    }
272

273
    row.itemCount = itemCount || 0;
34✔
274
    row.oldestMedia = oldestMedia ?? null;
34✔
275
    row.youngestMedia = youngestMedia ?? null;
34✔
276
    row.cover = coverMedia as any;
34✔
277
    row.valid = true;
34✔
278

279
    const ret = await cacheRepo.save(row);
34✔
280
    // we would not select these either
281
    delete ret.projectionKey;
34✔
282
    delete ret.album;
34✔
283
    delete ret.id;
34✔
284
    if (ret.cover) {
34✔
285
      delete ret.cover.id;
16✔
286
    }
287
    return ret;
34✔
288
  }
289

290
  @ExtensionDecorator(e => e.gallery.ProjectedCacheManager.invalidateDirectoryCache)
110✔
291
  protected async invalidateDirectoryCache(dir: ParentDirectoryDTO) {
1✔
292
    const connection = await SQLConnection.getConnection();
110✔
293
    const dirRepo = connection.getRepository(DirectoryEntity);
110✔
294

295
    // Collect directory paths from target to root
296
    const paths: { path: string; name: string }[] = [];
110✔
297
    let fullPath = DiskManager.normalizeDirPath(path.join(dir.path, dir.name));
110✔
298
    const root = DiskManager.pathFromRelativeDirName('.');
110✔
299

300
    // Build path-name pairs for current directory and all parents
301
    while (fullPath !== root) {
110✔
302
      const name = DiskManager.dirName(fullPath);
120✔
303
      const parentPath = DiskManager.pathFromRelativeDirName(fullPath);
120✔
304
      paths.push({path: parentPath, name});
120✔
305
      fullPath = parentPath;
120✔
306
    }
307

308
    // Add root directory
309
    paths.push({path: DiskManager.pathFromRelativeDirName(root), name: DiskManager.dirName(root)});
110✔
310

311
    if (paths.length === 0) {
110!
312
      return;
×
313
    }
314

315
    // Build query for all directories in one shot
316
    const qb = dirRepo.createQueryBuilder('d');
110✔
317
    paths.forEach((p, i) => {
110✔
318
      qb.orWhere(new Brackets(q => {
230✔
319
        q.where(`d.path = :path${i}`, {[`path${i}`]: p.path})
230✔
320
          .andWhere(`d.name = :name${i}`, {[`name${i}`]: p.name});
321
      }));
322
    });
323

324
    // Find matching directories and invalidate their cache entries
325
    const entities = await qb.getMany();
110✔
326
    if (entities.length === 0) {
110!
327
      return;
×
328
    }
329

330
    // Invalidate all related cache entries in one operation
331
    await connection.getRepository(ProjectedDirectoryCacheEntity)
110✔
332
      .createQueryBuilder()
333
      .update()
334
      .set({valid: false})
335
      .where('directoryId IN (:...dirIds)', {dirIds: entities.map(e => e.id)})
122✔
336
      .execute();
337
  }
338

339
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc