import axios, { AxiosError, type AxiosInstance } from 'axios'; import { browser } from '$app/environment'; import { goto } from '$app/navigation'; import { adoptSession, clearSession, session, toOriginalsPath, toUserPath, userBasePath } from '$lib/stores/session.svelte'; import { primaryFile } from '$lib/types/photoprism'; import type { PpClientConfig, PpPhoto, PpRole, PpSessionResponse, PpUser } from '$lib/types/photoprism'; /** * Axios client pre-configured for PhotoPrism's /api/v1. Same-origin in dev * (vite proxies /api → photoprism:2342), same-origin in prod (Caddy fronts * both the SPA and PhotoPrism on one hostname). */ const http: AxiosInstance = axios.create({ baseURL: '/api/v1', headers: { 'Content-Type': 'application/json' } }); /** Axios instance for sidecar endpoints — no baseURL prefix so paths * like `/api/sidecar/timeline` resolve directly through Caddy's * `/api/sidecar/*` rule instead of becoming `/api/v1/api/sidecar/*`. */ const sidecar: AxiosInstance = axios.create({ baseURL: '', headers: { 'Content-Type': 'application/json' } }); http.interceptors.request.use((config) => { if (session.accessToken) { config.headers = config.headers ?? {}; (config.headers as Record)['X-Auth-Token'] = session.accessToken; } return config; }); sidecar.interceptors.request.use((config) => { if (session.accessToken) { config.headers = config.headers ?? {}; (config.headers as Record)['X-Auth-Token'] = session.accessToken; } return config; }); http.interceptors.response.use( (r) => r, (err: AxiosError) => { if (err.response?.status === 401 && browser) { clearSession(); // Avoid redirect loops if the request was a login probe. const url = err.config?.url ?? ''; if (!url.endsWith('/session')) { void goto('/login', { replaceState: true }); } } return Promise.reject(err); } ); sidecar.interceptors.response.use( (r) => r, (err: AxiosError) => { if (err.response?.status === 401 && browser) { clearSession(); const url = err.config?.url ?? ''; if (!url.endsWith('/session')) { void goto('/login', { replaceState: true }); } } return Promise.reject(err); } ); // ── Auth ───────────────────────────────────────────────────────────────────── export async function login(username: string, password: string): Promise { const { data } = await http.post('/session', { username, password }); adoptSession(data); return data; } export async function logout(): Promise { if (session.id) { try { await http.delete(`/session/${session.id}`); } catch { // Best-effort: even if PhotoPrism rejects, drop the client state. } } clearSession(); } export async function fetchSession(id: string): Promise { const { data } = await http.get(`/session/${id}`); return data; } /** * After OIDC completes, PhotoPrism returns an HTML page that writes the * issued session into `localStorage` under the namespaced keys * `pp::session.{id,token,user,provider}` and then runs * `window.location.href = "/library/login"`. With our Caddy bouncing * `/library/*` back to `/`, the browser lands on the SvelteKit root with * those entries already in localStorage but with no PhotoPrism cookies set * — so we read them back to adopt the OIDC-issued session. * * Returns the adopted session, or null when nothing is waiting in storage * (caller treats null as "stay on /login"). */ export async function bootstrapSessionFromPhotoPrism(): Promise { if (!browser) return null; // PhotoPrism's storageNamespace is per-instance (build-time hash); fetch // it via /api/v1/config so we resolve the right key prefix. let namespace: string | undefined; try { const cfg = await getConfig(); namespace = cfg.storageNamespace; } catch { return null; } if (!namespace) return null; const prefix = `pp:${namespace}:`; const sid = localStorage.getItem(prefix + 'session.id'); const token = localStorage.getItem(prefix + 'session.token'); if (!sid || !token) return null; // Prime the http client so the X-Auth-Token interceptor fires for the // session lookup below. session.accessToken = token; try { const resp = await fetchSession(sid); adoptSession(resp); // adoptSession persists into our own storage key (`pp_session`); // PhotoPrism's `pp::session.*` entries are one-shot delivery, // so clear them now to avoid stale state on logout. for (const k of [ 'session.id', 'session.token', 'session.user', 'session.provider', 'session.error' ]) { localStorage.removeItem(prefix + k); } return resp; } catch { session.accessToken = null; return null; } } export async function getConfig(): Promise { const { data } = await http.get('/config'); return data; } // ── Photos ─────────────────────────────────────────────────────────────────── export interface ListPhotosParams { q?: string; count?: number; offset?: number; order?: 'newest' | 'oldest' | 'added' | 'edited' | 'name'; merged?: boolean; } export async function listPhotos(params: ListPhotosParams = {}): Promise { const { data } = await sidecar.get('/api/sidecar/timeline', { params: { count: 60, offset: 0, order: 'newest', merged: true, ...params } }); return data; } /** * Resolve photos for an explicit set of UIDs. Used by the Colors / Ratings * facets, whose member set comes from the mule-sidecar marks store and is NOT * bounded to the newest N photos — a marked photo anywhere in the library must * resolve. Fetches per-UID (concurrency-bounded) via the same `/photos/:uid` * endpoint the metadata panel uses, so it can't drift from PhotoPrism's search * DSL. Missing UIDs (deleted since marked) are skipped. */ export async function listPhotosByUids(uids: string[]): Promise { if (uids.length === 0) return []; const out: PpPhoto[] = []; const concurrency = 8; for (let i = 0; i < uids.length; i += concurrency) { const slice = uids.slice(i, i + concurrency); const fetched = await Promise.all(slice.map((uid) => getPhoto(uid).catch(() => null))); for (const p of fetched) if (p) out.push(p); } return out; } /** * Fetch a page of photos *anchored at* a specific TakenAt — `before` * older photos preceded by `after` newer ones, merged newest-first. * Uses PhotoPrism's `before:`/`after:` DSL clauses so the anchor's * neighbours can be loaded without paging through the whole filter. * * Used by the timeline's deep-link focus mode: an in-app navigation * stashes `{uid, takenAt}`, the timeline calls this with the anchor's * date for page 0, and the target photo lands ~`afterCount` tiles * down with `~beforeCount` older neighbours below it. * * Subsequent infinite-scroll pages use plain `listPhotos` with the * standard offset cursor — the anchor mode only matters for page 0. */ export interface AroundParams { /** Base DSL filter (e.g. `path:"2024/02*"`). Anchor clauses are appended. */ q?: string; /** Anchor's TakenAt as ISO string (e.g. `'2026-01-31T18:26:40Z'`). */ takenAt: string; /** How many photos newer than the anchor to fetch. */ afterCount?: number; /** How many photos at-or-older-than the anchor to fetch (includes the anchor itself). */ beforeCount?: number; merged?: boolean; } export async function listPhotosAround(p: AroundParams): Promise { const afterCount = p.afterCount ?? 30; const beforeCount = p.beforeCount ?? 90; const baseQ = p.q?.trim() ?? ''; // PhotoPrism's `before:`/`after:` operators take ISO timestamps. // `+1s` / `-1s` makes the bounds inclusive of the anchor itself in // the `before:` half (so the target tile is in the merged result). const anchorDate = new Date(p.takenAt); if (Number.isNaN(anchorDate.getTime())) { // Date parse failed — fall back to a plain newest-first page. return listPhotos({ q: baseQ, count: afterCount + beforeCount, order: 'newest', merged: p.merged }); } // PhotoPrism's DSL accepts date-only bounds (`YYYY-MM-DD`). Round // up/down by a day so the anchor's own day is included in the // `before:` half — the bounds are inclusive day boundaries, so a // timestamp-precision anchor lands inside the `[beforeBound, // afterBound]` window. function ymd(d: Date): string { const y = d.getUTCFullYear(); const m = String(d.getUTCMonth() + 1).padStart(2, '0'); const dd = String(d.getUTCDate()).padStart(2, '0'); return `${y}-${m}-${dd}`; } const dayMs = 86_400_000; const beforeBound = ymd(new Date(anchorDate.getTime() + dayMs)); const afterBound = ymd(new Date(anchorDate.getTime() - dayMs)); const newerQ = `${baseQ} after:${afterBound}`.trim(); const olderQ = `${baseQ} before:${beforeBound}`.trim(); const [newerOldestFirst, older] = await Promise.all([ listPhotos({ q: newerQ, count: afterCount, order: 'oldest', merged: p.merged ?? true }), listPhotos({ q: olderQ, count: beforeCount, order: 'newest', merged: p.merged ?? true }) ]); // `newerOldestFirst` is oldest→newest; reverse so it reads newest-first // to match the standard timeline order, then concat the older window. // Dedupe by UID in case the anchor itself shows up in both halves. const merged: PpPhoto[] = []; const seen = new Set(); for (const p of newerOldestFirst.slice().reverse()) { if (!seen.has(p.UID)) { merged.push(p); seen.add(p.UID); } } for (const p of older) { if (!seen.has(p.UID)) { merged.push(p); seen.add(p.UID); } } return merged; } /** * Count photos matching a DSL query, scoped to whatever the caller's * session ACL allows. PhotoPrism doesn't expose a dedicated "count * only" endpoint, but the `X-Count` header on `/photos` returns the * page size — which equals the total when `count` is set above the * library size. 10000 is generously above the realistic per-user * library and well under PhotoPrism's server-side ceiling, so a single * round-trip yields the true total without paginating. * * Used by the LeftSidebar to render bucket badges that reflect what * the signed-in user actually sees, not the global library aggregate * exposed by `/config.count`. */ export async function countPhotos(q: string, opts: { merged?: boolean } = {}): Promise { const merged = opts.merged ?? false; const resp = await sidecar.get('/api/sidecar/timeline', { params: { count: 10000, offset: 0, merged, q } }); // PhotoPrism's `X-Count` header counts SQL file rows (one row per // `Files[]` entry), regardless of `merged`. With `merged: true` the // response body is one entry per logical photo — so the body length // is the canonical photo count when callers need to match what the // timeline displays (e.g. the LeftSidebar root badge vs `Cmd+A`). if (merged) return Array.isArray(resp.data) ? resp.data.length : 0; const header = resp.headers['x-count']; const n = typeof header === 'string' ? parseInt(header, 10) : NaN; return Number.isFinite(n) ? n : 0; } export async function getPhoto(uid: string): Promise { const { data } = await http.get(`/photos/${uid}`); return data; } /** * Patch payload accepted by `updatePhoto`. Top-level scalars merge in * place; `Details` is shallow-merged onto the existing Details object so * callers can patch a single Details field (Keywords, Subject, …) without * clobbering siblings. * * **PhotoPrism quirk**: nested `Details.*` only persists when the PUT * carries the FULL photo body — partial PUTs silently no-op for those * fields. `updatePhoto` handles the fetch/merge/PUT round-trip so callers * can stick to a partial shape. */ export interface UpdatePhotoBody { OriginalName?: string; Caption?: string; CaptionSrc?: 'manual' | ''; Archived?: boolean; /** TakenAt + TakenAtLocal + Year/Month/Day must move in lockstep — * use `buildTakenAtPatch` to assemble all five fields from one ISO. */ TakenAt?: string; TakenAtLocal?: string; TakenSrc?: 'manual' | ''; Year?: number; Month?: number; Day?: number; TimeZone?: string; Lat?: number; Lng?: number; Altitude?: number; Country?: string; CountrySrc?: 'manual' | ''; Details?: Partial; } /** True when `s` is a real calendar date in strict `YYYY-MM-DD` form. Rejects * shape mismatches AND out-of-range parts that `Date` would silently roll * over (e.g. `2026-02-30` → Mar 2). */ export function isValidISODate(s: string): boolean { if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false; const d = new Date(`${s}T00:00:00Z`); if (Number.isNaN(d.getTime())) return false; return d.toISOString().slice(0, 10) === s; } export function buildTakenAtPatch(iso: string): UpdatePhotoBody { const d = new Date(iso); if (Number.isNaN(d.getTime())) return {}; const utc = d.toISOString().replace(/\.\d+Z$/, 'Z'); return { TakenAt: utc, TakenAtLocal: utc, TakenSrc: 'manual', Year: d.getUTCFullYear(), Month: d.getUTCMonth() + 1, Day: d.getUTCDate() }; } /** * Merge a partial `UpdatePhotoBody` onto a full photo and PUT the result. * Required for `Details.*` because PhotoPrism rejects partial bodies for * nested fields. Top-level fields would work with a thinner body but we * unify on full-body PUT to keep the call sites simple. */ export async function updatePhoto(photo: PpPhoto, patch: UpdatePhotoBody): Promise { const merged: Record = { ...photo, ...patch }; if (patch.Details) { merged.Details = { ...(photo.Details ?? {}), ...patch.Details }; } const { data } = await http.put(`/photos/${photo.UID}`, merged); return data; } // ── Batch ──────────────────────────────────────────────────────────────────── interface BatchPhotosBody { photos: string[]; } function toBatchBody(uids: string[]): BatchPhotosBody { // PhotoPrism's batch endpoints expect a flat array of UIDs, not // `{UID: ...}` objects (verified against the bundled Vue client). return { photos: uids }; } export async function batchArchive(uids: string[]): Promise { await http.post('/batch/photos/archive', toBatchBody(uids)); } export async function batchRestore(uids: string[]): Promise { await http.post('/batch/photos/restore', toBatchBody(uids)); } /** * Permanently delete photos. PhotoPrism only accepts UIDs that are already * archived — calling on a live photo returns 4xx. Irreversible; no undo * counterpart. */ export async function batchDelete(uids: string[]): Promise { await http.post('/batch/photos/delete', toBatchBody(uids)); } /** * Approve a photo in the review pile. PhotoPrism's indexer leaves photos * with low quality scores in `review:true` purgatory; approving bumps the * score above the review threshold (Quality goes to 3+) so the photo * lands in the main timeline. No corresponding "unapprove" endpoint — the * review pile is one-way out. */ export async function approvePhoto(uid: string): Promise { await http.post(`/photos/${uid}/approve`); } // ── Stack file operations ─────────────────────────────────────────────────── // PhotoPrism's stacks pack multiple file variants (RAW + JPG + Live + …) into // a single Photo entity. The duplicate-resolution flow needs two ops, both // nested under the photo UID: // - setPrimary: pick which file is the canonical/cover for the stack. // - unstackFile: pull a file out of the stack so it becomes its own Photo // record (which can then be archived via batchArchive). PhotoPrism returns // the freshly-promoted parent photo body on success. // // PhotoPrism refuses to unstack auto-generated sidecar files (e.g. `.jpg` // companions next to a RAW) and live-photo pairs — both return 4xx/5xx. The // callers above must surface the failure rather than retry, hence the // passthrough error from the axios layer. export async function setPrimary(photoUid: string, fileUid: string): Promise { await http.post(`/photos/${photoUid}/files/${fileUid}/primary`); } export async function unstackFile(photoUid: string, fileUid: string): Promise { const { data } = await http.post(`/photos/${photoUid}/files/${fileUid}/unstack`); return data; } /** * Remove a non-primary file from a stack. PhotoPrism cascades the delete * through related variants in the same logical group (e.g. deleting one * file of a Live Photo HEIC+MOV pair removes the whole pair). The file is * NOT erased from disk; PhotoPrism renames the on-disk file with a hash * suffix to take it out of the indexer's path. The response is the * updated parent photo body. * * Used by the duplicate-resolution flow as the practical "discard rest" * primitive because `/unstack` returns 5xx for live-photo and sidecar * files (`only originals can be unstacked` / `Changes could not be saved`). */ export async function deleteFile(photoUid: string, fileUid: string): Promise { const { data } = await http.delete(`/photos/${photoUid}/files/${fileUid}`); return data; } // ── Folders ────────────────────────────────────────────────────────────────── export interface PpFolder { UID: string; Path: string; Root: string; Title: string; FileCount?: number; } /** * Recursive list of subfolders under originals/. `uncached=true` because * PhotoPrism's folder cache lags new folders by a noticeable interval and * mule-image's folder tree expects to surface mutations immediately. * * Scoped to the signed-in user's `BasePath` on the way out: server-absolute * `Path` values get rewritten to user-relative (e.g. `users/alice/2024/01` * → `2024/01`) so every downstream consumer (FolderTree, sidebar, heap * convert picker) sees folders relative to the user's root. The BasePath * row itself is dropped — the sidebar synthesises the root entry. When * BasePath is empty (today's admin default) this is a no-op. */ export async function listFolders(): Promise { const { data } = await sidecar.get<{ folders?: PpFolder[] }>( '/api/sidecar/folders', { params: { recursive: true, uncached: true, files: false } } ); const bp = userBasePath(); // Sidecar already filters by BasePath; the frontend still applies the // filter + path rewrite as a safety net for admin (bp="") and for any // folders that might have slipped through. const folders = data.folders ?? []; if (bp === '') return folders; return folders .filter((f) => f.Path === bp || f.Path.startsWith(bp + '/')) .map((f) => ({ ...f, Path: toUserPath(f.Path) })) .filter((f) => f.Path !== ''); } /** * Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders` * endpoint reports `FileCount: 0` even when populated, so the count has * to be derived from a `/photos?q=path:X` lookup per folder. * * We hand this off to the sidecar (`POST /api/sidecar/folders/counts`) * which fans out to PhotoPrism over loopback, dedupes by UID, and * returns a single `{path: count}` payload of <5 KB. The previous * client-side implementation issued one `/photos?count=1000` per folder * from the browser — on a library with 30 folders that's ≈30 MB of JSON * pulled across the wire on every cold sidebar mount. * * `path:X` is non-recursive in PhotoPrism's q-DSL: it matches direct * children only, so summing the per-path counts (no double-counting * from nested folders) is the right way to derive the root-folder * photo count. Capped at PhotoPrism's 1000-row ceiling; folders larger * than that under-report (pre-existing limitation, unchanged here). * * Returns a plain object keyed by the input paths to keep it JSON- * friendly for TanStack's structural sharing. */ export async function listFolderCounts(paths: string[]): Promise> { if (paths.length === 0) return {}; // The sidecar walks the real filesystem and queries PhotoPrism with // server-absolute paths, but callers hand us user-relative paths // (because that's what `listFolders` returns post-scoping). Translate // on the way out, then re-key the response back to user-relative on // the way in so callers' map keys line up with their input array. const serverPaths = paths.map((p) => toOriginalsPath(p)); const data = (await callSidecar('POST', '/folders/counts', { paths: serverPaths })) as Record< string, number >; const out: Record = {}; for (let i = 0; i < paths.length; i += 1) { out[paths[i]] = data[serverPaths[i]] ?? 0; } return out; } // ── Geo ────────────────────────────────────────────────────────────────────── export interface PpGeoFeature { type: 'Feature'; id: string; geometry: { type: 'Point'; coordinates: [number, number] }; properties: { UID: string; Hash: string; Title?: string; TakenAt?: string; FavId?: number; }; } export interface PpGeoCollection { type: 'FeatureCollection'; features: PpGeoFeature[]; bbox?: number[]; } export async function listGeo(q = ''): Promise { // PhotoPrism's `/geo` returns a GeoJSON FeatureCollection of every // matching geocoded photo. MapLibre's native clustering handles 50k+ // points without breaking a sweat (PhotoPrism upstream documents // 500k); we ask for a generous cap that covers realistic libraries. const { data } = await http.get('/geo', { params: { count: 50000, q: q || undefined } }); return data; } // ── Labels ─────────────────────────────────────────────────────────────────── export interface PpLabel { UID: string; Slug: string; CustomSlug?: string; Name: string; Priority?: number; Description?: string; PhotoCount?: number; Thumb?: string; } /** * User-set keyword aggregation. PhotoPrism's `/labels` endpoint only * surfaces classifier-derived labels; user-typed keywords live in * `Details.Keywords` (a flat comma-separated string) which is *not* * included in the `/photos` list response. The only path to it is the * single-photo endpoint, so we fetch the library top-end (capped at * PhotoPrism's 1000-row ceiling), fan out `getPhoto` calls in batches, * and aggregate. * * Slow but cached upstream via TanStack — keys under the `['photos', …]` * prefix so the existing photo-mutation invalidations cascade to it. */ export interface AggregatedKeyword { keyword: string; count: number; /** UID of an arbitrary photo carrying this keyword — used as the * thumbnail source so the tile is visually consistent with * classifier-label tiles. */ sampleUid: string; sampleHash: string; } /** * Photos carrying a non-empty user note. mule-image's "Note" is * PhotoPrism's `Caption` field (see RightSidebar's Note textarea), which * is a top-level scalar — present on the list response, so a single * round-trip is enough. */ export interface PhotoWithNote { photo: PpPhoto; note: string; } export async function listPhotosWithNotes(): Promise { // The sidecar pages PhotoPrism to completion server-side and returns only // captioned, BasePath-scoped photos — paging client-side would stop early // because each page is BasePath-filtered before we see it (a full upstream // page can arrive short), silently hiding notes past the first slice. const { data } = await sidecar.get('/api/sidecar/notes'); const out: PhotoWithNote[] = []; for (const p of data) { const note = p.Caption?.trim(); if (!note) continue; out.push({ photo: p, note }); } return out; } export async function aggregateKeywords(): Promise { const list = await listPhotos({ count: 1000, merged: true }); const buckets = new Map(); const concurrency = 8; for (let i = 0; i < list.length; i += concurrency) { const slice = list.slice(i, i + concurrency); const fulls = await Promise.all( slice.map((p) => getPhoto(p.UID).catch(() => null)) ); for (let j = 0; j < slice.length; j++) { const photo = slice[j]; const full = fulls[j]; if (!full) continue; const raw = full.Details?.Keywords ?? ''; if (!raw) continue; for (const kw of raw.split(',').map((k) => k.trim()).filter(Boolean)) { const bucket = buckets.get(kw); if (bucket) { bucket.count++; continue; } const hash = photo.Hash ?? primaryFile(photo).Hash; buckets.set(kw, { keyword: kw, count: 1, sampleUid: photo.UID, sampleHash: hash }); } } } return Array.from(buckets.values()).sort((a, b) => b.count - a.count); } async function hasPhotosMatching(q: string): Promise { const resp = await sidecar.get('/api/sidecar/timeline', { params: { count: 1, offset: 0, q } }); return Array.isArray(resp.data) && resp.data.length > 0; } async function filterByUserPhotos( items: T[], queryFor: (item: T) => string ): Promise { if (userBasePath() === '') return items; const CONCURRENCY = 8; const out: T[] = []; for (let i = 0; i < items.length; i += CONCURRENCY) { const batch = items.slice(i, i + CONCURRENCY); const checks = await Promise.all( batch.map(async (item) => ({ item, has: await hasPhotosMatching(queryFor(item)) })) ); for (const { item, has } of checks) { if (has) out.push(item); } } return out; } export async function listLabels(): Promise { // `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden // low-confidence classifier hits, manually-removed labels). They're // still attached to photos in the DB and the `label:` query // still resolves; without `all=true` the /labels endpoint filters // them out and the tags page silently shows only ~40% of the user's // real tag set. `count` bumped to 1000 so a moderately tagged library // returns the full list in one round-trip. // // Uses the sidecar proxy (/api/sidecar/labels) instead of PhotoPrism's // /api/v1/labels so PhotoCount reflects only photos under the user's // BasePath. The sidecar proxies the request through to PP then // post-filters each label's count. const { data } = await sidecar.get('/api/sidecar/labels', { params: { count: 1000, order: 'count', all: true, perPage: 1000 } }); // Sidecar already filters to the user's scope and sets correct counts + // thumbs in one DB query — no need to probe each label individually. return data; } // ── Subjects (people / face recognition) ──────────────────────────────────── // // PhotoPrism's face indexer clusters detected faces into Subjects, each with a // stable UID, a human-editable Name, and a slug. The DSL operator `person:` // filters photos to those carrying a marker assigned to that subject. export interface PpSubject { UID: string; Slug: string; Name: string; Favorite?: boolean; Private?: boolean; Excluded?: boolean; PhotoCount?: number; Thumb?: string; } export async function listSubjects(): Promise { const { data } = await http.get('/subjects', { params: { count: 1000, order: 'count' } }); return filterByUserPhotos(data ?? [], (s) => `person:${s.Slug}`); } export async function updateSubject(uid: string, patch: Partial): Promise { const { data } = await http.put(`/subjects/${uid}`, patch); return data; } export async function deleteSubject(uid: string): Promise { await http.delete(`/subjects/${uid}`); } // ── Albums = Heaps ─────────────────────────────────────────────────────────── export interface PpAlbum { UID: string; Slug?: string; Type: string; Title: string; Description?: string; PhotoCount?: number; CreatedAt?: string; UpdatedAt?: string; Thumb?: string; } export async function listHeaps(): Promise { const { data } = await http.get('/albums', { params: { type: 'album', count: 500, order: 'newest' } }); return data; } export async function getHeap(uid: string): Promise { const { data } = await http.get(`/albums/${uid}`); return data; } export async function createHeap(title: string): Promise { const { data } = await http.post('/albums', { Title: title, Type: 'album' }); return data; } export async function renameHeap(uid: string, title: string): Promise { const { data } = await http.put(`/albums/${uid}`, { Title: title }); return data; } export async function deleteHeap(uid: string): Promise { await http.delete(`/albums/${uid}`); } /** * PhotoPrism returns `{ code, message, album, photos: [uids in album], added: [delta] }`. * Surface `added` so callers can detect "200-but-nothing-happened" — PhotoPrism * silently skips UIDs that are missing from the index or already in the album, * which used to look like a successful add to the user. */ export interface AddToHeapResult { added: string[]; } export async function addToHeap(uid: string, photos: string[]): Promise { const { data } = await http.post<{ added?: string[] }>(`/albums/${uid}/photos`, { photos }); return { added: data.added ?? [] }; } export async function removeFromHeap(uid: string, photos: string[]): Promise { await http.delete(`/albums/${uid}/photos`, { data: { photos } }); } /** * Clone a heap. PhotoPrism has no native duplicate endpoint, so we fan out * three round-trips: read the source title, list its members via the q-DSL * (the same `album:` filter the timeline uses for the heap view), create * a new "X (copy)" album, then add every member to it. Matches mule-image's * backend `POST /heaps/{id}/duplicate` behaviour. */ export async function duplicateHeap(uid: string): Promise { const source = await getHeap(uid); const members = await listPhotos({ q: `album:${uid}`, count: 1000 }); const copy = await createHeap(`${source.Title} (copy)`); if (members.length > 0) { await addToHeap( copy.UID, members.map((p) => p.UID) ); } return copy; } /** * URL for PhotoPrism's album-as-zip download. The download token comes from * the session config and is what authenticates the GET — no auth header * needed, which is why the URL can be opened in a fresh window/tab. */ export function heapDownloadUrl(uid: string): string { const t = session.downloadToken ?? ''; return `/api/v1/albums/${uid}/dl?t=${encodeURIComponent(t)}`; } /** * Trigger a browser download by injecting a transient element and * clicking it. Matches the pattern from mule-image's `downloads.trigger`. * Uses target=_blank so PhotoPrism's zip response (which streams) doesn't * navigate the current page away. */ export function triggerDownload(url: string): void { const a = document.createElement('a'); a.href = url; a.rel = 'noopener'; a.target = '_blank'; document.body.appendChild(a); a.click(); a.remove(); } // ── mule-sidecar (Node prototype today, Go in M4) ──────────────────────────── /** * Rename the primary file of a photo on disk. PhotoPrism's `OriginalName` * field only updates the display name; this calls the mule-sidecar service * to issue an actual `os.Rename` under `originals/` and then trigger a * PhotoPrism reindex of the parent path. * * Network: same-origin via the dev proxy entry `/api/sidecar/*`. */ export interface RenameResult { ok: boolean; oldName: string; newName: string; oldRelPath: string; newRelPath: string; } async function callSidecar(method: string, urlPath: string, body?: unknown): Promise { const res = await fetch(`/api/sidecar${urlPath}`, { method, headers: { 'Content-Type': 'application/json', 'X-Auth-Token': session.accessToken ?? '' }, body: body === undefined ? undefined : JSON.stringify(body) }); const data = await res.json().catch(() => ({})); if (!res.ok) { const err = (data as { error?: string }).error ?? `HTTP ${res.status}`; throw new Error(err); } return data; } export async function createFolder(relPath: string): Promise<{ path: string }> { return callSidecar('POST', '/folders', { path: relPath }) as Promise<{ path: string }>; } export async function renameFolder( relPath: string, newName: string ): Promise<{ oldPath: string; newPath: string }> { return callSidecar('POST', `/folders/${encodeURIComponent(relPath)}/rename`, { newName }) as Promise<{ oldPath: string; newPath: string }>; } export async function deleteFolder(relPath: string): Promise<{ path: string }> { return callSidecar('DELETE', `/folders/${encodeURIComponent(relPath)}`) as Promise<{ path: string; }>; } // ── Cross-folder duplicate detection (sidecar-driven) ─────────────────────── // PhotoPrism silently drops byte-identical files at index time, so duplicates // across folders never enter its DB. The sidecar walks the originals tree, // pre-filters by size, sha1s the survivors, and returns the hash-collision // groups. Resolution moves the unwanted copies into a `.duplicates/` // quarantine folder PhotoPrism's indexer ignores. export interface DupFileEntry { path: string; size: number; } export interface CrossFolderDuplicateGroup { hash: string; size: number; /** Path of the file PhotoPrism currently has indexed for this hash, * or null if none (the rare case of every copy being dropped). The * UI uses this to default the "keep" pick. */ indexedPath: string | null; files: DupFileEntry[]; } export interface CrossFolderScanResult { groups: CrossFolderDuplicateGroup[]; scannedMs: number; } export async function scanCrossFolderDuplicates(): Promise { return callSidecar('GET', '/duplicates/scan') as Promise; } export interface ArchiveDuplicatesResult { moved: { from: string; to: string }[]; errors: { path: string; error: string }[]; } export async function archiveDuplicatePaths( paths: string[] ): Promise { return callSidecar('POST', '/duplicates/archive', { paths }) as Promise; } // ── Heap convert (move/copy heap photos to a folder) ──────────────────────── // Lives on the sidecar because moving the underlying files is a filesystem // operation PhotoPrism's API doesn't expose. The sidecar lists album members // via PhotoPrism's q-DSL, fs.rename / fs.copyFile each primary file into the // target folder, then triggers a PhotoPrism reindex. export interface HeapConvertBody { /** Originals-relative target folder. Must exist. */ targetFolder: string; mode: 'move' | 'copy'; /** Optional subfolder name to create under `targetFolder` and place * files into. Lets the user keep a heap's worth of files grouped. */ subfolder?: string | null; /** Delete the album after a successful move. Ignored when mode='copy' * (a copy doesn't change membership). */ deleteHeap?: boolean; } export interface HeapConvertResult { moved: number; copied: number; errors: { uid: string; reason: string }[]; heap_deleted: boolean; } export async function convertHeap( uid: string, body: HeapConvertBody ): Promise { return callSidecar('POST', `/albums/${uid}/convert`, body) as Promise; } // ── Move arbitrary photos (by UID) to a folder ────────────────────────────── // Same on-disk move/copy + reindex as convertHeap, but the sidecar resolves the // photos from a UID list instead of an album. Backs the grid's move-to-folder. export interface PhotosMoveBody { uids: string[]; /** Originals-relative target folder. Empty string = originals root. */ targetFolder: string; mode: 'move' | 'copy'; /** Optional subfolder to create under `targetFolder` and place files into. */ subfolder?: string | null; } export interface PhotosMoveResult { moved: number; copied: number; errors: { uid: string; reason: string }[]; } export async function movePhotosToFolder(body: PhotosMoveBody): Promise { return callSidecar('POST', '/photos/move', body) as Promise; } // ── Reparent a folder (move the directory under a different parent) ────────── export interface FolderMoveResult { ok: boolean; oldPath: string; newPath: string; } export async function moveFolder(rel: string, targetParent: string): Promise { return callSidecar('POST', `/folders/${encodeURIComponent(rel)}/move`, { targetParent }) as Promise; } // ── Photo marks (rating + color) ───────────────────────────────────────────── // PhotoPrism's PUT silently drops Rating and Color (they're auto-computed // internal fields). We store them in mule-sidecar instead. export interface PhotoMark { rating?: number; color?: string; updatedAt?: string; } export type PhotoMarksMap = Record; export async function getAllMarks(): Promise { const data = await callSidecar('GET', '/photos/marks'); return (data ?? {}) as PhotoMarksMap; } export async function setMark(photoUid: string, patch: PhotoMark): Promise { return callSidecar('PUT', `/photos/${photoUid}/marks`, patch) as Promise; } export async function bulkSetMarks( ids: string[], patch: PhotoMark ): Promise<{ count: number; marks: PhotoMarksMap }> { return callSidecar('POST', '/photos/marks/bulk', { ids, patch }) as Promise<{ count: number; marks: PhotoMarksMap; }>; } export async function renameOnDisk(photoUid: string, newName: string): Promise { // Bypass the axios client because the sidecar lives at /api/sidecar, not // /api/v1 — http.baseURL would prepend the wrong prefix. const res = await fetch(`/api/sidecar/files/${photoUid}/rename`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Auth-Token': session.accessToken ?? '' }, body: JSON.stringify({ newName }) }); const data = (await res.json()) as Partial & { error?: string }; if (!res.ok) throw new Error(data.error ?? `Rename failed (${res.status})`); return data as RenameResult; } // ── Settings / Admin ───────────────────────────────────────────────────────── // // Thin wrappers over PhotoPrism's admin endpoints driving the settings dialog // (Library / Index / Import / Logs). Shapes are deliberately partial — newer // PhotoPrism versions ship extra fields we don't render, and the POST endpoint // merges server-side, so it's safe to round-trip an incomplete object. export interface PpSettings { ui?: { theme?: string; language?: string; timeZone?: string; startPage?: string; scrollbar?: boolean; zoom?: boolean; }; search?: { batchSize?: number; listView?: boolean; showTitles?: boolean; showCaptions?: boolean; }; maps?: { animate?: number; style?: string }; index?: { path?: string; convert?: boolean; rescan?: boolean; skipArchived?: boolean; skipMeta?: boolean; skipRaw?: boolean; skipHidden?: boolean; }; import?: { path?: string; move?: boolean; dest?: string }; stack?: { uuid?: boolean; meta?: boolean; name?: boolean }; download?: { name?: string; disabled?: boolean; originals?: boolean; mediaRaw?: boolean; mediaSidecar?: boolean; crc32?: boolean; sha1?: boolean; }; /** * PhotoPrism's feature-flag bag. Each key gates a UI surface (and the * matching API endpoints) inside PP's own SPA — disabling `share` for * example hides every share button. Optional because older PP versions * don't return the block; the Library tab only renders toggles for * keys it actually sees in the response. */ features?: { archive?: boolean; private?: boolean; review?: boolean; files?: boolean; folders?: boolean; moments?: boolean; calendar?: boolean; places?: boolean; edit?: boolean; share?: boolean; library?: boolean; import?: boolean; logs?: boolean; search?: boolean; account?: boolean; settings?: boolean; services?: boolean; people?: boolean; labels?: boolean; download?: boolean; upload?: boolean; delete?: boolean; ratings?: boolean; [k: string]: boolean | undefined; }; [k: string]: unknown; } export async function getSettings(): Promise { const { data } = await http.get('/settings'); return data; } export async function saveSettings(patch: Partial): Promise { const { data } = await http.post('/settings', patch); return data; } export interface IndexBody { path?: string; rescan?: boolean; cleanup?: boolean; } export async function startIndex(body: IndexBody = {}): Promise<{ message: string }> { const { data } = await http.post<{ message: string }>('/index', { path: '/', rescan: false, cleanup: false, ...body }); return data; } export async function cancelIndex(): Promise { await http.delete('/index'); } export interface ImportBody { path?: string; move?: boolean; dest?: string; } export async function startImport(body: ImportBody = {}): Promise<{ message: string }> { const { data } = await http.post<{ message: string }>('/import', { path: '/', move: false, dest: '', ...body }); return data; } export async function cancelImport(): Promise { await http.delete('/import'); } export interface PpLogEntry { Time: string; Level: string; Message: string; } export async function getErrors(opts: { limit?: number } = {}): Promise { const { data } = await http.get('/errors', { params: { limit: opts.limit ?? 200 } }); return data ?? []; } // ── Users ──────────────────────────────────────────────────────────────────── // // PhotoPrism's admin user endpoints. List/create/update/delete require an // admin session; the password endpoint accepts the user's own UID with their // current password as `old`. export interface CreateUserBody { Name: string; DisplayName?: string; Email?: string; Role: PpRole; BasePath?: string; UploadPath?: string; WebDAV?: boolean; Password?: string; } export type UpdateUserBody = Partial; export async function listUsers(): Promise { const { data } = await http.get('/users', { params: { count: 1000, order: 'name' } }); if (Array.isArray(data)) return data; return data.users ?? []; } export async function createUser(body: CreateUserBody): Promise { const { data } = await http.post('/users', body); return data; } export async function updateUser(uid: string, patch: UpdateUserBody): Promise { const { data } = await http.put(`/users/${uid}`, patch); return data; } export async function deleteUser(uid: string): Promise { await http.delete(`/users/${uid}`); } export async function setUserPassword( uid: string, oldPassword: string, newPassword: string ): Promise { await http.put(`/users/${uid}/password`, { old: oldPassword, new: newPassword }); } // ── Re-exports ─────────────────────────────────────────────────────────────── export type { PpClientConfig, PpPhoto, PpSessionResponse, PpUser };