From d0e2901c927011167c2af9d0f14de20886d9e517 Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Thu, 17 Sep 2026 11:12:03 -0500 Subject: [PATCH] Upload pipeline: staging, MD5 auto-match, IQDB, three-column board Backend: - TempUpload model: staged files (pending / visual_match / completed / error) with resolution, e621 payload, custom metadata and IQDB data - Files land in a temp folder and only move into the watched library folder once resolved; duplicates resolve immediately without a copy - Endpoints: stage (multipart), list, retrieve, temp file, IQDB save, resolve (link to post or custom metadata), discard/dismiss - cleanup_temp_uploads command for old staged files - Replaces the old direct-to-library upload endpoint Frontend: - Upload page is now a three-column board (Pending & Unmatched / Visual Similarity Detected / Auto-uploaded & Indexed) - After upload: MD5s are batch-checked against e621 and matches auto-complete with full post metadata; remaining files run through IQDB and move to the similarity column when candidates exist - Metadata modal with IQDB candidates, post-ID linking and custom tags/rating/notes; discard and dismiss actions - e621 client gains fetchPostsByMd5 and iqdbSearch helpers Roadmap updated with the completed upload items. --- ROADMAP.md | 45 +- .../commands/cleanup_temp_uploads.py | 35 + .../library/migrations/0004_tempupload.py | 42 ++ backend/apps/library/models.py | 58 ++ backend/apps/library/serializers.py | 36 +- backend/apps/library/services.py | 20 + backend/apps/library/uploads.py | 212 ++++++ backend/apps/library/urls.py | 5 +- backend/apps/library/views.py | 50 -- frontend/src/features/upload/UploadPage.tsx | 680 +++++++++++++++++- frontend/src/lib/api.ts | 13 +- frontend/src/lib/e621.ts | 84 +++ frontend/src/lib/types.ts | 27 + 13 files changed, 1189 insertions(+), 118 deletions(-) create mode 100644 backend/apps/library/management/commands/cleanup_temp_uploads.py create mode 100644 backend/apps/library/migrations/0004_tempupload.py create mode 100644 backend/apps/library/uploads.py diff --git a/ROADMAP.md b/ROADMAP.md index 71b2240..31176a7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,32 +37,31 @@ they land. - [ ] "Followed" nav entry - [ ] `F` shortcut to favorite a post, tag finder in the Ctrl+K palette -## 3. Uploads & ingestion pipeline (major rework) +## 3. Uploads & ingestion pipeline -Current upload writes straight into the watched folder. The target flow matches -the original app: +Files now stage first and are resolved before entering the library. -- [ ] **Backend staging storage** - - [ ] `TempUpload` model: temp_id, user, md5, filename, temp path, status - (`pending` / `visual_match` / `completed` / `error`), timestamps - - [ ] Files land in a temp folder first; only "completed" moves them into the - watched library folder - - [ ] Pending uploads list endpoint, delete/discard endpoint -- [ ] **Auto-upload / auto-match** - - [ ] Compute MD5 on upload; if it already exists in the library, mark as - duplicate and resolve automatically (no new file) - - [ ] If the MD5 matches an e621 post, auto-complete: fetch and store the post - metadata, seed rating/tags, mark as indexed - - [ ] Batch MD5 checks against e621 for staged uploads -- [ ] **IQDB / visual similarity on upload** - - [ ] Run IQDB on staged uploads and surface candidate posts - - [ ] Perceptual-hash check against the library → "Visual Similarity Detected" - state with side-by-side comparison -- [ ] **Upload UI** - - [ ] Three-column board: Pending & Unmatched / Visual Similarity Detected / +- [x] **Backend staging storage** + - [x] `TempUpload` model: user, md5, filename, temp path, status + (`pending` / `visual_match` / `completed` / `error`), resolution + - [x] Files land in a temp folder first; only completed uploads move into + the watched library folder + - [x] Pending/upload list endpoint, file serving, discard endpoint + - [x] `cleanup_temp_uploads` command for old staged files +- [x] **Auto-upload / auto-match** + - [x] MD5 computed on staging; exact duplicates resolve immediately + - [x] MD5 batch-checked against e621; matches auto-complete with post + metadata stored and rating seeded +- [x] **IQDB similarity on upload** (SPA-driven) + - [x] Automatic + manual IQDB checks with candidate posts + - [x] "Visual Similarity Detected" state with candidate picker + - [ ] Perceptual-hash comparison against the library (shared with the + duplicates engine, still pending) +- [x] **Upload UI** + - [x] Three-column board: Pending & Unmatched / Visual Similarity Detected / Auto-uploaded & Indexed - - [ ] Metadata modal (link to e621 post via search + custom tags/rating/notes) - - [ ] Per-file progress plus batch summary + - [x] Metadata modal (link to e621 post, IQDB candidates, custom metadata) + - [x] Per-file progress plus batch processing indicator ## 4. Staff tools diff --git a/backend/apps/library/management/commands/cleanup_temp_uploads.py b/backend/apps/library/management/commands/cleanup_temp_uploads.py new file mode 100644 index 0000000..47c309d --- /dev/null +++ b/backend/apps/library/management/commands/cleanup_temp_uploads.py @@ -0,0 +1,35 @@ +from datetime import timedelta + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from apps.library.models import TempUpload + + +class Command(BaseCommand): + help = "Delete staged uploads older than N hours (default 24)." + + def add_arguments(self, parser): + parser.add_argument("--hours", type=int, default=24) + parser.add_argument( + "--include-completed", + action="store_true", + help="Also remove completed staging records.", + ) + + def handle(self, *args, **options): + cutoff = timezone.now() - timedelta(hours=options["hours"]) + queryset = TempUpload.objects.filter(created_at__lt=cutoff) + if not options["include_completed"]: + queryset = queryset.exclude(status=TempUpload.STATUS_COMPLETED) + + removed = 0 + for temp in queryset: + if temp.file: + temp.file.delete(save=False) + temp.delete() + removed += 1 + + self.stdout.write( + self.style.SUCCESS(f"Removed {removed} staged upload(s).") + ) diff --git a/backend/apps/library/migrations/0004_tempupload.py b/backend/apps/library/migrations/0004_tempupload.py new file mode 100644 index 0000000..d7d4b57 --- /dev/null +++ b/backend/apps/library/migrations/0004_tempupload.py @@ -0,0 +1,42 @@ +# Generated by Django 6.1.1 on 2026-09-17 16:06 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0003_mediaitem_e621_data_mediaitem_e621_post_id'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TempUpload', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('file', models.FileField(blank=True, upload_to='uploads/temp/')), + ('original_filename', models.CharField(max_length=255)), + ('md5', models.CharField(db_index=True, max_length=32)), + ('size', models.BigIntegerField(default=0)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('visual_match', 'Visual Similarity Detected'), ('completed', 'Completed'), ('error', 'Error')], default='pending', max_length=20)), + ('resolution', models.CharField(blank=True, default='', max_length=20)), + ('e621_post_id', models.IntegerField(blank=True, null=True)), + ('e621_data', models.JSONField(blank=True, null=True)), + ('custom_rating', models.CharField(blank=True, default='', max_length=1)), + ('custom_tags', models.JSONField(blank=True, default=list)), + ('custom_notes', models.TextField(blank=True, default='')), + ('iqdb_data', models.JSONField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('library_item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='temp_uploads', to='library.mediaitem')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='temp_uploads', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/apps/library/models.py b/backend/apps/library/models.py index 392aad6..9705e4e 100644 --- a/backend/apps/library/models.py +++ b/backend/apps/library/models.py @@ -1,6 +1,8 @@ from django.conf import settings from django.db import models +import uuid + class MediaItem(models.Model): """A logical media file, identified by its MD5 fingerprint.""" @@ -57,3 +59,59 @@ class MediaLocation(models.Model): def __str__(self): return self.rel_path + + +class TempUpload(models.Model): + """A staged upload that has not been accepted into the library yet.""" + + STATUS_PENDING = "pending" + STATUS_VISUAL_MATCH = "visual_match" + STATUS_COMPLETED = "completed" + STATUS_ERROR = "error" + STATUS_CHOICES = [ + (STATUS_PENDING, "Pending"), + (STATUS_VISUAL_MATCH, "Visual Similarity Detected"), + (STATUS_COMPLETED, "Completed"), + (STATUS_ERROR, "Error"), + ] + + RESOLUTION_AUTO_MD5 = "auto_md5" + RESOLUTION_DUPLICATE = "duplicate" + RESOLUTION_LINKED = "linked" + RESOLUTION_CUSTOM = "custom" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="temp_uploads", + ) + file = models.FileField(upload_to="uploads/temp/", blank=True) + original_filename = models.CharField(max_length=255) + md5 = models.CharField(max_length=32, db_index=True) + size = models.BigIntegerField(default=0) + status = models.CharField( + max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING + ) + resolution = models.CharField(max_length=20, blank=True, default="") + e621_post_id = models.IntegerField(null=True, blank=True) + e621_data = models.JSONField(null=True, blank=True) + custom_rating = models.CharField(max_length=1, blank=True, default="") + custom_tags = models.JSONField(default=list, blank=True) + custom_notes = models.TextField(blank=True, default="") + iqdb_data = models.JSONField(null=True, blank=True) + library_item = models.ForeignKey( + MediaItem, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="temp_uploads", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"{self.original_filename} ({self.status})" diff --git a/backend/apps/library/serializers.py b/backend/apps/library/serializers.py index 6a6a989..c8f2e75 100644 --- a/backend/apps/library/serializers.py +++ b/backend/apps/library/serializers.py @@ -2,7 +2,7 @@ import os from rest_framework import serializers -from .models import MediaItem, MediaLocation +from .models import MediaItem, MediaLocation, TempUpload from .services import VIDEO_EXTENSIONS @@ -92,3 +92,37 @@ class MediaItemSerializer(serializers.ModelSerializer): def get_kind(self, obj): return "video" if "." + self.get_extension(obj) in VIDEO_EXTENSIONS else "image" + + +class TempUploadSerializer(serializers.ModelSerializer): + temp_id = serializers.UUIDField(source="id", read_only=True) + library_j_id = serializers.SerializerMethodField() + file_url = serializers.SerializerMethodField() + + class Meta: + model = TempUpload + fields = [ + "temp_id", + "original_filename", + "md5", + "size", + "status", + "resolution", + "e621_post_id", + "e621_data", + "custom_rating", + "custom_tags", + "custom_notes", + "iqdb_data", + "library_j_id", + "file_url", + "created_at", + "updated_at", + ] + read_only_fields = fields + + def get_library_j_id(self, obj): + return f"J-{obj.library_item_id}" if obj.library_item_id else None + + def get_file_url(self, obj): + return f"/api/uploads/{obj.id}/file/" if obj.file else None diff --git a/backend/apps/library/services.py b/backend/apps/library/services.py index 126bd7d..5785b9c 100644 --- a/backend/apps/library/services.py +++ b/backend/apps/library/services.py @@ -226,6 +226,26 @@ def trim_e621_post(post): } +def sanitize_iqdb_results(results): + """Keep a compact, size-bounded copy of IQDB candidates.""" + cleaned = [] + for result in results[:10]: + if not isinstance(result, dict): + continue + score = result.get("score") + post_id = result.get("post_id") + entry = { + "post_id": post_id if isinstance(post_id, int) else None, + "score": float(score) if isinstance(score, (int, float)) else None, + "preview_url": str(result.get("preview_url") or "")[:500] or None, + "rating": str(result.get("rating") or "")[:1] or None, + "md5": str(result.get("md5") or "")[:32] or None, + } + if entry["post_id"] or entry["preview_url"]: + cleaned.append(entry) + return cleaned + + def generate_video_thumbnail(md5, path): """Extract a JPEG thumbnail from a video, cached under MEDIA_ROOT/thumbs.""" if not shutil.which("ffmpeg"): diff --git a/backend/apps/library/uploads.py b/backend/apps/library/uploads.py new file mode 100644 index 0000000..38a54cf --- /dev/null +++ b/backend/apps/library/uploads.py @@ -0,0 +1,212 @@ +"""Staged uploads: files land in a temp folder until they are resolved. + +Flow: +- POST /api/uploads/ stage a file (computes MD5, detects duplicates) +- GET /api/uploads/ list my staged uploads +- GET /api/uploads//file/ serve the staged file (previews, IQDB) +- POST /api/uploads//iqdb/ store IQDB candidates -> visual_match +- POST /api/uploads//resolve/ link to a post or apply custom metadata +- DELETE /api/uploads// discard a staged upload / dismiss a record +""" + +import logging +import shutil +from pathlib import Path + +from django.conf import settings +from django.http import Http404 +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser +from rest_framework.response import Response + +from . import services +from .models import MediaItem, TempUpload +from .permissions import CanUpload +from .serializers import TempUploadSerializer + +logger = logging.getLogger(__name__) + + +def complete_temp_upload(temp): + """Move a staged file into the watched folder and index it.""" + folder = Path(settings.WATCHED_FOLDER) + if not temp.file: + raise ValueError("staged file is missing") + + destination = services.unique_destination( + folder, temp.original_filename or "upload" + ) + with temp.file.open("rb") as source, open(destination, "wb") as target: + shutil.copyfileobj(source, target) + + item, _, _ = services.index_file(destination, folder) + temp.file.delete(save=False) + temp.library_item = item + temp.status = TempUpload.STATUS_COMPLETED + + update_fields = [] + if item.uploaded_by_id is None: + item.uploaded_by = temp.user + update_fields.append("uploaded_by") + if temp.e621_post_id is not None: + item.e621_post_id = temp.e621_post_id + item.e621_data = temp.e621_data + update_fields += ["e621_post_id", "e621_data"] + if temp.custom_rating in {"s", "q", "e"} and not item.rating: + item.rating = temp.custom_rating + update_fields.append("rating") + if temp.custom_tags: + item.tags = temp.custom_tags + update_fields.append("tags") + if temp.custom_notes: + item.notes = temp.custom_notes + update_fields.append("notes") + if update_fields: + item.save(update_fields=update_fields + ["updated_at"]) + + temp.save() + return item + + +class TempUploadViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + serializer_class = TempUploadSerializer + permission_classes = [CanUpload] + parser_classes = [MultiPartParser, FormParser, JSONParser] + http_method_names = ["get", "post", "delete", "head", "options"] + + def get_queryset(self): + queryset = TempUpload.objects.select_related("library_item") + user = self.request.user + if not (user.is_staff or user.is_superuser): + queryset = queryset.filter(user=user) + return queryset + + def create(self, request): + upload = request.FILES.get("file") + if upload is None: + return Response( + {"detail": "A file is required."}, status=status.HTTP_400_BAD_REQUEST + ) + extension = Path(upload.name).suffix.lower() + if extension not in services.ALLOWED_EXTENSIONS: + return Response( + {"detail": f"Unsupported file type: {extension or 'unknown'}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + temp = TempUpload.objects.create( + user=request.user, + file=upload, + original_filename=upload.name, + size=upload.size, + ) + temp.md5 = services.compute_md5(temp.file.path) + + existing = MediaItem.objects.filter(md5=temp.md5).first() + if existing is not None: + temp.status = TempUpload.STATUS_COMPLETED + temp.resolution = TempUpload.RESOLUTION_DUPLICATE + temp.library_item = existing + temp.file.delete(save=False) + temp.save() + return Response( + TempUploadSerializer(temp).data, status=status.HTTP_201_CREATED + ) + + @action(detail=True, methods=["get"]) + def file(self, request, pk=None): + temp = self.get_object() + if not temp.file: + raise Http404 + return services.serve_file(request, temp.file.path) + + @action(detail=True, methods=["post"]) + def iqdb(self, request, pk=None): + temp = self.get_object() + results = request.data.get("results") + if not isinstance(results, list): + return Response( + {"detail": "results must be a list."}, + status=status.HTTP_400_BAD_REQUEST, + ) + temp.iqdb_data = services.sanitize_iqdb_results(results) + if temp.status == TempUpload.STATUS_PENDING and temp.iqdb_data: + temp.status = TempUpload.STATUS_VISUAL_MATCH + temp.save(update_fields=["iqdb_data", "status", "updated_at"]) + return Response(TempUploadSerializer(temp).data) + + @action(detail=True, methods=["post"]) + def resolve(self, request, pk=None): + 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, + ) + + mode = str(request.data.get("mode") or "").strip() + if mode == "link": + post = request.data.get("post") + post_id = request.data.get("post_id") + trimmed = services.trim_e621_post(post) + has_numeric_id = post_id is not None and str(post_id).isdigit() + if trimmed is None and not has_numeric_id: + return Response( + {"detail": "A post payload or post_id is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if trimmed is not None and trimmed.get("id") is not None: + temp.e621_post_id = int(trimmed["id"]) + elif has_numeric_id: + temp.e621_post_id = int(post_id) + temp.e621_data = trimmed + temp.resolution = ( + TempUpload.RESOLUTION_AUTO_MD5 + if request.data.get("auto") + else TempUpload.RESOLUTION_LINKED + ) + rating = str(request.data.get("rating") or "") + if rating in {"s", "q", "e"}: + temp.custom_rating = rating + tags = services.parse_tags(request.data.get("tags")) + if tags is not None: + temp.custom_tags = tags + notes = request.data.get("notes") + if notes is not None: + temp.custom_notes = str(notes) + elif mode == "custom": + rating = str(request.data.get("rating") or "") + temp.custom_rating = rating if rating in {"s", "q", "e"} else "" + temp.custom_tags = services.parse_tags(request.data.get("tags")) or [] + temp.custom_notes = str(request.data.get("notes") or "") + temp.resolution = TempUpload.RESOLUTION_CUSTOM + else: + return Response( + {"detail": "mode must be 'link' or 'custom'."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + temp.save() + try: + complete_temp_upload(temp) + except Exception as exc: # noqa: BLE001 - report completion failures + logger.exception("Could not complete staged upload %s", temp.id) + temp.status = TempUpload.STATUS_ERROR + temp.save(update_fields=["status", "updated_at"]) + return Response( + {"detail": f"Could not finish the upload: {exc}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + temp.refresh_from_db() + return Response(TempUploadSerializer(temp).data) + + def perform_destroy(self, instance): + if instance.file: + instance.file.delete(save=False) + instance.delete() diff --git a/backend/apps/library/urls.py b/backend/apps/library/urls.py index 87102d8..845401e 100644 --- a/backend/apps/library/urls.py +++ b/backend/apps/library/urls.py @@ -1,14 +1,15 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import DownloadToLibraryView, MediaItemViewSet, UploadView +from .uploads import TempUploadViewSet +from .views import DownloadToLibraryView, MediaItemViewSet router = DefaultRouter() router.register("files", MediaItemViewSet, basename="file") +router.register("uploads", TempUploadViewSet, basename="upload") urlpatterns = [ path("", include(router.urls)), - path("uploads/", UploadView.as_view(), name="upload"), path( "online/downloads/", DownloadToLibraryView.as_view(), diff --git a/backend/apps/library/views.py b/backend/apps/library/views.py index d77c426..e07e790 100644 --- a/backend/apps/library/views.py +++ b/backend/apps/library/views.py @@ -8,7 +8,6 @@ from django.http import Http404 from django.shortcuts import get_object_or_404 from rest_framework import mixins, status, viewsets from rest_framework.decorators import action -from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly from rest_framework.response import Response from rest_framework.views import APIView @@ -165,55 +164,6 @@ class MediaItemViewSet( ) -class UploadView(APIView): - permission_classes = [CanUpload] - parser_classes = [MultiPartParser, FormParser] - - def post(self, request): - upload = request.FILES.get("file") - if upload is None: - return Response( - {"detail": "A file is required."}, status=status.HTTP_400_BAD_REQUEST - ) - folder = Path(settings.WATCHED_FOLDER) - destination = services.unique_destination(folder, upload.name) - with open(destination, "wb") as output: - for chunk in upload.chunks(): - output.write(chunk) - try: - item, created_item, _ = services.index_file(destination, folder) - except Exception as exc: # noqa: BLE001 - report indexing failures - destination.unlink(missing_ok=True) - return Response( - {"detail": f"Could not index upload: {exc}"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - update_fields = [] - if created_item and item.uploaded_by_id is None: - item.uploaded_by = request.user - update_fields.append("uploaded_by") - rating = request.data.get("rating") or "" - if rating in {"s", "q", "e"} and item.rating != rating: - item.rating = rating - update_fields.append("rating") - tags = services.parse_tags(request.data.get("tags")) - if tags is not None: - item.tags = tags - update_fields.append("tags") - notes = request.data.get("notes") - if notes is not None: - item.notes = str(notes) - update_fields.append("notes") - if update_fields: - item.save(update_fields=update_fields + ["updated_at"]) - - return Response( - MediaItemSerializer(item, context={"request": request}).data, - status=status.HTTP_201_CREATED, - ) - - class DownloadToLibraryView(APIView): """Fetch an e621 file into the watched folder and index it.""" diff --git a/frontend/src/features/upload/UploadPage.tsx b/frontend/src/features/upload/UploadPage.tsx index 7928050..26a8f2e 100644 --- a/frontend/src/features/upload/UploadPage.tsx +++ b/frontend/src/features/upload/UploadPage.tsx @@ -1,12 +1,32 @@ -import { useQueryClient } from "@tanstack/react-query"; -import { UploadCloud } from "lucide-react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + CheckCircle2, + UploadCloud, + X, +} from "lucide-react"; import { useRef, useState, type DragEvent } from "react"; +import { Link } from "react-router-dom"; -import { Button, EmptyState } from "@/components/ui"; -import { errorMessage, uploadFile } from "@/lib/api"; +import { Button, EmptyState, Spinner, inputClass } from "@/components/ui"; +import { api, authHeaders, errorMessage, uploadFile } from "@/lib/api"; import { cn } from "@/lib/cn"; +import { + E621Error, + e621ErrorMessage, + effectiveCredentials, + fetchPost, + fetchPostsByMd5, + iqdbSearch, +} from "@/lib/e621"; import { formatBytes } from "@/lib/format"; +import type { + E621IqdbCandidate, + Paginated, + Rating, + TempUpload, +} from "@/lib/types"; import { useAuth } from "@/store/auth"; +import { useE621 } from "@/store/e621"; type UploadStatus = "queued" | "uploading" | "done" | "error"; @@ -18,13 +38,440 @@ interface UploadEntry { error?: string; } +const RESOLUTION_LABELS: Record = { + auto_md5: "auto-matched on e621", + duplicate: "already in the library", + linked: "linked to a post", + custom: "custom metadata", +}; + +const ratingChoices: { value: Rating; label: string; active: string }[] = [ + { + value: "", + label: "None", + active: "border-ctp-surface2 bg-ctp-surface0 text-ctp-text", + }, + { + value: "s", + label: "Safe", + active: "border-ctp-green/40 bg-ctp-green/15 text-ctp-green", + }, + { + value: "q", + label: "Questionable", + active: "border-ctp-peach/40 bg-ctp-peach/15 text-ctp-peach", + }, + { + value: "e", + label: "Explicit", + active: "border-ctp-red/40 bg-ctp-red/15 text-ctp-red", + }, +]; + +function describeError(error: unknown): string { + if (error instanceof E621Error) return e621ErrorMessage(error); + return errorMessage(error); +} + +async function fetchTempBlob(url: string): Promise { + const response = await fetch(url, { headers: authHeaders() }); + if (!response.ok) { + throw new Error(`Could not read the staged file (${response.status}).`); + } + return response.blob(); +} + +function TempCard({ + temp, + checking, + onOpen, + onCheck, + onDismiss, +}: { + temp: TempUpload; + checking: boolean; + onOpen: () => void; + onCheck: () => void; + onDismiss: () => void; +}) { + const isVideo = /\.(mp4|webm)$/i.test(temp.original_filename); + + return ( +
+
+ {temp.file_url ? ( + isVideo ? ( +
+ +

+ {temp.original_filename} +

+

+ {formatBytes(temp.size)} · {temp.md5.slice(0, 10)}… +

+ + {temp.status === "completed" && temp.library_j_id ? ( +

+ + + {temp.library_j_id} + + + {RESOLUTION_LABELS[temp.resolution] ?? temp.resolution} + +

+ ) : null} + + {temp.status === "error" ? ( +

+ Something went wrong while indexing this file. +

+ ) : null} + + {temp.status === "pending" || temp.status === "visual_match" ? ( +
+ + {temp.status === "pending" ? ( + + ) : null} +
+ ) : null} + + +
+ ); +} + +function MetadataModal({ + temp, + onClose, +}: { + temp: TempUpload; + onClose: () => void; +}) { + const queryClient = useQueryClient(); + const credentials = useE621((state) => state.credentials); + const [tab, setTab] = useState<"link" | "custom">("link"); + const [postId, setPostId] = useState(""); + const [rating, setRating] = useState(temp.custom_rating); + const [tags, setTags] = useState(temp.custom_tags.join(", ")); + const [notes, setNotes] = useState(temp.custom_notes); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const isVideo = /\.(mp4|webm)$/i.test(temp.original_filename); + + async function run(task: () => Promise) { + setBusy(true); + setError(null); + try { + await task(); + void queryClient.invalidateQueries({ queryKey: ["uploads"] }); + void queryClient.invalidateQueries({ queryKey: ["files"] }); + onClose(); + } catch (err) { + setError(describeError(err)); + setBusy(false); + } + } + + function linkById() { + const parsed = Number(postId.trim()); + if (!Number.isFinite(parsed) || parsed <= 0) { + setError("Enter a valid e621 post ID."); + return; + } + return run(async () => { + const post = await fetchPost(effectiveCredentials(credentials), parsed); + await api(`/api/uploads/${temp.temp_id}/resolve/`, { + method: "POST", + json: { mode: "link", post, rating, tags, notes }, + }); + }); + } + + function linkCandidate(candidate: E621IqdbCandidate) { + if (!candidate.post_id) return; + return run(async () => { + const post = await fetchPost( + effectiveCredentials(credentials), + candidate.post_id as number, + ); + await api(`/api/uploads/${temp.temp_id}/resolve/`, { + method: "POST", + json: { mode: "link", post, rating, tags, notes }, + }); + }); + } + + function saveCustom() { + return run(async () => { + await api(`/api/uploads/${temp.temp_id}/resolve/`, { + method: "POST", + json: { mode: "custom", rating, tags, notes }, + }); + }); + } + + return ( +
+
event.stopPropagation()} + > +
+
+

+ Set metadata +

+

+ {temp.original_filename} +

+
+ +
+ +
+
+ {temp.file_url ? ( + isVideo ? ( +
+ +
+
+ + +
+ + {tab === "link" ? ( +
+ {temp.iqdb_data && temp.iqdb_data.length > 0 ? ( +
+

+ IQDB candidates +

+
+ {temp.iqdb_data.map((candidate) => ( + + ))} +
+
+ ) : ( +

+ No IQDB candidates yet — run "Check similarity" from the + card, or enter a post ID below. +

+ )} + +
+ setPostId(event.target.value)} + /> + +
+
+ ) : ( +
+
+ {ratingChoices.map((choice) => ( + + ))} +
+ setTags(event.target.value)} + /> +