adding helper functions for syncing albums

This commit is contained in:
Marek Maslowski
2026-04-04 12:22:22 +02:00
parent 1305a07502
commit 68f0d399ca
4 changed files with 68 additions and 52 deletions

View File

@@ -11,6 +11,7 @@ import {
removeTripPhoto, removeTripPhoto,
setTripPhotoSharing, setTripPhotoSharing,
notifySharedTripPhotos, notifySharedTripPhotos,
normalizeSelections,
} from '../services/memoriesService'; } from '../services/memoriesService';
const router = express.Router(); 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) => { router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const { tripId } = req.params; 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( const result = addTripPhotos(
tripId, tripId,
authReq.user.id, authReq.user.id,
req.body?.shared, shared,
req.body?.selections, selections,
req.body?.provider,
req.body?.asset_ids,
); );
if ('error' in result) return res.status(result.status).json({ error: result.error }); if ('error' in result) return res.status(result.status).json({ error: result.error });

View File

@@ -2,6 +2,7 @@ import { db } from '../db/database';
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto'; import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
import { checkSsrf } from '../utils/ssrfGuard'; import { checkSsrf } from '../utils/ssrfGuard';
import { writeAudit } from './auditLog'; import { writeAudit } from './auditLog';
import { addTripPhotos, getAlbumIdFromLink, Selection, updateSyncTimeForAlbumLink } from './memoriesService';
// ── Credentials ──────────────────────────────────────────────────────────── // ── Credentials ────────────────────────────────────────────────────────────
@@ -313,15 +314,14 @@ export async function syncAlbumAssets(
linkId: string, linkId: string,
userId: number userId: number
): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: 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 = ?') const albumId = getAlbumIdFromLink(tripId, linkId, userId);
.get(linkId, tripId, userId, 'immich') as any; if (!albumId) return { error: 'Album link not found', status: 404 };
if (!link) return { error: 'Album link not found', status: 404 };
const creds = getImmichCredentials(userId); const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 }; if (!creds) return { error: 'Immich not configured', status: 400 };
try { 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' }, headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000), signal: AbortSignal.timeout(15000),
}); });
@@ -329,16 +329,17 @@ export async function syncAlbumAssets(
const albumData = await resp.json() as { assets?: any[] }; const albumData = await resp.json() as { assets?: any[] };
const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE'); 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)"); const selection: Selection = {
let added = 0; provider: 'immich',
for (const asset of assets) { asset_ids: assets.map((a: any) => a.id),
const r = insert.run(tripId, userId, asset.id); };
if (r.changes > 0) added++;
}
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 { } catch {
return { error: 'Could not reach Immich', status: 502 }; return { error: 'Could not reach Immich', status: 502 };
} }

View File

@@ -38,12 +38,14 @@ function accessDeniedIfMissing(tripId: string, userId: number): ServiceError | n
return null; return null;
} }
type Selection = { export type Selection = {
provider: string; 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 selectionsFromBody = Array.isArray(selectionsRaw) ? selectionsRaw : null;
const provider = String(providerRaw || '').toLowerCase(); const provider = String(providerRaw || '').toLowerCase();
@@ -145,40 +147,54 @@ export function removeAlbumLink(tripId: string, linkId: string, userId: number):
return { success: true }; 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( export function addTripPhotos(
tripId: string, tripId: string,
userId: number, userId: number,
sharedRaw: unknown, shared: boolean,
selectionsRaw: unknown, selections: Selection[],
providerRaw: unknown,
assetIdsRaw: unknown,
): { success: true; added: number; shared: boolean } | ServiceError { ): { success: true; added: number; shared: boolean } | ServiceError {
const denied = accessDeniedIfMissing(tripId, userId); const denied = accessDeniedIfMissing(tripId, userId);
if (denied) return denied; if (denied) return denied;
const shared = sharedRaw === undefined ? true : !!sharedRaw;
const selections = normalizeSelections(selectionsRaw, providerRaw, assetIdsRaw);
if (selections.length === 0) { 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; let added = 0;
for (const selection of selections) { for (const selection of selections) {
for (const raw of selection.asset_ids) { for (const raw of selection.asset_ids) {
const assetId = String(raw || '').trim(); const assetId = String(raw || '').trim();
if (!assetId) continue; if (!assetId) continue;
const result = insert.run(tripId, userId, assetId, selection.provider, shared ? 1 : 0); if (addTripPhoto(tripId, userId, selection.provider, assetId, shared)) {
if (result.changes > 0) added++; added++;
}
} }
} }
return { success: true, added, shared }; 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( export function removeTripPhoto(
tripId: string, tripId: string,
userId: number, userId: number,
@@ -256,3 +272,5 @@ export async function notifySharedTripPhotos(
count: String(added), count: String(added),
}); });
} }

View File

@@ -1,9 +1,11 @@
import { Readable } from 'node:stream'; import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises'; 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 { db } from '../db/database';
import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto'; import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto';
import { checkSsrf } from '../utils/ssrfGuard'; 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_API_TIMEOUT_MS = 30000;
const SYNOLOGY_PROVIDER = 'synologyphotos'; 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 }> { 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 = ?`) const albumId = getAlbumIdFromLink(tripId, linkId, userId);
.get(linkId, tripId, userId, SYNOLOGY_PROVIDER) as { album_id?: string | number } | undefined; if (!albumId) {
if (!link) {
throw new SynologyServiceError(404, 'Album link not found'); 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', api: 'SYNO.Foto.Browse.Item',
method: 'list', method: 'list',
version: 1, version: 1,
album_id: Number(link.album_id), album_id: Number(albumId),
offset, offset,
limit: pageSize, limit: pageSize,
additional: ['thumbnail'], additional: ['thumbnail'],
@@ -433,22 +433,17 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link
offset += pageSize; offset += pageSize;
} }
const insert = db.prepare( const selection: Selection = {
"INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'synologyphotos', 1)" provider: SYNOLOGY_PROVIDER,
); asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id),
};
let added = 0; const addResult = addTripPhotos(tripId, userId, true, [selection]);
for (const item of allItems) { if ('error' in addResult) throw new SynologyServiceError(addResult.status, addResult.error);
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++;
}
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 }> { export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise<{ assets: SynologyPhotoInfo[]; total: number; hasMore: boolean }> {