From bc7494e7be861c6b15d46fa2cd51bfc13400d913 Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Fri, 18 Sep 2026 23:42:45 -0500 Subject: [PATCH] Show IQDB checks in the metadata modal and run them for visual matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- backend/apps/library/tests/test_uploads.py | 87 ++++- frontend/src/features/upload/UploadPage.tsx | 348 ++++++++++++-------- 2 files changed, 303 insertions(+), 132 deletions(-) diff --git a/backend/apps/library/tests/test_uploads.py b/backend/apps/library/tests/test_uploads.py index d122946..b38dd27 100644 --- a/backend/apps/library/tests/test_uploads.py +++ b/backend/apps/library/tests/test_uploads.py @@ -1,4 +1,4 @@ -"""Staged uploads: complete-set listing and the bulk rating tool.""" +"""Staged uploads: complete-set listing, bulk rating and IQDB recording.""" import base64 import hashlib @@ -195,3 +195,88 @@ class BulkResolveTests(TestCase): self.assertEqual(len(body["resolved"]), 1) self.assertEqual(body["errors"][0]["error"], "already in the library") self.assertEqual(MediaItem.objects.count(), 1) + + +class IqdbRecordingTests(TestCase): + """The modal needs to tell "checked, no match" from "never checked".""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._tmp = tempfile.mkdtemp(prefix="j621-iqdb-") + cls._settings = override_settings(MEDIA_ROOT=cls._tmp) + cls._settings.enable() + + @classmethod + def tearDownClass(cls): + cls._settings.disable() + shutil.rmtree(cls._tmp, ignore_errors=True) + super().tearDownClass() + + def setUp(self): + self.uploader = User.objects.create_user( + username="iqdb-uploader", password="iqdb-pass-123456" + ) + self.uploader.role = "uploader" + self.uploader.save(update_fields=["role"]) + self.client = Client() + self.client.defaults["HTTP_AUTHORIZATION"] = ( + f"Token {Token.objects.create(user=self.uploader).key}" + ) + + def make_temp(self): + return TempUpload.objects.create( + user=self.uploader, + file=SimpleUploadedFile( + "checked.png", TINY_PNG, content_type="image/png" + ), + original_filename="checked.png", + md5=hashlib.md5(b"checked").hexdigest(), + size=len(TINY_PNG), + ) + + def test_empty_result_records_the_check_without_a_match(self): + temp = self.make_temp() + response = jpost( + self.client, f"/api/uploads/{temp.id}/iqdb/", {"results": []} + ) + self.assertEqual(response.status_code, 200) + temp.refresh_from_db() + self.assertEqual(temp.iqdb_data, []) + # No candidates must not masquerade as a visual match. + self.assertEqual(temp.status, TempUpload.STATUS_PENDING) + + def test_candidates_are_stored_and_flag_a_visual_match(self): + temp = self.make_temp() + response = jpost( + self.client, + f"/api/uploads/{temp.id}/iqdb/", + { + "results": [ + { + "post_id": 123, + "score": 91.5, + "preview_url": "https://static1.e621.net/data/preview/ab/cd/x.jpg", + "rating": "q", + "md5": "a" * 32, + "score_total": 12, + "fav_count": 3, + "width": 800, + "height": 600, + "tags_preview": ["canine", "solo"], + } + ] + }, + ) + self.assertEqual(response.status_code, 200) + temp.refresh_from_db() + self.assertEqual(len(temp.iqdb_data), 1) + self.assertEqual(temp.iqdb_data[0]["post_id"], 123) + self.assertEqual(temp.status, TempUpload.STATUS_VISUAL_MATCH) + + def test_rejects_a_non_list_payload(self): + temp = self.make_temp() + response = jpost( + self.client, f"/api/uploads/{temp.id}/iqdb/", {"results": "nope"} + ) + self.assertEqual(response.status_code, 400) diff --git a/frontend/src/features/upload/UploadPage.tsx b/frontend/src/features/upload/UploadPage.tsx index eb0b7a3..61ec58c 100644 --- a/frontend/src/features/upload/UploadPage.tsx +++ b/frontend/src/features/upload/UploadPage.tsx @@ -184,7 +184,7 @@ function TempCard({ - {temp.status === "pending" ? ( + {isVideo ? null : ( - ) : null} + )} ) : 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({ ) : null} - {temp.iqdb_data && temp.iqdb_data.length > 0 ? ( -
+
+

IQDB candidates

-
- {temp.iqdb_data.map((candidate) => ( - - ))} -
- - {selected ? ( -
-

- Post #{selected.post_id} -

-

- {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}` - : ""} -

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

- Linking downloads the post's file into the library and - drops this staged upload. -

-
- ) : null} + {isVideo || !credentials?.configured ? null : ( + + )}
- ) : ( -

- No IQDB candidates yet — run "Check similarity" from the - card, or enter a post ID below. -

- )} + + {isVideo ? ( +

+ IQDB works on images only. +

+ ) : !credentials?.configured ? ( +

+ Configure e621 credentials in Account to use IQDB. +

+ ) : checking && !(temp.iqdb_data?.length ?? 0) ? ( +

+ + Querying e621 IQDB… +

+ ) : checkError && !(temp.iqdb_data?.length ?? 0) ? ( +

+ IQDB check failed: {checkError} +

+ ) : temp.iqdb_data && temp.iqdb_data.length > 0 ? ( + <> +
+ {temp.iqdb_data.map((candidate) => ( + + ))} +
+ + {selected ? ( +
+

+ Post #{selected.post_id} +

+

+ {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}` + : ""} +

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

+ Linking downloads the post's file into the library + and drops this staged upload. +

+
+ ) : null} + + ) : temp.iqdb_data ? ( +

+ Checked against e621 IQDB — no match found. +

+ ) : ( +

+ Not checked yet. Uploads are checked automatically after + they finish. +

+ )} +
([]); - const [modalFor, setModalFor] = useState(null); + const [checkErrors, setCheckErrors] = useState>({}); + const [modalId, setModalId] = useState(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( + `/api/uploads/${temp.temp_id}/iqdb/`, + { method: "POST", json: { results } }, + ); + queryClient.setQueryData(["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() { )}
- {modalFor ? ( - setModalFor(null)} /> + {modalTemp ? ( + void checkSimilarity([modalTemp.temp_id])} + onClose={() => setModalId(null)} + /> ) : null} {bulkOpen ? (