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:
@@ -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 base64
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -195,3 +195,88 @@ class BulkResolveTests(TestCase):
|
|||||||
self.assertEqual(len(body["resolved"]), 1)
|
self.assertEqual(len(body["resolved"]), 1)
|
||||||
self.assertEqual(body["errors"][0]["error"], "already in the library")
|
self.assertEqual(body["errors"][0]["error"], "already in the library")
|
||||||
self.assertEqual(MediaItem.objects.count(), 1)
|
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)
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ function TempCard({
|
|||||||
<Button className="px-2 py-1 text-xs" onClick={onOpen}>
|
<Button className="px-2 py-1 text-xs" onClick={onOpen}>
|
||||||
Set metadata
|
Set metadata
|
||||||
</Button>
|
</Button>
|
||||||
{temp.status === "pending" ? (
|
{isVideo ? null : (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="px-2 py-1 text-xs"
|
className="px-2 py-1 text-xs"
|
||||||
@@ -193,7 +193,7 @@ function TempCard({
|
|||||||
>
|
>
|
||||||
{checking ? "Checking…" : "Check similarity"}
|
{checking ? "Checking…" : "Check similarity"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -216,9 +216,15 @@ function TempCard({
|
|||||||
|
|
||||||
function MetadataModal({
|
function MetadataModal({
|
||||||
temp,
|
temp,
|
||||||
|
checking,
|
||||||
|
checkError,
|
||||||
|
onCheck,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
temp: TempUpload;
|
temp: TempUpload;
|
||||||
|
checking: boolean;
|
||||||
|
checkError: string | null;
|
||||||
|
onCheck: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -437,122 +443,163 @@ function MetadataModal({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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">
|
<p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||||
IQDB candidates
|
IQDB candidates
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
{isVideo || !credentials?.configured ? null : (
|
||||||
{temp.iqdb_data.map((candidate) => (
|
<button
|
||||||
<button
|
type="button"
|
||||||
key={`${candidate.post_id}-${candidate.md5}`}
|
onClick={onCheck}
|
||||||
type="button"
|
disabled={checking || busy}
|
||||||
disabled={busy || !candidate.post_id}
|
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"
|
||||||
onClick={() => setSelected(candidate)}
|
>
|
||||||
className={cn(
|
{checking
|
||||||
"overflow-hidden rounded-md border bg-ctp-mantle text-left transition disabled:opacity-50",
|
? "checking…"
|
||||||
selected?.post_id === candidate.post_id
|
: temp.iqdb_data
|
||||||
? "border-ctp-mauve ring-2 ring-ctp-mauve/40"
|
? "re-check"
|
||||||
: "border-ctp-surface0 hover:border-ctp-surface2",
|
: "check now"}
|
||||||
)}
|
</button>
|
||||||
>
|
)}
|
||||||
<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}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<p className="text-xs text-ctp-overlay0">
|
{isVideo ? (
|
||||||
No IQDB candidates yet — run "Check similarity" from the
|
<p className="mt-2 text-xs text-ctp-overlay0">
|
||||||
card, or enter a post ID below.
|
IQDB works on images only.
|
||||||
</p>
|
</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">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -631,7 +678,8 @@ export default function UploadPage() {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
const [checkingIds, setCheckingIds] = useState<string[]>([]);
|
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);
|
const [bulkOpen, setBulkOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -649,6 +697,12 @@ export default function UploadPage() {
|
|||||||
});
|
});
|
||||||
const uploads = uploadsQuery.data ?? [];
|
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 pending = uploads.filter((temp) => temp.status === "pending");
|
||||||
const visual = uploads.filter((temp) => temp.status === "visual_match");
|
const visual = uploads.filter((temp) => temp.status === "visual_match");
|
||||||
const completed = uploads.filter(
|
const completed = uploads.filter(
|
||||||
@@ -725,13 +779,23 @@ export default function UploadPage() {
|
|||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
setCheckingIds((current) => [...current, ...ids]);
|
setCheckingIds((current) => [...current, ...ids]);
|
||||||
|
setCheckErrors((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
for (const id of ids) delete next[id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const fresh = await refreshUploads();
|
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(
|
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) {
|
for (const temp of targets) {
|
||||||
if (!temp.file_url) continue;
|
if (!temp.file_url) continue;
|
||||||
|
if (/\.(mp4|webm)$/i.test(temp.original_filename)) continue;
|
||||||
try {
|
try {
|
||||||
const blob = await fetchTempBlob(temp.file_url);
|
const blob = await fetchTempBlob(temp.file_url);
|
||||||
const results = await iqdbSearch(
|
const results = await iqdbSearch(
|
||||||
@@ -739,14 +803,24 @@ export default function UploadPage() {
|
|||||||
blob,
|
blob,
|
||||||
temp.original_filename,
|
temp.original_filename,
|
||||||
);
|
);
|
||||||
if (results.length > 0) {
|
// Store even an empty result: "checked, nothing found" must stay
|
||||||
await api(`/api/uploads/${temp.temp_id}/iqdb/`, {
|
// distinguishable from "never checked" in the metadata modal.
|
||||||
method: "POST",
|
const updated = await api<TempUpload>(
|
||||||
json: { results },
|
`/api/uploads/${temp.temp_id}/iqdb/`,
|
||||||
});
|
{ method: "POST", json: { results } },
|
||||||
}
|
);
|
||||||
} catch {
|
queryClient.setQueryData<TempUpload[]>(["uploads"], (current) =>
|
||||||
// Skip this file; the card stays pending.
|
current
|
||||||
|
? current.map((item) =>
|
||||||
|
item.temp_id === updated.temp_id ? updated : item,
|
||||||
|
)
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setCheckErrors((current) => ({
|
||||||
|
...current,
|
||||||
|
[temp.temp_id]: describeError(error),
|
||||||
|
}));
|
||||||
} finally {
|
} finally {
|
||||||
setCheckingIds((current) =>
|
setCheckingIds((current) =>
|
||||||
current.filter((value) => value !== temp.temp_id),
|
current.filter((value) => value !== temp.temp_id),
|
||||||
@@ -792,12 +866,18 @@ export default function UploadPage() {
|
|||||||
invalidateUploads();
|
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 fresh = await refreshUploads();
|
||||||
const stillPending = fresh
|
const unresolvedIds = fresh
|
||||||
.filter((temp) => temp.status === "pending")
|
.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);
|
.map((temp) => temp.temp_id);
|
||||||
void checkSimilarity(stillPending);
|
void checkSimilarity(unresolvedIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function drainQueue() {
|
async function drainQueue() {
|
||||||
@@ -909,7 +989,7 @@ export default function UploadPage() {
|
|||||||
key={temp.temp_id}
|
key={temp.temp_id}
|
||||||
temp={temp}
|
temp={temp}
|
||||||
checking={checkingIds.includes(temp.temp_id)}
|
checking={checkingIds.includes(temp.temp_id)}
|
||||||
onOpen={() => setModalFor(temp)}
|
onOpen={() => setModalId(temp.temp_id)}
|
||||||
onCheck={() => void checkSimilarity([temp.temp_id])}
|
onCheck={() => void checkSimilarity([temp.temp_id])}
|
||||||
onDismiss={() => dismiss(temp)}
|
onDismiss={() => dismiss(temp)}
|
||||||
/>
|
/>
|
||||||
@@ -1136,8 +1216,14 @@ export default function UploadPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{modalFor ? (
|
{modalTemp ? (
|
||||||
<MetadataModal temp={modalFor} onClose={() => setModalFor(null)} />
|
<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}
|
) : null}
|
||||||
|
|
||||||
{bulkOpen ? (
|
{bulkOpen ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user