From 83f3ff1f69743e52607685aa0ad2822f7dfbbaca Mon Sep 17 00:00:00 2001 From: William Valentin Date: Wed, 4 Feb 2026 23:38:24 -0800 Subject: [PATCH] feat: expose and display duplicates --- .../web/app/api/assets/[id]/dupes/handlers.ts | 56 ++++++++++++++++++ apps/web/app/api/assets/[id]/dupes/route.ts | 12 ++++ apps/web/app/components/MediaPanel.tsx | 57 +++++++++++++++++++ apps/web/src/__tests__/dupes-route.test.ts | 54 ++++++++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 apps/web/app/api/assets/[id]/dupes/handlers.ts create mode 100644 apps/web/app/api/assets/[id]/dupes/route.ts create mode 100644 apps/web/src/__tests__/dupes-route.test.ts diff --git a/apps/web/app/api/assets/[id]/dupes/handlers.ts b/apps/web/app/api/assets/[id]/dupes/handlers.ts new file mode 100644 index 0000000..b9c03a4 --- /dev/null +++ b/apps/web/app/api/assets/[id]/dupes/handlers.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +import { getDb } from "@tline/db"; + +const paramsSchema = z.object({ id: z.string().uuid() }); + +type DbLike = ReturnType; + +export async function handleGetDupes(input: { + params: { id: string }; + db?: DbLike; +}): Promise<{ status: number; body: unknown }> { + const paramsParsed = paramsSchema.safeParse(input.params); + if (!paramsParsed.success) { + return { + status: 400, + body: { error: "invalid_params", issues: paramsParsed.error.issues }, + }; + } + + const db = (input.db ?? getDb()) as DbLike; + const hashRows = await db< + { + bucket: string; + sha256: string; + }[] + >` + select bucket, sha256 + from asset_hashes + where asset_id = ${paramsParsed.data.id} + limit 1 + `; + + const hash = hashRows[0]; + if (!hash) { + return { status: 200, body: { items: [] } }; + } + + const dupes = await db< + { + id: string; + media_type: "image" | "video"; + status: "new" | "processing" | "ready" | "failed"; + }[] + >` + select a.id, a.media_type, a.status + from assets a + join asset_hashes h on h.asset_id = a.id + where h.bucket = ${hash.bucket} + and h.sha256 = ${hash.sha256} + and a.id <> ${paramsParsed.data.id} + order by a.id asc + `; + + return { status: 200, body: { items: dupes } }; +} diff --git a/apps/web/app/api/assets/[id]/dupes/route.ts b/apps/web/app/api/assets/[id]/dupes/route.ts new file mode 100644 index 0000000..07b2a07 --- /dev/null +++ b/apps/web/app/api/assets/[id]/dupes/route.ts @@ -0,0 +1,12 @@ +import { handleGetDupes } from "./handlers"; + +export const runtime = "nodejs"; + +export async function GET( + _request: Request, + context: { params: Promise<{ id: string }> }, +): Promise { + const rawParams = await context.params; + const result = await handleGetDupes({ params: rawParams }); + return Response.json(result.body, { status: result.status }); +} diff --git a/apps/web/app/components/MediaPanel.tsx b/apps/web/app/components/MediaPanel.tsx index 842cf55..392ea20 100644 --- a/apps/web/app/components/MediaPanel.tsx +++ b/apps/web/app/components/MediaPanel.tsx @@ -35,6 +35,7 @@ type VideoPlaybackVariant = { kind: "original" } | { kind: "video_mp4"; size: nu type VariantsResponse = Array<{ kind: string; size: number; key: string }>; type Tag = { id: string; name: string }; type Album = { id: string; name: string }; +type DupesResponse = { items: Array<{ id: string }> }; function startOfDayUtc(iso: string) { const d = new Date(iso); @@ -74,6 +75,8 @@ export function MediaPanel(props: { selectedDayIso: string | null }) { const [overrideError, setOverrideError] = useState(null); const [overrideBusy, setOverrideBusy] = useState(false); const [baseCaptureTs, setBaseCaptureTs] = useState(null); + const [dupes, setDupes] = useState | null>(null); + const [dupesError, setDupesError] = useState(null); const range = useMemo(() => { if (!props.selectedDayIso) return null; @@ -174,6 +177,15 @@ export function MediaPanel(props: { selectedDayIso: string | null }) { return { url, variant: { kind: "original" } }; } + async function loadDupes(assetId: string) { + setDupesError(null); + setDupes(null); + const res = await fetch(`/api/assets/${assetId}/dupes`, { cache: "no-store" }); + if (!res.ok) throw new Error(`dupes_fetch_failed:${res.status}`); + const json = (await res.json()) as DupesResponse; + setDupes(json.items); + } + async function openViewer(asset: Asset) { if (asset.status === "failed") { setViewerError(`${asset.id}: ${asset.error_message ?? "asset_failed"}`); @@ -196,6 +208,9 @@ export function MediaPanel(props: { selectedDayIso: string | null }) { setOverrideInput(asset.capture_ts_utc ?? ""); setOverrideError(null); void loadAdminLists(); + void loadDupes(asset.id).catch((err) => { + setDupesError(err instanceof Error ? err.message : String(err)); + }); return; } @@ -206,6 +221,9 @@ export function MediaPanel(props: { selectedDayIso: string | null }) { setOverrideInput(asset.capture_ts_utc ?? ""); setOverrideError(null); void loadAdminLists(); + void loadDupes(asset.id).catch((err) => { + setDupesError(err instanceof Error ? err.message : String(err)); + }); } catch (err) { setViewer(null); setViewerError( @@ -629,6 +647,45 @@ export function MediaPanel(props: { selectedDayIso: string | null }) { {viewer.asset.id} +
+ Duplicates + {dupesError ? ( +
+ {dupesError} +
+ ) : null} + {dupes === null ? ( +
+ Loading... +
+ ) : dupes.length === 0 ? ( +
+ No duplicates. +
+ ) : ( +
+
{dupes.length} duplicate(s)
+ {dupes.map((dupe) => ( +
{dupe.id.slice(0, 8)}
+ ))} +
+ )} +
+
{ + test("returns empty list when hash is missing", async () => { + let call = 0; + const db = async () => { + call += 1; + return [] as unknown[]; + }; + + const result = await handleGetDupes({ + params: { id: "00000000-0000-0000-0000-000000000000" }, + db, + }); + expect(result.status).toBe(200); + expect(result.body).toEqual({ items: [] }); + expect(call).toBe(1); + }); + + test("returns dupes excluding the asset id", async () => { + const calls: unknown[] = []; + const db = async () => { + calls.push(true); + if (calls.length === 1) { + return [{ bucket: "photos", sha256: "hash" }]; + } + return [ + { + id: "11111111-1111-1111-1111-111111111111", + media_type: "image", + status: "ready", + }, + ]; + }; + + const result = await handleGetDupes({ + params: { id: "00000000-0000-0000-0000-000000000000" }, + db, + }); + expect(result.status).toBe(200); + expect(result.body).toEqual({ + items: [ + { + id: "11111111-1111-1111-1111-111111111111", + media_type: "image", + status: "ready", + }, + ], + }); + expect(calls.length).toBe(2); + }); +});