Compare commits

...
7 Commits
Author SHA1 Message Date
JakeBreath e2bf1c457f Make upload processing phase-based: MD5 -> visual -> IQDB over the batch
Uploads were doing md5 + local visual matching inside the upload request
(backend create) while the frontend later ran its own e621 MD5 pass, so the
pipeline looked interleaved per file. Now every step is a phase applied to
the whole batch in order:

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

The board shows the active phase with its own progress bar (e621 MD5 in
peach, visual in lavender, IQDB in teal) and every step updates the staged
list as it lands. Verified from a headless run: one batched posts.json
request for 10 files, then 10 visual-match calls, then IQDB.
2026-09-19 10:25:57 -05:00
JakeBreath e62d7af42f Live-updating upload board, self-clearing tiles, original MD5 batch size
- MD5 auto-match now sends 75 md5: metatags per posts.json query, the same
  batch size the original J621-Django app used (was 20); the limit cap no
  longer truncates batches.
- every settled upload is upserted into the staged list right away, so the
  Pending / Visual Similarity / Auto-uploaded columns move as files land
  instead of waiting for the whole batch (auto-matched resolves and IQDB
  results use the same path).
- finished upload tiles fade out and remove themselves ~2s after completing;
  failures stay until cleared. Batch counters are tracked separately from the
  visible tiles so the header and progress bar stay accurate as tiles vanish.
- AGENTS.md now points at the original Django app for reference behavior.

Verified live with a headless upload run: the columns showed the new files
immediately and the 8 tiles were gone ~3s after finishing.
2026-09-19 01:18:50 -05:00
JakeBreath 9641862515 Back off from e621 rate limits and pace requests more conservatively
e621 intermittently answers 429 to the IQDB endpoint; browsers hide that
status behind CORS ('Access-Control-Allow-Origin missing'), so the SPA
cannot read it. Treat every network-level failure as a possible rate limit
and pause all e621 traffic for a minute. The cooldown is shared through
localStorage so extra tabs respect it, requests are spaced 1.5s apart
instead of 1s, user-cancelled requests do not trigger a cooldown, and the
upload queue waits the cooldown out with a countdown instead of looking
stuck.

Server side: the per-process e621 gap goes from 0.5s to 1s so two gunicorn
workers cannot together exceed e621's 2/s hard limit.
2026-09-19 00:37:32 -05:00
JakeBreath b96c311235 Fix cross-origin staging URLs, e621 UA format and IQDB queue stalls
The dev Vite proxy rewrites the request Host to 127.0.0.1:8000, so the
backend's absolute signed file URLs pointed at a different origin than the
SPA (localhost:5173). Images tolerated it, but the auth'd fetch that reads
the staging blob for IQDB was blocked ('Cross-Origin Request Blocked') and
every similarity check died before reaching e621.

apiUrl() now keeps API-built absolute URLs on the page's origin whenever the
SPA is in same-origin mode (dev proxy, deploy nginx) and leaves them
absolute when an explicit backend URL is configured. All consumers use it:
staging previews and the bulk modal, library cards, optimizer (range sniff +
worker), IQDB card, delete page, similar page.

e621 identification now follows the documented 'App/version (developer)'
form: server-side requests send 'J621/<hash> (JakeBreath)' and the browser
_client gets the same string, with the hash baked into the frontend image
(GIT_HASH build arg; guarded at runtime so the dev server still works).

IQDB stalls: requests now time out after 20s (a hung fetch used to block the
serialized e621 queue forever), and all checks run through one serial drain
so repeated 'Check similarity' clicks can no longer start overlapping runs
that re-download the same staging blobs. Auth/rate-limit/timeout/network
failures stop the queue with the reason and a retry button instead of
grinding through the rest.

Verified live: staged file URL is same-origin through the proxy and fetches
200 through it.
2026-09-19 00:30:42 -05:00
JakeBreath 2f613b7027 Stop Firefox from squashing upload tiles and show IQDB queue progress
The upload grid was its own scroll container (max-height + overflow on the
same element). Firefox sizes auto grid rows to min-content in that setup,
so every tile collapsed to its footer height and the image was clipped to a
wide strip; Chromium sizes them to max-content, which is why this only
showed up in the user's browser. auto-rows-max pins rows to max-content in
both; verified with headless Firefox screenshots and Chromium measurements
(60 tiles render 152x194 each, 1845px of content in a 639px scroller).
The box is now 70vh so it behaves as a proper fixed gallery area with its
own scrollbar instead of shrinking with the item count.

IQDB progress: the page header now shows a live 'Checking IQDB — x/y'
counter with a bar while background checks run, so the queue is visible
without opening the per-file modal (which keeps its spinner, candidates
and per-file errors).
2026-09-19 00:06:05 -05:00
JakeBreath bc7494e7be Show IQDB checks in the metadata modal and run them for visual matches
The modal held a snapshot of the staged upload, so IQDB results that landed
from the background check queue never appeared until it was closed and
reopened — the only hint a check was running was the e621 request history.
It now follows the live uploads query, so candidates, progress and errors
show up in place.

Related gaps fixed along the way:
- files flagged by the local visual-similarity check were skipped by the
  IQDB pass entirely (only 'pending' files were checked), so their modal
  could only ever show 'already in your library'; unresolved files of both
  statuses are now checked, and the check button shows on visual-match
  cards too;
- a check with no candidates posted nothing, leaving 'never checked' and
  'checked, no match' indistinguishable; results are stored even when
  empty and the modal now says which one it is;
