Compare commits
7
Commits
a761def65e
...
e2bf1c457f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2bf1c457f | ||
|
|
e62d7af42f | ||
|
|
9641862515 | ||
|
|
b96c311235 | ||
|
|
2f613b7027 | ||
|
|
bc7494e7be | ||
|
|
b076903ecd |
@@ -2,6 +2,11 @@ Use the venv on backend/venv/
|
||||
|
||||
DO NOT mess with the system's python
|
||||
|
||||
The original J621 (Django) app is kept for reference at
|
||||
/mnt/Disco/Proyects/Python3.13/J621-Django/
|
||||
Consult it when matching original behavior (batch sizes for e621 lookups,
|
||||
per-page sets, upload flow) instead of guessing.
|
||||
|
||||
For Frontend work:
|
||||
|
||||
read both Frontend Design related Markdown files on the frontend/ folder
|
||||
|
||||
@@ -13,7 +13,10 @@ import time
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
REQUEST_INTERVAL = 0.5 # seconds between requests, per process
|
||||
# Seconds between requests, per process. e621 allows 2/s hard and 1/s
|
||||
# sustained; each gunicorn worker throttles on its own, so leave enough
|
||||
# headroom that combined traffic does not trip the limit.
|
||||
REQUEST_INTERVAL = 1.0
|
||||
|
||||
|
||||
class E621Error(Exception):
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Staged uploads: complete-set listing and the bulk rating tool."""
|
||||
"""Staged uploads: listing, phases (MD5/visual/IQDB) and the bulk tool."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
@@ -11,6 +12,8 @@ from django.contrib.auth import get_user_model
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import Client, TestCase, override_settings
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
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):
|
||||
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)
|
||||
|
||||
|
||||
class BulkResolveTests(TestCase):
|
||||
class StagedUploadWorkflowTests(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
@@ -195,3 +205,143 @@ 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)
|
||||
|
||||
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):
|
||||
"""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)
|
||||
|
||||
@@ -190,18 +190,37 @@ class TempUploadViewSet(
|
||||
temp.resolution = TempUpload.RESOLUTION_DUPLICATE
|
||||
temp.library_item = existing
|
||||
temp.file.delete(save=False)
|
||||
else:
|
||||
matches = find_library_matches(
|
||||
temp.file.path, user=request.user, request=request
|
||||
)
|
||||
if matches:
|
||||
temp.visual_matches = matches
|
||||
temp.status = TempUpload.STATUS_VISUAL_MATCH
|
||||
# Visual similarity is deliberately a separate phase (the
|
||||
# /visual-match action) so a large batch uploads at full speed and
|
||||
# the board runs MD5 -> visual -> IQDB over the whole batch.
|
||||
temp.save()
|
||||
return Response(
|
||||
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])
|
||||
def file(self, request, pk=None):
|
||||
"""Serve the staged file; accepts a signed URL for media tags."""
|
||||
|
||||
@@ -206,7 +206,9 @@ WATCHED_FOLDER = str(WATCHED_FOLDER)
|
||||
# e621 integration
|
||||
|
||||
E621_BASE_URL = os.getenv("E621_BASE_URL", "https://e621.net").rstrip("/")
|
||||
USER_AGENT = os.getenv("USER_AGENT", "J621/0.1 (by J621 on e621)")
|
||||
# e621 asks for "Application name/version (developer)". Browser clients cannot
|
||||
# set a User-Agent, so the SPA sends the same string in its `_client` parameter.
|
||||
USER_AGENT = os.getenv("USER_AGENT", f"J621/{GIT_COMMIT_HASH} (JakeBreath)")
|
||||
# Hosts the client-download proxy is allowed to stream from.
|
||||
E621_MEDIA_HOSTS = [
|
||||
host.strip()
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22-alpine AS build
|
||||
ARG GIT_HASH=dev
|
||||
ENV GIT_HASH=$GIT_HASH
|
||||
WORKDIR /app
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
@@ -26,6 +26,7 @@ fi
|
||||
echo "==> Building + pushing $PLATFORMS -> $REGISTRY:{latest,$SHA} ..."
|
||||
docker buildx build --builder "$BUILDER" --push \
|
||||
--platform "$PLATFORMS" \
|
||||
--build-arg "GIT_HASH=$SHA" \
|
||||
-f deploy/J621-Frontend \
|
||||
-t "$REGISTRY:latest" \
|
||||
-t "$REGISTRY:$SHA" \
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { apiUrl } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
import type { MediaItem } from "@/lib/types";
|
||||
@@ -17,8 +18,9 @@ const ratingLabels: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function MediaCard({ item }: { item: MediaItem }) {
|
||||
const preview =
|
||||
item.kind === "video" ? item.thumbnail_url : item.raw_url;
|
||||
const preview = apiUrl(
|
||||
item.kind === "video" ? item.thumbnail_url : item.raw_url,
|
||||
);
|
||||
const rating = item.display_rating;
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
|
||||
import { api, errorMessage } from "@/lib/api";
|
||||
import { api, apiUrl, errorMessage } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
import type { MediaItem, Paginated, StorageInfo } from "@/lib/types";
|
||||
@@ -245,7 +245,7 @@ export default function DeletePage() {
|
||||
>
|
||||
<div className="relative aspect-square overflow-hidden bg-ctp-mantle">
|
||||
<img
|
||||
src={item.thumbnail_url}
|
||||
src={apiUrl(item.thumbnail_url)}
|
||||
alt={item.filename}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { Button, Spinner } from "@/components/ui";
|
||||
import { api, errorMessage } from "@/lib/api";
|
||||
import { api, apiUrl, errorMessage } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import {
|
||||
e621ErrorMessage,
|
||||
@@ -44,7 +44,7 @@ export function IqdbCard({
|
||||
setSelected(null);
|
||||
setCandidates(null);
|
||||
try {
|
||||
const response = await fetch(item.raw_url);
|
||||
const response = await fetch(apiUrl(item.raw_url));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not load the file (HTTP ${response.status}).`);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { X } from "lucide-react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
import { Button, Spinner, inputClass } from "@/components/ui";
|
||||
import { errorMessage, uploadFile } from "@/lib/api";
|
||||
import { apiUrl, errorMessage, uploadFile } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
import type { MediaItem } from "@/lib/types";
|
||||
@@ -171,6 +171,9 @@ export function OptimizeModal({
|
||||
const queryClient = useQueryClient();
|
||||
const extensionKind: OptimizeKind =
|
||||
kindForExtension(item.extension) ?? "image";
|
||||
// Same-origin through the proxy in dev; the worker and the range requests
|
||||
// cannot use a cross-origin URL without CORS.
|
||||
const fileUrl = apiUrl(item.raw_url);
|
||||
const [options, setOptions] = useState<OptimizeOptions>({
|
||||
...DEFAULT_OPTIONS,
|
||||
maxDimension: null,
|
||||
@@ -180,7 +183,7 @@ export function OptimizeModal({
|
||||
const sniffQuery = useQuery({
|
||||
queryKey: ["image-kind", item.j_id, item.updated_at],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(item.raw_url, {
|
||||
const response = await fetch(fileUrl, {
|
||||
headers: { Range: "bytes=0-4095" },
|
||||
});
|
||||
if (!response.ok && response.status !== 206) {
|
||||
@@ -315,7 +318,7 @@ export function OptimizeModal({
|
||||
if (result?.storageKey) void deleteStoredFile(result.storageKey);
|
||||
setResultInfo(null);
|
||||
run({
|
||||
url: item.raw_url,
|
||||
url: fileUrl,
|
||||
filename: item.filename,
|
||||
kind,
|
||||
options,
|
||||
@@ -366,7 +369,7 @@ export function OptimizeModal({
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<PreviewPanel
|
||||
title="Original"
|
||||
url={item.raw_url}
|
||||
url={fileUrl}
|
||||
isVideo={isVideo}
|
||||
size={originalSize}
|
||||
state="original"
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Link } from "react-router-dom";
|
||||
|
||||
import { ResponsivePanel } from "@/components/BottomSheet";
|
||||
import { Button, Spinner } from "@/components/ui";
|
||||
import { api, errorMessage, uploadFile } from "@/lib/api";
|
||||
import { api, apiUrl, errorMessage, uploadFile } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import {
|
||||
e621ErrorMessage,
|
||||
@@ -257,7 +257,7 @@ export default function SimilarPage() {
|
||||
>
|
||||
{exact.thumbnail_url ? (
|
||||
<img
|
||||
src={exact.thumbnail_url}
|
||||
src={apiUrl(exact.thumbnail_url)}
|
||||
alt={exact.j_id}
|
||||
className="h-14 w-14 rounded object-cover"
|
||||
/>
|
||||
@@ -309,7 +309,7 @@ export default function SimilarPage() {
|
||||
<span className="relative block aspect-square overflow-hidden bg-ctp-mantle">
|
||||
{match.thumbnail_url ? (
|
||||
<img
|
||||
src={match.thumbnail_url}
|
||||
src={apiUrl(match.thumbnail_url)}
|
||||
alt={match.j_id}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition group-hover:scale-[1.03]"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Video, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button, Spinner } from "@/components/ui";
|
||||
import { api, errorMessage } from "@/lib/api";
|
||||
import { api, apiUrl, errorMessage } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { TempUpload } from "@/lib/types";
|
||||
import { toast } from "@/store/toasts";
|
||||
@@ -26,15 +26,31 @@ const RATING_OPTIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* How many files one request resolves. Copying and hashing is serial per
|
||||
* file on the server, so a 200-file batch in one request risks the proxy
|
||||
* timeout and gives no feedback; small chunks keep every request short and
|
||||
* let the bar advance as they land.
|
||||
*/
|
||||
const CHUNK_SIZE = 8;
|
||||
|
||||
interface BulkResult {
|
||||
resolved: string[];
|
||||
errors: { temp_id: string; error: string }[];
|
||||
}
|
||||
|
||||
interface Failure {
|
||||
id: string;
|
||||
name: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
type Phase = "select" | "running" | "done";
|
||||
|
||||
/**
|
||||
* Bulk-rating tool for the Pending & Unmatched column: pick a rating, tick
|
||||
* every thumbnail that belongs to it, and they all move into the library as
|
||||
* custom entries in one request.
|
||||
* every thumbnail that belongs to it, and they all move into the library,
|
||||
* reporting progress chunk by chunk.
|
||||
*/
|
||||
export function BulkRateModal({
|
||||
uploads,
|
||||
@@ -46,28 +62,73 @@ export function BulkRateModal({
|
||||
const queryClient = useQueryClient();
|
||||
const [rating, setRating] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [phase, setPhase] = useState<Phase>("select");
|
||||
const [progress, setProgress] = useState({ done: 0, total: 0, moved: 0 });
|
||||
const [failures, setFailures] = useState<Failure[]>([]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (payload: { temp_ids: string[]; rating: string }) =>
|
||||
api<BulkResult>("/api/uploads/resolve-bulk/", {
|
||||
const names = new Map(uploads.map((temp) => [temp.temp_id, temp.original_filename]));
|
||||
const busy = phase === "running";
|
||||
const percent =
|
||||
progress.total > 0
|
||||
? Math.min(100, Math.round((progress.done / progress.total) * 100))
|
||||
: 0;
|
||||
const retryable = failures.filter((failure) => names.has(failure.id));
|
||||
|
||||
async function run() {
|
||||
if (!rating || selected.size === 0 || busy) return;
|
||||
const ids = [...selected];
|
||||
|
||||
setPhase("running");
|
||||
setFailures([]);
|
||||
setProgress({ done: 0, total: ids.length, moved: 0 });
|
||||
|
||||
let moved = 0;
|
||||
const problems: Failure[] = [];
|
||||
for (let index = 0; index < ids.length; index += CHUNK_SIZE) {
|
||||
const chunk = ids.slice(index, index + CHUNK_SIZE);
|
||||
try {
|
||||
const result = await api<BulkResult>("/api/uploads/resolve-bulk/", {
|
||||
method: "POST",
|
||||
json: payload,
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
json: { temp_ids: chunk, rating },
|
||||
});
|
||||
moved += result.resolved.length;
|
||||
for (const problem of result.errors) {
|
||||
problems.push({
|
||||
id: problem.temp_id,
|
||||
name: names.get(problem.temp_id) ?? problem.temp_id,
|
||||
error: problem.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
for (const id of chunk) {
|
||||
problems.push({
|
||||
id,
|
||||
name: names.get(id) ?? id,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
setProgress({
|
||||
done: Math.min(index + CHUNK_SIZE, ids.length),
|
||||
total: ids.length,
|
||||
moved,
|
||||
});
|
||||
}
|
||||
|
||||
void queryClient.invalidateQueries({ queryKey: ["uploads"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
||||
if (result.resolved.length > 0) {
|
||||
toast.ok(`Moved ${result.resolved.length} upload(s) into the library.`);
|
||||
}
|
||||
if (result.errors.length > 0) {
|
||||
toast.error(
|
||||
`${result.errors.length} could not be resolved: ${result.errors[0].error}`,
|
||||
);
|
||||
}
|
||||
setFailures(problems);
|
||||
setPhase("done");
|
||||
|
||||
if (problems.length === 0) {
|
||||
toast.ok(`Moved ${moved} upload(s) into the library.`);
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
} else if (moved > 0) {
|
||||
toast.error(`Moved ${moved}, ${problems.length} failed.`);
|
||||
} else {
|
||||
toast.error(`Nothing could be moved (${problems.length} failed).`);
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(id: string) {
|
||||
setSelected((current) => {
|
||||
@@ -78,10 +139,20 @@ export function BulkRateModal({
|
||||
});
|
||||
}
|
||||
|
||||
function requestClose() {
|
||||
if (!busy) onClose();
|
||||
}
|
||||
|
||||
function retryFailed() {
|
||||
setSelected(new Set(retryable.map((failure) => failure.id)));
|
||||
setFailures([]);
|
||||
setPhase("select");
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-start justify-center overflow-y-auto bg-ctp-crust/70 px-4 py-8 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
onClick={requestClose}
|
||||
>
|
||||
<div
|
||||
className="flex w-full max-w-6xl flex-col overflow-hidden rounded-lg border border-ctp-surface1 bg-ctp-base"
|
||||
@@ -93,15 +164,19 @@ export function BulkRateModal({
|
||||
Rate unmatched uploads
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-ctp-overlay0">
|
||||
Pick a rating, tick every file that belongs to it, and they move
|
||||
into the library in one go ({uploads.length} pending).
|
||||
{busy
|
||||
? "Moving files into the library — this can take a moment, keep this open."
|
||||
: "Pick a rating, tick every file that belongs to it, and they move into the library in one go (" +
|
||||
uploads.length +
|
||||
" pending)."}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
title="Close"
|
||||
className="rounded-md p-1 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
|
||||
onClick={requestClose}
|
||||
disabled={busy}
|
||||
title={busy ? "Wait for the move to finish" : "Close"}
|
||||
className="rounded-md p-1 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -112,9 +187,10 @@ export function BulkRateModal({
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setRating(option.value)}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1 text-xs font-medium transition",
|
||||
"rounded-full border px-3 py-1 text-xs font-medium transition disabled:pointer-events-none disabled:opacity-40",
|
||||
rating === option.value
|
||||
? option.active
|
||||
: "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text",
|
||||
@@ -127,6 +203,7 @@ export function BulkRateModal({
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="px-2 py-1 text-xs"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setSelected(new Set(uploads.map((temp) => temp.temp_id)))
|
||||
}
|
||||
@@ -136,7 +213,7 @@ export function BulkRateModal({
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
disabled={selected.size === 0}
|
||||
disabled={busy || selected.size === 0}
|
||||
onClick={() => setSelected(new Set())}
|
||||
>
|
||||
Clear
|
||||
@@ -150,18 +227,26 @@ export function BulkRateModal({
|
||||
Nothing pending.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(130px,1fr))] content-start gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-[repeat(auto-fill,minmax(130px,1fr))] content-start gap-2 transition",
|
||||
busy && "pointer-events-none opacity-60",
|
||||
)}
|
||||
>
|
||||
{uploads.map((temp) => {
|
||||
const isSelected = selected.has(temp.temp_id);
|
||||
const preview = temp.preview_url ?? temp.file_url;
|
||||
const preview = apiUrl(
|
||||
temp.preview_url ?? temp.file_url ?? "",
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={temp.temp_id}
|
||||
type="button"
|
||||
disabled={phase !== "select"}
|
||||
onClick={() => toggle(temp.temp_id)}
|
||||
title={temp.original_filename}
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-md border bg-ctp-mantle text-left transition",
|
||||
"relative overflow-hidden rounded-md border bg-ctp-mantle text-left transition disabled:cursor-default",
|
||||
isSelected
|
||||
? "border-ctp-mauve ring-2 ring-ctp-mauve/40"
|
||||
: "border-ctp-surface0 hover:border-ctp-surface2",
|
||||
@@ -197,6 +282,65 @@ export function BulkRateModal({
|
||||
</div>
|
||||
|
||||
<footer className="flex flex-wrap items-center justify-between gap-3 border-t border-ctp-surface0 px-4 py-3">
|
||||
{busy ? (
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-3 font-mono text-[11px] text-ctp-overlay0">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Spinner className="h-3 w-3" />
|
||||
Moving {progress.total} upload(s) into the library…
|
||||
</span>
|
||||
<span>
|
||||
{progress.moved} moved · {progress.done}/{progress.total} ·{" "}
|
||||
{percent}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-ctp-surface0">
|
||||
<div
|
||||
className="h-full bg-ctp-mauve transition-[width] duration-200"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : phase === "done" ? (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="max-h-32 overflow-y-auto overscroll-contain rounded-md border border-ctp-surface0 bg-ctp-mantle">
|
||||
{failures.map((failure) => (
|
||||
<div
|
||||
key={failure.id}
|
||||
className="flex items-start justify-between gap-3 border-b border-ctp-surface0/60 px-2 py-1 text-[11px] last:border-0"
|
||||
>
|
||||
<span
|
||||
className="truncate font-mono text-ctp-subtext1"
|
||||
title={failure.name}
|
||||
>
|
||||
{failure.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-ctp-red">
|
||||
{failure.error}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span className="font-mono text-[11px] text-ctp-overlay0">
|
||||
{progress.moved} moved · {failures.length} failed
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={retryable.length === 0}
|
||||
onClick={retryFailed}
|
||||
>
|
||||
Retry failed ({retryable.length})
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-mono text-[11px] text-ctp-overlay0">
|
||||
{selected.size} selected
|
||||
{rating ? "" : " · pick a rating"}
|
||||
@@ -206,21 +350,14 @@ export function BulkRateModal({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={selected.size === 0 || !rating || mutation.isPending}
|
||||
onClick={() => {
|
||||
if (!rating) return;
|
||||
mutation.mutate({ temp_ids: [...selected], rating });
|
||||
}}
|
||||
disabled={selected.size === 0 || !rating}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<Spinner className="h-3.5 w-3.5" /> Moving…
|
||||
</>
|
||||
) : (
|
||||
`Move ${selected.size} into the library`
|
||||
)}
|
||||
Move {selected.size} into the library
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { api, authHeaders, apiUrl, errorMessage, uploadFile } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import {
|
||||
E621Error,
|
||||
e621CooldownRemainingMs,
|
||||
e621ErrorMessage,
|
||||
effectiveCredentials,
|
||||
fetchPost,
|
||||
@@ -40,12 +41,19 @@ type UploadStatus = "queued" | "uploading" | "done" | "error";
|
||||
/** How many files upload at once; big batches were painfully serial. */
|
||||
const UPLOAD_CONCURRENCY = 3;
|
||||
|
||||
/** md5: metatags per posts.json query — same batch size as the original app. */
|
||||
const MD5_BATCH_SIZE = 75;
|
||||
|
||||
/** Finished upload tiles clear themselves this long after completing. */
|
||||
const ENTRY_CLEAR_MS = 2000;
|
||||
|
||||
interface UploadEntry {
|
||||
id: string;
|
||||
file: File;
|
||||
progress: number;
|
||||
status: UploadStatus;
|
||||
previewUrl: string;
|
||||
fading?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -91,13 +99,60 @@ function describeError(error: unknown): string {
|
||||
}
|
||||
|
||||
async function fetchTempBlob(url: string): Promise<Blob> {
|
||||
const response = await fetch(url, { headers: authHeaders() });
|
||||
const response = await fetch(apiUrl(url), { headers: authHeaders() });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not read the staged file (${response.status}).`);
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
/** Staged uploads that still need an IQDB pass (image-only). */
|
||||
function unresolvedIqdbIds(list: TempUpload[]): string[] {
|
||||
return list
|
||||
.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);
|
||||
}
|
||||
|
||||
/** e621 failures that mean "stop the queue", not "skip this file". */
|
||||
function isFatalIqdbError(error: unknown): boolean {
|
||||
if (error instanceof E621Error) {
|
||||
return [401, 403, 429, 503].includes(error.status);
|
||||
}
|
||||
// Network/CORS failures and aborted (timed out) requests.
|
||||
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({
|
||||
temp,
|
||||
checking,
|
||||
@@ -112,7 +167,7 @@ function TempCard({
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const isVideo = /\.(mp4|webm)$/i.test(temp.original_filename);
|
||||
const preview = temp.preview_url ?? temp.file_url;
|
||||
const preview = apiUrl(temp.preview_url ?? temp.file_url ?? "");
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-ctp-surface0 bg-ctp-base p-2.5">
|
||||
@@ -184,7 +239,7 @@ function TempCard({
|
||||
<Button className="px-2 py-1 text-xs" onClick={onOpen}>
|
||||
Set metadata
|
||||
</Button>
|
||||
{temp.status === "pending" ? (
|
||||
{isVideo ? null : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="px-2 py-1 text-xs"
|
||||
@@ -193,7 +248,7 @@ function TempCard({
|
||||
>
|
||||
{checking ? "Checking…" : "Check similarity"}
|
||||
</Button>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -216,9 +271,15 @@ function TempCard({
|
||||
|
||||
function MetadataModal({
|
||||
temp,
|
||||
checking,
|
||||
checkError,
|
||||
onCheck,
|
||||
onClose,
|
||||
}: {
|
||||
temp: TempUpload;
|
||||
checking: boolean;
|
||||
checkError: string | null;
|
||||
onCheck: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -233,6 +294,7 @@ function MetadataModal({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isVideo = /\.(mp4|webm)$/i.test(temp.original_filename);
|
||||
const mediaUrl = apiUrl(temp.preview_url ?? temp.file_url ?? "");
|
||||
|
||||
async function run(task: () => Promise<void>) {
|
||||
setBusy(true);
|
||||
@@ -334,17 +396,17 @@ function MetadataModal({
|
||||
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-[minmax(0,320px)_minmax(0,1fr)]">
|
||||
<div className="w-fit max-w-full overflow-hidden rounded-md border border-ctp-surface0 bg-ctp-mantle">
|
||||
{(temp.preview_url ?? temp.file_url) ? (
|
||||
{mediaUrl ? (
|
||||
isVideo ? (
|
||||
<video
|
||||
src={(temp.preview_url ?? temp.file_url) as string}
|
||||
src={mediaUrl}
|
||||
controls
|
||||
muted
|
||||
className="block h-auto max-h-[55vh] w-auto max-w-full"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={(temp.preview_url ?? temp.file_url) as string}
|
||||
src={mediaUrl}
|
||||
alt={temp.original_filename}
|
||||
className="block h-auto max-h-[55vh] w-auto max-w-full"
|
||||
/>
|
||||
@@ -437,11 +499,46 @@ function MetadataModal({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{temp.iqdb_data && temp.iqdb_data.length > 0 ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||
IQDB candidates
|
||||
</p>
|
||||
{isVideo || !credentials?.configured ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCheck}
|
||||
disabled={checking || busy}
|
||||
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"
|
||||
>
|
||||
{checking
|
||||
? "checking…"
|
||||
: temp.iqdb_data
|
||||
? "re-check"
|
||||
: "check now"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isVideo ? (
|
||||
<p className="mt-2 text-xs text-ctp-overlay0">
|
||||
IQDB works on images only.
|
||||
</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
|
||||
@@ -498,7 +595,8 @@ function MetadataModal({
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-ctp-overlay0">
|
||||
{selected.rating
|
||||
? (RATING_LABELS[selected.rating] ?? selected.rating)
|
||||
? (RATING_LABELS[selected.rating] ??
|
||||
selected.rating)
|
||||
: "Unknown rating"}
|
||||
{selected.score_total !== null &&
|
||||
selected.score_total !== undefined
|
||||
@@ -541,18 +639,23 @@ function MetadataModal({
|
||||
</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.
|
||||
Linking downloads the post's file into the library
|
||||
and drops this staged upload.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : temp.iqdb_data ? (
|
||||
<p className="mt-2 text-xs text-ctp-overlay0">
|
||||
Checked against e621 IQDB — no match found.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-ctp-overlay0">
|
||||
No IQDB candidates yet — run "Check similarity" from the
|
||||
card, or enter a post ID below.
|
||||
<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">
|
||||
<input
|
||||
@@ -625,13 +728,28 @@ export default function UploadPage() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const queueRef = useRef<UploadEntry[]>([]);
|
||||
const runningRef = useRef(false);
|
||||
const iqdbQueueRef = useRef<string[]>([]);
|
||||
const iqdbRunningRef = useRef(false);
|
||||
const objectUrls = useRef<string[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [entries, setEntries] = useState<UploadEntry[]>([]);
|
||||
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 [processing, setProcessing] = useState(false);
|
||||
const [checkingIds, setCheckingIds] = useState<string[]>([]);
|
||||
const [modalFor, setModalFor] = useState<TempUpload | null>(null);
|
||||
const [checkErrors, setCheckErrors] = useState<Record<string, string>>({});
|
||||
const [iqdbError, setIqdbError] = useState<string | null>(null);
|
||||
const [currentCheck, setCurrentCheck] = useState<string | null>(null);
|
||||
const [iqdbProgress, setIqdbProgress] = useState<{
|
||||
done: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [modalId, setModalId] = useState<string | null>(null);
|
||||
const [bulkOpen, setBulkOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -649,6 +767,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(
|
||||
@@ -661,20 +785,18 @@ export default function UploadPage() {
|
||||
(entry) => entry.status === "uploading",
|
||||
);
|
||||
|
||||
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(
|
||||
// Finished tiles clear themselves after a moment, so the counters come
|
||||
// from batch totals rather than the tiles currently on screen.
|
||||
const settledCount = batch.done + batch.failed;
|
||||
const inflightProgress = entries.reduce(
|
||||
(total, entry) =>
|
||||
total +
|
||||
(entry.status === "done"
|
||||
? 100
|
||||
: entry.status === "queued"
|
||||
? 0
|
||||
: entry.progress),
|
||||
total + (entry.status === "uploading" ? entry.progress : 0),
|
||||
0,
|
||||
) / entries.length,
|
||||
);
|
||||
const batchPercent = batch.total
|
||||
? Math.min(
|
||||
100,
|
||||
Math.round((settledCount * 100 + inflightProgress) / batch.total),
|
||||
)
|
||||
: 0;
|
||||
|
||||
@@ -695,6 +817,15 @@ export default function UploadPage() {
|
||||
};
|
||||
});
|
||||
setEntries((current) => [...current, ...next]);
|
||||
// Start a fresh batch when the previous one is fully settled.
|
||||
setBatch((current) => {
|
||||
const settled = current.done + current.failed >= current.total;
|
||||
return {
|
||||
total: (settled ? 0 : current.total) + next.length,
|
||||
done: settled ? 0 : current.done,
|
||||
failed: settled ? 0 : current.failed,
|
||||
};
|
||||
});
|
||||
queueRef.current.push(...next);
|
||||
void drainQueue();
|
||||
}
|
||||
@@ -715,23 +846,74 @@ export default function UploadPage() {
|
||||
void queryClient.invalidateQueries({ queryKey: ["uploads"] });
|
||||
}
|
||||
|
||||
/** Patch one staged upload into the list so the board moves as work happens. */
|
||||
function upsertUpload(temp: TempUpload) {
|
||||
queryClient.setQueryData<TempUpload[]>(["uploads"], (current) => {
|
||||
if (!current) return current;
|
||||
// The API lists newest first, so unseen uploads go on top.
|
||||
return current.some((item) => item.temp_id === temp.temp_id)
|
||||
? current.map((item) => (item.temp_id === temp.temp_id ? temp : item))
|
||||
: [temp, ...current];
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshUploads(): Promise<TempUpload[]> {
|
||||
const fresh = await api<TempUpload[]>("/api/uploads/");
|
||||
queryClient.setQueryData(["uploads"], fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
async function checkSimilarity(ids: string[]) {
|
||||
if (ids.length === 0) return;
|
||||
// One serial drain for all IQDB checks: clicking "Check similarity" many
|
||||
// times used to start overlapping runs that re-downloaded the same staging
|
||||
// blobs and piled requests onto the e621 queue until everything stalled.
|
||||
function checkSimilarity(ids: string[]) {
|
||||
const queued = new Set(iqdbQueueRef.current);
|
||||
const added = ids.filter((id) => !queued.has(id));
|
||||
if (added.length === 0) return;
|
||||
iqdbQueueRef.current.push(...added);
|
||||
void drainIqdbChecks();
|
||||
}
|
||||
|
||||
async function drainIqdbChecks() {
|
||||
if (iqdbRunningRef.current) return;
|
||||
iqdbRunningRef.current = true;
|
||||
setProcessing(true);
|
||||
setCheckingIds((current) => [...current, ...ids]);
|
||||
setIqdbError(null);
|
||||
try {
|
||||
const fresh = await refreshUploads();
|
||||
const targets = fresh.filter(
|
||||
(temp) => ids.includes(temp.temp_id) && temp.status === "pending",
|
||||
while (iqdbQueueRef.current.length > 0) {
|
||||
// Wait out a rate-limit cooldown before touching e621 again.
|
||||
const cooldown = e621CooldownRemainingMs();
|
||||
if (cooldown > 0) {
|
||||
setIqdbError(
|
||||
`e621 rate limit — resuming in ${Math.ceil(cooldown / 1000)}s`,
|
||||
);
|
||||
for (const temp of targets) {
|
||||
if (!temp.file_url) continue;
|
||||
await new Promise((resolve) => setTimeout(resolve, cooldown + 250));
|
||||
setIqdbError(null);
|
||||
}
|
||||
const tempId = iqdbQueueRef.current.shift() as string;
|
||||
const cached =
|
||||
queryClient.getQueryData<TempUpload[]>(["uploads"]) ?? [];
|
||||
const temp = cached.find((item) => item.temp_id === tempId);
|
||||
// Skip files that vanished, got resolved meanwhile, or are videos.
|
||||
if (
|
||||
!temp ||
|
||||
!temp.file_url ||
|
||||
(temp.status !== "pending" && temp.status !== "visual_match") ||
|
||||
/\.(mp4|webm)$/i.test(temp.original_filename)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
setCheckingIds((current) => [...current, tempId]);
|
||||
setCheckErrors((current) => {
|
||||
const next = { ...current };
|
||||
delete next[tempId];
|
||||
return next;
|
||||
});
|
||||
setIqdbProgress((current) => ({
|
||||
done: current?.done ?? 0,
|
||||
total: (current?.total ?? 0) + 1,
|
||||
}));
|
||||
setCurrentCheck(temp.original_filename);
|
||||
try {
|
||||
const blob = await fetchTempBlob(temp.file_url);
|
||||
const results = await iqdbSearch(
|
||||
@@ -739,65 +921,141 @@ export default function UploadPage() {
|
||||
blob,
|
||||
temp.original_filename,
|
||||
);
|
||||
if (results.length > 0) {
|
||||
await api(`/api/uploads/${temp.temp_id}/iqdb/`, {
|
||||
method: "POST",
|
||||
json: { results },
|
||||
});
|
||||
// Store even an empty result: "checked, nothing found" must stay
|
||||
// distinguishable from "never checked" in the metadata modal.
|
||||
const updated = await api<TempUpload>(
|
||||
`/api/uploads/${temp.temp_id}/iqdb/`,
|
||||
{ method: "POST", json: { results } },
|
||||
);
|
||||
upsertUpload(updated);
|
||||
} catch (error) {
|
||||
setCheckErrors((current) => ({
|
||||
...current,
|
||||
[tempId]: describeError(error),
|
||||
}));
|
||||
// Auth, rate limit, timeout or network problems would doom every
|
||||
// remaining file; stop and let the user retry once e621 answers.
|
||||
if (isFatalIqdbError(error)) {
|
||||
setIqdbError(describeError(error));
|
||||
iqdbQueueRef.current = [];
|
||||
setIqdbProgress(null);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip this file; the card stays pending.
|
||||
} finally {
|
||||
setCurrentCheck(null);
|
||||
setIqdbProgress((current) =>
|
||||
current ? { ...current, done: current.done + 1 } : current,
|
||||
);
|
||||
setCheckingIds((current) =>
|
||||
current.filter((value) => value !== temp.temp_id),
|
||||
current.filter((value) => value !== tempId),
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
iqdbRunningRef.current = false;
|
||||
setProcessing(false);
|
||||
setCheckingIds([]);
|
||||
setIqdbProgress((current) =>
|
||||
current === null || current.done >= current.total ? null : current,
|
||||
);
|
||||
invalidateUploads();
|
||||
}
|
||||
}
|
||||
|
||||
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 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.
|
||||
if (unresolved.length > 0) {
|
||||
// Phase 1: e621 MD5 lookup, 75 md5: metatags per query.
|
||||
setPipeline({ phase: "md5", done: 0, total: targets.length });
|
||||
try {
|
||||
for (let index = 0; index < unresolved.length; index += 20) {
|
||||
const batch = unresolved.slice(index, index + 20);
|
||||
for (let index = 0; index < targets.length; index += MD5_BATCH_SIZE) {
|
||||
const md5Batch = targets.slice(index, index + MD5_BATCH_SIZE);
|
||||
const posts = await fetchPostsByMd5(
|
||||
creds,
|
||||
batch.map((temp) => temp.md5),
|
||||
md5Batch.map((temp) => temp.md5),
|
||||
);
|
||||
for (const post of posts) {
|
||||
const match = batch.find((temp) => temp.md5 === post.file.md5);
|
||||
const match = md5Batch.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 },
|
||||
});
|
||||
const updated = await api<TempUpload>(
|
||||
`/api/uploads/${match.temp_id}/resolve/`,
|
||||
{ method: "POST", json: { mode: "link", post, auto: true } },
|
||||
);
|
||||
upsertUpload(updated);
|
||||
} catch {
|
||||
// Leave it pending.
|
||||
}
|
||||
}
|
||||
setPipeline((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
done: Math.min(index + MD5_BATCH_SIZE, targets.length),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// e621 unavailable; leave everything pending.
|
||||
// e621 unavailable; those files stay pending.
|
||||
}
|
||||
invalidateUploads();
|
||||
setPipeline(null);
|
||||
|
||||
// Phase 2: local visual similarity, one file at a time.
|
||||
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();
|
||||
checkSimilarity(unresolvedIqdbIds(fresh));
|
||||
}
|
||||
|
||||
// 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);
|
||||
/** Mark an entry done, fade it out and drop it once it has been seen. */
|
||||
function finishEntry(entry: UploadEntry, temp: TempUpload) {
|
||||
updateEntry(entry.id, { status: "done", progress: 100 });
|
||||
setBatch((current) => ({ ...current, done: current.done + 1 }));
|
||||
// The board must move the moment an upload lands, not when the batch ends.
|
||||
upsertUpload(temp);
|
||||
window.setTimeout(() => {
|
||||
updateEntry(entry.id, { fading: true });
|
||||
window.setTimeout(() => {
|
||||
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
||||
setEntries((current) =>
|
||||
current.filter((item) => item.id !== entry.id),
|
||||
);
|
||||
}, 500);
|
||||
}, ENTRY_CLEAR_MS);
|
||||
}
|
||||
|
||||
async function drainQueue() {
|
||||
@@ -819,13 +1077,14 @@ export default function UploadPage() {
|
||||
const temp = await uploadFile(entry.file, {}, (percent) =>
|
||||
updateEntry(entry.id, { progress: percent }),
|
||||
);
|
||||
updateEntry(entry.id, { status: "done", progress: 100 });
|
||||
finishEntry(entry, temp);
|
||||
created.push(temp);
|
||||
} catch (error) {
|
||||
updateEntry(entry.id, {
|
||||
status: "error",
|
||||
error: errorMessage(error),
|
||||
});
|
||||
setBatch((current) => ({ ...current, failed: current.failed + 1 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -839,7 +1098,7 @@ export default function UploadPage() {
|
||||
invalidateUploads();
|
||||
if (created.length > 0) {
|
||||
setProcessing(true);
|
||||
void autoMatchAndCheck(created).finally(() => setProcessing(false));
|
||||
void runUploadPipeline(created).finally(() => setProcessing(false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -909,7 +1168,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)}
|
||||
/>
|
||||
@@ -929,14 +1188,48 @@ export default function UploadPage() {
|
||||
then moved into the library once resolved.
|
||||
</p>
|
||||
</div>
|
||||
{busy || processing ? (
|
||||
{iqdbError ? (
|
||||
<div className="flex w-64 flex-col items-end gap-1 text-right">
|
||||
<p className="text-xs text-ctp-red">IQDB checks paused: {iqdbError}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void checkSimilarity(unresolvedIqdbIds(uploads))}
|
||||
className="rounded-md border border-ctp-surface1 px-2 py-0.5 font-mono text-[10px] text-ctp-subtext0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
|
||||
>
|
||||
retry checks
|
||||
</button>
|
||||
</div>
|
||||
) : busy ? (
|
||||
<PhaseBar
|
||||
label={`Uploading ${Math.min(settledCount + 1, batch.total)}/${batch.total}…`}
|
||||
percent={batchPercent}
|
||||
tone="bg-ctp-mauve"
|
||||
/>
|
||||
) : pipeline ? (
|
||||
<PhaseBar
|
||||
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" />
|
||||
{busy
|
||||
? `Uploading ${Math.min(uploadedCount + 1, entries.length)}/${entries.length}…`
|
||||
: checkingIds.length > 0
|
||||
? `Checking ${checkingIds.length} file(s) against IQDB…`
|
||||
: "Processing uploads…"}
|
||||
<Spinner className="h-3.5 w-3.5" /> Processing uploads…
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
@@ -991,8 +1284,8 @@ export default function UploadPage() {
|
||||
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||||
Uploads
|
||||
<span className="ml-2 font-mono text-[11px] text-ctp-overlay0">
|
||||
{uploadedCount}/{entries.length} uploaded
|
||||
{failedCount > 0 ? ` · ${failedCount} failed` : ""}
|
||||
{batch.done}/{batch.total} uploaded
|
||||
{batch.failed > 0 ? ` · ${batch.failed} failed` : ""}
|
||||
</span>
|
||||
</h2>
|
||||
<button
|
||||
@@ -1040,11 +1333,17 @@ export default function UploadPage() {
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid max-h-[60vh] grid-cols-[repeat(auto-fill,minmax(150px,1fr))] content-start gap-3 overflow-y-auto overscroll-contain pr-1">
|
||||
{/* auto-rows-max: when the grid is its own scroll container Firefox
|
||||
sizes auto rows to min-content and squashes the tiles; max-content
|
||||
keeps every tile at its aspect-ratio size and scrolls instead. */}
|
||||
<div className="grid max-h-[70vh] auto-rows-max grid-cols-[repeat(auto-fill,minmax(150px,1fr))] content-start gap-3 overflow-y-auto overscroll-contain pr-1">
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base"
|
||||
className={cn(
|
||||
"overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base transition-opacity duration-500",
|
||||
entry.fading && "opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="relative aspect-square overflow-hidden bg-ctp-mantle">
|
||||
{entry.previewUrl ? (
|
||||
@@ -1136,8 +1435,14 @@ export default function UploadPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modalFor ? (
|
||||
<MetadataModal temp={modalFor} onClose={() => setModalFor(null)} />
|
||||
{modalTemp ? (
|
||||
<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}
|
||||
|
||||
{bulkOpen ? (
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/** Baked at build time by Vite (see vite.config.ts). */
|
||||
declare const __GIT_HASH__: string;
|
||||
+14
-2
@@ -5,8 +5,20 @@ const TOKEN_KEY = "j621.token";
|
||||
|
||||
/** Absolutise a backend path; external/absolute URLs pass through. */
|
||||
export function apiUrl(path: string): string {
|
||||
if (!path.startsWith("/")) return path;
|
||||
return `${getBackendUrl()}${path}`;
|
||||
if (path.startsWith("/")) return `${getBackendUrl()}${path}`;
|
||||
// Absolute URL built by the API. In same-origin mode (the dev proxy, the
|
||||
// single deploy nginx) keep it on the page's origin: the proxy serves
|
||||
// /api too, and auth'd fetches like the IQDB staging blob would otherwise
|
||||
// need CORS. With an explicit backend URL, leave it pointing there.
|
||||
if (!getBackendUrl()) {
|
||||
try {
|
||||
const url = new URL(path);
|
||||
return `${url.pathname}${url.search}`;
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
|
||||
+76
-11
@@ -82,13 +82,58 @@ export function effectiveCredentials(
|
||||
return credentials ?? ANONYMOUS_CREDENTIALS;
|
||||
}
|
||||
|
||||
const CLIENT_VERSION = "J621/0.1";
|
||||
// e621 asks for "Application name/version (developer)". Browsers cannot set a
|
||||
// User-Agent, so the same string is sent in the `_client` parameter instead.
|
||||
// `__GIT_HASH__` is replaced by Vite at build time; guard it so the dev
|
||||
// server (which may not substitute it) still works.
|
||||
const GIT_HASH = typeof __GIT_HASH__ === "string" ? __GIT_HASH__ : "dev";
|
||||
const CLIENT_VERSION = `J621/${GIT_HASH} (JakeBreath)`;
|
||||
|
||||
// e621 allows 2 requests/second hard, 1/second sustained. Serialize every
|
||||
// request through a queue with a 1s minimum gap.
|
||||
// e621 allows 2 requests/second hard, 1/second sustained — and the IQDB
|
||||
// endpoint is stricter, so stay comfortably under it. Serialize every request
|
||||
// through a queue with a minimum gap.
|
||||
let lastRequestAt = 0;
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
/** A hung request would block the whole serialized queue forever. */
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
const REQUEST_GAP_MS = 1500;
|
||||
|
||||
/** A 429 (or a CORS-blocked failure) pauses every e621 call for a while. */
|
||||
const RATE_LIMIT_COOLDOWN_MS = 60_000;
|
||||
const COOLDOWN_KEY = "j621.e621.cooldown";
|
||||
let cooldownUntil = 0;
|
||||
|
||||
function storedCooldown(): number {
|
||||
try {
|
||||
return Number(window.localStorage.getItem(COOLDOWN_KEY)) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function e621CooldownRemainingMs(): number {
|
||||
return Math.max(0, Math.max(cooldownUntil, storedCooldown()) - Date.now());
|
||||
}
|
||||
|
||||
function startCooldown(): void {
|
||||
cooldownUntil = Date.now() + RATE_LIMIT_COOLDOWN_MS;
|
||||
try {
|
||||
window.localStorage.setItem(COOLDOWN_KEY, String(cooldownUntil));
|
||||
} catch {
|
||||
// Storage disabled; the in-memory cooldown still applies.
|
||||
}
|
||||
}
|
||||
|
||||
function isRateLimitError(error: unknown): boolean {
|
||||
if (error instanceof E621Error) {
|
||||
return error.status === 429 || error.status === 503;
|
||||
}
|
||||
// Browsers hide the status of a CORS-blocked response; a rate-limited 429
|
||||
// arrives here as a network error.
|
||||
return error instanceof TypeError || error instanceof DOMException;
|
||||
}
|
||||
|
||||
function schedule<T>(task: () => Promise<T>): Promise<T> {
|
||||
const run = queue.then(task, task);
|
||||
queue = run.then(
|
||||
@@ -99,7 +144,11 @@ function schedule<T>(task: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
|
||||
async function throttle(): Promise<void> {
|
||||
const wait = Math.max(0, lastRequestAt + 1000 - Date.now());
|
||||
const wait = Math.max(
|
||||
0,
|
||||
lastRequestAt + REQUEST_GAP_MS - Date.now(),
|
||||
e621CooldownRemainingMs(),
|
||||
);
|
||||
if (wait > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, wait));
|
||||
}
|
||||
@@ -124,10 +173,7 @@ export function e621Request<T>(
|
||||
const base = credentials.base_url.replace(/\/+$/, "");
|
||||
const url = new URL(`${base}/${path.replace(/^\/+/, "")}`);
|
||||
// Browsers cannot set User-Agent; e621 documents `_client` for this case.
|
||||
url.searchParams.set(
|
||||
"_client",
|
||||
`${CLIENT_VERSION} (by ${credentials.username || "anonymous"} on e621)`,
|
||||
);
|
||||
url.searchParams.set("_client", CLIENT_VERSION);
|
||||
for (const [key, value] of Object.entries(options.params ?? {})) {
|
||||
if (value !== undefined) {
|
||||
url.searchParams.set(key, String(value));
|
||||
@@ -143,12 +189,20 @@ export function e621Request<T>(
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
controller.abort(new DOMException("e621 request timed out", "TimeoutError")),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
const forwardAbort = () => controller.abort(options.signal?.reason);
|
||||
options.signal?.addEventListener("abort", forwardAbort, { once: true });
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: options.method ?? "GET",
|
||||
headers,
|
||||
body: options.body,
|
||||
signal: options.signal,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -164,7 +218,15 @@ export function e621Request<T>(
|
||||
return undefined as T;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
// Cancelled requests (navigation, unmounted query) are not rate limits.
|
||||
if (!options.signal?.aborted && isRateLimitError(error)) {
|
||||
startCooldown();
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
options.signal?.removeEventListener("abort", forwardAbort);
|
||||
useE621Metrics
|
||||
.getState()
|
||||
.record(path, performance.now() - startedAt);
|
||||
@@ -190,8 +252,11 @@ export function e621ErrorMessage(error: unknown): string {
|
||||
}
|
||||
return `e621 request failed (${error.status}).`;
|
||||
}
|
||||
if (error instanceof DOMException && error.name === "TimeoutError") {
|
||||
return "e621 did not answer in time.";
|
||||
}
|
||||
if (error instanceof TypeError) {
|
||||
return "Could not reach e621. Check the base URL and your connection.";
|
||||
return "Could not reach e621. If this keeps happening it is usually the rate limit — wait a minute and retry.";
|
||||
}
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
@@ -308,7 +373,7 @@ export async function fetchPostsByMd5(
|
||||
{
|
||||
params: {
|
||||
tags: `md5:${md5s.join(",")}`,
|
||||
limit: Math.min(md5s.length, 100),
|
||||
limit: Math.min(md5s.length, 320),
|
||||
},
|
||||
signal,
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
@@ -6,9 +7,28 @@ import { defineConfig } from "vite";
|
||||
|
||||
const backend = process.env.VITE_BACKEND ?? "http://127.0.0.1:8000";
|
||||
|
||||
/** Short commit hash baked into the bundle (e621 `_client` string). */
|
||||
function gitHash(): string {
|
||||
const configured = process.env.GIT_HASH?.trim();
|
||||
if (configured) return configured;
|
||||
try {
|
||||
return execSync("git rev-parse --short HEAD", {
|
||||
cwd: fileURLToPath(new URL(".", import.meta.url)),
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
return "dev";
|
||||
}
|
||||
}
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
define: {
|
||||
__GIT_HASH__: JSON.stringify(gitHash()),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
|
||||
Reference in New Issue
Block a user