feat: expose and display duplicates
This commit is contained in:
56
apps/web/app/api/assets/[id]/dupes/handlers.ts
Normal file
56
apps/web/app/api/assets/[id]/dupes/handlers.ts
Normal 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 } };
|
||||
}
|
||||
12
apps/web/app/api/assets/[id]/dupes/route.ts
Normal file
12
apps/web/app/api/assets/[id]/dupes/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
54
apps/web/src/__tests__/dupes-route.test.ts
Normal file
54
apps/web/src/__tests__/dupes-route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user