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 VariantsResponse = Array<{ kind: string; size: number; key: string }>;
|
||||||
type Tag = { id: string; name: string };
|
type Tag = { id: string; name: string };
|
||||||
type Album = { id: string; name: string };
|
type Album = { id: string; name: string };
|
||||||
|
type DupesResponse = { items: Array<{ id: string }> };
|
||||||
|
|
||||||
function startOfDayUtc(iso: string) {
|
function startOfDayUtc(iso: string) {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -74,6 +75,8 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
|||||||
const [overrideError, setOverrideError] = useState<string | null>(null);
|
const [overrideError, setOverrideError] = useState<string | null>(null);
|
||||||
const [overrideBusy, setOverrideBusy] = useState(false);
|
const [overrideBusy, setOverrideBusy] = useState(false);
|
||||||
const [baseCaptureTs, setBaseCaptureTs] = useState<string | null>(null);
|
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(() => {
|
const range = useMemo(() => {
|
||||||
if (!props.selectedDayIso) return null;
|
if (!props.selectedDayIso) return null;
|
||||||
@@ -174,6 +177,15 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
|||||||
return { url, variant: { kind: "original" } };
|
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) {
|
async function openViewer(asset: Asset) {
|
||||||
if (asset.status === "failed") {
|
if (asset.status === "failed") {
|
||||||
setViewerError(`${asset.id}: ${asset.error_message ?? "asset_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 ?? "");
|
setOverrideInput(asset.capture_ts_utc ?? "");
|
||||||
setOverrideError(null);
|
setOverrideError(null);
|
||||||
void loadAdminLists();
|
void loadAdminLists();
|
||||||
|
void loadDupes(asset.id).catch((err) => {
|
||||||
|
setDupesError(err instanceof Error ? err.message : String(err));
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +221,9 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
|||||||
setOverrideInput(asset.capture_ts_utc ?? "");
|
setOverrideInput(asset.capture_ts_utc ?? "");
|
||||||
setOverrideError(null);
|
setOverrideError(null);
|
||||||
void loadAdminLists();
|
void loadAdminLists();
|
||||||
|
void loadDupes(asset.id).catch((err) => {
|
||||||
|
setDupesError(err instanceof Error ? err.message : String(err));
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setViewer(null);
|
setViewer(null);
|
||||||
setViewerError(
|
setViewerError(
|
||||||
@@ -629,6 +647,45 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
|||||||
{viewer.asset.id}
|
{viewer.asset.id}
|
||||||
</div>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
borderTop: "1px solid #eee",
|
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