feat: expose and display duplicates

This commit is contained in:
William Valentin
2026-02-04 23:38:24 -08:00
parent 1952fbaf30
commit 83f3ff1f69
4 changed files with 179 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
import { z } from "zod";
import { getDb } from "@tline/db";
const paramsSchema = z.object({ id: z.string().uuid() });
type DbLike = ReturnType<typeof getDb>;
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 } };
}

View File

@@ -0,0 +1,12 @@
import { handleGetDupes } from "./handlers";
export const runtime = "nodejs";
export async function GET(
_request: Request,
context: { params: Promise<{ id: string }> },
): Promise<Response> {
const rawParams = await context.params;
const result = await handleGetDupes({ params: rawParams });
return Response.json(result.body, { status: result.status });
}

View File

@@ -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<string | null>(null);
const [overrideBusy, setOverrideBusy] = useState(false);
const [baseCaptureTs, setBaseCaptureTs] = useState<string | null>(null);
const [dupes, setDupes] = useState<Array<{ id: string }> | null>(null);
const [dupesError, setDupesError] = useState<string | null>(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}
</div>
<div
style={{
borderTop: "1px solid #eee",
paddingTop: 12,
display: "grid",
gap: 6,
}}
>
<strong style={{ fontSize: 13 }}>Duplicates</strong>
{dupesError ? (
<div style={{ color: "#b00", fontSize: 12 }}>
{dupesError}
</div>
) : null}
{dupes === null ? (
<div style={{ color: "#666", fontSize: 12 }}>
Loading...
</div>
) : dupes.length === 0 ? (
<div style={{ color: "#666", fontSize: 12 }}>
No duplicates.
</div>
) : (
<div
style={{
display: "grid",
gap: 4,
fontSize: 12,
color: "#444",
}}
>
<div>{dupes.length} duplicate(s)</div>
{dupes.map((dupe) => (
<div key={dupe.id}>{dupe.id.slice(0, 8)}</div>
))}
</div>
)}
</div>
<div
style={{
borderTop: "1px solid #eee",

View File

@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test";
import { handleGetDupes } from "../../app/api/assets/[id]/dupes/handlers";
describe("handleGetDupes", () => {
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);
});
});