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

@ -27,7 +27,8 @@
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"sharp": "^0.33.0",
"uuid": "^11.0.0"
"uuid": "^11.0.0",
"exifr": "^7.1.3"
},
"devDependencies": {
"@manacore/shared-drizzle-config": "workspace:*",

View file

@ -38,6 +38,17 @@ export const media = pgTable(
height: integer('height'),
format: text('format'),
hasAlpha: boolean('has_alpha'),
// EXIF metadata
exifData: jsonb('exif_data'),
dateTaken: timestamp('date_taken', { withTimezone: true }),
cameraMake: text('camera_make'),
cameraModel: text('camera_model'),
focalLength: text('focal_length'),
aperture: text('aperture'),
iso: integer('iso'),
exposureTime: text('exposure_time'),
gpsLatitude: text('gps_latitude'),
gpsLongitude: text('gps_longitude'),
// Generated variants
thumbnailKey: text('thumbnail_key'),
mediumKey: text('medium_key'),
@ -50,6 +61,8 @@ export const media = pgTable(
index('media_content_hash_idx').on(table.contentHash),
index('media_status_idx').on(table.status),
index('media_created_at_idx').on(table.createdAt),
index('media_date_taken_idx').on(table.dateTaken),
index('media_camera_idx').on(table.cameraMake, table.cameraModel),
]
);

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { ExifService } from './exif.service';
@Module({
providers: [ExifService],
exports: [ExifService],
})
export class ExifModule {}

View file

@ -0,0 +1,110 @@
import { Injectable, Logger } from '@nestjs/common';
import exifr from 'exifr';
export interface ExifData {
// Camera info
cameraMake?: string;
cameraModel?: string;
// Lens info
focalLength?: string;
aperture?: string;
// Exposure
iso?: number;
exposureTime?: string;
// Date/time
dateTaken?: Date;
// GPS
gpsLatitude?: string;
gpsLongitude?: string;
// Full raw EXIF data
raw?: Record<string, unknown>;
}
@Injectable()
export class ExifService {
private readonly logger = new Logger(ExifService.name);
/**
* Extract EXIF data from an image buffer
*/
async extract(buffer: Buffer): Promise<ExifData | null> {
try {
const exif = await exifr.parse(buffer, {
// Include GPS data
gps: true,
// Parse all EXIF data
tiff: true,
exif: true,
});
if (!exif) {
return null;
}
const result: ExifData = {
raw: exif,
};
// Camera info
if (exif.Make) {
result.cameraMake = String(exif.Make).trim();
}
if (exif.Model) {
result.cameraModel = String(exif.Model).trim();
}
// Lens/exposure settings
if (exif.FocalLength) {
result.focalLength = `${exif.FocalLength}mm`;
}
if (exif.FNumber) {
result.aperture = String(exif.FNumber);
}
if (exif.ISO) {
result.iso = Number(exif.ISO);
}
if (exif.ExposureTime) {
// Format as fraction (e.g., "1/125")
if (exif.ExposureTime < 1) {
result.exposureTime = `1/${Math.round(1 / exif.ExposureTime)}`;
} else {
result.exposureTime = `${exif.ExposureTime}s`;
}
}
// Date taken
if (exif.DateTimeOriginal) {
result.dateTaken = new Date(exif.DateTimeOriginal);
} else if (exif.CreateDate) {
result.dateTaken = new Date(exif.CreateDate);
}
// GPS coordinates
if (exif.latitude !== undefined && exif.longitude !== undefined) {
result.gpsLatitude = String(exif.latitude);
result.gpsLongitude = String(exif.longitude);
}
this.logger.debug(
`Extracted EXIF: camera=${result.cameraMake} ${result.cameraModel}, date=${result.dateTaken}`
);
return result;
} catch (error) {
this.logger.warn(`Failed to extract EXIF data: ${error}`);
return null;
}
}
/**
* Check if the buffer likely contains EXIF data (quick check)
*/
hasExif(buffer: Buffer): boolean {
// JPEG files with EXIF start with FFD8 and contain "Exif" marker
if (buffer[0] === 0xff && buffer[1] === 0xd8) {
const exifMarker = buffer.indexOf('Exif');
return exifMarker !== -1 && exifMarker < 100;
}
return false;
}
}

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}`
);
}
}

View file

@ -27,9 +27,38 @@ interface UploadResponse {
medium?: string;
large?: string;
};
metadata?: {
width?: number;
height?: number;
format?: string;
};
exif?: {
cameraMake?: string;
cameraModel?: string;
dateTaken?: Date;
focalLength?: string;
aperture?: string;
iso?: number;
exposureTime?: string;
gpsLatitude?: string;
gpsLongitude?: string;
};
createdAt: Date;
}
interface ListAllResponse {
items: UploadResponse[];
total: number;
hasMore: boolean;
}
interface StatsResponse {
totalCount: number;
totalSize: number;
byApp: Record<string, { count: number; size: number }>;
byYear: Record<string, number>;
}
interface ImportFromMatrixDto {
mxcUrl: string;
app: string;
@ -135,6 +164,58 @@ export class UploadController {
return records.map((r) => this.toResponse(r));
}
/**
* List media across all apps for a user with advanced filtering
* Supports filtering by multiple apps, date range, MIME type, etc.
*/
@Get('list/all')
async listAll(
@Query('userId') userId: string,
@Query('apps') apps?: string,
@Query('mimeType') mimeType?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
@Query('hasLocation') hasLocation?: string,
@Query('limit') limit?: string,
@Query('offset') offset?: string,
@Query('sortBy') sortBy?: 'createdAt' | 'dateTaken' | 'size',
@Query('sortOrder') sortOrder?: 'asc' | 'desc'
): Promise<ListAllResponse> {
if (!userId) {
throw new BadRequestException('userId is required');
}
const result = await this.uploadService.listAll({
userId,
apps: apps ? apps.split(',').map((a) => a.trim()) : undefined,
mimeType,
dateFrom: dateFrom ? new Date(dateFrom) : undefined,
dateTo: dateTo ? new Date(dateTo) : undefined,
hasLocation: hasLocation === 'true',
limit: limit ? parseInt(limit) : 50,
offset: offset ? parseInt(offset) : 0,
sortBy: sortBy || 'createdAt',
sortOrder: sortOrder || 'desc',
});
return {
items: result.items.map((r) => this.toResponse(r)),
total: result.total,
hasMore: result.hasMore,
};
}
/**
* Get media statistics for a user
*/
@Get('stats')
async stats(@Query('userId') userId: string): Promise<StatsResponse> {
if (!userId) {
throw new BadRequestException('userId is required');
}
return this.uploadService.getStats(userId);
}
@Delete(':id')
async delete(@Param('id') id: string): Promise<{ success: boolean }> {
const deleted = await this.uploadService.delete(id);
@ -160,6 +241,8 @@ export class UploadController {
medium: record.keys.medium ? `${baseUrl}/media/${record.id}/file/medium` : undefined,
large: record.keys.large ? `${baseUrl}/media/${record.id}/file/large` : undefined,
},
metadata: record.metadata,
exif: record.exif,
createdAt: record.createdAt,
};
}

View file

@ -3,7 +3,7 @@ import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import * as mime from 'mime-types';
import * as crypto from 'crypto';
import { eq } from 'drizzle-orm';
import { eq, and, or, gte, lte, like, isNotNull, sql, desc, asc, inArray } from 'drizzle-orm';
import { StorageService } from '../storage/storage.service';
import { MatrixService } from '../matrix/matrix.service';
import { PROCESS_QUEUE } from '../process/process.constants';
@ -38,10 +38,47 @@ export interface MediaRecord {
format?: string;
hasAlpha?: boolean;
};
exif?: {
cameraMake?: string;
cameraModel?: string;
dateTaken?: Date;
focalLength?: string;
aperture?: string;
iso?: number;
exposureTime?: string;
gpsLatitude?: string;
gpsLongitude?: string;
};
createdAt: Date;
updatedAt: Date;
}
export interface ListAllOptions {
userId: string;
apps?: string[];
mimeType?: string;
dateFrom?: Date;
dateTo?: Date;
hasLocation?: boolean;
limit?: number;
offset?: number;
sortBy?: 'createdAt' | 'dateTaken' | 'size';
sortOrder?: 'asc' | 'desc';
}
export interface ListAllResult {
items: MediaRecord[];
total: number;
hasMore: boolean;
}
export interface StatsResult {
totalCount: number;
totalSize: number;
byApp: Record<string, { count: number; size: number }>;
byYear: Record<string, number>;
}
@Injectable()
export class UploadService {
constructor(
@ -208,6 +245,16 @@ export class UploadService {
| 'height'
| 'format'
| 'hasAlpha'
| 'exifData'
| 'dateTaken'
| 'cameraMake'
| 'cameraModel'
| 'focalLength'
| 'aperture'
| 'iso'
| 'exposureTime'
| 'gpsLatitude'
| 'gpsLongitude'
>
>
): Promise<MediaRecord | null> {
@ -277,6 +324,136 @@ export class UploadService {
return results.map((r) => this.toMediaRecord(r));
}
/**
* List media across all apps for a user with advanced filtering
*/
async listAll(options: ListAllOptions): Promise<ListAllResult> {
const conditions = [eq(mediaReferences.userId, options.userId)];
// Filter by multiple apps
if (options.apps && options.apps.length > 0) {
conditions.push(inArray(mediaReferences.app, options.apps));
}
// Filter by MIME type (supports wildcards like "image/*")
if (options.mimeType) {
if (options.mimeType.endsWith('/*')) {
const prefix = options.mimeType.slice(0, -1);
conditions.push(like(media.mimeType, `${prefix}%`));
} else {
conditions.push(eq(media.mimeType, options.mimeType));
}
}
// Filter by date range
if (options.dateFrom) {
conditions.push(gte(media.createdAt, options.dateFrom));
}
if (options.dateTo) {
conditions.push(lte(media.createdAt, options.dateTo));
}
// Filter by location
if (options.hasLocation) {
conditions.push(isNotNull(media.gpsLatitude));
conditions.push(isNotNull(media.gpsLongitude));
}
// Only show ready media
conditions.push(eq(media.status, 'ready'));
// Build order by
const orderColumn =
options.sortBy === 'dateTaken'
? media.dateTaken
: options.sortBy === 'size'
? media.size
: media.createdAt;
const orderFn = options.sortOrder === 'asc' ? asc : desc;
// Get total count
const countResult = await this.db
.select({ count: sql<number>`count(distinct ${media.id})` })
.from(media)
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId))
.where(and(...conditions));
const total = Number(countResult[0]?.count || 0);
// Get paginated results
const limit = options.limit || 50;
const offset = options.offset || 0;
const results = await this.db
.selectDistinct({ media: media })
.from(media)
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId))
.where(and(...conditions))
.orderBy(orderFn(orderColumn))
.limit(limit)
.offset(offset);
return {
items: results.map((r) => this.toMediaRecord(r.media)),
total,
hasMore: offset + results.length < total,
};
}
/**
* Get media statistics for a user
*/
async getStats(userId: string): Promise<StatsResult> {
// Total count and size
const totalResult = await this.db
.select({
count: sql<number>`count(distinct ${media.id})`,
size: sql<number>`sum(${media.size})`,
})
.from(media)
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId))
.where(eq(mediaReferences.userId, userId));
// By app
const byAppResult = await this.db
.select({
app: mediaReferences.app,
count: sql<number>`count(distinct ${media.id})`,
size: sql<number>`sum(${media.size})`,
})
.from(media)
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId))
.where(eq(mediaReferences.userId, userId))
.groupBy(mediaReferences.app);
// By year
const byYearResult = await this.db
.select({
year: sql<string>`extract(year from ${media.createdAt})::text`,
count: sql<number>`count(distinct ${media.id})`,
})
.from(media)
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId))
.where(eq(mediaReferences.userId, userId))
.groupBy(sql`extract(year from ${media.createdAt})`);
const byApp: Record<string, { count: number; size: number }> = {};
for (const row of byAppResult) {
byApp[row.app] = { count: Number(row.count), size: Number(row.size) };
}
const byYear: Record<string, number> = {};
for (const row of byYearResult) {
byYear[row.year] = Number(row.count);
}
return {
totalCount: Number(totalResult[0]?.count || 0),
totalSize: Number(totalResult[0]?.size || 0),
byApp,
byYear,
};
}
private async findByHash(hash: string): Promise<Media | null> {
const [result] = await this.db.select().from(media).where(eq(media.contentHash, hash)).limit(1);
return result || null;
@ -322,6 +499,20 @@ export class UploadService {
hasAlpha: m.hasAlpha || undefined,
}
: undefined,
exif:
m.cameraMake || m.dateTaken || m.gpsLatitude
? {
cameraMake: m.cameraMake || undefined,
cameraModel: m.cameraModel || undefined,
dateTaken: m.dateTaken || undefined,
focalLength: m.focalLength || undefined,
aperture: m.aperture || undefined,
iso: m.iso || undefined,
exposureTime: m.exposureTime || undefined,
gpsLatitude: m.gpsLatitude || undefined,
gpsLongitude: m.gpsLongitude || undefined,
}
: undefined,
createdAt: m.createdAt,
updatedAt: m.updatedAt,
};