Show IQDB checks in the metadata modal and run them for visual matches

The modal held a snapshot of the staged upload, so IQDB results that landed
from the background check queue never appeared until it was closed and
reopened — the only hint a check was running was the e621 request history.
It now follows the live uploads query, so candidates, progress and errors
show up in place.

Related gaps fixed along the way:
- files flagged by the local visual-similarity check were skipped by the
  IQDB pass entirely (only 'pending' files were checked), so their modal
  could only ever show 'already in your library'; unresolved files of both
  statuses are now checked, and the check button shows on visual-match
  cards too;
- a check with no candidates posted nothing, leaving 'never checked' and
  'checked, no match' indistinguishable; results are stored even when
  empty and the modal now says which one it is;
- per-file failures surface in the modal instead of being swallowed, the
  modal shows a spinner while the query runs and a check now/re-check
  button, and auto-runs skip files already checked (and videos, since IQDB
  is image-only).

Backend production code unchanged; tests pin the empty-result recording
(18 library tests, full suite 56 green).
This commit is contained in:
2026-09-18 23:42:45 -05:00
parent b076903ecd
commit bc7494e7be
2 changed files with 303 additions and 132 deletions
+217 -131
View File
@@ -184,7 +184,7 @@ function TempCard({
<Button className="px-2 py-1 text-xs" onClick={onOpen}>
Set metadata
</Button>
{temp.status === "pending" ? (
{isVideo ? null : (
<Button
variant="secondary"
className="px-2 py-1 text-xs"
@@ -193,7 +193,7 @@ function TempCard({
>
{checking ? "Checking…" : "Check similarity"}
</Button>
) : null}
)}
</div>
) : null}
@@ -216,9 +216,15 @@ function TempCard({
function MetadataModal({
temp,
checking,
checkError,
onCheck,
onClose,
}: {
temp: TempUpload;
checking: boolean;
checkError: string | null;
onCheck: () => void;
onClose: () => void;
}) {
const queryClient = useQueryClient();
@@ -437,122 +443,163 @@ function MetadataModal({
</div>
) : null}
{temp.iqdb_data && temp.iqdb_data.length > 0 ? (
<div>
<div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
IQDB candidates
</p>
<div className="mt-2 grid grid-cols-3 gap-2">
{temp.iqdb_data.map((candidate) => (
<button
key={`${candidate.post_id}-${candidate.md5}`}
type="button"
disabled={busy || !candidate.post_id}
onClick={() => setSelected(candidate)}
className={cn(
"overflow-hidden rounded-md border bg-ctp-mantle text-left transition disabled:opacity-50",
selected?.post_id === candidate.post_id
? "border-ctp-mauve ring-2 ring-ctp-mauve/40"
: "border-ctp-surface0 hover:border-ctp-surface2",
)}
>
<span className="relative block aspect-square overflow-hidden">
{candidate.preview_url ? (
<img
src={candidate.preview_url}
alt={`Post #${candidate.post_id}`}
loading="lazy"
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-[10px] text-ctp-overlay0">
No preview
</span>
)}
{candidate.rating ? (
<span
className={cn(
"absolute left-1 top-1 rounded-full px-1 py-0.5 font-mono text-[9px] font-semibold uppercase",
ratingPill[candidate.rating] ??
"bg-ctp-surface1 text-ctp-text",
)}
>
{candidate.rating}
</span>
) : null}
</span>
<span className="block px-1.5 py-1 font-mono text-[10px] text-ctp-overlay0">
#{candidate.post_id}
{candidate.score !== null
? ` · ${Math.round(candidate.score)}%`
: ""}
</span>
</button>
))}
</div>
{selected ? (
<div className="mt-2 rounded-md border border-ctp-surface0 bg-ctp-surface0/40 p-3">
<p className="font-mono text-xs text-ctp-subtext1">
Post #{selected.post_id}
</p>
<p className="mt-1 text-xs text-ctp-overlay0">
{selected.rating
? (RATING_LABELS[selected.rating] ?? selected.rating)
: "Unknown rating"}
{selected.score_total !== null &&
selected.score_total !== undefined
? ` · ▲ ${selected.score_total}`
: ""}
{selected.fav_count !== null &&
selected.fav_count !== undefined
? ` · ${selected.fav_count} favs`
: ""}
{selected.width && selected.height
? ` · ${selected.width}×${selected.height}`
: ""}
</p>
{selected.tags_preview &&
selected.tags_preview.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{selected.tags_preview.map((tag) => (
<span
key={tag}
className="rounded border border-ctp-surface1 px-1 py-0.5 font-mono text-[10px] text-ctp-subtext0"
>
{tag}
</span>
))}
</div>
) : null}
<div className="mt-3 flex items-center gap-3">
<Button
onClick={() => void linkCandidate(selected)}
disabled={busy || !selected.post_id}
>
Link selected post
</Button>
<Link
to={`/detail/${selected.post_id}`}
target="_blank"
className="text-xs text-ctp-blue hover:underline"
>
View post
</Link>
</div>
<p className="mt-2 text-[11px] text-ctp-overlay0">
Linking downloads the post's file into the library and
drops this staged upload.
</p>
</div>
) : null}
{isVideo || !credentials?.configured ? null : (
<button
type="button"
onClick={onCheck}
disabled={checking || busy}
className="rounded-md px-1.5 py-0.5 font-mono text-[10px] text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text disabled:opacity-50"
>
{checking
? "checking…"
: temp.iqdb_data
? "re-check"
: "check now"}
</button>
)}
</div>
) : (
<p className="text-xs text-ctp-overlay0">
No IQDB candidates yet — run "Check similarity" from the
card, or enter a post ID below.
</p>
)}
{isVideo ? (
<p className="mt-2 text-xs text-ctp-overlay0">
IQDB works on images only.
</p>
) : !credentials?.configured ? (
<p className="mt-2 text-xs text-ctp-overlay0">
Configure e621 credentials in Account to use IQDB.
</p>
) : checking && !(temp.iqdb_data?.length ?? 0) ? (
<p className="mt-2 flex items-center gap-1.5 text-xs text-ctp-overlay0">
<Spinner className="h-3.5 w-3.5" />
Querying e621 IQDB…
</p>
) : checkError && !(temp.iqdb_data?.length ?? 0) ? (
<p className="mt-2 text-xs text-ctp-red">
IQDB check failed: {checkError}
</p>
) : temp.iqdb_data && temp.iqdb_data.length > 0 ? (
<>
<div className="mt-2 grid grid-cols-3 gap-2">
{temp.iqdb_data.map((candidate) => (
<button
key={`${candidate.post_id}-${candidate.md5}`}
type="button"
disabled={busy || !candidate.post_id}
onClick={() => setSelected(candidate)}
className={cn(
"overflow-hidden rounded-md border bg-ctp-mantle text-left transition disabled:opacity-50",
selected?.post_id === candidate.post_id
? "border-ctp-mauve ring-2 ring-ctp-mauve/40"
: "border-ctp-surface0 hover:border-ctp-surface2",
)}
>
<span className="relative block aspect-square overflow-hidden">
{candidate.preview_url ? (
<img
src={candidate.preview_url}
alt={`Post #${candidate.post_id}`}
loading="lazy"
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-[10px] text-ctp-overlay0">
No preview
</span>
)}
{candidate.rating ? (
<span
className={cn(
"absolute left-1 top-1 rounded-full px-1 py-0.5 font-mono text-[9px] font-semibold uppercase",
ratingPill[candidate.rating] ??
"bg-ctp-surface1 text-ctp-text",
)}
>
{candidate.rating}
</span>
) : null}
</span>
<span className="block px-1.5 py-1 font-mono text-[10px] text-ctp-overlay0">
#{candidate.post_id}
{candidate.score !== null
? ` · ${Math.round(candidate.score)}%`
: ""}
</span>
</button>
))}
</div>
{selected ? (
<div className="mt-2 rounded-md border border-ctp-surface0 bg-ctp-surface0/40 p-3">
<p className="font-mono text-xs text-ctp-subtext1">
Post #{selected.post_id}
</p>
<p className="mt-1 text-xs text-ctp-overlay0">
{selected.rating
? (RATING_LABELS[selected.rating] ??
selected.rating)
: "Unknown rating"}
{selected.score_total !== null &&
selected.score_total !== undefined
? ` · ▲ ${selected.score_total}`
: ""}
{selected.fav_count !== null &&
selected.fav_count !== undefined
? ` · ${selected.fav_count} favs`
: ""}
{selected.width && selected.height
? ` · ${selected.width}×${selected.height}`
: ""}
</p>
{selected.tags_preview &&
selected.tags_preview.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{selected.tags_preview.map((tag) => (
<span
key={tag}
className="rounded border border-ctp-surface1 px-1 py-0.5 font-mono text-[10px] text-ctp-subtext0"
>
{tag}
</span>
))}
</div>
) : null}
<div className="mt-3 flex items-center gap-3">
<Button
onClick={() => void linkCandidate(selected)}
disabled={busy || !selected.post_id}
>
Link selected post
</Button>
<Link
to={`/detail/${selected.post_id}`}
target="_blank"
className="text-xs text-ctp-blue hover:underline"
>
View post
</Link>
</div>
<p className="mt-2 text-[11px] text-ctp-overlay0">
Linking downloads the post's file into the library
and drops this staged upload.
</p>
</div>
) : null}
</>
) : temp.iqdb_data ? (
<p className="mt-2 text-xs text-ctp-overlay0">
Checked against e621 IQDB — no match found.
</p>
) : (
<p className="mt-2 text-xs text-ctp-overlay0">
Not checked yet. Uploads are checked automatically after
they finish.
</p>
)}
</div>
<div className="flex gap-2">
<input
@@ -631,7 +678,8 @@ export default function UploadPage() {
const [busy, setBusy] = useState(false);
const [processing, setProcessing] = useState(false);
const [checkingIds, setCheckingIds] = useState<string[]>([]);
const [modalFor, setModalFor] = useState<TempUpload | null>(null);
const [checkErrors, setCheckErrors] = useState<Record<string, string>>({});
const [modalId, setModalId] = useState<string | null>(null);
const [bulkOpen, setBulkOpen] = useState(false);
useEffect(() => {
@@ -649,6 +697,12 @@ export default function UploadPage() {
});
const uploads = uploadsQuery.data ?? [];
// Follow the live query data so background IQDB results land in the open
// modal as they arrive (a snapshot object never sees them).
const modalTemp = modalId
? (uploads.find((temp) => temp.temp_id === modalId) ?? null)
: null;
const pending = uploads.filter((temp) => temp.status === "pending");
const visual = uploads.filter((temp) => temp.status === "visual_match");
const completed = uploads.filter(
@@ -725,13 +779,23 @@ export default function UploadPage() {
if (ids.length === 0) return;
setProcessing(true);
setCheckingIds((current) => [...current, ...ids]);
setCheckErrors((current) => {
const next = { ...current };
for (const id of ids) delete next[id];
return next;
});
try {
const fresh = await refreshUploads();
// Local visual matches still get an IQDB pass: the library check only
// proves near-duplicates, IQDB finds the actual post.
const targets = fresh.filter(
(temp) => ids.includes(temp.temp_id) && temp.status === "pending",
(temp) =>
ids.includes(temp.temp_id) &&
(temp.status === "pending" || temp.status === "visual_match"),
);
for (const temp of targets) {
if (!temp.file_url) continue;
if (/\.(mp4|webm)$/i.test(temp.original_filename)) continue;
try {
const blob = await fetchTempBlob(temp.file_url);
const results = await iqdbSearch(
@@ -739,14 +803,24 @@ export default function UploadPage() {
blob,
temp.original_filename,
);
if (results.length > 0) {
await api(`/api/uploads/${temp.temp_id}/iqdb/`, {
method: "POST",
json: { results },
});
}
} catch {
// Skip this file; the card stays pending.
// Store even an empty result: "checked, nothing found" must stay
// distinguishable from "never checked" in the metadata modal.
const updated = await api<TempUpload>(
`/api/uploads/${temp.temp_id}/iqdb/`,
{ method: "POST", json: { results } },
);
queryClient.setQueryData<TempUpload[]>(["uploads"], (current) =>
current
? current.map((item) =>
item.temp_id === updated.temp_id ? updated : item,
)
: current,
);
} catch (error) {
setCheckErrors((current) => ({
...current,
[temp.temp_id]: describeError(error),
}));
} finally {
setCheckingIds((current) =>
current.filter((value) => value !== temp.temp_id),
@@ -792,12 +866,18 @@ export default function UploadPage() {
invalidateUploads();
}
// 2) IQDB similarity for whatever is still pending.
// 2) IQDB similarity for every unresolved file that has not been checked
// yet (pending and local visual matches alike; IQDB is image-only).
const fresh = await refreshUploads();
const stillPending = fresh
.filter((temp) => temp.status === "pending")
const unresolvedIds = fresh
.filter(
(temp) =>
(temp.status === "pending" || temp.status === "visual_match") &&
temp.iqdb_data === null &&
!/\.(mp4|webm)$/i.test(temp.original_filename),
)
.map((temp) => temp.temp_id);
void checkSimilarity(stillPending);
void checkSimilarity(unresolvedIds);
}
async function drainQueue() {
@@ -909,7 +989,7 @@ export default function UploadPage() {
key={temp.temp_id}
temp={temp}
checking={checkingIds.includes(temp.temp_id)}
onOpen={() => setModalFor(temp)}
onOpen={() => setModalId(temp.temp_id)}
onCheck={() => void checkSimilarity([temp.temp_id])}
onDismiss={() => dismiss(temp)}
/>
@@ -1136,8 +1216,14 @@ export default function UploadPage() {
)}
</div>
{modalFor ? (
<MetadataModal temp={modalFor} onClose={() => setModalFor(null)} />
{modalTemp ? (
<MetadataModal
temp={modalTemp}
checking={checkingIds.includes(modalTemp.temp_id)}
checkError={checkErrors[modalTemp.temp_id] ?? null}
onCheck={() => void checkSimilarity([modalTemp.temp_id])}
onClose={() => setModalId(null)}
/>
) : null}
{bulkOpen ? (