feat(synology): merge personal, shared-out, and shared-with-me albums in listSynologyAlbums

Fire all three Synology album sources in parallel via Promise.allSettled so a
permissions failure on one source (e.g. SYNO.Foto.Sharing.Misc) never blocks
personal album display. Deduplicate by album id (last-write-wins), propagate
passphrase from shared/shared-with-me entries, and return the merged list sorted
by albumName. Extends AlbumsList type to carry optional passphrase.

Adds SYNO-027/028/029 integration tests; updates SYNO-060/061/081 to match
the new multi-source call pattern.
This commit is contained in:
jubnl
2026-04-16 19:56:10 +02:00
parent 465b78411a
commit 8a6d1b2aaf
3 changed files with 203 additions and 22 deletions
+19 -1
View File
@@ -3,6 +3,7 @@ import { Readable } from 'node:stream';
import { Response } from 'express';
import { canAccessTrip, db } from "../../db/database";
import { safeFetch, SsrfBlockedError } from '../../utils/ssrfGuard';
import { decrypt_api_key } from '../apiKeyCrypto';
// helpers for handling return types
@@ -59,7 +60,7 @@ export type SyncAlbumResult = {
export type AlbumsList = {
albums: Array<{ id: string; albumName: string; assetCount: number }>
albums: Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>
};
export type Asset = {
@@ -230,6 +231,23 @@ export function getAlbumIdFromLink(tripId: string, linkId: string, userId: numbe
}
}
export function getAlbumLinkForSync(tripId: string, linkId: string, userId: number): ServiceResult<{ albumId: string; passphrase?: string }> {
const access = canAccessTrip(tripId, userId);
if (!access) return fail('Trip not found or access denied', 404);
try {
const row = db.prepare('SELECT album_id, passphrase FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
.get(linkId, tripId, userId) as { album_id: string; passphrase: string | null } | null;
if (!row) return fail('Album link not found', 404);
const decrypted = row.passphrase ? decrypt_api_key(row.passphrase) ?? undefined : undefined;
return success({ albumId: row.album_id, passphrase: decrypted || undefined });
} catch {
return fail('Failed to retrieve album link', 500);
}
}
export function updateSyncTimeForAlbumLink(linkId: string): void {
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
}
+37 -13
View File
@@ -433,21 +433,45 @@ export async function testSynologyConnection(userId: number, synologyUrl: string
}
export async function listSynologyAlbums(userId: number): Promise<ServiceResult<AlbumsList>> {
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
api: 'SYNO.Foto.Browse.Album',
method: 'list',
version: 4,
offset: 0,
limit: 100,
});
if (!result.success) return result as ServiceResult<AlbumsList>;
const [personal, shared, sharedWithMe] = await Promise.allSettled([
_requestSynologyApi<{ list: any[] }>(userId, {
api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4,
offset: 0, limit: 100,
}),
_requestSynologyApi<{ list: any[] }>(userId, {
api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4,
offset: 0, limit: 100, category: 'shared',
}),
_requestSynologyApi<{ list: any[] }>(userId, {
api: 'SYNO.Foto.Sharing.Misc', method: 'list_shared_with_me_album', version: 1,
offset: 0, limit: 100, additional: ['thumbnail', 'sharing_info'],
}),
]);
const albums = (result.data.list || []).map((album: any) => ({
id: String(album.id),
albumName: album.name || '',
assetCount: album.item_count || 0,
}));
const map = new Map<string, { id: string; albumName: string; assetCount: number; passphrase?: string }>();
const addAlbums = (result: PromiseSettledResult<ServiceResult<{ list: any[] }>>, extractPassphrase: (a: any) => string | undefined) => {
if (result.status === 'rejected') return;
if (!result.value.success) {
console.warn('[Synology] album list partial failure:', (result.value as any).error?.message);
return;
}
for (const album of (result.value as any).data?.list ?? []) {
const id = String(album.id);
const passphrase = extractPassphrase(album);
map.set(id, { id, albumName: album.name || '', assetCount: album.item_count || 0, passphrase });
}
};
addAlbums(personal, () => undefined);
addAlbums(shared, (a) => a.passphrase || undefined);
addAlbums(sharedWithMe, (a) => a.passphrase || a.sharing_info?.passphrase || undefined);
if (map.size === 0 && personal.status === 'fulfilled' && !personal.value.success) {
return personal.value as ServiceResult<AlbumsList>;
}
const albums = [...map.values()].sort((a, b) => a.albumName.localeCompare(b.albumName));
return success({ albums });
}