feat(photos): add Photos app with mana-media EXIF integration

- Add Photos NestJS backend (port 3019) with albums, favorites, tags
- Add Photos SvelteKit web app (port 5189) with gallery, upload, filters
- Extend mana-media with EXIF extraction service using exifr
- Add cross-app photo listing endpoint to mana-media
- Add photo stats endpoint to mana-media
- Add photos to setup-databases.sh

Backend features:
- Albums CRUD with cover image and items management
- Favorites toggle with status check
- Tags CRUD with photo-tag associations
- Photo proxy to mana-media with local data enrichment

Web features:
- Photo grid with infinite scroll
- Photo detail modal with EXIF display
- Album grid and detail views
- Upload dropzone with progress tracking
- Filter bar (app, date range, location, sort)
- i18n support (de/en)
- Svelte 5 runes mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-11 17:58:44 +01:00
parent d3392f69a9
commit 90c2f8573e
80 changed files with 6891 additions and 503 deletions

View file

@ -4,6 +4,8 @@ import { ProcessService } from './process.service';
import { ProcessWorker } from './process.worker';
import { PROCESS_QUEUE } from './process.constants';
import { UploadModule } from '../upload/upload.module';
import { ExifModule } from '../exif/exif.module';
import { StorageModule } from '../storage/storage.module';
@Module({
imports: [
@ -11,6 +13,8 @@ import { UploadModule } from '../upload/upload.module';
name: PROCESS_QUEUE,
}),
forwardRef(() => UploadModule),
ExifModule,
StorageModule,
],
providers: [ProcessService, ProcessWorker],
exports: [ProcessService],

View file

@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import sharp from 'sharp';
import { StorageService } from '../storage/storage.service';
import { ExifService, type ExifData } from '../exif/exif.service';
import { IMAGE_VARIANTS, SUPPORTED_IMAGE_TYPES } from './process.constants';
export interface ProcessResult {
@ -13,11 +14,15 @@ export interface ProcessResult {
format?: string;
hasAlpha?: boolean;
};
exif?: ExifData;
}
@Injectable()
export class ProcessService {
constructor(private storage: StorageService) {}
constructor(
private storage: StorageService,
private exifService: ExifService
) {}
async processImage(
mediaId: string,
@ -35,6 +40,9 @@ export class ProcessService {
const image = sharp(originalBuffer);
const metadata = await image.metadata();
// Extract EXIF data
const exifData = await this.exifService.extract(originalBuffer);
const result: ProcessResult = {
metadata: {
width: metadata.width,
@ -42,6 +50,7 @@ export class ProcessService {
format: metadata.format,
hasAlpha: metadata.hasAlpha,
},
exif: exifData || undefined,
};
// Generate variants

View file

@ -57,10 +57,21 @@ export class ProcessWorker extends WorkerHost {
height: result.metadata?.height,
format: result.metadata?.format,
hasAlpha: result.metadata?.hasAlpha,
// EXIF data
exifData: result.exif?.raw,
dateTaken: result.exif?.dateTaken,
cameraMake: result.exif?.cameraMake,
cameraModel: result.exif?.cameraModel,
focalLength: result.exif?.focalLength,
aperture: result.exif?.aperture,
iso: result.exif?.iso,
exposureTime: result.exif?.exposureTime,
gpsLatitude: result.exif?.gpsLatitude,
gpsLongitude: result.exif?.gpsLongitude,
});
this.logger.log(
`Processed image ${mediaId}: thumbnail=${!!result.thumbnail}, medium=${!!result.medium}, large=${!!result.large}`
`Processed image ${mediaId}: thumbnail=${!!result.thumbnail}, medium=${!!result.medium}, large=${!!result.large}, exif=${!!result.exif}`
);
}
}