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,
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 });

View File

@@ -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 };
}

View File

@@ -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),
});
}

View File

@@ -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 }> {