diff --git a/server/src/routes/memories.ts b/server/src/routes/memories.ts index 5a6f1cf..1b1fac3 100644 --- a/server/src/routes/memories.ts +++ b/server/src/routes/memories.ts @@ -11,6 +11,7 @@ import { removeTripPhoto, setTripPhotoSharing, notifySharedTripPhotos, + normalizeSelections, } from '../services/memoriesService'; const router = express.Router(); @@ -44,13 +45,14 @@ router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; + + const selections = normalizeSelections(req.body?.selections, req.body?.provider, req.body?.asset_ids); + const shared = req.body?.shared === undefined ? true : !!req.body?.shared; const result = addTripPhotos( tripId, authReq.user.id, - req.body?.shared, - req.body?.selections, - req.body?.provider, - req.body?.asset_ids, + shared, + selections, ); if ('error' in result) return res.status(result.status).json({ error: result.error }); diff --git a/server/src/services/immichService.ts b/server/src/services/immichService.ts index baef3bb..4550b2a 100644 --- a/server/src/services/immichService.ts +++ b/server/src/services/immichService.ts @@ -2,6 +2,7 @@ import { db } from '../db/database'; import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto'; import { checkSsrf } from '../utils/ssrfGuard'; import { writeAudit } from './auditLog'; +import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService'; // ── Credentials ──────────────────────────────────────────────────────────── @@ -313,15 +314,14 @@ export async function syncAlbumAssets( linkId: string, userId: number ): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> { - const link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = ?') - .get(linkId, tripId, userId, 'immich') as any; - if (!link) return { error: 'Album link not found', status: 404 }; + const albumId = getAlbumIdFromLink(tripId, linkId, userId); + if (!albumId) return { error: 'Album link not found', status: 404 }; const creds = getImmichCredentials(userId); if (!creds) return { error: 'Immich not configured', status: 400 }; try { - const resp = await fetch(`${creds.immich_url}/api/albums/${link.album_id}`, { + const resp = await fetch(`${creds.immich_url}/api/albums/${albumId}`, { headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, signal: AbortSignal.timeout(15000), }); @@ -329,16 +329,17 @@ export async function syncAlbumAssets( 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, userId, asset.id); - if (r.changes > 0) added++; - } + const selection: Selection = { + provider: 'immich', + asset_ids: assets.map((a: any) => a.id), + }; - db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); + const addResult = addTripPhotos(tripId, userId, true, [selection]); + if ('error' in addResult) return { error: addResult.error, status: addResult.status }; - return { success: true, added, total: assets.length }; + updateSyncTimeForAlbumLink(linkId); + + return { success: true, added: addResult.added, total: assets.length }; } catch { return { error: 'Could not reach Immich', status: 502 }; } diff --git a/server/src/services/memoriesService.ts b/server/src/services/memoriesService.ts index 3886c8d..0470559 100644 --- a/server/src/services/memoriesService.ts +++ b/server/src/services/memoriesService.ts @@ -38,12 +38,14 @@ function accessDeniedIfMissing(tripId: string, userId: number): ServiceError | n return null; } -type Selection = { +export type Selection = { provider: string; - asset_ids: unknown[]; + asset_ids: string[]; }; -function normalizeSelections(selectionsRaw: unknown, providerRaw: unknown, assetIdsRaw: unknown): Selection[] { + +//fallback for old clients that don't send selections as an array of provider/asset_id groups +export function normalizeSelections(selectionsRaw: unknown, providerRaw: unknown, assetIdsRaw: unknown): Selection[] { const selectionsFromBody = Array.isArray(selectionsRaw) ? selectionsRaw : null; const provider = String(providerRaw || '').toLowerCase(); @@ -145,40 +147,54 @@ export function removeAlbumLink(tripId: string, linkId: string, userId: number): return { success: true }; } +function addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean): boolean { + const result = db.prepare( + 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' + ).run(tripId, userId, assetId, provider, shared ? 1 : 0); + return result.changes > 0; +} + export function addTripPhotos( tripId: string, userId: number, - sharedRaw: unknown, - selectionsRaw: unknown, - providerRaw: unknown, - assetIdsRaw: unknown, + shared: boolean, + selections: Selection[], ): { success: true; added: number; shared: boolean } | ServiceError { const denied = accessDeniedIfMissing(tripId, userId); if (denied) return denied; - const shared = sharedRaw === undefined ? true : !!sharedRaw; - const selections = normalizeSelections(selectionsRaw, providerRaw, assetIdsRaw); if (selections.length === 0) { - return { error: 'selections required', status: 400 }; + return { error: 'No photos selected', status: 400 }; } - const insert = db.prepare( - 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' - ); - let added = 0; for (const selection of selections) { for (const raw of selection.asset_ids) { const assetId = String(raw || '').trim(); if (!assetId) continue; - const result = insert.run(tripId, userId, assetId, selection.provider, shared ? 1 : 0); - if (result.changes > 0) added++; + if (addTripPhoto(tripId, userId, selection.provider, assetId, shared)) { + added++; + } } } - return { success: true, added, shared }; } +export function getAlbumIdFromLink(tripId: string, linkId: string, userId: number): string { + const denied = accessDeniedIfMissing(tripId, userId); + if (denied) return null; + + const { album_id } = db.prepare('SELECT album_id FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?').get(linkId, tripId, userId) as { album_id: string } | null; + + return album_id; +} + +export function updateSyncTimeForAlbumLink(linkId: string): void { + db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); +} + + + export function removeTripPhoto( tripId: string, userId: number, @@ -256,3 +272,5 @@ export async function notifySharedTripPhotos( count: String(added), }); } + + diff --git a/server/src/services/synologyService.ts b/server/src/services/synologyService.ts index 0cb3308..7a01c1c 100644 --- a/server/src/services/synologyService.ts +++ b/server/src/services/synologyService.ts @@ -1,9 +1,11 @@ import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; -import { Request, Response as ExpressResponse } from 'express'; +import { Response as ExpressResponse } from 'express'; import { db } from '../db/database'; import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto'; import { checkSsrf } from '../utils/ssrfGuard'; +import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService'; +import { error } from 'node:console'; const SYNOLOGY_API_TIMEOUT_MS = 30000; const SYNOLOGY_PROVIDER = 'synologyphotos'; @@ -401,10 +403,8 @@ export async function listSynologyAlbums(userId: number): Promise<{ albums: Arra export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise<{ added: number; total: number }> { - const link = db.prepare(`SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = ?`) - .get(linkId, tripId, userId, SYNOLOGY_PROVIDER) as { album_id?: string | number } | undefined; - - if (!link) { + const albumId = getAlbumIdFromLink(tripId, linkId, userId); + if (!albumId) { throw new SynologyServiceError(404, 'Album link not found'); } @@ -417,7 +417,7 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, - album_id: Number(link.album_id), + album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'], @@ -433,22 +433,17 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link offset += pageSize; } - const insert = db.prepare( - "INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'synologyphotos', 1)" - ); + const selection: Selection = { + provider: SYNOLOGY_PROVIDER, + asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id), + }; - let added = 0; - for (const item of allItems) { - const transformed = normalizeSynologyPhotoInfo(item); - const assetId = String(transformed?.id || '').trim(); - if (!assetId) continue; - const result = insert.run(tripId, userId, assetId); - if (result.changes > 0) added++; - } + const addResult = addTripPhotos(tripId, userId, true, [selection]); + if ('error' in addResult) throw new SynologyServiceError(addResult.status, addResult.error); - db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); + updateSyncTimeForAlbumLink(linkId); - return { added, total: allItems.length }; + return { added: addResult.added, total: allItems.length }; } export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise<{ assets: SynologyPhotoInfo[]; total: number; hasMore: boolean }> {