Unpaginate staged uploads and make big upload batches visible
The upload board partitions /api/uploads/ into Pending / Visual similarity / Auto-uploaded, but the endpoint was paginated at 48 — a 69-file batch silently lost 21 entries, and the similarity sweep (which reads the same list back after uploading) only ever saw the first page. The staged-upload list is now unpaginated: it is a transient per-user set, still limited to the caller's rows and the uploader role. The page takes a plain array. Watching progress with dozens of files was also poor: - the queue uploads three files at a time instead of strictly one at a time; - the Uploads section now shows a batch bar and 'n/m uploaded · x%' next to the count, so the overall progress never scrolls out of sight; - entries are ordered active-first (uploading, queued, failed, done) so the file being uploaded is always at the top of the grid; - tiles are larger (4 columns at lg instead of 5); - the header reads 'Uploading n/m…' and 'Checking n file(s) against IQDB…' instead of a bare spinner. Tests: staged-upload list unpaginated past 48, per-user, uploader-only (3 new; full suite 49 green). Live-checked the bare-array response.
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
"""The staged-upload list is a complete set, not a page.
|
||||||
|
|
||||||
|
The upload board partitions it into Pending / Visual similarity / Auto-uploaded
|
||||||
|
columns; with the standard 48-item pagination a 69-file batch silently lost 21
|
||||||
|
entries from the board (and from the similarity sweep that reads it back).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import Client, TestCase
|
||||||
|
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
|
from apps.library.models import TempUpload
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class TempUploadListTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.uploader = User.objects.create_user(
|
||||||
|
username="upload-user", password="upload-pass-123456"
|
||||||
|
)
|
||||||
|
self.uploader.role = "uploader"
|
||||||
|
self.uploader.save(update_fields=["role"])
|
||||||
|
self.other = User.objects.create_user(
|
||||||
|
username="upload-other", password="upload-pass-123456"
|
||||||
|
)
|
||||||
|
self.other.role = "uploader"
|
||||||
|
self.other.save(update_fields=["role"])
|
||||||
|
|
||||||
|
def api_client(self, user):
|
||||||
|
client = Client()
|
||||||
|
client.defaults["HTTP_AUTHORIZATION"] = (
|
||||||
|
f"Token {Token.objects.create(user=user).key}"
|
||||||
|
)
|
||||||
|
return client
|
||||||
|
|
||||||
|
def test_list_is_not_paginated_past_48(self):
|
||||||
|
TempUpload.objects.bulk_create(
|
||||||
|
[
|
||||||
|
TempUpload(
|
||||||
|
user=self.uploader,
|
||||||
|
original_filename=f"file_{index:03d}.png",
|
||||||
|
md5=hashlib.md5(f"file-{index}".encode()).hexdigest(),
|
||||||
|
size=index,
|
||||||
|
)
|
||||||
|
for index in range(69)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
response = self.api_client(self.uploader).get("/api/uploads/")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertIsInstance(data, list, "the list must not be paginated")
|
||||||
|
self.assertEqual(len(data), 69)
|
||||||
|
|
||||||
|
def test_list_only_contains_the_callers_uploads(self):
|
||||||
|
TempUpload.objects.create(
|
||||||
|
user=self.uploader,
|
||||||
|
original_filename="mine.png",
|
||||||
|
md5=hashlib.md5(b"mine").hexdigest(),
|
||||||
|
size=1,
|
||||||
|
)
|
||||||
|
TempUpload.objects.create(
|
||||||
|
user=self.other,
|
||||||
|
original_filename="theirs.png",
|
||||||
|
md5=hashlib.md5(b"theirs").hexdigest(),
|
||||||
|
size=1,
|
||||||
|
)
|
||||||
|
mine = self.api_client(self.uploader).get("/api/uploads/").json()
|
||||||
|
self.assertEqual([row["original_filename"] for row in mine], ["mine.png"])
|
||||||
|
|
||||||
|
def test_anonymous_cannot_list(self):
|
||||||
|
self.assertEqual(Client().get("/api/uploads/").status_code, 401)
|
||||||
@@ -150,6 +150,9 @@ class TempUploadViewSet(
|
|||||||
serializer_class = TempUploadSerializer
|
serializer_class = TempUploadSerializer
|
||||||
permission_classes = [CanUpload]
|
permission_classes = [CanUpload]
|
||||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
# Unpaginated: the board shows every staged upload (69-file batches were
|
||||||
|
# silently cut to the API's 48-item page).
|
||||||
|
pagination_class = None
|
||||||
http_method_names = ["get", "post", "delete", "head", "options"]
|
http_method_names = ["get", "post", "delete", "head", "options"]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type DragEvent,
|
type DragEvent,
|
||||||
@@ -27,12 +28,7 @@ import {
|
|||||||
RATING_LABELS,
|
RATING_LABELS,
|
||||||
} from "@/lib/e621";
|
} from "@/lib/e621";
|
||||||
import { formatBytes } from "@/lib/format";
|
import { formatBytes } from "@/lib/format";
|
||||||
import type {
|
import type { E621IqdbCandidate, Rating, TempUpload } from "@/lib/types";
|
||||||
E621IqdbCandidate,
|
|
||||||
Paginated,
|
|
||||||
Rating,
|
|
||||||
TempUpload,
|
|
||||||
} from "@/lib/types";
|
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
import { confirmAction } from "@/store/confirm";
|
import { confirmAction } from "@/store/confirm";
|
||||||
import { useE621 } from "@/store/e621";
|
import { useE621 } from "@/store/e621";
|
||||||
@@ -40,6 +36,17 @@ import { toast } from "@/store/toasts";
|
|||||||
|
|
||||||
type UploadStatus = "queued" | "uploading" | "done" | "error";
|
type UploadStatus = "queued" | "uploading" | "done" | "error";
|
||||||
|
|
||||||
|
/** How many files upload at once; big batches were painfully serial. */
|
||||||
|
const UPLOAD_CONCURRENCY = 3;
|
||||||
|
|
||||||
|
/** Display order: the file being uploaded stays at the top of the grid. */
|
||||||
|
const STATUS_ORDER: Record<UploadStatus, number> = {
|
||||||
|
uploading: 0,
|
||||||
|
queued: 1,
|
||||||
|
error: 2,
|
||||||
|
done: 3,
|
||||||
|
};
|
||||||
|
|
||||||
interface UploadEntry {
|
interface UploadEntry {
|
||||||
id: string;
|
id: string;
|
||||||
file: File;
|
file: File;
|
||||||
@@ -642,10 +649,11 @@ export default function UploadPage() {
|
|||||||
|
|
||||||
const uploadsQuery = useQuery({
|
const uploadsQuery = useQuery({
|
||||||
queryKey: ["uploads"],
|
queryKey: ["uploads"],
|
||||||
queryFn: () => api<Paginated<TempUpload>>("/api/uploads/"),
|
// Unpaginated: the board partitions the complete staging list.
|
||||||
|
queryFn: () => api<TempUpload[]>("/api/uploads/"),
|
||||||
enabled: Boolean(user?.can_upload),
|
enabled: Boolean(user?.can_upload),
|
||||||
});
|
});
|
||||||
const uploads = uploadsQuery.data?.results ?? [];
|
const uploads = uploadsQuery.data ?? [];
|
||||||
|
|
||||||
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");
|
||||||
@@ -653,6 +661,32 @@ export default function UploadPage() {
|
|||||||
(temp) => temp.status === "completed" || temp.status === "error",
|
(temp) => temp.status === "completed" || temp.status === "error",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Active work first so a long batch never hides the file in progress.
|
||||||
|
const orderedEntries = useMemo(
|
||||||
|
() =>
|
||||||
|
[...entries].sort(
|
||||||
|
(a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status],
|
||||||
|
),
|
||||||
|
[entries],
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadedCount = entries.filter((entry) => entry.status === "done").length;
|
||||||
|
const failedCount = entries.filter((entry) => entry.status === "error").length;
|
||||||
|
const batchPercent = entries.length
|
||||||
|
? Math.round(
|
||||||
|
entries.reduce(
|
||||||
|
(total, entry) =>
|
||||||
|
total +
|
||||||
|
(entry.status === "done"
|
||||||
|
? 100
|
||||||
|
: entry.status === "queued"
|
||||||
|
? 0
|
||||||
|
: entry.progress),
|
||||||
|
0,
|
||||||
|
) / entries.length,
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
function addFiles(files: FileList | null) {
|
function addFiles(files: FileList | null) {
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
const next: UploadEntry[] = Array.from(files).map((file) => {
|
const next: UploadEntry[] = Array.from(files).map((file) => {
|
||||||
@@ -691,9 +725,9 @@ export default function UploadPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshUploads(): Promise<TempUpload[]> {
|
async function refreshUploads(): Promise<TempUpload[]> {
|
||||||
const fresh = await api<Paginated<TempUpload>>("/api/uploads/");
|
const fresh = await api<TempUpload[]>("/api/uploads/");
|
||||||
queryClient.setQueryData(["uploads"], fresh);
|
queryClient.setQueryData(["uploads"], fresh);
|
||||||
return fresh.results;
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkSimilarity(ids: string[]) {
|
async function checkSimilarity(ids: string[]) {
|
||||||
@@ -780,27 +814,35 @@ export default function UploadPage() {
|
|||||||
runningRef.current = true;
|
runningRef.current = true;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const created: TempUpload[] = [];
|
const created: TempUpload[] = [];
|
||||||
while (queueRef.current.length > 0) {
|
|
||||||
const entry = queueRef.current.shift();
|
async function worker() {
|
||||||
if (!entry) break;
|
while (queueRef.current.length > 0) {
|
||||||
updateEntry(entry.id, {
|
const entry = queueRef.current.shift();
|
||||||
status: "uploading",
|
if (!entry) return;
|
||||||
progress: 0,
|
|
||||||
error: undefined,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
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, {
|
updateEntry(entry.id, {
|
||||||
status: "error",
|
status: "uploading",
|
||||||
error: errorMessage(error),
|
progress: 0,
|
||||||
|
error: undefined,
|
||||||
});
|
});
|
||||||
|
try {
|
||||||
|
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",
|
||||||
|
error: errorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: UPLOAD_CONCURRENCY }, () => worker()),
|
||||||
|
);
|
||||||
|
|
||||||
runningRef.current = false;
|
runningRef.current = false;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
invalidateUploads();
|
invalidateUploads();
|
||||||
@@ -899,7 +941,11 @@ export default function UploadPage() {
|
|||||||
{busy || processing ? (
|
{busy || processing ? (
|
||||||
<p className="flex items-center gap-2 text-xs text-ctp-overlay0">
|
<p className="flex items-center gap-2 text-xs text-ctp-overlay0">
|
||||||
<Spinner className="h-3.5 w-3.5" />
|
<Spinner className="h-3.5 w-3.5" />
|
||||||
{busy ? "Uploading…" : "Processing uploads…"}
|
{busy
|
||||||
|
? `Uploading ${Math.min(uploadedCount + 1, entries.length)}/${entries.length}…`
|
||||||
|
: checkingIds.length > 0
|
||||||
|
? `Checking ${checkingIds.length} file(s) against IQDB…`
|
||||||
|
: "Processing uploads…"}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
@@ -950,11 +996,12 @@ export default function UploadPage() {
|
|||||||
|
|
||||||
{entries.length > 0 ? (
|
{entries.length > 0 ? (
|
||||||
<section className="flex flex-col gap-2">
|
<section className="flex flex-col gap-2">
|
||||||
<header className="flex items-center justify-between gap-2">
|
<header className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||||||
Uploads
|
Uploads
|
||||||
<span className="ml-2 font-mono text-[11px] text-ctp-overlay0">
|
<span className="ml-2 font-mono text-[11px] text-ctp-overlay0">
|
||||||
{entries.length}
|
{uploadedCount}/{entries.length} uploaded
|
||||||
|
{failedCount > 0 ? ` · ${failedCount} failed` : ""}
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
@@ -965,8 +1012,19 @@ export default function UploadPage() {
|
|||||||
clear finished
|
clear finished
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div className="grid max-h-[46rem] grid-cols-2 gap-3 overflow-y-auto pr-1 sm:grid-cols-3 lg:grid-cols-5">
|
<div className="flex items-center gap-3">
|
||||||
{entries.map((entry) => (
|
<div className="h-1.5 min-w-32 flex-1 overflow-hidden rounded-full bg-ctp-surface0">
|
||||||
|
<div
|
||||||
|
className="h-full bg-ctp-mauve transition-[width]"
|
||||||
|
style={{ width: `${batchPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 font-mono text-[11px] text-ctp-overlay0">
|
||||||
|
{batchPercent}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid max-h-[46rem] grid-cols-2 gap-3 overflow-y-auto pr-1 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{orderedEntries.map((entry) => (
|
||||||
<div
|
<div
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
className="overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base"
|
className="overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base"
|
||||||
|
|||||||
Reference in New Issue
Block a user