feat(journey): journey-thumbs orphan sweep and delete-site derived-artifact reclaim

This commit is contained in:
jubnl
2026-08-17 06:23:13 +02:00
parent 616897b60b
commit 5870dd4fda
8 changed files with 217 additions and 10 deletions
@@ -1234,22 +1234,22 @@ export class JourneyDomainService {
deleteGalleryPhoto(
journeyPhotoId: number,
userId: number,
): { photo_id: number; file_path?: string | null } | null {
): { photo_id: number; file_path?: string | null; thumbnail_path?: string | null } | null {
const row = this.db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(journeyPhotoId) as
| { id: number; journey_id: number; photo_id: number }
| undefined;
if (!row) return null;
if (!this.canEdit(row.journey_id, userId)) return null;
const trekRow = this.db.prepare('SELECT file_path, provider FROM trek_photos WHERE id = ?').get(row.photo_id) as
| { file_path?: string; provider?: string }
const trekRow = this.db.prepare('SELECT file_path, thumbnail_path, provider FROM trek_photos WHERE id = ?').get(row.photo_id) as
| { file_path?: string; thumbnail_path?: string; provider?: string }
| undefined;
// cascade on journey_entry_photos.journey_photo_id handles junction cleanup
this.db.prepare('DELETE FROM journey_photos WHERE id = ?').run(journeyPhotoId);
this.photos.deleteIfOrphan(row.photo_id);
return { photo_id: row.photo_id, file_path: trekRow?.file_path ?? null };
return { photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, thumbnail_path: trekRow?.thumbnail_path ?? null };
}
setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
@@ -1299,21 +1299,21 @@ export class JourneyDomainService {
deletePhoto(
photoId: number,
userId: number,
): { id: number; photo_id: number; file_path?: string | null; journey_id: number } | null {
): { id: number; photo_id: number; file_path?: string | null; thumbnail_path?: string | null; journey_id: number } | null {
const row = this.db.prepare('SELECT id, journey_id, photo_id FROM journey_photos WHERE id = ?').get(photoId) as
| { id: number; journey_id: number; photo_id: number }
| undefined;
if (!row) return null;
if (!this.canEdit(row.journey_id, userId)) return null;
const trekRow = this.db.prepare('SELECT file_path, provider FROM trek_photos WHERE id = ?').get(row.photo_id) as
| { file_path?: string; provider?: string }
const trekRow = this.db.prepare('SELECT file_path, thumbnail_path, provider FROM trek_photos WHERE id = ?').get(row.photo_id) as
| { file_path?: string; thumbnail_path?: string; provider?: string }
| undefined;
this.db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
this.photos.deleteIfOrphan(row.photo_id);
return { id: row.id, photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, journey_id: row.journey_id };
return { id: row.id, photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, thumbnail_path: trekRow?.thumbnail_path ?? null, journey_id: row.journey_id };
}
// ── Contributors ─────────────────────────────────────────────────────────
@@ -23,6 +23,7 @@ import fs from 'node:fs';
import crypto from 'node:crypto';
import type { User } from '../../types';
import { StorageService } from '../storage/storage.service';
import { journeyThumbName } from '../memories/thumbnail.service';
import { JourneyService } from './journey.service';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
@@ -287,6 +288,7 @@ export class JourneyController {
throw new HttpException({ error: 'Photo not found' }, 404);
}
await this.deletePhotoObject(photo.file_path);
await this.reclaimDerived(photo.file_path, photo.thumbnail_path);
return { success: true };
}
@@ -302,6 +304,21 @@ export class JourneyController {
await this.storage.delete('journey', name).catch(() => { /* file already gone */ });
}
/**
* Delete a photo's derived artifacts with it (spec In-scope fix #2): the
* hash-named thumbnail derivable from file_path, and whatever
* thumbnail_path recorded — a generated thumb (usually the same object) or
* a video's uploaded poster. Both best-effort, both idempotent.
*/
private async reclaimDerived(filePath: string | null | undefined, thumbnailPath: string | null | undefined): Promise<void> {
if (filePath?.startsWith('journey/')) {
await this.storage.delete('journey', journeyThumbName(filePath)).catch(() => { /* already gone */ });
}
if (thumbnailPath?.startsWith('journey/')) {
await this.storage.delete('journey', thumbnailPath.slice('journey/'.length)).catch(() => { /* already gone */ });
}
}
// ── Gallery (prefix /:id/gallery — before /:id) ─────────────────────────
@Post(':id/gallery/photos')
@UseInterceptors(FilesInterceptor('photos'))
@@ -393,6 +410,7 @@ export class JourneyController {
throw new HttpException({ error: 'Photo not found or not allowed' }, 404);
}
await this.deletePhotoObject(photo.file_path);
await this.reclaimDerived(photo.file_path, photo.thumbnail_path);
}
// ── Journeys /:id ───────────────────────────────────────────────────────
@@ -0,0 +1,37 @@
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
import { logInfo, logError } from '../audit/audit-log.logger';
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
import { ThumbnailService } from './thumbnail.service';
/**
* Journey thumbnail cleanup: daily — reclaim journey/thumbs/ objects whose
* photo row is gone (spec In-scope fix #2: delete sites only started removing
* the derived thumb with this slice, so historical orphans need a sweeper).
* The Journey addon gate lives inside sweepOrphanThumbs, so toggling the
* addon takes effect without a restart.
*/
@Injectable()
export class JourneyThumbsJob implements OnApplicationBootstrap {
constructor(
private readonly thumbnails: ThumbnailService,
private readonly registrar: CronRegistrarService,
) {}
onApplicationBootstrap(): void {
if (!this.registrar.isEnabled()) return;
// Run once on startup to reclaim orphans left over from before this sweeper existed.
void this.sweep();
this.registrar.register('journey-thumbs', '0 4 * * *', () => {
void this.sweep();
});
}
async sweep(): Promise<void> {
try {
const removed = await this.thumbnails.sweepOrphanThumbs();
if (removed > 0) logInfo(`Journey thumbnail cleanup: removed ${removed} orphaned thumbnail(s)`);
} catch (err: unknown) {
logError(`Journey thumbnail cleanup: ${err instanceof Error ? err.message : err}`);
}
}
}
@@ -8,6 +8,7 @@ import { PhotoResolverService } from './photo-resolver.service';
import { ThumbnailService } from './thumbnail.service';
import { TrekPhotoCacheService } from './trek-photo-cache.service';
import { TrekPhotoCacheJob } from './trek-photo-cache.job';
import { JourneyThumbsJob } from './journey-thumbs.job';
import { SchedulingModule } from '../scheduling/scheduling.module';
import { UnifiedMemoriesController } from './unified.controller';
import { ImmichMemoriesController } from './immich.controller';
@@ -56,6 +57,7 @@ import { StorageModule } from '../storage/storage.module';
ThumbnailService,
TrekPhotoCacheService,
TrekPhotoCacheJob,
JourneyThumbsJob,
ImmichPhotoProvider,
SynologyPhotoProvider,
PhotoProviderRegistry,
@@ -4,6 +4,7 @@ import crypto from 'node:crypto'
import { Injectable } from '@nestjs/common'
import { ADDON_IDS } from '../../addons'
import { AddonsService } from '../addons/addons.service'
import { DatabaseService } from '../database/database.service'
import { StorageService } from '../storage/storage.service'
const THUMB_MAX = 800
@@ -30,6 +31,7 @@ export class ThumbnailService {
constructor(
private readonly addons: AddonsService,
private readonly storage: StorageService,
private readonly db: DatabaseService,
) {}
async ensureLocalThumbnail(
@@ -75,4 +77,39 @@ export class ThumbnailService {
return null
}
}
/**
* Reclaim journey/thumbs/ objects no live trek_photos row can account for
* (spec In-scope fix #2). The live set is every hash derivable from a
* journey file_path plus every recorded thumbnail_path under thumbs/ —
* video posters live in journey/ proper and are reclaimed at the delete
* sites, not here. Gated on the Journey addon like the generator, inside
* the method so an admin toggle takes effect without a restart
* (airtrail-sync.job.ts precedent).
*/
async sweepOrphanThumbs(): Promise<number> {
if (!this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) return 0
const live = new Set<string>()
for (const row of this.db.all<{ file_path: string }>(
"SELECT file_path FROM trek_photos WHERE file_path LIKE 'journey/%'",
)) {
live.add(journeyThumbName(row.file_path))
}
for (const row of this.db.all<{ thumbnail_path: string }>(
"SELECT thumbnail_path FROM trek_photos WHERE thumbnail_path LIKE 'journey/thumbs/%'",
)) {
live.add(row.thumbnail_path.slice('journey/'.length))
}
let removed = 0
for await (const stat of this.storage.list('journey', 'thumbs/')) {
if (!stat.key.endsWith('.jpg') || live.has(stat.key)) continue
try {
await this.storage.delete('journey', stat.key)
removed++
} catch { /* race */ }
}
return removed
}
}