- per-file failures surface in the modal instead of being swallowed, the
  modal shows a spinner while the query runs and a check now/re-check
  button, and auto-runs skip files already checked (and videos, since IQDB
  is image-only).

Backend production code unchanged; tests pin the empty-result recording
(18 library tests, full suite 56 green).
2026-09-18 23:42:45 -05:00
JakeBreath b076903ecd Show live progress while the bulk rating tool moves files
A 200-file bulk move is one long server-side copy+index chain per file, so
the old single request sat on a spinner the whole time (and got uncomfortably
close to the 120s proxy timeout). The modal now resolves the selection in
chunks of 8:

- footer switches to a progress bar with 'n moved / done / total / percent'
  while running, and the header explains that files are being indexed;
- the rating pills, selection actions, grid and close button are locked
  while it runs so progress can't be lost by accident;
- failures are collected with their filenames, the modal stays open for a
  summary, and 'Retry failed' re-selects only the files that are still
  pending; a clean run still auto-closes with a toast.
2026-09-18 23:09:42 -05:00
18 changed files with 1020 additions and 292 deletions
+5
View File
@@ -2,6 +2,11 @@ Use the venv on backend/venv/
DO NOT mess with the system's python 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: For Frontend work:
read both Frontend Design related Markdown files on the frontend/ folder read both Frontend Design related Markdown files on the frontend/ folder
+4 -1
View File
@@ -13,7 +13,10 @@ import time
import requests import requests
from django.conf import settings 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): class E621Error(Exception):
+152 -2
View File
@@ -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 base64
import hashlib import hashlib
import io
import json import json
import shutil import shutil
import tempfile import tempfile
@@ -11,6 +12,8 @@ from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase, override_settings from django.test import Client, TestCase, override_settings
from PIL import Image
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from apps.library.models import MediaItem, TempUpload from apps.library.models import MediaItem, TempUpload
@@ -23,6 +26,13 @@ TINY_PNG = base64.b64decode(
) )
def similar_png(color=(200, 30, 40)):
"""A 1x1 PNG with the same visual hashes but a different MD5."""
buffer = io.BytesIO()
Image.new("RGB", (1, 1), color).save(buffer, format="PNG")
return buffer.getvalue()
def jpost(client, path, body=None): def jpost(client, path, body=None):
return client.post(path, data=json.dumps(body or {}), content_type="application/json") return client.post(path, data=json.dumps(body or {}), content_type="application/json")
@@ -85,7 +95,7 @@ class TempUploadListTests(TestCase):
self.assertEqual(Client().get("/api/uploads/").status_code, 401) self.assertEqual(Client().get("/api/uploads/").status_code, 401)
class BulkResolveTests(TestCase): class StagedUploadWorkflowTests(TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super().setUpClass() super().setUpClass()
@@ -195,3 +205,143 @@ class BulkResolveTests(TestCase):
self.assertEqual(len(body["resolved"]), 1) self.assertEqual(len(body["resolved"]), 1)
self.assertEqual(body["errors"][0]["error"], "already in the library") self.assertEqual(body["errors"][0]["error"], "already in the library")
self.assertEqual(MediaItem.objects.count(), 1) self.assertEqual(MediaItem.objects.count(), 1)
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)
+26 -7
View File
@@ -190,18 +190,37 @@ class TempUploadViewSet(
temp.resolution = TempUpload.RESOLUTION_DUPLICATE temp.resolution = TempUpload.RESOLUTION_DUPLICATE
temp.library_item = existing temp.library_item = existing
temp.file.delete(save=False) temp.file.delete(save=False)
else: # Visual similarity is deliberately a separate phase (the
matches = find_library_matches( # /visual-match action) so a large batch uploads at full speed and
temp.file.path, user=request.user, request=request # the board runs MD5 -> visual -> IQDB over the whole batch.
)
if matches:
temp.visual_matches = matches
temp.status = TempUpload.STATUS_VISUAL_MATCH
temp.save() temp.save()
return Response( return Response(
self.get_serializer(temp).data, status=status.HTTP_201_CREATED self.get_serializer(temp).data, status=status.HTTP_201_CREATED
) )
@action(detail=True, methods=["post"], url_path="visual-match")
def visual_match(self, request, pk=None):
"""Run the local visual-similarity pass for one staged upload."""
temp = self.get_object()
if temp.status == TempUpload.STATUS_COMPLETED:
return Response(
{"detail": "This upload is already in the library."},
status=status.HTTP_400_BAD_REQUEST,
)
if not temp.file:
return Response(
{"detail": "The staged file is missing."},
status=status.HTTP_400_BAD_REQUEST,
)
matches = find_library_matches(
temp.file.path, user=request.user, request=request
)
temp.visual_matches = matches
if matches and temp.status == TempUpload.STATUS_PENDING:
temp.status = TempUpload.STATUS_VISUAL_MATCH
temp.save(update_fields=["visual_matches", "status", "updated_at"])
return Response(self.get_serializer(temp).data)
@action(detail=True, methods=["get", "head"], permission_classes=[AllowAny]) @action(detail=True, methods=["get", "head"], permission_classes=[AllowAny])
def file(self, request, pk=None): def file(self, request, pk=None):
"""Serve the staged file; accepts a signed URL for media tags.""" """Serve the staged file; accepts a signed URL for media tags."""
+3 -1
View File
@@ -206,7 +206,9 @@ WATCHED_FOLDER = str(WATCHED_FOLDER)
# e621 integration # e621 integration
E621_BASE_URL = os.getenv("E621_BASE_URL", "https://e621.net").rstrip("/") 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. # Hosts the client-download proxy is allowed to stream from.
E621_MEDIA_HOSTS = [ E621_MEDIA_HOSTS = [
host.strip() host.strip()
+2
View File
@@ -10,6 +10,8 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM node:22-alpine AS build FROM node:22-alpine AS build
ARG GIT_HASH=dev
ENV GIT_HASH=$GIT_HASH
WORKDIR /app WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --no-audit --no-fund RUN npm ci --no-audit --no-fund
+1
View File
@@ -26,6 +26,7 @@ fi
echo "==> Building + pushing $PLATFORMS -> $REGISTRY:{latest,$SHA} ..." echo "==> Building + pushing $PLATFORMS -> $REGISTRY:{latest,$SHA} ..."
docker buildx build --builder "$BUILDER" --push \ docker buildx build --builder "$BUILDER" --push \
--platform "$PLATFORMS" \ --platform "$PLATFORMS" \
--build-arg "GIT_HASH=$SHA" \
-f deploy/J621-Frontend \ -f deploy/J621-Frontend \
-t "$REGISTRY:latest" \ -t "$REGISTRY:latest" \
-t "$REGISTRY:$SHA" \ -t "$REGISTRY:$SHA" \
+4 -2
View File
@@ -1,5 +1,6 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { apiUrl } from "@/lib/api";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { formatBytes } from "@/lib/format"; import { formatBytes } from "@/lib/format";
import type { MediaItem } from "@/lib/types"; import type { MediaItem } from "@/lib/types";
@@ -17,8 +18,9 @@ const ratingLabels: Record<string, string> = {
}; };
export function MediaCard({ item }: { item: MediaItem }) { export function MediaCard({ item }: { item: MediaItem }) {
const preview = const preview = apiUrl(
item.kind === "video" ? item.thumbnail_url : item.raw_url; item.kind === "video" ? item.thumbnail_url : item.raw_url,
);
const rating = item.display_rating; const rating = item.display_rating;
return ( return (
+2 -2
View File
@@ -3,7 +3,7 @@ import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui"; 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 { cn } from "@/lib/cn";
import { formatBytes } from "@/lib/format"; import { formatBytes } from "@/lib/format";
import type { MediaItem, Paginated, StorageInfo } from "@/lib/types"; 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"> <div className="relative aspect-square overflow-hidden bg-ctp-mantle">
<img <img
src={item.thumbnail_url} src={apiUrl(item.thumbnail_url)}
alt={item.filename} alt={item.filename}
loading="lazy" loading="lazy"
className="h-full w-full object-cover" className="h-full w-full object-cover"
+2 -2
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Button, Spinner } from "@/components/ui"; 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 { cn } from "@/lib/cn";
import { import {
e621ErrorMessage, e621ErrorMessage,
@@ -44,7 +44,7 @@ export function IqdbCard({
setSelected(null); setSelected(null);
setCandidates(null); setCandidates(null);
try { try {
const response = await fetch(item.raw_url); const response = await fetch(apiUrl(item.raw_url));
if (!response.ok) { if (!response.ok) {
throw new Error(`Could not load the file (HTTP ${response.status}).`); 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 { useState, type ReactNode } from "react";
import { Button, Spinner, inputClass } from "@/components/ui"; 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 { cn } from "@/lib/cn";
import { formatBytes } from "@/lib/format"; import { formatBytes } from "@/lib/format";
import type { MediaItem } from "@/lib/types"; import type { MediaItem } from "@/lib/types";
@@ -171,6 +171,9 @@ export function OptimizeModal({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const extensionKind: OptimizeKind = const extensionKind: OptimizeKind =
kindForExtension(item.extension) ?? "image"; 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>({ const [options, setOptions] = useState<OptimizeOptions>({
...DEFAULT_OPTIONS, ...DEFAULT_OPTIONS,
maxDimension: null, maxDimension: null,
@@ -180,7 +183,7 @@ export function OptimizeModal({
const sniffQuery = useQuery({ const sniffQuery = useQuery({
queryKey: ["image-kind", item.j_id, item.updated_at], queryKey: ["image-kind", item.j_id, item.updated_at],
queryFn: async () => { queryFn: async () => {
const response = await fetch(item.raw_url, { const response = await fetch(fileUrl, {
headers: { Range: "bytes=0-4095" }, headers: { Range: "bytes=0-4095" },
}); });
if (!response.ok && response.status !== 206) { if (!response.ok && response.status !== 206) {
@@ -315,7 +318,7 @@ export function OptimizeModal({
if (result?.storageKey) void deleteStoredFile(result.storageKey); if (result?.storageKey) void deleteStoredFile(result.storageKey);
setResultInfo(null); setResultInfo(null);
run({ run({
url: item.raw_url, url: fileUrl,
filename: item.filename, filename: item.filename,
kind, kind,
options, options,
@@ -366,7 +369,7 @@ export function OptimizeModal({
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<PreviewPanel <PreviewPanel
title="Original" title="Original"
url={item.raw_url} url={fileUrl}
isVideo={isVideo} isVideo={isVideo}
size={originalSize} size={originalSize}
state="original" state="original"
@@ -10,7 +10,7 @@ import { Link } from "react-router-dom";
import { ResponsivePanel } from "@/components/BottomSheet"; import { ResponsivePanel } from "@/components/BottomSheet";
import { Button, Spinner } from "@/components/ui"; 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 { cn } from "@/lib/cn";
import { import {
e621ErrorMessage, e621ErrorMessage,
@@ -257,7 +257,7 @@ export default function SimilarPage() {
> >
{exact.thumbnail_url ? ( {exact.thumbnail_url ? (
<img <img
src={exact.thumbnail_url} src={apiUrl(exact.thumbnail_url)}
alt={exact.j_id} alt={exact.j_id}
className="h-14 w-14 rounded object-cover" 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"> <span className="relative block aspect-square overflow-hidden bg-ctp-mantle">
{match.thumbnail_url ? ( {match.thumbnail_url ? (
<img <img
src={match.thumbnail_url} src={apiUrl(match.thumbnail_url)}
alt={match.j_id} alt={match.j_id}
loading="lazy" loading="lazy"
className="h-full w-full object-cover transition group-hover:scale-[1.03]" className="h-full w-full object-cover transition group-hover:scale-[1.03]"
+181 -44
View File
@@ -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 { Check, Video, X } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Button, Spinner } from "@/components/ui"; 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 { cn } from "@/lib/cn";
import type { TempUpload } from "@/lib/types"; import type { TempUpload } from "@/lib/types";
import { toast } from "@/store/toasts"; 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 { interface BulkResult {
resolved: string[]; resolved: string[];
errors: { temp_id: string; error: 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 * 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 * every thumbnail that belongs to it, and they all move into the library,
* custom entries in one request. * reporting progress chunk by chunk.
*/ */
export function BulkRateModal({ export function BulkRateModal({
uploads, uploads,
@@ -46,28 +62,73 @@ export function BulkRateModal({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [rating, setRating] = useState<string | null>(null); const [rating, setRating] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set()); 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({ const names = new Map(uploads.map((temp) => [temp.temp_id, temp.original_filename]));
mutationFn: (payload: { temp_ids: string[]; rating: string }) => const busy = phase === "running";
api<BulkResult>("/api/uploads/resolve-bulk/", { 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", method: "POST",
json: payload, json: { temp_ids: chunk, rating },
}), });
onSuccess: (result) => { 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: ["uploads"] });
void queryClient.invalidateQueries({ queryKey: ["files"] }); void queryClient.invalidateQueries({ queryKey: ["files"] });
if (result.resolved.length > 0) { setFailures(problems);
toast.ok(`Moved ${result.resolved.length} upload(s) into the library.`); setPhase("done");
}
if (result.errors.length > 0) { if (problems.length === 0) {
toast.error( toast.ok(`Moved ${moved} upload(s) into the library.`);
`${result.errors.length} could not be resolved: ${result.errors[0].error}`,
);
}
onClose(); onClose();
}, } else if (moved > 0) {
onError: (error) => toast.error(errorMessage(error)), toast.error(`Moved ${moved}, ${problems.length} failed.`);
}); } else {
toast.error(`Nothing could be moved (${problems.length} failed).`);
}
}
function toggle(id: string) { function toggle(id: string) {
setSelected((current) => { 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 ( return (
<div <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" 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 <div
className="flex w-full max-w-6xl flex-col overflow-hidden rounded-lg border border-ctp-surface1 bg-ctp-base" 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 Rate unmatched uploads
</h2> </h2>
<p className="mt-0.5 text-xs text-ctp-overlay0"> <p className="mt-0.5 text-xs text-ctp-overlay0">
Pick a rating, tick every file that belongs to it, and they move {busy
into the library in one go ({uploads.length} pending). ? "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> </p>
</div> </div>
<button <button
type="button" type="button"
onClick={onClose} onClick={requestClose}
title="Close" disabled={busy}
className="rounded-md p-1 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text" 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" /> <X className="h-4 w-4" />
</button> </button>
@@ -112,9 +187,10 @@ export function BulkRateModal({
<button <button
key={option.value} key={option.value}
type="button" type="button"
disabled={busy}
onClick={() => setRating(option.value)} onClick={() => setRating(option.value)}
className={cn( 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 rating === option.value
? option.active ? option.active
: "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text", : "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text",
@@ -127,6 +203,7 @@ export function BulkRateModal({
<Button <Button
variant="secondary" variant="secondary"
className="px-2 py-1 text-xs" className="px-2 py-1 text-xs"
disabled={busy}
onClick={() => onClick={() =>
setSelected(new Set(uploads.map((temp) => temp.temp_id))) setSelected(new Set(uploads.map((temp) => temp.temp_id)))
} }
@@ -136,7 +213,7 @@ export function BulkRateModal({
<Button <Button
variant="ghost" variant="ghost"
className="px-2 py-1 text-xs" className="px-2 py-1 text-xs"
disabled={selected.size === 0} disabled={busy || selected.size === 0}
onClick={() => setSelected(new Set())} onClick={() => setSelected(new Set())}
> >
Clear Clear
@@ -150,18 +227,26 @@ export function BulkRateModal({
Nothing pending. Nothing pending.
</p> </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) => { {uploads.map((temp) => {
const isSelected = selected.has(temp.temp_id); 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 ( return (
<button <button
key={temp.temp_id} key={temp.temp_id}
type="button" type="button"
disabled={phase !== "select"}
onClick={() => toggle(temp.temp_id)} onClick={() => toggle(temp.temp_id)}
title={temp.original_filename} title={temp.original_filename}
className={cn( 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 isSelected
? "border-ctp-mauve ring-2 ring-ctp-mauve/40" ? "border-ctp-mauve ring-2 ring-ctp-mauve/40"
: "border-ctp-surface0 hover:border-ctp-surface2", : "border-ctp-surface0 hover:border-ctp-surface2",
@@ -197,6 +282,65 @@ export function BulkRateModal({
</div> </div>
<footer className="flex flex-wrap items-center justify-between gap-3 border-t border-ctp-surface0 px-4 py-3"> <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"> <span className="font-mono text-[11px] text-ctp-overlay0">
{selected.size} selected {selected.size} selected
{rating ? "" : " · pick a rating"} {rating ? "" : " · pick a rating"}
@@ -206,21 +350,14 @@ export function BulkRateModal({
Cancel Cancel
</Button> </Button>
<Button <Button
disabled={selected.size === 0 || !rating || mutation.isPending} disabled={selected.size === 0 || !rating}
onClick={() => { onClick={() => void run()}
if (!rating) return;
mutation.mutate({ temp_ids: [...selected], rating });
}}
> >
{mutation.isPending ? ( Move {selected.size} into the library
<>
<Spinner className="h-3.5 w-3.5" /> Moving…
</>
) : (
`Move ${selected.size} into the library`
)}
</Button> </Button>
</div> </div>
</>
)}
</footer> </footer>
</div> </div>
</div> </div>
+385 -80
View File
@@ -21,6 +21,7 @@ import { api, authHeaders, apiUrl, errorMessage, uploadFile } from "@/lib/api";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { import {
E621Error, E621Error,
e621CooldownRemainingMs,
e621ErrorMessage, e621ErrorMessage,
effectiveCredentials, effectiveCredentials,
fetchPost, fetchPost,
@@ -40,12 +41,19 @@ type UploadStatus = "queued" | "uploading" | "done" | "error";
/** How many files upload at once; big batches were painfully serial. */ /** How many files upload at once; big batches were painfully serial. */
const UPLOAD_CONCURRENCY = 3; 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 { interface UploadEntry {
id: string; id: string;
file: File; file: File;
progress: number; progress: number;
status: UploadStatus; status: UploadStatus;
previewUrl: string; previewUrl: string;
fading?: boolean;
error?: string; error?: string;
} }
@@ -91,13 +99,60 @@ function describeError(error: unknown): string {
} }
async function fetchTempBlob(url: string): Promise<Blob> { 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) { if (!response.ok) {
throw new Error(`Could not read the staged file (${response.status}).`); throw new Error(`Could not read the staged file (${response.status}).`);
} }
return response.blob(); 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({ function TempCard({
temp, temp,
checking, checking,
@@ -112,7 +167,7 @@ function TempCard({
onDismiss: () => void; onDismiss: () => void;
}) { }) {
const isVideo = /\.(mp4|webm)$/i.test(temp.original_filename); 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 ( return (
<div className="rounded-lg border border-ctp-surface0 bg-ctp-base p-2.5"> <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}> <Button className="px-2 py-1 text-xs" onClick={onOpen}>
Set metadata Set metadata
</Button> </Button>
{temp.status === "pending" ? ( {isVideo ? null : (
<Button <Button
variant="secondary" variant="secondary"
className="px-2 py-1 text-xs" className="px-2 py-1 text-xs"
@@ -193,7 +248,7 @@ function TempCard({
> >
{checking ? "Checking…" : "Check similarity"} {checking ? "Checking…" : "Check similarity"}
</Button> </Button>
) : null} )}
</div> </div>
) : null} ) : null}
@@ -216,9 +271,15 @@ function TempCard({
function MetadataModal({ function MetadataModal({
temp, temp,
checking,
checkError,
onCheck,
onClose, onClose,
}: { }: {
temp: TempUpload; temp: TempUpload;
checking: boolean;
checkError: string | null;
onCheck: () => void;
onClose: () => void; onClose: () => void;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -233,6 +294,7 @@ function MetadataModal({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isVideo = /\.(mp4|webm)$/i.test(temp.original_filename); const isVideo = /\.(mp4|webm)$/i.test(temp.original_filename);
const mediaUrl = apiUrl(temp.preview_url ?? temp.file_url ?? "");
async function run(task: () => Promise<void>) { async function run(task: () => Promise<void>) {
setBusy(true); 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="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"> <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 ? ( isVideo ? (
<video <video
src={(temp.preview_url ?? temp.file_url) as string} src={mediaUrl}
controls controls
muted muted
className="block h-auto max-h-[55vh] w-auto max-w-full" className="block h-auto max-h-[55vh] w-auto max-w-full"
/> />
) : ( ) : (
<img <img
src={(temp.preview_url ?? temp.file_url) as string} src={mediaUrl}
alt={temp.original_filename} alt={temp.original_filename}
className="block h-auto max-h-[55vh] w-auto max-w-full" className="block h-auto max-h-[55vh] w-auto max-w-full"
/> />
@@ -437,11 +499,46 @@ function MetadataModal({
</div> </div>
) : null} ) : null}
{temp.iqdb_data && temp.iqdb_data.length > 0 ? (
<div> <div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1"> <p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
IQDB candidates IQDB candidates
</p> </p>
{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"> <div className="mt-2 grid grid-cols-3 gap-2">
{temp.iqdb_data.map((candidate) => ( {temp.iqdb_data.map((candidate) => (
<button <button
@@ -498,7 +595,8 @@ function MetadataModal({
</p> </p>
<p className="mt-1 text-xs text-ctp-overlay0"> <p className="mt-1 text-xs text-ctp-overlay0">
{selected.rating {selected.rating
? (RATING_LABELS[selected.rating] ?? selected.rating) ? (RATING_LABELS[selected.rating] ??
selected.rating)
: "Unknown rating"} : "Unknown rating"}
{selected.score_total !== null && {selected.score_total !== null &&
selected.score_total !== undefined selected.score_total !== undefined
@@ -541,18 +639,23 @@ function MetadataModal({
</Link> </Link>
</div> </div>
<p className="mt-2 text-[11px] text-ctp-overlay0"> <p className="mt-2 text-[11px] text-ctp-overlay0">
Linking downloads the post's file into the library and Linking downloads the post's file into the library
drops this staged upload. and drops this staged upload.
</p> </p>
</div> </div>
) : null} ) : 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"> <p className="mt-2 text-xs text-ctp-overlay0">
No IQDB candidates yet — run "Check similarity" from the Not checked yet. Uploads are checked automatically after
card, or enter a post ID below. they finish.
</p> </p>
)} )}
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
@@ -625,13 +728,28 @@ export default function UploadPage() {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const queueRef = useRef<UploadEntry[]>([]); const queueRef = useRef<UploadEntry[]>([]);
const runningRef = useRef(false); const runningRef = useRef(false);
const iqdbQueueRef = useRef<string[]>([]);
const iqdbRunningRef = useRef(false);
const objectUrls = useRef<string[]>([]); const objectUrls = useRef<string[]>([]);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [entries, setEntries] = useState<UploadEntry[]>([]); const [entries, setEntries] = useState<UploadEntry[]>([]);
const [batch, setBatch] = useState({ total: 0, done: 0, failed: 0 });
const [pipeline, setPipeline] = useState<{
phase: "md5" | "visual";
done: number;
total: number;
} | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [checkingIds, setCheckingIds] = useState<string[]>([]); const [checkingIds, setCheckingIds] = useState<string[]>([]);
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); const [bulkOpen, setBulkOpen] = useState(false);
useEffect(() => { useEffect(() => {
@@ -649,6 +767,12 @@ export default function UploadPage() {
}); });
const uploads = uploadsQuery.data ?? []; const uploads = uploadsQuery.data ?? [];
// Follow the live query data so background IQDB results land in the open
// modal as they arrive (a snapshot object never sees them).
const modalTemp = modalId
? (uploads.find((temp) => temp.temp_id === modalId) ?? null)
: null;
const pending = uploads.filter((temp) => temp.status === "pending"); const pending = uploads.filter((temp) => temp.status === "pending");
const visual = uploads.filter((temp) => temp.status === "visual_match"); const visual = uploads.filter((temp) => temp.status === "visual_match");
const completed = uploads.filter( const completed = uploads.filter(
@@ -661,20 +785,18 @@ export default function UploadPage() {
(entry) => entry.status === "uploading", (entry) => entry.status === "uploading",
); );
const uploadedCount = entries.filter((entry) => entry.status === "done").length; // Finished tiles clear themselves after a moment, so the counters come
const failedCount = entries.filter((entry) => entry.status === "error").length; // from batch totals rather than the tiles currently on screen.
const batchPercent = entries.length const settledCount = batch.done + batch.failed;
? Math.round( const inflightProgress = entries.reduce(
entries.reduce(
(total, entry) => (total, entry) =>
total + total + (entry.status === "uploading" ? entry.progress : 0),
(entry.status === "done"
? 100
: entry.status === "queued"
? 0
: entry.progress),
0, 0,
) / entries.length, );
const batchPercent = batch.total
? Math.min(
100,
Math.round((settledCount * 100 + inflightProgress) / batch.total),
) )
: 0; : 0;
@@ -695,6 +817,15 @@ export default function UploadPage() {
}; };
}); });
setEntries((current) => [...current, ...next]); 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); queueRef.current.push(...next);
void drainQueue(); void drainQueue();
} }
@@ -715,23 +846,74 @@ export default function UploadPage() {
void queryClient.invalidateQueries({ queryKey: ["uploads"] }); 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[]> { async function refreshUploads(): Promise<TempUpload[]> {
const fresh = await api<TempUpload[]>("/api/uploads/"); const fresh = await api<TempUpload[]>("/api/uploads/");
queryClient.setQueryData(["uploads"], fresh); queryClient.setQueryData(["uploads"], fresh);
return fresh; return fresh;
} }
async function checkSimilarity(ids: string[]) { // One serial drain for all IQDB checks: clicking "Check similarity" many
if (ids.length === 0) return; // 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); setProcessing(true);
setCheckingIds((current) => [...current, ...ids]); setIqdbError(null);
try { try {
const fresh = await refreshUploads(); while (iqdbQueueRef.current.length > 0) {
const targets = fresh.filter( // Wait out a rate-limit cooldown before touching e621 again.
(temp) => ids.includes(temp.temp_id) && temp.status === "pending", const cooldown = e621CooldownRemainingMs();
if (cooldown > 0) {
setIqdbError(
`e621 rate limit — resuming in ${Math.ceil(cooldown / 1000)}s`,
); );
for (const temp of targets) { await new Promise((resolve) => setTimeout(resolve, cooldown + 250));
if (!temp.file_url) continue; 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 { try {
const blob = await fetchTempBlob(temp.file_url); const blob = await fetchTempBlob(temp.file_url);
const results = await iqdbSearch( const results = await iqdbSearch(
@@ -739,65 +921,141 @@ export default function UploadPage() {
blob, blob,
temp.original_filename, temp.original_filename,
); );
if (results.length > 0) { // Store even an empty result: "checked, nothing found" must stay
await api(`/api/uploads/${temp.temp_id}/iqdb/`, { // distinguishable from "never checked" in the metadata modal.
method: "POST", const updated = await api<TempUpload>(
json: { results }, `/api/uploads/${temp.temp_id}/iqdb/`,
}); { method: "POST", json: { results } },
);
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 { } finally {
setCurrentCheck(null);
setIqdbProgress((current) =>
current ? { ...current, done: current.done + 1 } : current,
);
setCheckingIds((current) => setCheckingIds((current) =>
current.filter((value) => value !== temp.temp_id), current.filter((value) => value !== tempId),
); );
} }
} }
} finally { } finally {
iqdbRunningRef.current = false;
setProcessing(false); setProcessing(false);
setCheckingIds([]); setCheckingIds([]);
setIqdbProgress((current) =>
current === null || current.done >= current.total ? null : current,
);
invalidateUploads(); 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 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. // Phase 1: e621 MD5 lookup, 75 md5: metatags per query.
if (unresolved.length > 0) { setPipeline({ phase: "md5", done: 0, total: targets.length });
try { try {
for (let index = 0; index < unresolved.length; index += 20) { for (let index = 0; index < targets.length; index += MD5_BATCH_SIZE) {
const batch = unresolved.slice(index, index + 20); const md5Batch = targets.slice(index, index + MD5_BATCH_SIZE);
const posts = await fetchPostsByMd5( const posts = await fetchPostsByMd5(
creds, creds,
batch.map((temp) => temp.md5), md5Batch.map((temp) => temp.md5),
); );
for (const post of posts) { 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; if (!match) continue;
try { try {
await api(`/api/uploads/${match.temp_id}/resolve/`, { const updated = await api<TempUpload>(
method: "POST", `/api/uploads/${match.temp_id}/resolve/`,
json: { mode: "link", post, auto: true }, { method: "POST", json: { mode: "link", post, auto: true } },
}); );
upsertUpload(updated);
} catch { } catch {
// Leave it pending. // Leave it pending.
} }
} }
setPipeline((current) =>
current
? {
...current,
done: Math.min(index + MD5_BATCH_SIZE, targets.length),
}
: current,
);
} }
} catch { } catch {
// e621 unavailable; leave everything pending. // e621 unavailable; those files stay pending.
} }
invalidateUploads(); setPipeline(null);
// 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. /** Mark an entry done, fade it out and drop it once it has been seen. */
const fresh = await refreshUploads(); function finishEntry(entry: UploadEntry, temp: TempUpload) {
const stillPending = fresh updateEntry(entry.id, { status: "done", progress: 100 });
.filter((temp) => temp.status === "pending") setBatch((current) => ({ ...current, done: current.done + 1 }));
.map((temp) => temp.temp_id); // The board must move the moment an upload lands, not when the batch ends.
void checkSimilarity(stillPending); 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() { async function drainQueue() {
@@ -819,13 +1077,14 @@ export default function UploadPage() {
const temp = await uploadFile(entry.file, {}, (percent) => const temp = await uploadFile(entry.file, {}, (percent) =>
updateEntry(entry.id, { progress: percent }), updateEntry(entry.id, { progress: percent }),
); );
updateEntry(entry.id, { status: "done", progress: 100 }); finishEntry(entry, temp);
created.push(temp); created.push(temp);
} catch (error) { } catch (error) {
updateEntry(entry.id, { updateEntry(entry.id, {
status: "error", status: "error",
error: errorMessage(error), error: errorMessage(error),
}); });
setBatch((current) => ({ ...current, failed: current.failed + 1 }));
} }
} }
} }
@@ -839,7 +1098,7 @@ export default function UploadPage() {
invalidateUploads(); invalidateUploads();
if (created.length > 0) { if (created.length > 0) {
setProcessing(true); setProcessing(true);
void autoMatchAndCheck(created).finally(() => setProcessing(false)); void runUploadPipeline(created).finally(() => setProcessing(false));
} }
} }
@@ -909,7 +1168,7 @@ export default function UploadPage() {
key={temp.temp_id} key={temp.temp_id}
temp={temp} temp={temp}
checking={checkingIds.includes(temp.temp_id)} checking={checkingIds.includes(temp.temp_id)}
onOpen={() => setModalFor(temp)} onOpen={() => setModalId(temp.temp_id)}
onCheck={() => void checkSimilarity([temp.temp_id])} onCheck={() => void checkSimilarity([temp.temp_id])}
onDismiss={() => dismiss(temp)} onDismiss={() => dismiss(temp)}
/> />
@@ -929,14 +1188,48 @@ export default function UploadPage() {
then moved into the library once resolved. then moved into the library once resolved.
</p> </p>
</div> </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"> <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" /> 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>
@@ -991,8 +1284,8 @@ export default function UploadPage() {
<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">
{uploadedCount}/{entries.length} uploaded {batch.done}/{batch.total} uploaded
{failedCount > 0 ? ` · ${failedCount} failed` : ""} {batch.failed > 0 ? ` · ${batch.failed} failed` : ""}
</span> </span>
</h2> </h2>
<button <button
@@ -1040,11 +1333,17 @@ export default function UploadPage() {
))} ))}
</div> </div>
) : null} ) : 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) => ( {entries.map((entry) => (
<div <div
key={entry.id} 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"> <div className="relative aspect-square overflow-hidden bg-ctp-mantle">
{entry.previewUrl ? ( {entry.previewUrl ? (
@@ -1136,8 +1435,14 @@ export default function UploadPage() {
)} )}
</div> </div>
{modalFor ? ( {modalTemp ? (
<MetadataModal temp={modalFor} onClose={() => setModalFor(null)} /> <MetadataModal
temp={modalTemp}
checking={checkingIds.includes(modalTemp.temp_id)}
checkError={checkErrors[modalTemp.temp_id] ?? null}
onCheck={() => void checkSimilarity([modalTemp.temp_id])}
onClose={() => setModalId(null)}
/>
) : null} ) : null}
{bulkOpen ? ( {bulkOpen ? (
+2
View File
@@ -0,0 +1,2 @@
/** Baked at build time by Vite (see vite.config.ts). */
declare const __GIT_HASH__: string;
+14 -2
View File
@@ -5,8 +5,20 @@ const TOKEN_KEY = "j621.token";
/** Absolutise a backend path; external/absolute URLs pass through. */ /** Absolutise a backend path; external/absolute URLs pass through. */
export function apiUrl(path: string): string { export function apiUrl(path: string): string {
if (!path.startsWith("/")) return path; if (path.startsWith("/")) return `${getBackendUrl()}${path}`;
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 { export function getToken(): string | null {
+76 -11
View File
@@ -82,13 +82,58 @@ export function effectiveCredentials(
return credentials ?? ANONYMOUS_CREDENTIALS; 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 // e621 allows 2 requests/second hard, 1/second sustained — and the IQDB
// request through a queue with a 1s minimum gap. // endpoint is stricter, so stay comfortably under it. Serialize every request
// through a queue with a minimum gap.
let lastRequestAt = 0; let lastRequestAt = 0;
let queue: Promise<unknown> = Promise.resolve(); 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> { function schedule<T>(task: () => Promise<T>): Promise<T> {
const run = queue.then(task, task); const run = queue.then(task, task);
queue = run.then( queue = run.then(
@@ -99,7 +144,11 @@ function schedule<T>(task: () => Promise<T>): Promise<T> {
} }
async function throttle(): Promise<void> { 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) { if (wait > 0) {
await new Promise((resolve) => setTimeout(resolve, wait)); await new Promise((resolve) => setTimeout(resolve, wait));
} }
@@ -124,10 +173,7 @@ export function e621Request<T>(
const base = credentials.base_url.replace(/\/+$/, ""); const base = credentials.base_url.replace(/\/+$/, "");
const url = new URL(`${base}/${path.replace(/^\/+/, "")}`); const url = new URL(`${base}/${path.replace(/^\/+/, "")}`);
// Browsers cannot set User-Agent; e621 documents `_client` for this case. // Browsers cannot set User-Agent; e621 documents `_client` for this case.
url.searchParams.set( url.searchParams.set("_client", CLIENT_VERSION);
"_client",
`${CLIENT_VERSION} (by ${credentials.username || "anonymous"} on e621)`,
);
for (const [key, value] of Object.entries(options.params ?? {})) { for (const [key, value] of Object.entries(options.params ?? {})) {
if (value !== undefined) { if (value !== undefined) {
url.searchParams.set(key, String(value)); url.searchParams.set(key, String(value));
@@ -143,12 +189,20 @@ export function e621Request<T>(
} }
const startedAt = performance.now(); 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 { try {
const response = await fetch(url, { const response = await fetch(url, {
method: options.method ?? "GET", method: options.method ?? "GET",
headers, headers,
body: options.body, body: options.body,
signal: options.signal, signal: controller.signal,
}); });
if (!response.ok) { if (!response.ok) {
@@ -164,7 +218,15 @@ export function e621Request<T>(
return undefined as T; return undefined as T;
} }
return (await response.json()) 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 { } finally {
clearTimeout(timer);
options.signal?.removeEventListener("abort", forwardAbort);
useE621Metrics useE621Metrics
.getState() .getState()
.record(path, performance.now() - startedAt); .record(path, performance.now() - startedAt);
@@ -190,8 +252,11 @@ export function e621ErrorMessage(error: unknown): string {
} }
return `e621 request failed (${error.status}).`; return `e621 request failed (${error.status}).`;
} }
if (error instanceof DOMException && error.name === "TimeoutError") {
return "e621 did not answer in time.";
}
if (error instanceof TypeError) { 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 return error instanceof Error
? error.message ? error.message
@@ -308,7 +373,7 @@ export async function fetchPostsByMd5(
{ {
params: { params: {
tags: `md5:${md5s.join(",")}`, tags: `md5:${md5s.join(",")}`,
limit: Math.min(md5s.length, 100), limit: Math.min(md5s.length, 320),
}, },
signal, signal,
}, },
+20
View File
@@ -1,3 +1,4 @@
import { execSync } from "node:child_process";
import { fileURLToPath, URL } from "node:url"; import { fileURLToPath, URL } from "node:url";
import tailwindcss from "@tailwindcss/vite"; 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"; 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/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
define: {
__GIT_HASH__: JSON.stringify(gitHash()),
},
resolve: { resolve: {
alias: { alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)), "@": fileURLToPath(new URL("./src", import.meta.url)),