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
|
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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" \
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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]"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
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. */
|
/** 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
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
Reference in New Issue
Block a user