refactor(memories): serve photo bytes through storage

This commit is contained in:
jubnl
2026-08-16 10:54:06 +02:00
parent a0a5bb8203
commit 980f8be6e8
5 changed files with 128 additions and 28 deletions
+2 -1
View File
@@ -21,6 +21,7 @@ import { PhotoProviderRegistry } from './photo-provider.registry';
import { ImmichPhotoProvider } from './providers/immich.provider';
import { SynologyPhotoProvider } from './providers/synology.provider';
import { NotificationsModule } from '../notifications/notifications.module';
import { StorageModule } from '../storage/storage.module';
/**
* Memories (photo-providers) domain — mounted at /api/integrations/memories.
@@ -43,7 +44,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
* adapters themselves so the set is readable in one place.
*/
@Module({
imports: [NotificationsModule, AddonsModule, AuditModule, TrekPhotosModule, RealtimeModule, SchedulingModule],
imports: [NotificationsModule, AddonsModule, AuditModule, TrekPhotosModule, RealtimeModule, SchedulingModule, StorageModule],
controllers: [UnifiedMemoriesController, ImmichMemoriesController, SynologyMemoriesController],
providers: [
MemoriesService,
@@ -1,7 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Response } from 'express';
import path from 'path';
import fs from 'fs';
import type { TrekPhoto } from '../../types';
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
@@ -11,6 +9,7 @@ import { TrekPhotoCacheService } from './trek-photo-cache.service';
import { fail, success, type AssetInfo, type ServiceResult } from './memories.helpers';
import { PhotoProviderRegistry } from './photo-provider.registry';
import type { PhotoAssetRef } from './photo-provider';
import { StorageService } from '../storage/storage.service';
/**
* Resolves a stored trek_photo to bytes or metadata by asking whichever provider
@@ -29,8 +28,16 @@ export class PhotoResolverService {
private readonly thumbnails: ThumbnailService,
private readonly cache: TrekPhotoCacheService,
private readonly providers: PhotoProviderRegistry,
private readonly storage: StorageService,
) {}
/** photos.file_path / thumbnail_path are uploads-relative 'journey/…' by
* every writer; anything else reads as a local miss, matching the old
* existsSync(join(uploadsRoot, file_path)) === false path. */
private storageName(relPath: string): string | null {
return relPath.startsWith('journey/') ? relPath.slice('journey/'.length) : null;
}
// ── Streaming ────────────────────────────────────────────────────────────
private async streamCachedThumbnail(
@@ -41,12 +48,12 @@ export class PhotoResolverService {
): Promise<void> {
const key = this.cache.cacheKey(photo.provider!, photo.asset_id!, 'thumbnail', photo.owner_id!);
if (this.cache.serveFresh(res, key)) return;
if (await this.cache.serveFresh(res, key)) return;
const existing = this.cache.getInFlight(key);
if (existing) {
const bytes = await existing;
if (bytes && this.cache.serveFresh(res, key)) return;
if (bytes && (await this.cache.serveFresh(res, key))) return;
await fallback();
return;
}
@@ -59,7 +66,7 @@ export class PhotoResolverService {
this.cache.setInFlight(key, promise);
const bytes = await promise;
if (bytes && this.cache.serveFresh(res, key)) return;
if (bytes && (await this.cache.serveFresh(res, key))) return;
await fallback();
}
@@ -77,26 +84,26 @@ export class PhotoResolverService {
}
if (photo.file_path) {
const uploadsRoot = UPLOADS_ROOT;
if (kind === 'thumbnail') {
const isVideo = photo.media_type === 'video';
let thumbRel = photo.thumbnail_path ?? null;
// Only raster images get a lazily-generated Jimp thumbnail; Jimp can't decode
// video, so a video relies on the poster captured at upload (#823).
if (!thumbRel && !isVideo) {
const result = await this.thumbnails.ensureLocalThumbnail(uploadsRoot, photo.file_path);
// Generation stays root-addressed until ThumbnailService becomes
// category-addressed in slice 4; serving below is already storage-backed.
const result = await this.thumbnails.ensureLocalThumbnail(UPLOADS_ROOT, photo.file_path);
if (result) {
thumbRel = result.thumbnailRelPath;
this.photos.recordLocalThumbnail(photo.id, thumbRel, result.width, result.height);
}
}
if (thumbRel) {
const thumbAbs = path.join(uploadsRoot, thumbRel);
if (fs.existsSync(thumbAbs)) {
const thumbName = this.storageName(thumbRel);
if (thumbName && (await this.storage.exists('journey', thumbName).catch(() => false))) {
res.set('Cache-Control', 'public, max-age=86400, immutable');
res.set('X-Content-Type-Options', 'nosniff');
res.sendFile(thumbAbs);
await this.storage.sendToResponse('journey', thumbName, res);
return;
}
}
@@ -109,11 +116,11 @@ export class PhotoResolverService {
// Images fall through to original if the thumbnail is unavailable.
}
const localPath = path.join(uploadsRoot, photo.file_path);
if (fs.existsSync(localPath)) {
const name = this.storageName(photo.file_path);
if (name && (await this.storage.exists('journey', name).catch(() => false))) {
res.set('Cache-Control', 'public, max-age=86400');
res.set('X-Content-Type-Options', 'nosniff');
res.sendFile(localPath);
await this.storage.sendToResponse('journey', name, res);
return;
}
}
@@ -5,6 +5,8 @@ import crypto from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { Response } from 'express';
import { DatabaseService } from '../database/database.service';
import { StorageService } from '../storage/storage.service';
import { StorageNotFoundError } from '../storage/storage.types';
import { UPLOADS_ROOT } from './uploads-root';
const TREK_PHOTO_DIR = path.join(UPLOADS_ROOT, 'photos/trek');
@@ -36,7 +38,10 @@ function cachedFilePath(key: string): string {
/** Disk + metadata cache for provider thumbnails and originals. */
@Injectable()
export class TrekPhotoCacheService {
constructor(private readonly db: DatabaseService) {}
constructor(
private readonly db: DatabaseService,
private readonly storage: StorageService,
) {}
cacheKey(provider: string, assetId: string, kind: string, ownerId: number): string {
return crypto.createHash('sha1').update(`${provider}:${assetId}:${kind}:${ownerId}`).digest('hex');
@@ -79,13 +84,21 @@ export class TrekPhotoCacheService {
);
}
serveFresh(res: Response, key: string): boolean {
async serveFresh(res: Response, key: string): Promise<boolean> {
const entry = this.getFresh(key);
if (!entry) return false;
res.set('Content-Type', entry.contentType);
res.set('Cache-Control', 'public, max-age=3600');
res.sendFile(entry.filePath);
try {
// send() keeps a pre-set Content-Type, so entry.contentType survives the
// .bin extension.
await this.storage.sendToResponse('photos-trek', `${key}.bin`, res);
} catch (err) {
// getFresh→send delete race: fall back like a cache miss.
if (err instanceof StorageNotFoundError && !res.headersSent) return false;
throw err;
}
return true;
}