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

bpatrik / pigallery2 / 19001452145

01 Nov 2025 07:16PM UTC coverage: 68.462% (+0.02%) from 68.446%
19001452145

push

github

bpatrik
Add missing events to extension manager.

1405 of 2301 branches covered (61.06%)

Branch coverage included in aggregate %.

5155 of 7281 relevant lines covered (70.8%)

4233.9 hits per line

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

91.58
/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

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

24
export class ProjectedCacheManager implements IObjectManager {
1✔
25

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

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

38
  public async getAllProjections(): Promise<string[]> {
39
    const connection = await SQLConnection.getConnection();
255✔
40
    const activeKeys = new Set<string>();
255✔
41

42
    // Always include default projection key
43
    activeKeys.add(SessionManager.NO_PROJECTION_KEY);
255✔
44

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

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

65
    return Array.from(activeKeys);
255✔
66
  }
67

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

77
    await connection.getRepository(ProjectedDirectoryCacheEntity)
255✔
78
      .createQueryBuilder()
79
      .delete()
80
      .where('projectionKey NOT IN (:...keys)', {keys})
81
      .execute();
82

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

89
    await connection.getRepository(ProjectedPersonCacheEntity)
255✔
90
      .createQueryBuilder()
91
      .delete()
92
      .where('projectionKey NOT IN (:...keys)', {keys})
93
      .execute();
94
  }
95

96

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

110
    if (session.projectionQuery) {
270✔
111
      baseQb.andWhere(session.projectionQuery);
26✔
112
    }
113

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

122
    const mediaCount: number = agg?.mediaCount != null ? parseInt(agg.mediaCount as any, 10) : 0;
270!
123
    const oldestMedia: number = agg?.oldest != null ? parseInt(agg.oldest as any, 10) : null;
270✔
124
    const youngestMedia: number = agg?.youngest != null ? parseInt(agg.youngest as any, 10) : null;
270✔
125

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

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

149

150
    // Compute cover respecting projection
151
    const coverMedia = await ObjectManagers.getInstance().CoverManager.getCoverForDirectory(session, dir);
270✔
152

153
    const cacheRepo = connection.getRepository(ProjectedDirectoryCacheEntity);
270✔
154

155
    // Find existing cache row by (projectionKey, directory)
156
    const projectionKey = session?.user?.projectionKey;
270✔
157

158
    let row = await cacheRepo
270✔
159
      .createQueryBuilder('pdc')
160
      .leftJoin('pdc.directory', 'd')
161
      .where('pdc.projectionKey = :pk AND d.id = :dir', {pk: projectionKey, dir: dir.id})
162
      .getOne();
163

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

171
    row.mediaCount = mediaCount || 0;
270✔
172
    row.recursiveMediaCount = recursiveMediaCount || 0;
270✔
173
    row.oldestMedia = oldestMedia ?? null;
270✔
174
    row.youngestMedia = youngestMedia ?? null;
270✔
175
    row.cover = coverMedia as any;
270✔
176
    row.valid = true;
270✔
177

178
    return row;
270✔
179
  }
180

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

