adding helper functions for syncing albums
This commit is contained in:
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 }> {
|
||||||
|
|||||||
Reference in New Issue
Block a user