mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
refactor(memories): serve photo bytes through storage
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user