From 70de4b65ecd5e00dfdf1e768c114d09c3d1e133c Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 18 May 2026 20:48:20 +0200 Subject: [PATCH] feat(duplicates): sidebar count + auto-run cross-folder scan Sidebar Duplicates badge now sums stacks + cross-folder groups, with cross-folder observed from cache (no eager disk scan from the sidebar). Cross-folder tab auto-fires the scan on access; button becomes Rescan. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../duplicates/DuplicatesView.svelte | 38 +++++++----------- .../lib/components/layout/LeftSidebar.svelte | 40 ++++++++++++++++++- 2 files changed, 52 insertions(+), 26 deletions(-) diff --git a/web/src/lib/components/duplicates/DuplicatesView.svelte b/web/src/lib/components/duplicates/DuplicatesView.svelte index fdd3118..e6d1c91 100644 --- a/web/src/lib/components/duplicates/DuplicatesView.svelte +++ b/web/src/lib/components/duplicates/DuplicatesView.svelte @@ -12,9 +12,10 @@ 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). + The cross-folder scan auto-fires when its tab is active — with size + pre-filtering it stays fast (~250ms for 400 files in practice) and a + long staleTime keeps tab bounces from re-running it. The button is + kept for manual "rescan after I moved files" refreshes. Tabs themselves render in the parent route's Toolbar so they line up visually with the `/tags` pill row. @@ -42,23 +43,19 @@ const qc = useQueryClient(); - // 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); + // Cross-folder scan auto-fires when the tab is active. The 5-minute + // staleTime means a fresh visit reuses the prior result; the + // "Rescan filesystem" button invalidates to force a re-scan after + // the user has moved files around. const crossQuery = createQuery(() => ({ queryKey: ['duplicates-cross-folder'], queryFn: scanCrossFolderDuplicates, - enabled: scanRequested && activeTab === 'cross-folder', + enabled: activeTab === 'cross-folder', staleTime: 5 * 60_000 })); - function triggerScan() { - if (scanRequested && !crossQuery.isFetching) { - void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] }); - } else { - scanRequested = true; - } + function rescan() { + void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] }); } $effect(() => { @@ -114,24 +111,17 @@ 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} + onclick={rescan} > {#if crossQuery.isFetching} Scanning… - {:else if scanRequested} - Rescan filesystem {:else} - Scan filesystem + Rescan filesystem {/if} - {#if !scanRequested} -

- Click Scan filesystem to look for byte-identical files spread across - folders. Pre-filtered by size, so even large libraries finish in a few seconds. -

- {:else if crossQuery.isFetching && !crossQuery.data} + {#if crossQuery.isFetching && !crossQuery.data}

Hashing files under originals…

{:else if crossQuery.isError}

diff --git a/web/src/lib/components/layout/LeftSidebar.svelte b/web/src/lib/components/layout/LeftSidebar.svelte index 762ea96..d7dc2b2 100644 --- a/web/src/lib/components/layout/LeftSidebar.svelte +++ b/web/src/lib/components/layout/LeftSidebar.svelte @@ -21,13 +21,19 @@ logout, renameFolder, renameHeap, + scanCrossFolderDuplicates, triggerDownload, + type CrossFolderScanResult, type ImportInfo, type PhotoMarksMap, type PpAlbum, type PpClientConfig, type PpFolder } from '$lib/services/photoprism'; + import { + listDuplicateGroups, + type DuplicateGroup + } from '$lib/services/adapters/duplicates'; import { filters, setFolderPath, @@ -101,6 +107,25 @@ staleTime: 60_000 })); + // Duplicates counts for the sidebar badge. Stacks is a cheap + // PhotoPrism query so we always fetch it; cross-folder is an + // O(disk) scan, so the sidebar only *observes* its cache + // (enabled:false) and the duplicates page itself is what populates + // it on first visit. Both share queryKeys with the /duplicates + // view so cache is reused. + const stacksQuery = createQuery(() => ({ + queryKey: ['duplicates'], + queryFn: listDuplicateGroups, + enabled: isAuthenticated(), + staleTime: 60_000 + })); + const crossFolderQuery = createQuery(() => ({ + queryKey: ['duplicates-cross-folder'], + queryFn: scanCrossFolderDuplicates, + enabled: false, + staleTime: 5 * 60_000 + })); + const ratingsCount = $derived(countRatings(marksQuery.data)); const colorsCount = $derived(countColors(marksQuery.data)); @@ -365,7 +390,9 @@ // "everything visible in the main timeline" tally), so it matches what // the All photos view actually renders. `places` is the count of // geocoded locations — semantically what the Map view groups by. - // Duplicates has no precomputed counter; we omit its badge. + // Duplicates sums stacks + cross-folder groups; cross-folder only + // contributes once its tab has been visited (the scan is opt-in + // per-visit, not eager from the sidebar). type ViewItem = | { kind: 'section'; id: Section; label: string; getCount: () => number | undefined } | { kind: 'route'; href: string; label: string; getCount: () => number | undefined }; @@ -395,7 +422,16 @@ const manageViews: ViewItem[] = [ { kind: 'route', href: '/review', label: 'Review', getCount: () => configQuery.data?.count?.review }, - { kind: 'route', href: '/duplicates', label: 'Duplicates', getCount: () => undefined }, + { + kind: 'route', + href: '/duplicates', + label: 'Duplicates', + getCount: () => { + const stacks = stacksQuery.data?.length; + if (stacks === undefined) return undefined; + return stacks + (crossFolderQuery.data?.groups.length ?? 0); + } + }, { kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => configQuery.data?.count?.hidden }, { kind: 'section', id: 'archive', label: 'Archive', getCount: () => configQuery.data?.count?.archived } ];