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) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 20:48:20 +02:00
parent 79a9ef49d4
commit 70de4b65ec
2 changed files with 52 additions and 26 deletions

View File

@@ -12,9 +12,10 @@
mule-sidecar. Resolution moves the unwanted copies into a mule-sidecar. Resolution moves the unwanted copies into a
`.duplicates/` quarantine folder PhotoPrism's indexer ignores. `.duplicates/` quarantine folder PhotoPrism's indexer ignores.
The cross-folder scan is opt-in (button-triggered) rather than The cross-folder scan auto-fires when its tab is active — with size
auto-run because it's an O(disk) operation. With size pre-filtering pre-filtering it stays fast (~250ms for 400 files in practice) and a
the scan stays fast (~250ms for 400 files in practice). 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 Tabs themselves render in the parent route's Toolbar so they line up
visually with the `/tags` pill row. visually with the `/tags` pill row.
@@ -42,23 +43,19 @@
const qc = useQueryClient(); const qc = useQueryClient();
// Cross-folder scan is a manually-triggered query: `enabled` stays // Cross-folder scan auto-fires when the tab is active. The 5-minute
// false until the user clicks "Scan filesystem". Subsequent clicks // staleTime means a fresh visit reuses the prior result; the
// invalidate the cache so each press kicks a fresh scan. // "Rescan filesystem" button invalidates to force a re-scan after
let scanRequested = $state(false); // the user has moved files around.
const crossQuery = createQuery<CrossFolderScanResult>(() => ({ const crossQuery = createQuery<CrossFolderScanResult>(() => ({
queryKey: ['duplicates-cross-folder'], queryKey: ['duplicates-cross-folder'],
queryFn: scanCrossFolderDuplicates, queryFn: scanCrossFolderDuplicates,
enabled: scanRequested && activeTab === 'cross-folder', enabled: activeTab === 'cross-folder',
staleTime: 5 * 60_000 staleTime: 5 * 60_000
})); }));
function triggerScan() { function rescan() {
if (scanRequested && !crossQuery.isFetching) { void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] });
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] });
} else {
scanRequested = true;
}
} }
$effect(() => { $effect(() => {
@@ -114,24 +111,17 @@
type="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" 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} disabled={crossQuery.isFetching}
onclick={triggerScan} onclick={rescan}
> >
{#if crossQuery.isFetching} {#if crossQuery.isFetching}
Scanning… Scanning…
{:else if scanRequested}
Rescan filesystem
{:else} {:else}
Scan filesystem Rescan filesystem
{/if} {/if}
</button> </button>
</header> </header>
{#if !scanRequested} {#if crossQuery.isFetching && !crossQuery.data}
<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> <p class="text-sm text-muted-foreground">Hashing files under originals…</p>
{:else if crossQuery.isError} {:else if crossQuery.isError}
<p class="text-sm text-destructive"> <p class="text-sm text-destructive">

View File

@@ -21,13 +21,19 @@
logout, logout,
renameFolder, renameFolder,
renameHeap, renameHeap,
scanCrossFolderDuplicates,
triggerDownload, triggerDownload,
type CrossFolderScanResult,
type ImportInfo, type ImportInfo,
type PhotoMarksMap, type PhotoMarksMap,
type PpAlbum, type PpAlbum,
type PpClientConfig, type PpClientConfig,
type PpFolder type PpFolder
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import {
listDuplicateGroups,
type DuplicateGroup
} from '$lib/services/adapters/duplicates';
import { import {
filters, filters,
setFolderPath, setFolderPath,
@@ -101,6 +107,25 @@
staleTime: 60_000 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<DuplicateGroup[]>(() => ({
queryKey: ['duplicates'],
queryFn: listDuplicateGroups,
enabled: isAuthenticated(),
staleTime: 60_000
}));
const crossFolderQuery = createQuery<CrossFolderScanResult>(() => ({
queryKey: ['duplicates-cross-folder'],
queryFn: scanCrossFolderDuplicates,
enabled: false,
staleTime: 5 * 60_000
}));
const ratingsCount = $derived(countRatings(marksQuery.data)); const ratingsCount = $derived(countRatings(marksQuery.data));
const colorsCount = $derived(countColors(marksQuery.data)); const colorsCount = $derived(countColors(marksQuery.data));
@@ -365,7 +390,9 @@
// "everything visible in the main timeline" tally), so it matches what // "everything visible in the main timeline" tally), so it matches what
// the All photos view actually renders. `places` is the count of // the All photos view actually renders. `places` is the count of
// geocoded locations — semantically what the Map view groups by. // 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 = type ViewItem =
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined } | { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
| { kind: 'route'; href: string; label: string; getCount: () => number | undefined }; | { kind: 'route'; href: string; label: string; getCount: () => number | undefined };
@@ -395,7 +422,16 @@
const manageViews: ViewItem[] = [ const manageViews: ViewItem[] = [
{ kind: 'route', href: '/review', label: 'Review', getCount: () => configQuery.data?.count?.review }, { 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: 'hidden', label: 'Hidden', getCount: () => configQuery.data?.count?.hidden },
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => configQuery.data?.count?.archived } { kind: 'section', id: 'archive', label: 'Archive', getCount: () => configQuery.data?.count?.archived }
]; ];