refactor(memories): generalize photo providers and decouple from immich

This commit is contained in:
Marek Maslowski
2026-04-03 02:54:35 +02:00
parent 5be2e9b268
commit 8e9f8784dc
13 changed files with 1076 additions and 226 deletions
+94 -74
View File
@@ -62,13 +62,41 @@ router.put('/settings', authenticate, async (req: Request, res: Response) => {
router.get('/status', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json(await getConnectionStatus(authReq.user.id));
const creds = getImmichCredentials(authReq.user.id);
if (!creds) {
return res.json({ connected: false, error: 'Not configured' });
}
try {
const resp = await fetch(`${creds.immich_url}/api/users/me`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` });
const data = await resp.json() as { name?: string; email?: string };
res.json({ connected: true, user: { name: data.name, email: data.email } });
} catch (err: unknown) {
res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' });
}
});
// Test connection with saved credentials
router.post('/test', authenticate, async (req: Request, res: Response) => {
const { immich_url, immich_api_key } = req.body;
if (!immich_url || !immich_api_key) return res.json({ connected: false, error: 'URL and API key required' });
res.json(await testConnection(immich_url, immich_api_key));
const authReq = req as AuthRequest;
const creds = getImmichCredentials(authReq.user.id);
if (!creds) return res.json({ connected: false, error: 'No credentials configured' });
const ssrf = await checkSsrf(creds.immich_url);
if (!ssrf.allowed) return res.json({ connected: false, error: ssrf.error ?? 'Invalid Immich URL' });
try {
const resp = await fetch(`${creds.immich_url}/api/users/me`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` });
const data = await resp.json() as { name?: string; email?: string };
res.json({ connected: true, user: { name: data.name, email: data.email } });
} catch (err: unknown) {
res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' });
}
});
// ── Browse Immich Library (for photo picker) ───────────────────────────────
@@ -88,55 +116,6 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
res.json({ assets: result.assets });
});
// ── Trip Photos (selected by user) ────────────────────────────────────────
router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
res.json({ photos: listTripPhotos(tripId, authReq.user.id) });
});
router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const { asset_ids, shared = true } = req.body;
if (!Array.isArray(asset_ids) || asset_ids.length === 0) {
return res.status(400).json({ error: 'asset_ids required' });
}
const added = addTripPhotos(tripId, authReq.user.id, asset_ids, shared);
res.json({ success: true, added });
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
// Notify trip members about shared photos
if (shared && added > 0) {
import('../services/notifications').then(({ notifyTripMembers }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, count: String(added) }).catch(() => {});
});
}
});
router.delete('/trips/:tripId/photos/:assetId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
removeTripPhoto(req.params.tripId, authReq.user.id, req.params.assetId);
res.json({ success: true });
broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
});
router.put('/trips/:tripId/photos/:assetId/sharing', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const { shared } = req.body;
togglePhotoSharing(req.params.tripId, authReq.user.id, req.params.assetId, shared);
res.json({ success: true });
broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
});
// ── Asset Details ──────────────────────────────────────────────────────────
router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Response) => {
@@ -176,15 +155,27 @@ router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res:
router.get('/albums', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = await listAlbums(authReq.user.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ albums: result.albums });
});
const creds = getImmichCredentials(authReq.user.id);
if (!creds) return res.status(400).json({ error: 'Immich not configured' });
router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
res.json({ links: listAlbumLinks(req.params.tripId) });
try {
const resp = await fetch(`${creds.immich_url}/api/albums`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch albums' });
const albums = (await resp.json() as any[]).map((a: any) => ({
id: a.id,
albumName: a.albumName,
assetCount: a.assetCount || 0,
startDate: a.startDate,
endDate: a.endDate,
albumThumbnailAssetId: a.albumThumbnailAssetId,
}));
res.json({ albums });
} catch {
res.status(502).json({ error: 'Could not reach Immich' });
}
});
router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => {
@@ -193,25 +184,54 @@ router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const { album_id, album_name } = req.body;
if (!album_id) return res.status(400).json({ error: 'album_id required' });
const result = createAlbumLink(tripId, authReq.user.id, album_id, album_name);
if (!result.success) return res.status(400).json({ error: result.error });
res.json({ success: true });
});
router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
deleteAlbumLink(req.params.linkId, req.params.tripId, authReq.user.id);
res.json({ success: true });
try {
db.prepare(
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
).run(tripId, authReq.user.id, 'immich', album_id, album_name || '');
res.json({ success: true });
} catch (err: any) {
res.status(400).json({ error: 'Album already linked' });
}
});
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, linkId } = req.params;
const result = await syncAlbumAssets(tripId, linkId, authReq.user.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ success: true, added: result.added, total: result.total });
if (result.added! > 0) {
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
const link = db.prepare("SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = 'immich'")
.get(linkId, tripId, authReq.user.id) as any;
if (!link) return res.status(404).json({ error: 'Album link not found' });
const creds = getImmichCredentials(authReq.user.id);
if (!creds) return res.status(400).json({ error: 'Immich not configured' });
try {
const resp = await fetch(`${creds.immich_url}/api/albums/${link.album_id}`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch album' });
const albumData = await resp.json() as { assets?: any[] };
const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE');
const insert = db.prepare(
"INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'immich', 1)"
);
let added = 0;
for (const asset of assets) {
const r = insert.run(tripId, authReq.user.id, asset.id);
if (r.changes > 0) added++;
}
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
res.json({ success: true, added, total: assets.length });
if (added > 0) {
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
}
} catch {
res.status(502).json({ error: 'Could not reach Immich' });
}
});