Make upload processing phase-based: MD5 -> visual -> IQDB over the batch

Uploads were doing md5 + local visual matching inside the upload request
(backend create) while the frontend later ran its own e621 MD5 pass, so the
pipeline looked interleaved per file. Now every step is a phase applied to
the whole batch in order:

1. upload (fast: md5 + exact-duplicate check only),
2. e621 MD5 lookup, 75 md5: metatags per posts.json request,
3. local visual similarity, one file at a time via the new
   POST /api/uploads/<id>/visual-match/ action,
4. IQDB through the existing serial queue.

The board shows the active phase with its own progress bar (e621 MD5 in
peach, visual in lavender, IQDB in teal) and every step updates the staged
list as it lands. Verified from a headless run: one batched posts.json
request for 10 files, then 10 visual-match calls, then IQDB.
This commit is contained in:
2026-09-19 10:25:57 -05:00
parent e62d7af42f
commit e2bf1c457f
3 changed files with 226 additions and 71 deletions
+67 -2
View File
@@ -1,7 +1,8 @@
"""Staged uploads: complete-set listing, bulk rating and IQDB recording.""" """Staged uploads: listing, phases (MD5/visual/IQDB) and the bulk tool."""
import base64 import base64
import hashlib import hashlib
import io
import json import json
import shutil import shutil
import tempfile import tempfile
@@ -11,6 +12,8 @@ from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase, override_settings from django.test import Client, TestCase, override_settings
from PIL import Image
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from apps.library.models import MediaItem, TempUpload from apps.library.models import MediaItem, TempUpload
@@ -23,6 +26,13 @@ TINY_PNG = base64.b64decode(
) )
def similar_png(color=(200, 30, 40)):
"""A 1x1 PNG with the same visual hashes but a different MD5."""
buffer = io.BytesIO()
Image.new("RGB", (1, 1), color).save(buffer, format="PNG")
return buffer.getvalue()
def jpost(client, path, body=None): def jpost(client, path, body=None):
return client.post(path, data=json.dumps(body or {}), content_type="application/json") return client.post(path, data=json.dumps(body or {}), content_type="application/json")
@@ -85,7 +95,7 @@ class TempUploadListTests(TestCase):
self.assertEqual(Client().get("/api/uploads/").status_code, 401) self.assertEqual(Client().get("/api/uploads/").status_code, 401)
class BulkResolveTests(TestCase): class StagedUploadWorkflowTests(TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super().setUpClass() super().setUpClass()
@@ -196,6 +206,61 @@ class BulkResolveTests(TestCase):
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)
def upload_via_api(self, client, label="phase.png"):
return client.post(
"/api/uploads/",
{
"file": SimpleUploadedFile(
label, similar_png(), content_type="image/png"
)
},
)
def seed_library_item(self, client, label):
seed = self.make_temp(self.uploader, label)
response = self.resolve_bulk(client, [seed.id], "s")
self.assertEqual(response.status_code, 200)
return seed
def test_upload_defers_visual_similarity_to_its_phase(self):
client = self.api_client(self.uploader)
self.seed_library_item(client, "seed-defer")
response = self.upload_via_api(client)
self.assertEqual(response.status_code, 201)
body = response.json()
# Uploading must not run the expensive library scan.
self.assertEqual(body["status"], TempUpload.STATUS_PENDING)
self.assertFalse(body["visual_matches"])
def test_visual_match_phase_flags_similar_library_items(self):
client = self.api_client(self.uploader)
self.seed_library_item(client, "seed-visual")
temp = self.make_temp(self.uploader, "check-visual")
response = client.post(f"/api/uploads/{temp.id}/visual-match/")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["status"], TempUpload.STATUS_VISUAL_MATCH)
self.assertGreaterEqual(len(body["visual_matches"]), 1)
temp.refresh_from_db()
self.assertEqual(temp.status, TempUpload.STATUS_VISUAL_MATCH)
def test_visual_match_phase_with_empty_library_stays_pending(self):
client = self.api_client(self.uploader)
temp = self.make_temp(self.uploader, "no-matches")
response = client.post(f"/api/uploads/{temp.id}/visual-match/")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["visual_matches"], [])
self.assertEqual(body["status"], TempUpload.STATUS_PENDING)
def test_visual_match_phase_rejects_completed_uploads(self):
client = self.api_client(self.uploader)
temp = self.make_temp(
self.uploader, "done-visual", status=TempUpload.STATUS_COMPLETED
)
response = client.post(f"/api/uploads/{temp.id}/visual-match/")
self.assertEqual(response.status_code, 400)
class IqdbRecordingTests(TestCase): class IqdbRecordingTests(TestCase):
"""The modal needs to tell "checked, no match" from "never checked".""" """The modal needs to tell "checked, no match" from "never checked"."""
+26 -7
View File
@@ -190,18 +190,37 @@ class TempUploadViewSet(
temp.resolution = TempUpload.RESOLUTION_DUPLICATE temp.resolution = TempUpload.RESOLUTION_DUPLICATE
temp.library_item = existing temp.library_item = existing
temp.file.delete(save=False) temp.file.delete(save=False)
else: # Visual similarity is deliberately a separate phase (the
matches = find_library_matches( # /visual-match action) so a large batch uploads at full speed and
temp.file.path, user=request.user, request=request # the board runs MD5 -> visual -> IQDB over the whole batch.
)
if matches:
temp.visual_matches = matches
temp.status = TempUpload.STATUS_VISUAL_MATCH
temp.save() temp.save()
return Response( return Response(
self.get_serializer(temp).data, status=status.HTTP_201_CREATED self.get_serializer(temp).data, status=status.HTTP_201_CREATED
) )
@action(detail=True, methods=["post"], url_path="visual-match")
def visual_match(self, request, pk=None):
"""Run the local visual-similarity pass for one staged upload."""
temp = self.get_object()
if temp.status == TempUpload.STATUS_COMPLETED:
return Response(
{"detail": "This upload is already in the library."},
status=status.HTTP_400_BAD_REQUEST,
)
if not temp.file:
return Response(
{"detail": "The staged file is missing."},
status=status.HTTP_400_BAD_REQUEST,
)
matches = find_library_matches(
temp.file.path, user=request.user, request=request
)
temp.visual_matches = matches
if matches and temp.status == TempUpload.STATUS_PENDING:
temp.status = TempUpload.STATUS_VISUAL_MATCH
temp.save(update_fields=["visual_matches", "status", "updated_at"])
return Response(self.get_serializer(temp).data)
@action(detail=True, methods=["get", "head"], permission_classes=[AllowAny]) @action(detail=True, methods=["get", "head"], permission_classes=[AllowAny])
def file(self, request, pk=None): def file(self, request, pk=None):
"""Serve the staged file; accepts a signed URL for media tags.""" """Serve the staged file; accepts a signed URL for media tags."""
+115 -44
View File
@@ -127,6 +127,32 @@ function isFatalIqdbError(error: unknown): boolean {
return error instanceof TypeError || error instanceof DOMException; return error instanceof TypeError || error instanceof DOMException;
} }
/** Small labeled progress bar for one upload-pipeline phase. */
function PhaseBar({
label,
percent,
tone,
}: {
label: string;
percent: number;
tone: string;
}) {
return (
<div className="flex w-64 flex-col gap-1.5">
<p className="flex items-center gap-2 text-xs text-ctp-overlay0">
<Spinner className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{label}</span>
</p>
<div className="h-1 overflow-hidden rounded-full bg-ctp-surface0">
<div
className={cn("h-full transition-[width]", tone)}
style={{ width: `${Math.min(100, Math.max(0, percent))}%` }}
/>
</div>
</div>
);
}
function TempCard({ function TempCard({
temp, temp,
checking, checking,
@@ -708,6 +734,11 @@ export default function UploadPage() {
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [entries, setEntries] = useState<UploadEntry[]>([]); const [entries, setEntries] = useState<UploadEntry[]>([]);
const [batch, setBatch] = useState({ total: 0, done: 0, failed: 0 }); const [batch, setBatch] = useState({ total: 0, done: 0, failed: 0 });
const [pipeline, setPipeline] = useState<{
phase: "md5" | "visual";
done: number;
total: number;
} | null>(null);
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[]>([]);
@@ -931,20 +962,26 @@ export default function UploadPage() {
} }
} }
async function autoMatchAndCheck(created: TempUpload[]) { /**
* Phased processing for one finished upload batch: every file goes through
* the e621 MD5 lookup (batched), then local visual similarity, then IQDB.
* Phases never interleave per file, and each step lands on the board as it
* finishes.
*/
async function runUploadPipeline(created: TempUpload[]) {
const creds = effectiveCredentials(credentials); const creds = effectiveCredentials(credentials);
const unresolved = created.filter((temp) => temp.status === "pending"); const targets = created.filter((temp) => temp.status === "pending");
if (targets.length === 0) {
invalidateUploads();
return;
}
const targetIds = new Set(targets.map((temp) => temp.temp_id));
// 1) Auto-complete files whose MD5 matches an e621 post. The original app // Phase 1: e621 MD5 lookup, 75 md5: metatags per query.
// sent 75 md5: metatags per posts.json query. setPipeline({ phase: "md5", done: 0, total: targets.length });
if (unresolved.length > 0) {
try { try {
for ( for (let index = 0; index < targets.length; index += MD5_BATCH_SIZE) {
let index = 0; const md5Batch = targets.slice(index, index + MD5_BATCH_SIZE);
index < unresolved.length;
index += MD5_BATCH_SIZE
) {
const md5Batch = unresolved.slice(index, index + MD5_BATCH_SIZE);
const posts = await fetchPostsByMd5( const posts = await fetchPostsByMd5(
creds, creds,
md5Batch.map((temp) => temp.md5), md5Batch.map((temp) => temp.md5),
@@ -962,17 +999,46 @@ export default function UploadPage() {
// Leave it pending. // Leave it pending.
} }
} }
setPipeline((current) =>
current
? {
...current,
done: Math.min(index + MD5_BATCH_SIZE, targets.length),
}
: current,
);
} }
} catch { } catch {
// e621 unavailable; leave everything pending. // e621 unavailable; those files stay pending.
}
invalidateUploads();
} }
setPipeline(null);
// 2) IQDB similarity for every unresolved file that has not been checked // Phase 2: local visual similarity, one file at a time.
// yet (pending and local visual matches alike; IQDB is image-only). const cached = queryClient.getQueryData<TempUpload[]>(["uploads"]) ?? [];
const visualTargets = cached.filter(
(temp) => targetIds.has(temp.temp_id) && temp.status === "pending",
);
setPipeline({ phase: "visual", done: 0, total: visualTargets.length });
for (const temp of visualTargets) {
try {
const updated = await api<TempUpload>(
`/api/uploads/${temp.temp_id}/visual-match/`,
{ method: "POST" },
);
upsertUpload(updated);
} catch {
// Keep it pending.
}
setPipeline((current) =>
current ? { ...current, done: current.done + 1 } : current,
);
}
setPipeline(null);
// Phase 3: IQDB for everything still unresolved (serial queue with its
// own progress, cooldown and fatal-error stop).
const fresh = await refreshUploads(); const fresh = await refreshUploads();
void checkSimilarity(unresolvedIqdbIds(fresh)); checkSimilarity(unresolvedIqdbIds(fresh));
} }
/** Mark an entry done, fade it out and drop it once it has been seen. */ /** Mark an entry done, fade it out and drop it once it has been seen. */
@@ -1032,7 +1098,7 @@ export default function UploadPage() {
invalidateUploads(); invalidateUploads();
if (created.length > 0) { if (created.length > 0) {
setProcessing(true); setProcessing(true);
void autoMatchAndCheck(created).finally(() => setProcessing(false)); void runUploadPipeline(created).finally(() => setProcessing(false));
} }
} }
@@ -1133,33 +1199,38 @@ export default function UploadPage() {
retry checks retry checks
</button> </button>
</div> </div>
) : busy || processing ? ( ) : busy ? (
<div className="flex w-64 flex-col gap-1.5"> <PhaseBar
<p className="flex items-center gap-2 text-xs text-ctp-overlay0"> label={`Uploading ${Math.min(settledCount + 1, batch.total)}/${batch.total}…`}
<Spinner className="h-3.5 w-3.5 shrink-0" /> percent={batchPercent}
<span className="truncate"> tone="bg-ctp-mauve"
{busy
? `Uploading ${Math.min(settledCount + 1, batch.total)}/${batch.total}…`
: iqdbProgress
? `Checking IQDB — ${iqdbProgress.done}/${iqdbProgress.total}${
currentCheck ? ` · ${currentCheck}` : ""
}`
: "Processing uploads…"}
</span>
</p>
{!busy && iqdbProgress ? (
<div className="h-1 overflow-hidden rounded-full bg-ctp-surface0">
<div
className="h-full bg-ctp-teal transition-[width]"
style={{
width: `${Math.round(
(iqdbProgress.done / iqdbProgress.total) * 100,
)}%`,
}}
/> />
</div> ) : pipeline ? (
) : null} <PhaseBar
</div> label={`${
pipeline.phase === "md5" ? "e621 MD5" : "Visual similarity"
} — ${pipeline.done}/${pipeline.total}`}
percent={
pipeline.total
? Math.round((pipeline.done / pipeline.total) * 100)
: 0
}
tone={pipeline.phase === "md5" ? "bg-ctp-peach" : "bg-ctp-lavender"}
/>
) : processing && iqdbProgress ? (
<PhaseBar
label={`Checking IQDB — ${iqdbProgress.done}/${iqdbProgress.total}${
currentCheck ? ` · ${currentCheck}` : ""
}`}
percent={Math.round(
(iqdbProgress.done / iqdbProgress.total) * 100,
)}
tone="bg-ctp-teal"
/>
) : processing ? (
<p className="flex items-center gap-2 text-xs text-ctp-overlay0">
<Spinner className="h-3.5 w-3.5" /> Processing uploads…
</p>
) : null} ) : null}
</header> </header>