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, 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' } }); http.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); } ); // ── 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 http.get('/photos', { params: { count: 60, offset: 0, order: 'newest', merged: true, ...params } }); return data; } 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 http.get<{ folders?: PpFolder[] }>( '/folders/originals', { params: { recursive: true, uncached: true, files: false } } ); const bp = userBasePath(); 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 sidecar('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; } 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); } 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. const { data } = await http.get('/labels', { params: { count: 1000, order: 'count', all: true } }); return data; } // ── 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 sidecar(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 sidecar('POST', '/folders', { path: relPath }) as Promise<{ path: string }>; } export async function renameFolder( relPath: string, newName: string ): Promise<{ oldPath: string; newPath: string }> { return sidecar('POST', `/folders/${encodeURIComponent(relPath)}/rename`, { newName }) as Promise<{ oldPath: string; newPath: string }>; } export async function deleteFolder(relPath: string): Promise<{ path: string }> { return sidecar('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 sidecar('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 sidecar('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 sidecar('POST', `/albums/${uid}/convert`, body) 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 sidecar('GET', '/photos/marks'); return (data ?? {}) as PhotoMarksMap; } export async function setMark(photoUid: string, patch: PhotoMark): Promise { return sidecar('PUT', `/photos/${photoUid}/marks`, patch) as Promise; } export async function bulkSetMarks( ids: string[], patch: PhotoMark ): Promise<{ count: number; marks: PhotoMarksMap }> { return sidecar('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 }; 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; }; [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 ?? []; } // ── Re-exports ─────────────────────────────────────────────────────────────── export type { PpClientConfig, PpPhoto, PpSessionResponse, PpUser };