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

bpatrik / pigallery2 / 17778692742

16 Sep 2025 08:51PM UTC coverage: 66.26% (-0.3%) from 66.537%
17778692742

push

github

bpatrik
Implement reloading mechanics #1032

1332 of 2254 branches covered (59.09%)

Branch coverage included in aggregate %.

1 of 4 new or added lines in 2 files covered. (25.0%)

155 existing lines in 6 files now uncovered.

4858 of 7088 relevant lines covered (68.54%)

4314.83 hits per line

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

61.72
/src/backend/model/database/GalleryManager.ts
1
import {ParentDirectoryDTO, SubDirectoryDTO,} from '../../../common/entities/DirectoryDTO';
2
import * as path from 'path';
1✔
3
import * as fs from 'fs';
1✔
4
import {DirectoryEntity} from './enitites/DirectoryEntity';
1✔
5
import {SQLConnection} from './SQLConnection';
1✔
6
import {PhotoEntity} from './enitites/PhotoEntity';
1✔
7
import {ProjectPath} from '../../ProjectPath';
1✔
8
import {Config} from '../../../common/config/private/Config';
1✔
9
import {Brackets, Connection} from 'typeorm';
1✔
10
import {MediaEntity} from './enitites/MediaEntity';
1✔
11
import {VideoEntity} from './enitites/VideoEntity';
1✔
12
import {Logger} from '../../Logger';
1✔
13
import {ObjectManagers} from '../ObjectManagers';
1✔
14
import {DuplicatesDTO} from '../../../common/entities/DuplicatesDTO';
15
import {ReIndexingSensitivity} from '../../../common/config/private/PrivateConfig';
1✔
16
import {DiskManager} from '../fileaccess/DiskManager';
1✔
17
import {SessionContext} from '../SessionContext';
18

19
const LOG_TAG = '[GalleryManager]';
1✔
20

