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 ? (