Files
mule-image/web/src/lib/services/photoprism.ts
dtoro d70244f17e feat(web): fold duplicates+inbox into /review; sidebar UX cleanup
- /duplicates and /inbox routes removed and folded into /review as
  additional tabs alongside cause tabs; /duplicates keeps a redirect
  for bookmarks.
- LeftSidebar: drop import/inbox tile and favorites; show per-user
  BasePath label at the folder root.
- RightSidebar: split file header into read-only path over editable
  basename (matches sidecar rename contract); date field switches to
  plain-text ISO YYYY-MM-DD (no native datetime picker) with strict
  validation and revert-on-invalid-blur; preserves original hour.
- BulkMetadataSidebar: same ISO-only date input with invalid-state
  styling and apply-button gating.
- BulkActionBar: drop redundant Restore and Undo buttons; ⌘Z still
  reachable via gridKeyNav.
- gridKeyNav: remove favorite toggle (F) alongside the favorites view
  retirement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:48:38 +02:00

891 lines
30 KiB
TypeScript

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<string, string>)['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<PpSessionResponse> {
const { data } = await http.post<PpSessionResponse>('/session', { username, password });
adoptSession(data);
return data;
}
export async function logout(): Promise<void> {
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<PpSessionResponse> {
const { data } = await http.get<PpSessionResponse>(`/session/${id}`);
return data;
}
/**
* After OIDC completes, PhotoPrism returns an HTML page that writes the
* issued session into `localStorage` under the namespaced keys
* `pp:<storageNamespace>: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<PpSessionResponse | null> {
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:<ns>: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<PpClientConfig> {
const { data } = await http.get<PpClientConfig>('/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<PpPhoto[]> {
const { data } = await http.get<PpPhoto[]>('/photos', {
params: {
count: 60,
offset: 0,
order: 'newest',
merged: true,
...params
}
});
return data;
}
export async function getPhoto(uid: string): Promise<PpPhoto> {
const { data } = await http.get<PpPhoto>(`/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<import('$lib/types/photoprism').PpDetails>;
}
/** 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<PpPhoto> {
const merged: Record<string, unknown> = { ...photo, ...patch };
if (patch.Details) {
merged.Details = { ...(photo.Details ?? {}), ...patch.Details };
}
const { data } = await http.put<PpPhoto>(`/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<void> {
await http.post('/batch/photos/archive', toBatchBody(uids));
}
export async function batchRestore(uids: string[]): Promise<void> {
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<void> {
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<void> {
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<void> {
await http.post(`/photos/${photoUid}/files/${fileUid}/primary`);
}
export async function unstackFile(photoUid: string, fileUid: string): Promise<PpPhoto> {
const { data } = await http.post<PpPhoto>(`/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<PpPhoto> {
const { data } = await http.delete<PpPhoto>(`/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<PpFolder[]> {
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<Record<string, number>> {
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<string, number> = {};
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<PpGeoCollection> {
// 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<PpGeoCollection>('/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<AggregatedKeyword[]> {
const list = await listPhotos({ count: 1000, merged: true });
const buckets = new Map<string, AggregatedKeyword>();
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<PpLabel[]> {
// `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:<slug>` 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<PpLabel[]>('/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<PpAlbum[]> {
const { data } = await http.get<PpAlbum[]>('/albums', {
params: { type: 'album', count: 500, order: 'newest' }
});
return data;
}
export async function getHeap(uid: string): Promise<PpAlbum> {
const { data } = await http.get<PpAlbum>(`/albums/${uid}`);
return data;
}
export async function createHeap(title: string): Promise<PpAlbum> {
const { data } = await http.post<PpAlbum>('/albums', {
Title: title,
Type: 'album'
});
return data;
}
export async function renameHeap(uid: string, title: string): Promise<PpAlbum> {
const { data } = await http.put<PpAlbum>(`/albums/${uid}`, { Title: title });
return data;
}
export async function deleteHeap(uid: string): Promise<void> {
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<AddToHeapResult> {
const { data } = await http.post<{ added?: string[] }>(`/albums/${uid}/photos`, { photos });
return { added: data.added ?? [] };
}
export async function removeFromHeap(uid: string, photos: string[]): Promise<void> {
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:<UID>` 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<PpAlbum> {
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 <a> 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<unknown> {
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<CrossFolderScanResult> {
return sidecar('GET', '/duplicates/scan') as Promise<CrossFolderScanResult>;
}
export interface ArchiveDuplicatesResult {
moved: { from: string; to: string }[];
errors: { path: string; error: string }[];
}
export async function archiveDuplicatePaths(
paths: string[]
): Promise<ArchiveDuplicatesResult> {
return sidecar('POST', '/duplicates/archive', { paths }) as Promise<ArchiveDuplicatesResult>;
}
// ── 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<HeapConvertResult> {
return sidecar('POST', `/albums/${uid}/convert`, body) as Promise<HeapConvertResult>;
}
// ── 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<string, PhotoMark>;
export async function getAllMarks(): Promise<PhotoMarksMap> {
const data = await sidecar('GET', '/photos/marks');
return (data ?? {}) as PhotoMarksMap;
}
export async function setMark(photoUid: string, patch: PhotoMark): Promise<PhotoMark> {
return sidecar('PUT', `/photos/${photoUid}/marks`, patch) as Promise<PhotoMark>;
}
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<RenameResult> {
// 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<RenameResult> & { 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<PpSettings> {
const { data } = await http.get<PpSettings>('/settings');
return data;
}
export async function saveSettings(patch: Partial<PpSettings>): Promise<PpSettings> {
const { data } = await http.post<PpSettings>('/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<void> {
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<void> {
await http.delete('/import');
}
export interface PpLogEntry {
Time: string;
Level: string;
Message: string;
}
export async function getErrors(opts: { limit?: number } = {}): Promise<PpLogEntry[]> {
const { data } = await http.get<PpLogEntry[]>('/errors', {
params: { limit: opts.limit ?? 200 }
});
return data ?? [];
}
// ── Re-exports ───────────────────────────────────────────────────────────────
export type { PpClientConfig, PpPhoto, PpSessionResponse, PpUser };