mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
feat(journey): journey-thumbs orphan sweep and delete-site derived-artifact reclaim
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user