feat: PhotoPrism M0 bring-up — compose stack, web client, sidecar, migrate
Replace the legacy mule-image backend with PhotoPrism plus a thin SvelteKit client and a Node sidecar for endpoints PhotoPrism doesn't expose (file rename), and add a two-phase migrator (metadata via PUT, heaps → albums) for the existing Postgres library. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
41
web/src/lib/services/adapters/duplicates.ts
Normal file
41
web/src/lib/services/adapters/duplicates.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Adapter that shapes PhotoPrism's `q=stack:true` photo list into a
|
||||
* `DuplicateGroup[]` the UI consumes. Mirrors mule-image's
|
||||
* `DuplicateGroup` interface so the view code stays declarative.
|
||||
*
|
||||
* PhotoPrism's `/photos?merged=true` inlines `Files[]` on each row
|
||||
* already — no follow-up GET per group needed (confirmed against
|
||||
* photoprism/photoprism:latest, May 2026).
|
||||
*/
|
||||
|
||||
import { listPhotos } from '$lib/services/photoprism';
|
||||
import type { PpFile, PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
export interface DuplicateGroup {
|
||||
/** The Photo record that owns this stack. Carries marks, keywords,
|
||||
* taken_at — everything the metadata sidebar reads off of. */
|
||||
photo: PpPhoto;
|
||||
/** The variants the user picks among. Always 2+ entries; groups with
|
||||
* a single file are filtered out (PhotoPrism shouldn't return them
|
||||
* for `stack:true` anyway, but the guard is cheap). */
|
||||
files: PpFile[];
|
||||
/** Pre-selected "keep this" file = the one PhotoPrism marks Primary.
|
||||
* The view starts with this highlighted and updates it on click. */
|
||||
bestFileUid: string;
|
||||
}
|
||||
|
||||
export async function listDuplicateGroups(): Promise<DuplicateGroup[]> {
|
||||
const photos = await listPhotos({
|
||||
q: 'stack:true',
|
||||
count: 200,
|
||||
merged: true,
|
||||
order: 'newest'
|
||||
});
|
||||
return photos
|
||||
.filter((p) => (p.Files?.length ?? 0) > 1)
|
||||
.map((p) => {
|
||||
const files = p.Files ?? [];
|
||||
const primary = files.find((f) => f.Primary) ?? files[0];
|
||||
return { photo: p, files, bestFileUid: primary.UID };
|
||||
});
|
||||
}
|
||||
50
web/src/lib/services/batch.ts
Normal file
50
web/src/lib/services/batch.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Fan-out helper used wherever PhotoPrism lacks a true batch endpoint.
|
||||
* Bounded concurrency keeps the indexer happy on slow hosts; each item's
|
||||
* result is collected and the aggregate `{updated, errors[]}` mirrors the
|
||||
* shape the legacy mule-image bulk endpoint returned, so existing toast
|
||||
* + undo plumbing slots in without changes.
|
||||
*/
|
||||
|
||||
export interface BatchResult<T> {
|
||||
updated: T[];
|
||||
errors: { id: string; message: string }[];
|
||||
}
|
||||
|
||||
export interface BatchOptions {
|
||||
concurrency?: number;
|
||||
onProgress?: (done: number, total: number) => void;
|
||||
}
|
||||
|
||||
export async function batchEdit<T>(
|
||||
ids: string[],
|
||||
fn: (id: string) => Promise<T>,
|
||||
opts: BatchOptions = {}
|
||||
): Promise<BatchResult<T>> {
|
||||
const concurrency = Math.max(1, opts.concurrency ?? 8);
|
||||
const updated: T[] = [];
|
||||
const errors: { id: string; message: string }[] = [];
|
||||
let i = 0;
|
||||
let done = 0;
|
||||
|
||||
async function worker() {
|
||||
while (true) {
|
||||
const idx = i++;
|
||||
if (idx >= ids.length) return;
|
||||
const id = ids[idx];
|
||||
try {
|
||||
updated.push(await fn(id));
|
||||
} catch (err) {
|
||||
errors.push({ id, message: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
done++;
|
||||
opts.onProgress?.(done, ids.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(concurrency, ids.length) }, () => worker())
|
||||
);
|
||||
return { updated, errors };
|
||||
}
|
||||
87
web/src/lib/services/bulk.ts
Normal file
87
web/src/lib/services/bulk.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { batchEdit } from './batch';
|
||||
import { getPhoto, updatePhoto, type UpdatePhotoBody } from './photoprism';
|
||||
import { queryClient } from '$lib/queryClient';
|
||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import type { PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
/**
|
||||
* Shared helpers for bulk metadata mutations across the timeline. Three call
|
||||
* sites: the keyboard culling layer (`gridKeyNav`), the `BulkActionBar` row,
|
||||
* and the bulk metadata sidebar shown on multi-select. Each needs the same
|
||||
* "fetch full photo body → deep-merge patch → PUT → invalidate" round-trip
|
||||
* (PhotoPrism's PUT only persists nested fields when the body is whole), so
|
||||
* the wiring lives here to keep the call sites declarative.
|
||||
*/
|
||||
|
||||
/** Fetch the freshest photo body, seeding the per-photo cache. PhotoPrism's
|
||||
* PUT needs the full body to persist `Rating` / `Color` / `Details.*`; the
|
||||
* cache lookup means the subsequent patch pass reuses this fetch. */
|
||||
export async function freshPhoto(uid: string): Promise<PpPhoto> {
|
||||
const cached = queryClient.getQueryData<PpPhoto>(['photo', uid]);
|
||||
if (cached) return cached;
|
||||
const p = await getPhoto(uid);
|
||||
queryClient.setQueryData(['photo', uid], p);
|
||||
return p;
|
||||
}
|
||||
|
||||
export function invalidatePhotos(uids: string[]): void {
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
for (const id of uids) {
|
||||
void queryClient.invalidateQueries({ queryKey: ['photo', id] });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a patch to every uid. The patch can be a static body or a per-photo
|
||||
* function (used by keyword merges which need to read each photo's current
|
||||
* Details.Keywords before extending it). When `inverseBuilder` is provided,
|
||||
* an undo entry is registered that restores each photo's pre-patch state.
|
||||
*/
|
||||
export async function patchTargets(
|
||||
ids: string[],
|
||||
patch: UpdatePhotoBody | ((p: PpPhoto) => UpdatePhotoBody),
|
||||
label: string,
|
||||
inverseBuilder?: (photo: PpPhoto) => UpdatePhotoBody
|
||||
): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
|
||||
const inverses = inverseBuilder
|
||||
? new Map<string, UpdatePhotoBody>(
|
||||
await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const p = await freshPhoto(id);
|
||||
return [id, inverseBuilder(p)] as const;
|
||||
})
|
||||
)
|
||||
)
|
||||
: null;
|
||||
|
||||
const { updated, errors } = await batchEdit(ids, async (id) => {
|
||||
const p = await freshPhoto(id);
|
||||
const body = typeof patch === 'function' ? patch(p) : patch;
|
||||
// An empty body is a no-op signal — e.g. "keyword already present".
|
||||
if (Object.keys(body).length === 0) return p;
|
||||
return updatePhoto(p, body);
|
||||
});
|
||||
|
||||
invalidatePhotos(ids);
|
||||
|
||||
if (errors.length) {
|
||||
toast.error(`${label} · ${updated.length} ok, ${errors.length} failed`);
|
||||
} else {
|
||||
toast.success(`${label} · ${ids.length}`);
|
||||
}
|
||||
|
||||
if (inverses) {
|
||||
pushUndo(`${label} (${ids.length})`, async () => {
|
||||
await batchEdit(ids, async (id) => {
|
||||
const p = await freshPhoto(id);
|
||||
const inv = inverses.get(id) ?? {};
|
||||
if (Object.keys(inv).length === 0) return p;
|
||||
return updatePhoto(p, inv);
|
||||
});
|
||||
invalidatePhotos(ids);
|
||||
});
|
||||
}
|
||||
}
|
||||
607
web/src/lib/services/photoprism.ts
Normal file
607
web/src/lib/services/photoprism.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
import axios, { AxiosError, type AxiosInstance } from 'axios';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { adoptSession, clearSession, session } from '$lib/stores/session.svelte';
|
||||
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;
|
||||
}
|
||||
|
||||
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' | '';
|
||||
Favorite?: boolean;
|
||||
Private?: boolean;
|
||||
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>;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the heart/favorite flag. PhotoPrism has dedicated like/unlike
|
||||
* routes that are atomic; preferred over PUT for this one field.
|
||||
*/
|
||||
export async function likePhoto(uid: string): Promise<void> {
|
||||
await http.post(`/photos/${uid}/like`);
|
||||
}
|
||||
|
||||
export async function unlikePhoto(uid: string): Promise<void> {
|
||||
await http.delete(`/photos/${uid}/like`);
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
Favorite?: boolean;
|
||||
Private?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function listFolders(): Promise<PpFolder[]> {
|
||||
const { data } = await http.get<{ folders?: PpFolder[] }>(
|
||||
'/folders/originals',
|
||||
{ params: { recursive: true, uncached: true, files: false } }
|
||||
);
|
||||
return data.folders ?? [];
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
Favorite?: boolean;
|
||||
Priority?: number;
|
||||
Description?: string;
|
||||
PhotoCount?: number;
|
||||
Thumb?: string;
|
||||
}
|
||||
|
||||
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;
|
||||
Favorite?: boolean;
|
||||
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}`);
|
||||
}
|
||||
|
||||
export async function addToHeap(uid: string, photos: string[]): Promise<void> {
|
||||
await http.post(`/albums/${uid}/photos`, { photos });
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Re-exports ───────────────────────────────────────────────────────────────
|
||||
|
||||
export type { PpClientConfig, PpPhoto, PpSessionResponse, PpUser };
|
||||
Reference in New Issue
Block a user