21
export class GalleryManager {
1✔
22
  public static parseRelativeDirPath(relativeDirectoryName: string): {
23
    name: string;
24
    parent: string;
25
  } {
26
    relativeDirectoryName = DiskManager.normalizeDirPath(
280✔
27
      relativeDirectoryName
28
    );
29
    return {
280✔
30
      name: path.basename(relativeDirectoryName),
31
      parent: path.join(path.dirname(relativeDirectoryName), path.sep),
32
    };
33
  }
34

35
  public async listDirectory(
36
    session: SessionContext,
37
    relativeDirectoryName: string,
38
    knownLastModified?: number,
39
    knownLastScanned?: number
40
  ): Promise<ParentDirectoryDTO> {
41
    const directoryPath = GalleryManager.parseRelativeDirPath(
42✔
42
      relativeDirectoryName
43
    );
44

45
    const connection = await SQLConnection.getConnection();
42✔
46
    const dir = await this.getDirIdAndTime(connection, directoryPath.name, directoryPath.parent);
42✔
47

48

49
    if (dir && dir.lastScanned != null) {
42✔
50
      // Return as soon as possible without touching the original data source (hdd)
51
      // See https://github.com/bpatrik/pigallery2/issues/613
52
      if (
30✔
53
        Config.Indexing.reIndexingSensitivity === ReIndexingSensitivity.never
54
      ) {
55
        if (knownLastModified && knownLastScanned) {
4!
56
          return null;
×
57
        }
58
        return await this.getParentDirFromId(connection, session, dir.id);
4✔
59
      }
60

61
      const stat = fs.statSync(
26✔
62
        path.join(ProjectPath.ImageFolder, relativeDirectoryName)
63
      );
64
      const lastModified = DiskManager.calcLastModified(stat);
26✔
65

66
      // If it seems that the content did not change, do not work on it
67
      if (
26✔
68
        knownLastModified && knownLastScanned &&
50✔
69
        lastModified === knownLastModified &&
70
        dir.lastScanned === knownLastScanned
71
      ) {
72
        if (
8✔
73
          Config.Indexing.reIndexingSensitivity === ReIndexingSensitivity.low
74
        ) {
75
          return null;
6✔
76
        }
77
        if (
2✔
78
          Date.now() - dir.lastScanned <= Config.Indexing.cachedFolderTimeout &&
4✔
79
          Config.Indexing.reIndexingSensitivity === ReIndexingSensitivity.medium
80
        ) {
81
          return null;
2✔
82
        }
83
      }
84

85
      if (dir.lastModified !== lastModified) {
18✔
86
        Logger.silly(LOG_TAG, 'Reindexing reason: lastModified mismatch: known: ' + dir.lastModified + ', current:' + lastModified);
4✔
87
        if (session?.projectionQuery) {
4✔
88
          // Need to wait for save, then return a DB-based result with projection
89
          await ObjectManagers.getInstance().IndexingManager.indexDirectory(relativeDirectoryName, true);
2✔
90
          return await this.getParentDirFromId(connection, session, dir.id);
2✔
91
        } else {
92
          const ret =
93
            await ObjectManagers.getInstance().IndexingManager.indexDirectory(relativeDirectoryName);
2✔
94
          for (const subDir of ret.directories) {
2✔
95
            if (!subDir.cache.cover) {
×
96
              // if subdirectories do not have photos, so cannot show a cover, try getting one from DB
97
              await this.fillCacheForSubDir(connection, session, subDir);
×
98
            }
99
          }
100
          return ret;
2✔
101
        }
102
      }
103

104
      // not indexed since a while, index it lazily
105
      if (
14✔
106
        (Date.now() - dir.lastScanned > Config.Indexing.cachedFolderTimeout &&
30✔
107
          Config.Indexing.reIndexingSensitivity >= ReIndexingSensitivity.medium) ||
108
        Config.Indexing.reIndexingSensitivity >= ReIndexingSensitivity.high
109
      ) {
110
        // on the fly reindexing
111
        Logger.silly(LOG_TAG, 'lazy reindexing reason: cache timeout: lastScanned: ' + (Date.now() - dir.lastScanned) +
8✔
112
          'ms ago, cachedFolderTimeout:' + Config.Indexing.cachedFolderTimeout);
113
        ObjectManagers.getInstance()
8✔
114
          .IndexingManager.indexDirectory(relativeDirectoryName)
115
          .catch(console.error);
116
      }
117
      return await this.getParentDirFromId(connection, session, dir.id);
14✔
118
    }
119

120
    // never scanned (deep indexed), do it and return with it
121
    Logger.silly(LOG_TAG, 'Reindexing reason: never scanned');
12✔
122
    if (session?.projectionQuery) {
12✔
123
      // Save must be completed to query with projection
124
      await ObjectManagers.getInstance().IndexingManager.indexDirectory(
2✔
125
        relativeDirectoryName,
126
        true
127
      );
128
      const connection = await SQLConnection.getConnection();
2✔
129
      const dir = await this.getDirIdAndTime(connection, directoryPath.name, directoryPath.parent);
2✔
130
      return await this.getParentDirFromId(connection, session, dir.id);
2✔
131
    }
132
    return ObjectManagers.getInstance().IndexingManager.indexDirectory(relativeDirectoryName);
10✔
133
  }
134

135
  async countDirectories(): Promise<number> {
136
    const connection = await SQLConnection.getConnection();
×
137
    return await connection
×
138
      .getRepository(DirectoryEntity)
139
      .createQueryBuilder('directory')
140
      .getCount();
141
  }
142

143
  async countMediaSize(): Promise<number> {
144
    const connection = await SQLConnection.getConnection();
×
145
    const {sum} = await connection
×
146
      .getRepository(MediaEntity)
147
      .createQueryBuilder('media')
148
      .select('SUM(media.metadata.fileSize)', 'sum')
149
      .getRawOne();
150
    return sum || 0;
×
151
  }
152

153
  async countPhotos(): Promise<number> {
154
    const connection = await SQLConnection.getConnection();
×
155
    return await connection
×
156
      .getRepository(PhotoEntity)
157
      .createQueryBuilder('directory')
158
      .getCount();
159
  }
160

161
  async countVideos(): Promise<number> {
162
    const connection = await SQLConnection.getConnection();
×
163
    return await connection
×
164
      .getRepository(VideoEntity)
165
      .createQueryBuilder('directory')
166
      .getCount();
167
  }
168

169
  public async getPossibleDuplicates(): Promise<DuplicatesDTO[]> {
170
    const connection = await SQLConnection.getConnection();
×
171
    const mediaRepository = connection.getRepository(MediaEntity);
×
172

173
    let duplicates = await mediaRepository
×
174
      .createQueryBuilder('media')
175
      .innerJoin(
176
        (query) =>
177
          query
×
178
            .from(MediaEntity, 'innerMedia')
179
            .select([
180
              'innerMedia.name as name',
181
              'innerMedia.metadata.fileSize as fileSize',
182
              'count(*)',
183
            ])
184
            .groupBy('innerMedia.name, innerMedia.metadata.fileSize')
185
            .having('count(*)>1'),
186
        'innerMedia',
187
        'media.name=innerMedia.name AND media.metadata.fileSize = innerMedia.fileSize'
188
      )
189
      .innerJoinAndSelect('media.directory', 'directory')
190
      .orderBy('media.name, media.metadata.fileSize')
191
      .limit(Config.Duplicates.listingLimit)
192
      .getMany();
193

194
    const duplicateParis: DuplicatesDTO[] = [];
×
195
    const processDuplicates = (
×
196
      duplicateList: MediaEntity[],
197
      equalFn: (a: MediaEntity, b: MediaEntity) => boolean,
198
      checkDuplicates = false
×
199
    ): void => {
200
      let i = duplicateList.length - 1;
×
201
      while (i >= 0) {
×
202
        const list = [duplicateList[i]];
×
203
        let j = i - 1;
×
204
        while (j >= 0 && equalFn(duplicateList[i], duplicateList[j])) {
×
205
          list.push(duplicateList[j]);
×
206
          j--;
×
207
        }
208
        i = j;
×
209
        // if we cut the select list with the SQL LIMIT, filter unpaired media
210
        if (list.length < 2) {
×
211
          continue;
×
212
        }
213
        if (checkDuplicates) {
×
214
          // ad to group if one already existed
215
          const foundDuplicates = duplicateParis.find(
×
216
            (dp): boolean =>
217
              !!dp.media.find(
×
218
                (m): boolean => !!list.find((lm): boolean => lm.id === m.id)
×
219
              )
220
          );
221
          if (foundDuplicates) {
×
222
            list.forEach((lm): void => {
×
223
              if (
×
224
                foundDuplicates.media.find((m): boolean => m.id === lm.id)
×
225
              ) {
226
                return;
×
227
              }
228
              foundDuplicates.media.push(lm);
×
229
            });
230
            continue;
×
231
          }
232
        }
233

234
        duplicateParis.push({media: list});
×
235
      }
236
    };
237

238
    processDuplicates(
×
239
      duplicates,
240
      (a, b): boolean =>
241
        a.name === b.name && a.metadata.fileSize === b.metadata.fileSize
×
242
    );
243

244
    duplicates = await mediaRepository
×
245
      .createQueryBuilder('media')
246
      .innerJoin(
247
        (query) =>
248
          query
×
249
            .from(MediaEntity, 'innerMedia')
250
            .select([
251
              'innerMedia.metadata.creationDate as creationDate',
252
              'innerMedia.metadata.fileSize as fileSize',
253
              'count(*)',
254
            ])
255
            .groupBy(
256
              'innerMedia.metadata.creationDate, innerMedia.metadata.fileSize'
257
            )
258
            .having('count(*)>1'),
259
        'innerMedia',
260
        'media.metadata.creationDate=innerMedia.creationDate AND media.metadata.fileSize = innerMedia.fileSize'
261
      )
262
      .innerJoinAndSelect('media.directory', 'directory')
263
      .orderBy('media.metadata.creationDate, media.metadata.fileSize')
264
      .limit(Config.Duplicates.listingLimit)
265
      .getMany();
266

267
    processDuplicates(
×
268
      duplicates,
269
      (a, b): boolean =>
270
        a.metadata.creationDate === b.metadata.creationDate &&
×
271
        a.metadata.fileSize === b.metadata.fileSize,
272
      true
273
    );
274

275
    return duplicateParis;
×
276
  }
277

278
  /**
279
   * Returns with the directories only, does not include media or metafiles
280
   */
281
  public async selectDirStructure(
282
    relativeDirectoryName: string
283
  ): Promise<DirectoryEntity> {
284
    const directoryPath = GalleryManager.parseRelativeDirPath(
×
285
      relativeDirectoryName
286
    );
287
    const connection = await SQLConnection.getConnection();
×
288
    const query = connection
×
289
      .getRepository(DirectoryEntity)
290
      .createQueryBuilder('directory')
291
      .where('directory.name = :name AND directory.path = :path', {
292
        name: directoryPath.name,
293
        path: directoryPath.parent,
294
      })
295
      .leftJoinAndSelect('directory.directories', 'directories');
296

297
    return await query.getOne();
×
298
  }
299

300
  /**
301
   * Sets cover for the directory and caches it in the DB
302
   */
303
  public async fillCacheForSubDir(
304
    connection: Connection,
305
    session: SessionContext,
306
    dir: SubDirectoryDTO
307
  ): Promise<void> {
308
    if (!dir.cache?.valid) {
154✔
309
      dir.cache = await ObjectManagers.getInstance().ProjectedCacheManager.setAndGetCacheForDirectory(connection, session, dir);
154✔
310
    }
311

312
    dir.media = [];
154✔
313
    dir.isPartial = true;
154✔
314
  }
315

316
  async getMedia(session: SessionContext, mediaPath: string): Promise<MediaEntity> {
317
    // Validate media is available under projectionQuery
UNCOV
318
    const fileName = path.basename(mediaPath);
×
UNCOV
319
    const dirRelPath = path.dirname(mediaPath);
×
UNCOV
320
    const directoryName = path.basename(dirRelPath);
×
UNCOV
321
    const directoryParent = path.join(path.dirname(dirRelPath), path.sep);
×
322

UNCOV
323
    const connection = await SQLConnection.getConnection();
×
UNCOV
324
    const qb = connection
×
325
      .getRepository(MediaEntity)
326
      .createQueryBuilder('media')
327
      .innerJoinAndSelect('media.directory', 'directory')
328
      .where('media.name = :name', {name: fileName})
329
      .andWhere('directory.name = :dname AND directory.path = :dpath', {dname: directoryName, dpath: directoryParent});
UNCOV
330
    if (session.projectionQuery) {
×
UNCOV
331
      qb.andWhere(session.projectionQuery);
×
332
    }
UNCOV
333
    return await qb.getOne();
×
334
  }
335

336
  async authoriseMedia(session: SessionContext, mediaPath: string) {
337
    // If no projection set for session, proceed
338
    if (!session?.projectionQuery) {
12✔
339
      return true;
4✔
340
    }
341

342
    // Validate media is available under projectionQuery
343
    const fileName = path.basename(mediaPath);
8✔
344
    const dirRelPath = path.dirname(mediaPath);
8✔
345
    const directoryName = path.basename(dirRelPath);
8✔
346
    const directoryParent = path.join(path.dirname(dirRelPath), path.sep);
8✔
347

348
    const connection = await SQLConnection.getConnection();
8✔
349
    const qb = connection
8✔
350
      .getRepository(MediaEntity)
351
      .createQueryBuilder('media')
352
      .innerJoin('media.directory', 'directory')
353
      .where('media.name = :name', {name: fileName})
354
      .andWhere('directory.name = :dname AND directory.path = :dpath', {dname: directoryName, dpath: directoryParent})
355
      .andWhere(session.projectionQuery);
356

357
    const count = await qb.getCount();
8✔
358

359
    return count !== 0;
8✔
360
  }
361

362
  async authoriseMetaFile(session: SessionContext, p: string) {
363
    // If no projection set for session, proceed
364
    if (!session?.projectionQuery) {
6✔
365
      return true;
2✔
366
    }
367

368
    // Authorize metafile if its directory contains any media that matches the projectionQuery
369
    const dirRelPath = path.dirname(p);
4✔
370
    const directoryName = path.basename(dirRelPath);
4✔
371
    const directoryParent = path.join(path.dirname(dirRelPath), path.sep);
4✔
372

373
    const connection = await SQLConnection.getConnection();
4✔
374
    const qb = connection
4✔
375
      .getRepository(MediaEntity)
376
      .createQueryBuilder('media')
377
      .innerJoin('media.directory', 'directory')
378
      .where('directory.name = :dname AND directory.path = :dpath', {
379
        dname: directoryName,
380
        dpath: directoryParent,
381
      })
382
      .andWhere(session.projectionQuery);
383

384
    const count = await qb.getCount();
4✔
385

386
    return count !== 0;
4✔
387
  }
388

389
  protected async getDirIdAndTime(connection: Connection, name: string, path: string): Promise<{
390
    id: number,
391
    lastScanned: number,
392
    lastModified: number
393
  }> {
394
    return await connection
160✔
395
      .getRepository(DirectoryEntity)
396
      .createQueryBuilder('directory')
397
      .where('directory.name = :name AND directory.path = :path', {
398
        name: name,
399
        path: path,
400
      })
401
      .select([
402
        'directory.id',
403
        'directory.lastScanned',
404
        'directory.lastModified',
405
      ]).getOne();
406
  }
407

408
  protected async getParentDirFromId(
409
    connection: Connection,
410
    session: SessionContext,
411
    partialDirId: number
412
  ): Promise<ParentDirectoryDTO> {
413

414
    const query = connection
158✔
415
      .getRepository(DirectoryEntity)
416
      .createQueryBuilder('directory')
417
      .where('directory.id = :id', {
418
        id: partialDirId
419
      })
420
      .leftJoinAndSelect('directory.cache', 'cache', 'cache.projectionKey = :pk AND cache.valid = 1', {pk: session.user.projectionKey})
421
      .leftJoinAndSelect('cache.cover', 'cover')
422
      .leftJoinAndSelect('cover.directory', 'coverDirectory')
423

424
      .leftJoinAndSelect('directory.directories', 'directories')
425
      .leftJoinAndSelect('directories.cache', 'dcache', 'dcache.projectionKey = :pk AND dcache.valid = 1', {pk: session.user.projectionKey})
426
      .leftJoinAndSelect('dcache.cover', 'dcover')
427
      .leftJoinAndSelect('dcover.directory', 'dcoverDirectory')
428

429
      .select([
430
        'directory',
431
        'directories',
432
        'cover.name',
433
        'coverDirectory.name',
434
        'coverDirectory.path',
435
        'dcover.name',
436
        'dcoverDirectory.name',
437
        'dcoverDirectory.path',
438
      ]);
439

440
    // search does not return a directory if that is recursively having 0 media
441
    // gallery listing should otherwise, we won't be able to trigger lazy indexing
442
    // this behavior lets us explicitly hid a directory if it is explicitly blocked
443
    if (session.projectionQueryForSubDir) {
158✔
444
      query.andWhere(new Brackets(q => {
4✔
445
        q.where(session.projectionQueryForSubDir);
4✔
446
        // also select directories when they have no child dirs.
447
        q.orWhere('directories.id is NULL');
4✔
448
      }));
449
    }
450
    if (!session.projectionQuery) {
158✔
451
      query.leftJoinAndSelect('directory.media', 'media');
150✔
452
    }
453

454
    // TODO: do better filtering
455
    // NOTE: it should not cause an issue as it also do not save to the DB
456
    if (
158✔
457
      Config.MetaFile.gpx === true ||
166✔
458
      Config.MetaFile.pg2conf === true ||
459
      Config.MetaFile.markdown === true
460
    ) {
461
      query.leftJoinAndSelect('directory.metaFile', 'metaFile');
154✔
462
    }
463
    try {
158✔
464
      const dir = await query.getOne();
158✔
465

466
      if (!dir.cache?.valid) {
158✔
467
        dir.cache = await ObjectManagers.getInstance().ProjectedCacheManager.setAndGetCacheForDirectory(connection, session, dir);
158✔
468
      }
469

470
      if (dir.directories) {
158✔
471
        for (const item of dir.directories) {
158✔
472
          await this.fillCacheForSubDir(connection, session, item);
144✔
473
        }
474
      }
475

476
      // TODO: transform projection query to plain SQL query (String) and
477
      //  use it as leftJoinAndSelect on the dir query for performance improvement
478
      if (session.projectionQuery) {
158✔
479
        const mQuery = connection.getRepository(MediaEntity)
8✔
480
          .createQueryBuilder('media')
481
          .leftJoin('media.directory', 'directory')
482
          .where('media.directory = :id', {
483
            id: partialDirId
484
          })
485
          .andWhere(session.projectionQuery);
486
        dir.media = await mQuery.getMany();
8✔
487
      }
488
      return dir;
158✔
489
    } catch (e) {
UNCOV
490
      Logger.error(LOG_TAG, 'Failed to get parent directory: ' + e);
×
UNCOV
491
      Logger.debug(LOG_TAG, query.getQuery(), query.getParameters());
×
UNCOV
492
      throw e;
×
493
    }
494
  }
495
}
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