199
  public async setAndGetCacheForAlbum(connection: Connection, session: SessionContext, album: {
200
    id: number,
201
    searchQuery: any
202
  }): Promise<ProjectedAlbumCacheEntity> {
203

204

205
    const cacheRepo = connection.getRepository(ProjectedAlbumCacheEntity);
34✔
206

207
    // Find existing cache row by (projectionKey, album)
208
    const projectionKey = session?.user?.projectionKey;
34✔
209

210
    let row = await cacheRepo
34✔
211
      .createQueryBuilder('pac')
212
      .leftJoin('pac.album', 'a')
213
      .where('pac.projectionKey = :pk AND a.id = :albumId', {pk: projectionKey, albumId: album.id})
214
      .getOne();
215

216
    if (row && row.valid === true) {
34!
217
      return row;
×
218
    }
219

220
    // Compute aggregates under the current projection (if any)
221
    const mediaRepo = connection.getRepository(MediaEntity);
34✔
222

223
    // Build base query from album's search query
224
    const baseQb = mediaRepo
34✔
225
      .createQueryBuilder('media')
226
      .leftJoin('media.directory', 'directory');
227

228
    // Apply album search query constraints
229
    const albumWhereQuery = await ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(
34✔
230
      album.searchQuery
231
    );
232
    baseQb.andWhere(albumWhereQuery);
34✔
233

234
    // Apply projection constraints if any
235
    if (session.projectionQuery) {
34✔
236
      baseQb.andWhere(session.projectionQuery);
14✔
237
    }
238

239
    const agg = await baseQb
34✔
240
      .select([
241
        'COUNT(*) as itemCount',
242
        'MIN(media.metadata.creationDate) as oldest',
243
        'MAX(media.metadata.creationDate) as youngest',
244
      ])
245
      .getRawOne();
246

247
    const itemCount: number = agg?.itemCount != null ? parseInt(agg.itemCount as any, 10) : 0;
34!
248
    const oldestMedia: number = agg?.oldest != null ? parseInt(agg.oldest as any, 10) : null;
34✔
249
    const youngestMedia: number = agg?.youngest != null ? parseInt(agg.youngest as any, 10) : null;
34✔
250

251
    // Compute cover respecting projection
252
    const coverMedia = await ObjectManagers.getInstance().CoverManager.getCoverForAlbum(session, album as any);
34✔
253

254

255
    if (!row) {
34✔
256
      row = new ProjectedAlbumCacheEntity();
32✔
257
      row.projectionKey = projectionKey;
32✔
258
      // Avoid fetching the full album graph; assign relation by id only
259
      row.album = {id: album.id} as any;
32✔
260
    }
261

262
    row.itemCount = itemCount || 0;
34✔
263
    row.oldestMedia = oldestMedia ?? null;
34✔
264
    row.youngestMedia = youngestMedia ?? null;
34✔
265
    row.cover = coverMedia as any;
34✔
266
    row.valid = true;
34✔
267

268
    const ret = await cacheRepo.save(row);
34✔
269
    // we would not select these either
270
    delete ret.projectionKey;
34✔
271
    delete ret.album;
34✔
272
    delete ret.id;
34✔
273
    if (ret.cover) {
34✔
274
      delete ret.cover.id;
16✔
275
    }
276
    return ret;
34✔
277
  }
278

279
  @ExtensionDecorator(e => e.gallery.ProjectedCacheManager.invalidateDirectoryCache)
110✔
280
  protected async invalidateDirectoryCache(dir: ParentDirectoryDTO) {
1✔
281
    const connection = await SQLConnection.getConnection();
110✔
282
    const dirRepo = connection.getRepository(DirectoryEntity);
110✔
283

284
    // Collect directory paths from target to root
285
    const paths: { path: string; name: string }[] = [];
110✔
286
    let fullPath = DiskManager.normalizeDirPath(path.join(dir.path, dir.name));
110✔
287
    const root = DiskManager.pathFromRelativeDirName('.');
110✔
288

289
    // Build path-name pairs for current directory and all parents
290
    while (fullPath !== root) {
110✔
291
      const name = DiskManager.dirName(fullPath);
120✔
292
      const parentPath = DiskManager.pathFromRelativeDirName(fullPath);
120✔
293
      paths.push({path: parentPath, name});
120✔
294
      fullPath = parentPath;
120✔
295
    }
296

297
    // Add root directory
298
    paths.push({path: DiskManager.pathFromRelativeDirName(root), name: DiskManager.dirName(root)});
110✔
299

300
    if (paths.length === 0) {
110!
301
      return;
×
302
    }
303

304
    // Build query for all directories in one shot
305
    const qb = dirRepo.createQueryBuilder('d');
110✔
306
    paths.forEach((p, i) => {
110✔
307
      qb.orWhere(new Brackets(q => {
230✔
308
        q.where(`d.path = :path${i}`, {[`path${i}`]: p.path})
230✔
309
          .andWhere(`d.name = :name${i}`, {[`name${i}`]: p.name});
310
      }));
311
    });
312

313
    // Find matching directories and invalidate their cache entries
314
    const entities = await qb.getMany();
110✔
315
    if (entities.length === 0) {
110!
316
      return;
×
317
    }
318

319
    // Invalidate all related cache entries in one operation
320
    await connection.getRepository(ProjectedDirectoryCacheEntity)
110✔
321
      .createQueryBuilder()
322
      .update()
323
      .set({valid: false})
324
      .where('directoryId IN (:...dirIds)', {dirIds: entities.map(e => e.id)})
122✔
325
      .execute();
326
  }
327

328
}
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