From c55fa1fdca01d30fc25a5d1ad260aa5630a579b2 Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Thu, 17 Sep 2026 13:53:23 -0500 Subject: [PATCH] IQDB reverse search from the library detail - New IQDB card on local item pages: fetches the signed raw file, POSTs it to e621's /iqdb_queries.json with the user's credentials, and lists candidates as tiles (thumbnail, rating, score%) with exact-MD5 markers. - Selecting a candidate opens a detail panel (rating, score, favs, size, tag preview) and linking is an explicit confirm that reuses the e621 match endpoint; videos show a note since IQDB is image-only. - Guests don't see the card; linking follows the uploader/staff rule. --- ROADMAP.md | 8 +- frontend/src/features/library/IqdbCard.tsx | 282 ++++++++++++++++++ .../src/features/library/LibraryDetail.tsx | 5 + 3 files changed, 292 insertions(+), 3 deletions(-) create mode 100644 frontend/src/features/library/IqdbCard.tsx diff --git a/ROADMAP.md b/ROADMAP.md index 77c7b34..2683395 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -39,9 +39,11 @@ they land. `manage.py match_e621` - [x] Show match status + e621 metadata in the library: status badge and match card on the detail, and `not_found` / `deleted` status filters -- [ ] **IQDB reverse search** - - [ ] Search from a local file (`POST /iqdb_queries.json`) - - [ ] Show candidate posts to link +- [x] **IQDB reverse search** + - [x] Search from a local file (`POST /iqdb_queries.json` from the library detail, + using the signed raw file) + - [x] Show candidate posts to link (thumbnail, rating, score, favs, tags; + select then confirm — same flow as uploads, exact MD5 marked) - [ ] **Follows** - [ ] Follow tags/pools, Followed screens with unseen badges - [ ] "Followed" nav entry diff --git a/frontend/src/features/library/IqdbCard.tsx b/frontend/src/features/library/IqdbCard.tsx new file mode 100644 index 0000000..05f6bb3 --- /dev/null +++ b/frontend/src/features/library/IqdbCard.tsx @@ -0,0 +1,282 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { ScanSearch } from "lucide-react"; +import { useState } from "react"; +import { Link } from "react-router-dom"; + +import { Button, Spinner } from "@/components/ui"; +import { api, errorMessage } from "@/lib/api"; +import { cn } from "@/lib/cn"; +import { + e621ErrorMessage, + effectiveCredentials, + iqdbSearch, + RATING_LABELS, + type E621IqdbResult, +} from "@/lib/e621"; +import type { MediaItem } from "@/lib/types"; +import { useE621 } from "@/store/e621"; + +const ratingPill: Record = { + s: "bg-ctp-green text-ctp-crust", + q: "bg-ctp-peach text-ctp-crust", + e: "bg-ctp-red text-ctp-crust", +}; + +type Notice = { kind: "ok" | "warn" | "error"; text: string } | null; + +export function IqdbCard({ + item, + canLink, +}: { + item: MediaItem; + canLink: boolean; +}) { + const queryClient = useQueryClient(); + const credentials = useE621((state) => state.credentials); + const [busy, setBusy] = useState(false); + const [candidates, setCandidates] = useState(null); + const [selected, setSelected] = useState(null); + const [notice, setNotice] = useState(null); + + const isImage = item.kind === "image"; + const configured = Boolean(credentials?.configured); + + async function search() { + setBusy(true); + setNotice(null); + setSelected(null); + setCandidates(null); + try { + const response = await fetch(item.raw_url); + if (!response.ok) { + throw new Error(`Could not load the file (HTTP ${response.status}).`); + } + const blob = await response.blob(); + const results = await iqdbSearch( + effectiveCredentials(credentials), + blob, + item.filename, + ); + setCandidates(results); + if (results.length === 0) { + setNotice({ kind: "warn", text: "No similar posts found on e621." }); + } + } catch (error) { + setNotice({ kind: "error", text: e621ErrorMessage(error) }); + } finally { + setBusy(false); + } + } + + const linkMutation = useMutation({ + mutationFn: (postId: number) => + api( + `/api/files/${item.j_id}/match/`, + { method: "POST", json: { post_id: postId } }, + ), + onSuccess: (data) => { + void queryClient.invalidateQueries({ queryKey: ["file", item.j_id] }); + void queryClient.invalidateQueries({ queryKey: ["files"] }); + void queryClient.invalidateQueries({ queryKey: ["tag-cloud"] }); + setSelected(null); + setNotice( + data.md5_match + ? { + kind: "ok", + text: `Linked to post #${data.e621_post_id} — MD5 matches.`, + } + : { + kind: "warn", + text: `Linked to post #${data.e621_post_id}, but its MD5 differs from this file.`, + }, + ); + }, + onError: (error) => + setNotice({ kind: "error", text: errorMessage(error) }), + }); + + return ( +
+
+

IQDB search

+ {candidates && candidates.length > 0 ? ( + + {candidates.length} candidates + + ) : null} +
+ +

+ Reverse-search e621 with this file to find its original post. +

+ + + + {!configured ? ( +

+ Configure e621 credentials in Account to use IQDB. +

+ ) : !isImage ? ( +

+ IQDB works on images only. +

+ ) : null} + + {notice ? ( +

+ {notice.text} +

+ ) : null} + + {candidates && candidates.length > 0 ? ( +
+ {candidates.map((candidate) => { + const exact = candidate.md5 !== null && candidate.md5 === item.md5; + return ( + + ); + })} +
+ ) : null} + + {selected ? ( +
+
+

+ Post #{selected.post_id} +

+ {selected.md5 === item.md5 ? ( + + MD5 match + + ) : null} +
+

+ {selected.rating + ? (RATING_LABELS[selected.rating] ?? selected.rating) + : "Unknown rating"} + {selected.score_total !== null + ? ` · ▲ ${selected.score_total}` + : ""} + {selected.fav_count !== null ? ` · ${selected.fav_count} favs` : ""} + {selected.width && selected.height + ? ` · ${selected.width}×${selected.height}` + : ""} +

+ {selected.tags_preview.length > 0 ? ( +
+ {selected.tags_preview.map((tag) => ( + + {tag} + + ))} +
+ ) : null} +
+ + + View post + +
+

+ {canLink + ? "Linking stores this post's metadata on the item." + : "Only the uploader or staff can link posts."} +

+
+ ) : null} + + {candidates && candidates.length > 0 && !selected ? ( +

+ Select a candidate first, then confirm the link. +

+ ) : null} +
+ ); +} diff --git a/frontend/src/features/library/LibraryDetail.tsx b/frontend/src/features/library/LibraryDetail.tsx index 100e153..91a3ee8 100644 --- a/frontend/src/features/library/LibraryDetail.tsx +++ b/frontend/src/features/library/LibraryDetail.tsx @@ -6,6 +6,7 @@ import { Link, useNavigate } from "react-router-dom"; import { Button, EmptyState, Spinner, inputClass, linkButtonClass } from "@/components/ui"; import { TagChip } from "@/components/TagChip"; import { DetailShell } from "@/features/detail/DetailShell"; +import { IqdbCard } from "@/features/library/IqdbCard"; import { api, errorMessage } from "@/lib/api"; import { cn } from "@/lib/cn"; import { isTypingTarget } from "@/lib/dom"; @@ -697,6 +698,10 @@ export function LibraryDetail({ jId }: { jId: string }) { + {user ? ( + + ) : null} +

Locations ({item.locations.length})