Upload pipeline: staging, MD5 auto-match, IQDB, three-column board

Backend:
- TempUpload model: staged files (pending / visual_match / completed /
  error) with resolution, e621 payload, custom metadata and IQDB data
- Files land in a temp folder and only move into the watched library
  folder once resolved; duplicates resolve immediately without a copy
- Endpoints: stage (multipart), list, retrieve, temp file, IQDB save,
  resolve (link to post or custom metadata), discard/dismiss
- cleanup_temp_uploads command for old staged files
- Replaces the old direct-to-library upload endpoint

Frontend:
- Upload page is now a three-column board (Pending & Unmatched /
  Visual Similarity Detected / Auto-uploaded & Indexed)
- After upload: MD5s are batch-checked against e621 and matches
  auto-complete with full post metadata; remaining files run through
  IQDB and move to the similarity column when candidates exist
- Metadata modal with IQDB candidates, post-ID linking and custom
  tags/rating/notes; discard and dismiss actions
- e621 client gains fetchPostsByMd5 and iqdbSearch helpers

Roadmap updated with the completed upload items.
This commit is contained in:
2026-09-17 11:12:03 -05:00
parent 60e1113231
commit d0e2901c92
13 changed files with 1189 additions and 118 deletions
+641 -39
View File
@@ -1,12 +1,32 @@
import { useQueryClient } from "@tanstack/react-query";
import { UploadCloud } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
CheckCircle2,
UploadCloud,
X,
} from "lucide-react";
import { useRef, useState, type DragEvent } from "react";
import { Link } from "react-router-dom";
import { Button, EmptyState } from "@/components/ui";
import { errorMessage, uploadFile } from "@/lib/api";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
import { api, authHeaders, errorMessage, uploadFile } from "@/lib/api";
import { cn } from "@/lib/cn";
import {
E621Error,
e621ErrorMessage,
effectiveCredentials,
fetchPost,
fetchPostsByMd5,
iqdbSearch,
} from "@/lib/e621";
import { formatBytes } from "@/lib/format";
import type {
E621IqdbCandidate,
Paginated,
Rating,
TempUpload,
} from "@/lib/types";
import { useAuth } from "@/store/auth";
import { useE621 } from "@/store/e621";
type UploadStatus = "queued" | "uploading" | "done" | "error";
@@ -18,13 +38,440 @@ interface UploadEntry {
error?: string;
}
const RESOLUTION_LABELS: Record<string, string> = {
auto_md5: "auto-matched on e621",
duplicate: "already in the library",
linked: "linked to a post",
custom: "custom metadata",
};
const ratingChoices: { value: Rating; label: string; active: string }[] = [
{
value: "",
label: "None",
active: "border-ctp-surface2 bg-ctp-surface0 text-ctp-text",
},
{
value: "s",
label: "Safe",
active: "border-ctp-green/40 bg-ctp-green/15 text-ctp-green",
},
{
value: "q",
label: "Questionable",
active: "border-ctp-peach/40 bg-ctp-peach/15 text-ctp-peach",
},
{
value: "e",
label: "Explicit",
active: "border-ctp-red/40 bg-ctp-red/15 text-ctp-red",
},
];
function describeError(error: unknown): string {
if (error instanceof E621Error) return e621ErrorMessage(error);
return errorMessage(error);
}
async function fetchTempBlob(url: string): Promise<Blob> {
const response = await fetch(url, { headers: authHeaders() });
if (!response.ok) {
throw new Error(`Could not read the staged file (${response.status}).`);
}
return response.blob();
}
function TempCard({
temp,
checking,
onOpen,
onCheck,
onDismiss,
}: {
temp: TempUpload;
checking: boolean;
onOpen: () => void;
onCheck: () => void;
onDismiss: () => void;
}) {
const isVideo = /\.(mp4|webm)$/i.test(temp.original_filename);
return (
<div className="rounded-lg border border-ctp-surface0 bg-ctp-base p-2.5">
<div className="relative aspect-video overflow-hidden rounded-md bg-ctp-mantle">
{temp.file_url ? (
isVideo ? (
<video
src={temp.file_url}
muted
playsInline
preload="metadata"
className="h-full w-full object-cover"
/>
) : (
<img
src={temp.file_url}
alt={temp.original_filename}
loading="lazy"
className="h-full w-full object-cover"
/>
)
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-ctp-overlay0">
{temp.status === "completed" ? "Indexed" : "No preview"}
</div>
)}
{temp.status === "visual_match" && temp.iqdb_data?.length ? (
<span className="absolute left-2 top-2 rounded-full bg-ctp-peach/90 px-1.5 py-0.5 font-mono text-[10px] font-medium text-ctp-crust">
{temp.iqdb_data.length} similar
</span>
) : null}
</div>
<p
className="mt-2 truncate font-mono text-xs text-ctp-subtext1"
title={temp.original_filename}
>
{temp.original_filename}
</p>
<p className="font-mono text-[10px] text-ctp-overlay0">
{formatBytes(temp.size)} · {temp.md5.slice(0, 10)}…
</p>
{temp.status === "completed" && temp.library_j_id ? (
<p className="mt-2 flex items-center gap-1.5 text-xs text-ctp-green">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
<Link
to={`/detail/${temp.library_j_id}`}
className="font-mono hover:underline"
>
{temp.library_j_id}
</Link>
<span className="truncate text-ctp-overlay0">
{RESOLUTION_LABELS[temp.resolution] ?? temp.resolution}
</span>
</p>
) : null}
{temp.status === "error" ? (
<p className="mt-2 text-xs text-ctp-red">
Something went wrong while indexing this file.
</p>
) : null}
{temp.status === "pending" || temp.status === "visual_match" ? (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<Button className="px-2 py-1 text-xs" onClick={onOpen}>
Set metadata
</Button>
{temp.status === "pending" ? (
<Button
variant="secondary"
className="px-2 py-1 text-xs"
onClick={onCheck}
disabled={checking}
>
{checking ? "Checking…" : "Check similarity"}
</Button>
) : null}
</div>
) : null}
<button
type="button"
onClick={onDismiss}
title={
temp.status === "completed"
? "Dismiss this record"
: "Discard this staged upload"
}
className="mt-2 inline-flex items-center gap-1 rounded-md px-1.5 py-1 font-mono text-[10px] text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
>
<X className="h-3 w-3" />
{temp.status === "completed" ? "dismiss" : "discard"}
</button>
</div>
);
}
function MetadataModal({
temp,
onClose,
}: {
temp: TempUpload;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const credentials = useE621((state) => state.credentials);
const [tab, setTab] = useState<"link" | "custom">("link");
const [postId, setPostId] = useState("");
const [rating, setRating] = useState<Rating>(temp.custom_rating);
const [tags, setTags] = useState(temp.custom_tags.join(", "));
const [notes, setNotes] = useState(temp.custom_notes);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const isVideo = /\.(mp4|webm)$/i.test(temp.original_filename);
async function run(task: () => Promise<void>) {
setBusy(true);
setError(null);
try {
await task();
void queryClient.invalidateQueries({ queryKey: ["uploads"] });
void queryClient.invalidateQueries({ queryKey: ["files"] });
onClose();
} catch (err) {
setError(describeError(err));
setBusy(false);
}
}
function linkById() {
const parsed = Number(postId.trim());
if (!Number.isFinite(parsed) || parsed <= 0) {
setError("Enter a valid e621 post ID.");
return;
}
return run(async () => {
const post = await fetchPost(effectiveCredentials(credentials), parsed);
await api(`/api/uploads/${temp.temp_id}/resolve/`, {
method: "POST",
json: { mode: "link", post, rating, tags, notes },
});
});
}
function linkCandidate(candidate: E621IqdbCandidate) {
if (!candidate.post_id) return;
return run(async () => {
const post = await fetchPost(
effectiveCredentials(credentials),
candidate.post_id as number,
);
await api(`/api/uploads/${temp.temp_id}/resolve/`, {
method: "POST",
json: { mode: "link", post, rating, tags, notes },
});
});
}
function saveCustom() {
return run(async () => {
await api(`/api/uploads/${temp.temp_id}/resolve/`, {
method: "POST",
json: { mode: "custom", rating, tags, notes },
});
});
}
return (
<div
className="fixed inset-0 z-[70] flex items-start justify-center overflow-y-auto bg-ctp-crust/70 px-4 py-10 backdrop-blur-sm"
onClick={onClose}
>
<div
className="w-full max-w-2xl rounded-lg border border-ctp-surface1 bg-ctp-base p-5"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Set metadata
</h2>
<p className="truncate font-mono text-xs text-ctp-overlay0">
{temp.original_filename}
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-md p-1 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
title="Close"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-[200px_minmax(0,1fr)]">
<div className="overflow-hidden rounded-md border border-ctp-surface0 bg-ctp-mantle">
{temp.file_url ? (
isVideo ? (
<video
src={temp.file_url}
controls
muted
className="max-h-52 w-full object-contain"
/>
) : (
<img
src={temp.file_url}
alt={temp.original_filename}
className="max-h-52 w-full object-contain"
/>
)
) : null}
</div>
<div className="flex min-w-0 flex-col gap-3">
<div className="flex gap-1.5">
<button
type="button"
onClick={() => setTab("link")}
className={cn(
"rounded-md px-3 py-1.5 text-sm font-medium transition",
tab === "link"
? "bg-ctp-surface0 text-ctp-text"
: "text-ctp-subtext0 hover:bg-ctp-surface0/60 hover:text-ctp-text",
)}
>
Link to e621 post
</button>
<button
type="button"
onClick={() => setTab("custom")}
className={cn(
"rounded-md px-3 py-1.5 text-sm font-medium transition",
tab === "custom"
? "bg-ctp-surface0 text-ctp-text"
: "text-ctp-subtext0 hover:bg-ctp-surface0/60 hover:text-ctp-text",
)}
>
Custom metadata
</button>
</div>
{tab === "link" ? (
<div className="flex flex-col gap-3">
{temp.iqdb_data && temp.iqdb_data.length > 0 ? (
<div>
<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={() => void linkCandidate(candidate)}
className="overflow-hidden rounded-md border border-ctp-surface0 bg-ctp-mantle text-left transition hover:border-ctp-surface2 disabled:opacity-50"
>
{candidate.preview_url ? (
<img
src={candidate.preview_url}
alt={`Post #${candidate.post_id}`}
loading="lazy"
className="aspect-square w-full object-cover"
/>
) : null}
<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>
</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>
)}
<div className="flex gap-2">
<input
className={cn(inputClass, "font-mono")}
placeholder="e621 post ID"
value={postId}
onChange={(event) => setPostId(event.target.value)}
/>
<Button onClick={() => void linkById()} disabled={busy}>
Link
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-1.5">
{ratingChoices.map((choice) => (
<button
key={choice.value || "none"}
type="button"
onClick={() => setRating(choice.value)}
className={cn(
"rounded-full border px-2.5 py-1 text-xs font-medium transition",
rating === choice.value
? choice.active
: "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text",
)}
>
{choice.label}
</button>
))}
</div>
<input
className={inputClass}
placeholder="Tags, comma separated"
value={tags}
onChange={(event) => setTags(event.target.value)}
/>
<textarea
className={cn(inputClass, "min-h-16 resize-y")}
placeholder="Notes…"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
<Button onClick={() => void saveCustom()} disabled={busy}>
Save custom metadata
</Button>
</div>
)}
{error ? (
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
{error}
</p>
) : null}
{busy ? (
<p className="flex items-center gap-2 text-xs text-ctp-overlay0">
<Spinner className="h-3.5 w-3.5" /> Working…
</p>
) : null}
</div>
</div>
</div>
</div>
);
}
export default function UploadPage() {
const queryClient = useQueryClient();
const user = useAuth((state) => state.user);
const credentials = useE621((state) => state.credentials);
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [entries, setEntries] = useState<UploadEntry[]>([]);
const [busy, setBusy] = useState(false);
const [processing, setProcessing] = useState(false);
const [checkingIds, setCheckingIds] = useState<string[]>([]);
const [modalFor, setModalFor] = useState<TempUpload | null>(null);
const uploadsQuery = useQuery({
queryKey: ["uploads"],
queryFn: () => api<Paginated<TempUpload>>("/api/uploads/"),
enabled: Boolean(user?.can_upload),
});
const uploads = uploadsQuery.data?.results ?? [];
const pending = uploads.filter((temp) => temp.status === "pending");
const visual = uploads.filter((temp) => temp.status === "visual_match");
const completed = uploads.filter(
(temp) => temp.status === "completed" || temp.status === "error",
);
const pendingCount = entries.filter(
(entry) => entry.status === "queued" || entry.status === "error",
@@ -55,19 +502,115 @@ export default function UploadPage() {
);
}
function invalidateUploads() {
void queryClient.invalidateQueries({ queryKey: ["uploads"] });
}
async function refreshUploads(): Promise<TempUpload[]> {
const fresh = await api<Paginated<TempUpload>>("/api/uploads/");
queryClient.setQueryData(["uploads"], fresh);
return fresh.results;
}
async function checkSimilarity(ids: string[]) {
if (ids.length === 0) return;
setProcessing(true);
setCheckingIds((current) => [...current, ...ids]);
try {
const fresh = await refreshUploads();
const targets = fresh.filter(
(temp) => ids.includes(temp.temp_id) && temp.status === "pending",
);
for (const temp of targets) {
if (!temp.file_url) continue;
try {
const blob = await fetchTempBlob(temp.file_url);
const results = await iqdbSearch(
effectiveCredentials(credentials),
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.
} finally {
setCheckingIds((current) =>
current.filter((value) => value !== temp.temp_id),
);
}
}
} finally {
setProcessing(false);
setCheckingIds([]);
invalidateUploads();
}
}
async function autoMatchAndCheck(created: TempUpload[]) {
const creds = effectiveCredentials(credentials);
const unresolved = created.filter((temp) => temp.status === "pending");
// 1) Auto-complete files whose MD5 matches an e621 post.
if (unresolved.length > 0) {
try {
for (let index = 0; index < unresolved.length; index += 20) {
const batch = unresolved.slice(index, index + 20);
const posts = await fetchPostsByMd5(
creds,
batch.map((temp) => temp.md5),
);
for (const post of posts) {
const match = batch.find((temp) => temp.md5 === post.file.md5);
if (!match) continue;
try {
await api(`/api/uploads/${match.temp_id}/resolve/`, {
method: "POST",
json: { mode: "link", post, auto: true },
});
} catch {
// Leave it pending.
}
}
}
} catch {
// e621 unavailable; leave everything pending.
}
invalidateUploads();
}
// 2) IQDB similarity for whatever is still pending.
const fresh = await refreshUploads();
const stillPending = fresh
.filter((temp) => temp.status === "pending")
.map((temp) => temp.temp_id);
void checkSimilarity(stillPending);
}
async function startUploads() {
if (busy) return;
setBusy(true);
setProcessing(true);
const queue = entries.filter(
(entry) => entry.status === "queued" || entry.status === "error",
);
const created: TempUpload[] = [];
for (const entry of queue) {
updateEntry(entry.id, { status: "uploading", progress: 0, error: undefined });
updateEntry(entry.id, {
status: "uploading",
progress: 0,
error: undefined,
});
try {
await uploadFile(entry.file, {}, (percent) =>
const temp = await uploadFile(entry.file, {}, (percent) =>
updateEntry(entry.id, { progress: percent }),
);
updateEntry(entry.id, { status: "done", progress: 100 });
created.push(temp);
} catch (error) {
updateEntry(entry.id, {
status: "error",
@@ -75,8 +618,15 @@ export default function UploadPage() {
});
}
}
void queryClient.invalidateQueries({ queryKey: ["files"] });
setBusy(false);
invalidateUploads();
void autoMatchAndCheck(created).finally(() => setProcessing(false));
}
function dismiss(temp: TempUpload) {
void api(`/api/uploads/${temp.temp_id}/`, { method: "DELETE" }).then(() =>
invalidateUploads(),
);
}
function clearFinished() {
@@ -97,13 +647,54 @@ export default function UploadPage() {
);
}
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
<header>
<h1 className="text-lg font-semibold">Upload</h1>
<p className="mt-1 text-sm text-ctp-overlay0">
Files are written to the watched folder and indexed into the library.
const column = (
title: string,
items: TempUpload[],
emptyText: string,
) => (
<section className="flex min-w-0 flex-col gap-3">
<header className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-ctp-subtext1">{title}</h2>
<span className="font-mono text-[11px] text-ctp-overlay0">
{items.length}
</span>
</header>
{items.length === 0 ? (
<p className="rounded-lg border border-dashed border-ctp-surface1 px-3 py-6 text-center text-xs text-ctp-overlay0">
{emptyText}
</p>
) : (
<div className="flex flex-col gap-3">
{items.map((temp) => (
<TempCard
key={temp.temp_id}
temp={temp}
checking={checkingIds.includes(temp.temp_id)}
onOpen={() => setModalFor(temp)}
onCheck={() => void checkSimilarity([temp.temp_id])}
onDismiss={() => dismiss(temp)}
/>
))}
</div>
)}
</section>
);
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<header className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">Upload</h1>
<p className="mt-1 text-sm text-ctp-overlay0">
Files are staged first, auto-matched by MD5, checked against IQDB,
then moved into the library once resolved.
</p>
</div>
{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}
</header>
<div
@@ -122,13 +713,13 @@ export default function UploadPage() {
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
className={cn(
"flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-6 py-14 text-center transition",
"flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-6 py-10 text-center transition",
dragging
? "border-ctp-mauve bg-ctp-mauve/5"
: "border-ctp-surface1 hover:border-ctp-surface2",
)}
>
<UploadCloud className="h-8 w-8 text-ctp-overlay0" />
<UploadCloud className="h-7 w-7 text-ctp-overlay0" />
<p className="text-sm font-medium text-ctp-subtext1">
Drag &amp; drop files here
</p>
@@ -148,7 +739,7 @@ export default function UploadPage() {
</div>
{entries.length > 0 ? (
<section className="flex flex-col gap-3">
<section className="flex flex-col gap-2">
{entries.map((entry) => (
<div
key={entry.id}
@@ -177,35 +768,46 @@ export default function UploadPage() {
}}
/>
</div>
<div className="mt-1.5 flex items-center justify-between text-xs">
<span className="text-ctp-overlay0">
{entry.status === "queued"
? "Queued"
: entry.status === "uploading"
? `Uploading… ${entry.progress}%`
: entry.status === "done"
? "Done"
: (entry.error ?? "Failed")}
</span>
<div className="mt-1.5 text-xs text-ctp-overlay0">
{entry.status === "queued"
? "Queued"
: entry.status === "uploading"
? `Uploading… ${entry.progress}%`
: entry.status === "done"
? "Uploaded — processing"
: (entry.error ?? "Failed")}
</div>
</div>
))}
<div className="flex items-center gap-3">
<Button
onClick={() => void startUploads()}
disabled={busy || pendingCount === 0}
>
{busy
? "Uploading…"
: `Upload ${pendingCount || ""} file${pendingCount === 1 ? "" : "s"}`.trim()}
</Button>
<Button variant="ghost" onClick={clearFinished} disabled={busy}>
Clear finished
</Button>
</div>
</section>
) : (
<EmptyState
title="No files selected"
description="Drop files above to queue them for upload."
/>
)}
) : null}
<div className="flex items-center gap-3">
<Button onClick={() => void startUploads()} disabled={busy || pendingCount === 0}>
{busy ? "Uploading…" : `Upload ${pendingCount || ""} file${pendingCount === 1 ? "" : "s"}`.trim()}
</Button>
<Button variant="ghost" onClick={clearFinished} disabled={busy}>
Clear finished
</Button>
<div className="grid gap-6 lg:grid-cols-3">
{column("Pending & Unmatched", pending, "Nothing waiting.")}
{column(
"Visual Similarity Detected",
visual,
"No IQDB matches right now.",
)}
{column("Auto-uploaded & Indexed", completed, "Nothing indexed yet.")}
</div>
{modalFor ? (
<MetadataModal temp={modalFor} onClose={() => setModalFor(null)} />
) : null}
</div>
);
}