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:
2026-05-17 16:06:58 +02:00
parent 423a73a8a6
commit 8c2526d982
69 changed files with 12048 additions and 0 deletions

View File

@@ -0,0 +1,225 @@
<!--
Duplicate-resolution page body. Two tabs:
1. Stacks — PhotoPrism's own auto-grouped variants (RAW+JPG, Live
HEIC+MOV, etc.). Source of truth is PhotoPrism's DB; we list via
`stack:true` and resolve via `setPrimary` + `deleteFile`.
2. Cross-folder — files PhotoPrism silently rejected at index time
because they were byte-identical to an existing entry. PhotoPrism
never adds those rows to its DB, so we scan the filesystem via the
mule-sidecar. Resolution moves the unwanted copies into a
`.duplicates/` quarantine folder PhotoPrism's indexer ignores.
The cross-folder scan is opt-in (button-triggered) rather than
auto-run because it's an O(disk) operation. With size pre-filtering
the scan stays fast (~250ms for 400 files in practice).
-->
<script lang="ts">
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import {
scanCrossFolderDuplicates,
type CrossFolderScanResult
} from '$lib/services/photoprism';
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
import StackGroupCard from './StackGroupCard.svelte';
import CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
interface Props {
groups: DuplicateGroup[];
pending: boolean;
error: unknown;
}
let { groups, pending, error }: Props = $props();
const qc = useQueryClient();
type Tab = 'stacks' | 'cross-folder';
let activeTab = $state<Tab>('stacks');
// Cross-folder scan is a manually-triggered query: `enabled` stays
// false until the user clicks "Scan filesystem". Subsequent clicks
// invalidate the cache so each press kicks a fresh scan.
let scanRequested = $state(false);
const crossQuery = createQuery<CrossFolderScanResult>(() => ({
queryKey: ['duplicates-cross-folder'],
queryFn: scanCrossFolderDuplicates,
enabled: scanRequested,
staleTime: 5 * 60_000
}));
function triggerScan() {
if (scanRequested && !crossQuery.isFetching) {
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] });
} else {
scanRequested = true;
}
}
$effect(() => {
if (crossQuery.error) {
toast.error(
crossQuery.error instanceof Error
? crossQuery.error.message
: 'Cross-folder scan failed'
);
}
});
const stackCount = $derived(groups.length);
const crossCount = $derived(crossQuery.data?.groups.length ?? 0);
// Tabs: only the visible card under the active tab should auto-focus.
// We pass `autoFocus={i === 0}` into the FIRST card of the active tab
// (and only when that tab is selected) so keyboard navigation lands
// on the right place when the user switches tabs.
function tabBtnClass(tab: Tab) {
const base =
'inline-flex items-center gap-2 border-b-2 px-3 py-1.5 text-sm transition-colors';
return tab === activeTab
? `${base} border-foreground text-foreground`
: `${base} border-transparent text-muted-foreground hover:text-foreground`;
}
</script>
<div class="space-y-4">
<!-- Tab bar — sticky so it stays visible while the panel scrolls.
Same horizontal padding as the panels below so labels line up. -->
<div
role="tablist"
class="sticky top-0 z-10 flex items-center gap-1 border-b border-border bg-background px-6"
>
<button
type="button"
role="tab"
aria-selected={activeTab === 'stacks'}
class={tabBtnClass('stacks')}
onclick={() => (activeTab = 'stacks')}
>
Stacks
<span
class="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
>
{pending ? '…' : stackCount}
</span>
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'cross-folder'}
class={tabBtnClass('cross-folder')}
onclick={() => (activeTab = 'cross-folder')}
>
Cross-folder
<span
class="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
>
{#if !scanRequested}
·
{:else if crossQuery.isFetching && !crossQuery.data}
{:else}
{crossCount}
{/if}
</span>
</button>
</div>
<!-- Stacks tab ----------------------------------------------------- -->
{#if activeTab === 'stacks'}
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 pb-6">
{#if pending}
<p class="text-sm text-muted-foreground">Loading stacks…</p>
{:else if error}
<p class="text-sm text-destructive">
Could not load stacks: {error instanceof Error
? error.message
: 'unknown error'}
</p>
{:else if stackCount === 0}
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
<p>No stacks.</p>
<p class="text-xs">
PhotoPrism stacks byte-identical (or EXIF-identical) files. If you
don't have any, this tab stays empty. Cross-folder copies that
PhotoPrism rejected at index time live under the
<button
type="button"
class="underline hover:text-foreground"
onclick={() => (activeTab = 'cross-folder')}
>
Cross-folder
</button>
tab.
</p>
</div>
{:else}
<div class="space-y-3">
{#each groups as group, i (group.photo.UID)}
<StackGroupCard {group} autoFocus={i === 0} />
{/each}
</div>
{/if}
</div>
{/if}
<!-- Cross-folder tab ----------------------------------------------- -->
{#if activeTab === 'cross-folder'}
<div role="tabpanel" aria-label="Cross-folder duplicates" class="space-y-3 px-6 pb-6">
<header class="flex items-baseline justify-between gap-3">
<p class="text-[11px] text-muted-foreground">
Byte-identical files PhotoPrism dropped at index time. Found by
scanning the originals tree directly.
</p>
<button
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={crossQuery.isFetching}
onclick={triggerScan}
>
{#if crossQuery.isFetching}
Scanning…
{:else if scanRequested}
Rescan filesystem
{:else}
Scan filesystem
{/if}
</button>
</header>
{#if !scanRequested}
<p class="text-sm text-muted-foreground">
Click <em>Scan filesystem</em> to look for byte-identical files spread
across folders. Pre-filtered by size, so even large libraries finish
in a few seconds.
</p>
{:else if crossQuery.isFetching && !crossQuery.data}
<p class="text-sm text-muted-foreground">
Hashing files under originals…
</p>
{:else if crossQuery.isError}
<p class="text-sm text-destructive">
Scan failed: {crossQuery.error instanceof Error
? crossQuery.error.message
: 'unknown error'}
</p>
{:else if crossCount === 0}
<p class="text-sm text-muted-foreground">
No cross-folder duplicates found.
{#if crossQuery.data}
<span class="ml-1 text-[10px] text-muted-foreground/70">
(scanned in {crossQuery.data.scannedMs} ms)
</span>
{/if}
</p>
{:else}
<div class="space-y-3">
{#each crossQuery.data?.groups ?? [] as group, i (group.hash)}
<CrossFolderGroupCard {group} autoFocus={i === 0} />
{/each}
</div>
{/if}
</div>
{/if}
</div>