mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
feat: unified photo provider abstraction layer (#584)
Introduce trek_photos as central photo registry. Frontend uses /api/photos/:id/:kind instead of provider-specific URLs. Adding a new photo provider is now backend-only work. - New trek_photos table (migration 98) with photo_id FK in trip_photos and journey_photos - Unified /api/photos/:id/thumbnail|original|info endpoint - photoResolverService for central resolution and streaming - ProviderPicker: add "All Photos" tab, rename tabs, fix i18n - Localize all hardcoded strings in JourneyDetailPage (14 langs) - Fix date formatting to use browser locale instead of hardcoded 'en' - Journey stats as styled tile cards
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import { Response } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { db } from '../../db/database';
|
||||
import type { TrekPhoto } from '../../types';
|
||||
import { streamImmichAsset, getAssetInfo as getImmichAssetInfo } from './immichService';
|
||||
import { streamSynologyAsset, getSynologyAssetInfo } from './synologyService';
|
||||
import type { ServiceResult, AssetInfo } from './helpersService';
|
||||
import { fail, success } from './helpersService';
|
||||
|
||||
// ── Lookup / Register ────────────────────────────────────────────────────
|
||||
|
||||
export function getOrCreateTrekPhoto(
|
||||
provider: string,
|
||||
assetId: string,
|
||||
ownerId: number,
|
||||
): number {
|
||||
const existing = db.prepare(
|
||||
'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?'
|
||||
).get(provider, assetId, ownerId) as { id: number } | undefined;
|
||||
if (existing) return existing.id;
|
||||
|
||||
const res = db.prepare(
|
||||
'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)'
|
||||
).run(provider, assetId, ownerId);
|
||||
return Number(res.lastInsertRowid);
|
||||
}
|
||||
|
||||
export function getOrCreateLocalTrekPhoto(
|
||||
filePath: string,
|
||||
thumbnailPath?: string | null,
|
||||
width?: number | null,
|
||||
height?: number | null,
|
||||
): number {
|
||||
const existing = db.prepare(
|
||||
"SELECT id FROM trek_photos WHERE provider = 'local' AND file_path = ?"
|
||||
).get(filePath) as { id: number } | undefined;
|
||||
if (existing) return existing.id;
|
||||
|
||||
const res = db.prepare(
|
||||
'INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height) VALUES (?, ?, ?, ?, ?)'
|
||||
).run('local', filePath, thumbnailPath || null, width || null, height || null);
|
||||
return Number(res.lastInsertRowid);
|
||||
}
|
||||
|
||||
export function resolveTrekPhoto(photoId: number): TrekPhoto | null {
|
||||
return db.prepare('SELECT * FROM trek_photos WHERE id = ?').get(photoId) as TrekPhoto | undefined || null;
|
||||
}
|
||||
|
||||
// ── Streaming ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function streamPhoto(
|
||||
res: Response,
|
||||
userId: number,
|
||||
photoId: number,
|
||||
kind: 'thumbnail' | 'original',
|
||||
): Promise<void> {
|
||||
const photo = resolveTrekPhoto(photoId);
|
||||
if (!photo) {
|
||||
res.status(404).json({ error: 'Photo not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (photo.provider) {
|
||||
case 'local': {
|
||||
const filePath = path.join(__dirname, '../../../uploads', photo.file_path!);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.status(404).json({ error: 'File not found' });
|
||||
return;
|
||||
}
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(filePath);
|
||||
return;
|
||||
}
|
||||
case 'immich': {
|
||||
await streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!);
|
||||
return;
|
||||
}
|
||||
case 'synologyphotos': {
|
||||
await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
res.status(400).json({ error: `Unknown provider: ${photo.provider}` });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Asset Info ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getPhotoInfo(
|
||||
userId: number,
|
||||
photoId: number,
|
||||
): Promise<ServiceResult<AssetInfo>> {
|
||||
const photo = resolveTrekPhoto(photoId);
|
||||
if (!photo) return fail('Photo not found', 404);
|
||||
|
||||
switch (photo.provider) {
|
||||
case 'local': {
|
||||
return success({
|
||||
id: String(photo.id),
|
||||
takenAt: photo.created_at,
|
||||
city: null,
|
||||
country: null,
|
||||
width: photo.width,
|
||||
height: photo.height,
|
||||
fileName: photo.file_path?.split('/').pop() || null,
|
||||
} as AssetInfo);
|
||||
}
|
||||
case 'immich': {
|
||||
const result = await getImmichAssetInfo(userId, photo.asset_id!, photo.owner_id!);
|
||||
if (result.error) return fail(result.error, result.status || 500);
|
||||
return success(result.data as AssetInfo);
|
||||
}
|
||||
case 'synologyphotos': {
|
||||
return getSynologyAssetInfo(userId, photo.asset_id!, photo.owner_id!);
|
||||
}
|
||||
default:
|
||||
return fail(`Unknown provider: ${photo.provider}`, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update provider on existing trek_photo (for Immich upload sync) ─────
|
||||
|
||||
export function setTrekPhotoProvider(
|
||||
trekPhotoId: number,
|
||||
provider: string,
|
||||
assetId: string,
|
||||
ownerId: number,
|
||||
): void {
|
||||
db.prepare(
|
||||
'UPDATE trek_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?'
|
||||
).run(provider, assetId, ownerId, trekPhotoId);
|
||||
}
|
||||
|
||||
// ── Delete local file for a trek_photo ──────────────────────────────────
|
||||
|
||||
export function getTrekPhotoFilePath(photoId: number): string | null {
|
||||
const photo = resolveTrekPhoto(photoId);
|
||||
if (!photo || photo.provider !== 'local' || !photo.file_path) return null;
|
||||
return path.join(__dirname, '../../../uploads', photo.file_path);
|
||||
}
|
||||
Reference in New Issue
Block a user