Match local files to e621: MD5 lookups, manual links, batch scans

- MediaItem gains e621_match_status (unknown/matched/not_found/deleted)
  and e621_checked_at, backfilled for existing matched items.
- Server-side e621 client (apps/library/e621.py) using the user's stored
  credentials, throttled to 2 req/s, with typed errors.
- Matching service: MD5 lookup, manual post linking (flags MD5
  mismatches), unlink, metadata refresh, deleted-post detection.
- Detail actions POST /api/files/J-x/match/ and /unlink/ (uploader or
  staff only).
- Background library scans: MatchTask + /api/matches/ with missing/all
  scopes, progress polling, cancel and stale-task reaping; the scan
  counts toward the footer's Active Workers. Same pass available as
  manage.py match_e621 for cron.
- Library gains not_found/deleted status filters; the detail page adds
  an e621 match card (check / link by post ID / unlink) and the metadata
  card warns when a post was deleted on e621.
This commit is contained in:
2026-09-17 13:41:08 -05:00
parent db74f7ab18
commit 09405d1a0f
16 changed files with 1084 additions and 27 deletions
+10 -5
View File
@@ -31,10 +31,14 @@ they land.
- [x] The status footer's "Active Workers" now counts running download tasks - [x] The status footer's "Active Workers" now counts running download tasks
- [x] **Download to client** — streams the e621 original straight to the - [x] **Download to client** — streams the e621 original straight to the
browser (works for guests; host-restricted proxy, no library write) browser (works for guests; host-restricted proxy, no library write)
- [ ] **Match local files to e621** - [x] **Match local files to e621**
- [ ] Match by MD5 from the library detail (manual post ID entry) - [x] Match by MD5 from the library detail, plus manual post ID linking
- [ ] Batch cache status for the whole library (`matched` / `not_found` / `deleted`) (with an MD5-mismatch warning) and unlink
- [ ] Show match status + e621 metadata in the library (model already supports it) - [x] Batch cache status for the whole library (`matched` / `not_found` /
`deleted`) via background scan tasks (`/api/matches/`) and
`manage.py match_e621`
- [x] Show match status + e621 metadata in the library: status badge and
match card on the detail, and `not_found` / `deleted` status filters
- [ ] **IQDB reverse search** - [ ] **IQDB reverse search**
- [ ] Search from a local file (`POST /iqdb_queries.json`) - [ ] Search from a local file (`POST /iqdb_queries.json`)
- [ ] Show candidate posts to link - [ ] Show candidate posts to link
@@ -104,4 +108,5 @@ Files now stage first and are resolved before entering the library.
- Download progress, optimization jobs and the stats "Active Workers" count all - Download progress, optimization jobs and the stats "Active Workers" count all
want the same background-task/progress primitive — design it once. want the same background-task/progress primitive — design it once.
- IQDB and e621 matching depend on e621 credentials being configured; the SPA - IQDB and e621 matching depend on e621 credentials being configured; the SPA
already talks to e621 directly where possible. talks to e621 directly for browsing, while the backend e621 client
(`apps/library/e621.py`) handles metadata matching and batch scans.
+5 -5
View File
@@ -9,15 +9,15 @@ from .system_info import get_os_info
def _worker_counts(): def _worker_counts():
"""Active/queued background jobs (download tasks for now).""" """Active/queued background jobs (downloads and e621 match scans)."""
from apps.library.models import DownloadTask from apps.library.models import DownloadTask, MatchTask
active = DownloadTask.objects.filter( active = DownloadTask.objects.filter(
status=DownloadTask.STATUS_DOWNLOADING status=DownloadTask.STATUS_DOWNLOADING
).count() ).count() + MatchTask.objects.filter(status=MatchTask.STATUS_RUNNING).count()
queued = DownloadTask.objects.filter( queued = DownloadTask.objects.filter(
status=DownloadTask.STATUS_PENDING status=DownloadTask.STATUS_PENDING
).count() ).count() + MatchTask.objects.filter(status=MatchTask.STATUS_PENDING).count()
return {"active": active, "queued": queued} return {"active": active, "queued": queued}
@@ -35,7 +35,7 @@ class StatusView(APIView):
"version": settings.APP_VERSION, "version": settings.APP_VERSION,
"os": get_os_info(), "os": get_os_info(),
"watched_folder": settings.WATCHED_FOLDER, "watched_folder": settings.WATCHED_FOLDER,
"e621_time_ms": None, # no e621 calls in the backend yet "e621_time_ms": None, # e621 latency is measured client-side
"workers": _worker_counts(), "workers": _worker_counts(),
} }
payload["server_time_ms"] = round((time.perf_counter() - started) * 1000, 1) payload["server_time_ms"] = round((time.perf_counter() - started) * 1000, 1)
+8 -1
View File
@@ -111,7 +111,14 @@ def run_download_task(task_id):
if task.e621_data: if task.e621_data:
item.e621_post_id = task.post_id item.e621_post_id = task.post_id
item.e621_data = task.e621_data item.e621_data = task.e621_data
update_fields += ["e621_post_id", "e621_data"] item.e621_match_status = MediaItem.E621_MATCHED
item.e621_checked_at = timezone.now()
update_fields += [
"e621_post_id",
"e621_data",
"e621_match_status",
"e621_checked_at",
]
rating = (task.e621_data or {}).get("rating") rating = (task.e621_data or {}).get("rating")
if not item.rating and rating in {"s", "q", "e"}: if not item.rating and rating in {"s", "q", "e"}:
item.rating = rating item.rating = rating
+89
View File
@@ -0,0 +1,89 @@
"""Minimal e621 API client for server-side matching and metadata refresh.
The SPA talks to e621 directly for browsing; this client exists for work the
browser cannot do reliably: long batch scans, and requests tied to a library
item rather than an open page. It uses the requesting user's stored
credentials and a global throttle (e621 asks for at most two requests per
second).
"""
import threading
import time
import requests
from django.conf import settings
REQUEST_INTERVAL = 0.5 # seconds between requests, per process
class E621Error(Exception):
"""A non-404 failure while talking to e621."""
class E621NotFound(E621Error):
"""The requested post does not exist (HTTP 404)."""
_throttle_lock = threading.Lock()
_last_request_at = 0.0
def credentials_configured(user):
return bool(user is not None and getattr(user, "e621_configured", False))
def _wait_for_slot():
global _last_request_at
with _throttle_lock:
delay = _last_request_at + REQUEST_INTERVAL - time.monotonic()
if delay > 0:
time.sleep(delay)
_last_request_at = time.monotonic()
def get(user, path, params=None, timeout=30):
"""GET an e621 API path using the user's credentials.
Raises E621NotFound for 404s and E621Error for everything else that isn't
a 2xx, so callers never see requests exceptions.
"""
if not credentials_configured(user):
raise E621Error("Configure your e621 credentials in Account first.")
base = (getattr(user, "e621_base_url", "") or "https://e621.net").rstrip("/")
_wait_for_slot()
try:
response = requests.get(
f"{base}{path}",
params=params,
auth=(user.e621_username, user.e621_api_key),
headers={"User-Agent": settings.USER_AGENT},
timeout=timeout,
)
except requests.RequestException as exc:
raise E621Error(f"Could not reach e621: {exc}") from exc
if response.status_code == 404:
raise E621NotFound(f"e621 returned 404 for {path}")
if response.status_code >= 400:
raise E621Error(f"e621 replied {response.status_code} for {path}")
try:
return response.json()
except ValueError as exc:
raise E621Error("e621 returned an unexpected response.") from exc
def find_post_by_md5(user, md5):
"""The e621 post with this exact MD5, or None."""
payload = get(user, "/posts.json", params={"tags": f"md5:{md5}", "limit": 1})
posts = payload.get("posts") if isinstance(payload, dict) else None
if not posts:
return None
return posts[0]
def fetch_post(user, post_id):
"""One post by id. Raises E621NotFound when the post is gone."""
payload = get(user, f"/posts/{int(post_id)}.json")
post = payload.get("post") if isinstance(payload, dict) else None
if not isinstance(post, dict):
raise E621Error("e621 returned an unexpected post payload.")
return post
@@ -0,0 +1,70 @@
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from apps.library import e621, matching
from apps.library.models import MatchTask, MediaItem
class Command(BaseCommand):
help = (
"Match library items against e621 by MD5 and refresh matched metadata "
"(also detects deleted posts). Uses a user's stored e621 credentials."
)
def add_arguments(self, parser):
parser.add_argument(
"--user",
help="Username whose e621 credentials to use "
"(defaults to the first staff account with credentials).",
)
parser.add_argument(
"--scope",
choices=[MatchTask.SCOPE_MISSING, MatchTask.SCOPE_ALL],
default=MatchTask.SCOPE_MISSING,
help="'missing' checks unchecked items only; 'all' also refreshes "
"already matched items (default: missing).",
)
def handle(self, *args, **options):
User = get_user_model()
username = options.get("user")
if username:
user = User.objects.filter(username=username).first()
if user is None:
raise CommandError(f"No such user: {username}")
else:
user = (
User.objects.filter(
is_staff=True, e621_api_key__gt="", e621_username__gt=""
)
.order_by("id")
.first()
)
if not e621.credentials_configured(user):
raise CommandError(
"No e621 credentials found. Pass --user or configure them in Account."
)
queryset = matching.scope_queryset(options["scope"])
total = queryset.count()
counters = {"matched": 0, "not_found": 0, "deleted": 0}
for index, item_id in enumerate(queryset.values_list("id", flat=True), start=1):
item = MediaItem.objects.filter(id=item_id).first()
if item is None:
continue
try:
status = matching.check_item(item, user)
except e621.E621Error as exc:
raise CommandError(f"Aborted after {index - 1}/{total}: {exc}")
counters[status] = counters.get(status, 0) + 1
self.stdout.write(
f"[{index}/{total}] J-{item_id}: {status}", ending="\r"
)
self.stdout.write(
self.style.SUCCESS(
"\nDone: "
f"{counters['matched']} matched, {counters['not_found']} not found, "
f"{counters['deleted']} deleted (scope: {options['scope']})."
)
)
+209
View File
@@ -0,0 +1,209 @@
"""Server-side matching of library items against e621.
MD5 lookups, manual post links, metadata refresh (which is also how deleted
posts are detected) and the background library-wide scan.
"""
import logging
import threading
from datetime import timedelta
from django.db import connection
from django.utils import timezone
from . import e621
from .models import MatchTask, MediaItem
from .services import trim_e621_post
logger = logging.getLogger(__name__)
STALE_AFTER = timedelta(minutes=30)
RATING_VALUES = {"s", "q", "e"}
# --- single-item operations -------------------------------------------------
def apply_post_to_item(item, post):
"""Store trimmed e621 metadata on an item and mark it matched."""
trimmed = trim_e621_post(post)
if trimmed is None or not trimmed.get("id"):
raise e621.E621Error("e621 returned an unexpected post payload.")
item.e621_post_id = int(trimmed["id"])
item.e621_data = trimmed
item.e621_match_status = MediaItem.E621_MATCHED
item.e621_checked_at = timezone.now()
update_fields = [
"e621_post_id",
"e621_data",
"e621_match_status",
"e621_checked_at",
]
rating = trimmed.get("rating")
if not item.rating and rating in RATING_VALUES:
item.rating = rating
update_fields.append("rating")
item.save(update_fields=update_fields + ["updated_at"])
return trimmed
def mark_not_found(item):
item.e621_match_status = MediaItem.E621_NOT_FOUND
item.e621_checked_at = timezone.now()
item.save(update_fields=["e621_match_status", "e621_checked_at", "updated_at"])
def mark_deleted(item):
item.e621_match_status = MediaItem.E621_DELETED
item.e621_checked_at = timezone.now()
item.save(update_fields=["e621_match_status", "e621_checked_at", "updated_at"])
def match_item_by_md5(item, user):
"""Look the item's MD5 up on e621. Returns the resulting match status."""
post = e621.find_post_by_md5(user, item.md5)
if post is None:
mark_not_found(item)
return item.e621_match_status
apply_post_to_item(item, post)
return item.e621_match_status
def refresh_item(item, user):
"""Re-fetch a matched post: refresh metadata or flag it as deleted."""
if not item.e621_post_id:
return match_item_by_md5(item, user)
try:
post = e621.fetch_post(user, item.e621_post_id)
except e621.E621NotFound:
mark_deleted(item)
return item.e621_match_status
apply_post_to_item(item, post)
return item.e621_match_status
def link_item_to_post(item, post_id, user):
"""Manually link an item to a post id. Returns True if the MD5s match."""
post = e621.fetch_post(user, post_id)
trimmed = apply_post_to_item(item, post)
post_md5 = str((trimmed.get("file") or {}).get("md5") or "").strip().lower()
return bool(post_md5) and post_md5 == item.md5.lower()
def check_item(item, user):
"""Best check for this item: refresh a known link, else MD5 lookup."""
if item.e621_post_id and item.e621_match_status == MediaItem.E621_MATCHED:
return refresh_item(item, user)
return match_item_by_md5(item, user)
def unlink_item(item):
"""Forget the e621 link so the item can be checked again."""
item.e621_post_id = None
item.e621_data = None
item.e621_match_status = MediaItem.E621_UNKNOWN
item.e621_checked_at = None
item.save(
update_fields=[
"e621_post_id",
"e621_data",
"e621_match_status",
"e621_checked_at",
"updated_at",
]
)
return item
# --- batch scan -------------------------------------------------------------
def reap_stale_match_tasks():
"""Surface scans left hanging by a recycled worker as errors."""
cutoff = timezone.now() - STALE_AFTER
return MatchTask.objects.filter(
status__in=[MatchTask.STATUS_PENDING, MatchTask.STATUS_RUNNING],
updated_at__lt=cutoff,
).update(
status=MatchTask.STATUS_ERROR,
error="The worker restarted before this scan finished.",
updated_at=timezone.now(),
)
def scope_queryset(scope):
queryset = MediaItem.objects.order_by("id")
if scope == MatchTask.SCOPE_ALL:
return queryset
return queryset.filter(e621_match_status=MediaItem.E621_UNKNOWN)
def start_match_task(task_id):
thread = threading.Thread(target=run_match_task, args=(task_id,), daemon=True)
thread.start()
def run_match_task(task_id):
task = MatchTask.objects.filter(id=task_id).first()
if task is None:
return
item_ids = list(scope_queryset(task.scope).values_list("id", flat=True))
counters = {"matched": 0, "not_found": 0, "deleted": 0}
failed = 0
MatchTask.objects.filter(id=task_id).update(
status=MatchTask.STATUS_RUNNING,
total=len(item_ids),
processed=0,
updated_at=timezone.now(),
)
def save_progress(processed):
MatchTask.objects.filter(id=task_id).update(
processed=processed,
failed=failed,
updated_at=timezone.now(),
**counters,
)
try:
processed = 0
for item_id in item_ids:
if MatchTask.objects.filter(id=task_id, cancelled=True).exists():
MatchTask.objects.filter(id=task_id).update(
status=MatchTask.STATUS_CANCELLED, updated_at=timezone.now()
)
return
item = MediaItem.objects.filter(id=item_id).first()
if item is None:
processed += 1
continue
try:
status = check_item(item, task.user)
counters[status] = counters.get(status, 0) + 1
except e621.E621Error as exc:
# A broken credential or an e621 outage would fail every
# remaining item; abort instead of hammering the API.
logger.warning("Match scan %s aborted: %s", task_id, exc)
failed += 1
MatchTask.objects.filter(id=task_id).update(
status=MatchTask.STATUS_ERROR,
processed=processed,
failed=failed,
error=str(exc),
updated_at=timezone.now(),
**counters,
)
return
processed += 1
save_progress(processed)
MatchTask.objects.filter(id=task_id).update(
status=MatchTask.STATUS_COMPLETE,
processed=len(item_ids),
updated_at=timezone.now(),
**counters,
)
finally:
# Background threads hold their own DB connection; release it so
# Gunicorn workers do not leak connections when threads finish.
connection.close()
@@ -0,0 +1,67 @@
# Generated by Django 6.1.1 on 2026-09-17 18:35
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
def mark_existing_matches(apps, schema_editor):
"""Items downloaded/linked before the match cache existed."""
MediaItem = apps.get_model("library", "MediaItem")
MediaItem.objects.filter(e621_post_id__isnull=False).update(
e621_match_status="matched",
e621_checked_at=models.F("updated_at"),
)
def unmark_matches(apps, schema_editor):
MediaItem = apps.get_model("library", "MediaItem")
MediaItem.objects.filter(e621_post_id__isnull=False).update(
e621_match_status="unknown",
e621_checked_at=None,
)
class Migration(migrations.Migration):
dependencies = [
('library', '0008_mediaitem_has_custom_data_mediaitem_search_tags'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='mediaitem',
name='e621_checked_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='mediaitem',
name='e621_match_status',
field=models.CharField(choices=[('unknown', 'Not checked'), ('matched', 'Matched'), ('not_found', 'Not found on e621'), ('deleted', 'Post deleted on e621')], db_index=True, default='unknown', max_length=20),
),
migrations.CreateModel(
name='MatchTask',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('scope', models.CharField(choices=[('missing', 'Unchecked items'), ('all', 'Whole library')], default='missing', max_length=20)),
('status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('complete', 'Complete'), ('error', 'Error'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
('total', models.IntegerField(default=0)),
('processed', models.IntegerField(default=0)),
('matched', models.IntegerField(default=0)),
('not_found', models.IntegerField(default=0)),
('deleted', models.IntegerField(default=0)),
('failed', models.IntegerField(default=0)),
('error', models.TextField(blank=True, default='')),
('cancelled', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='match_tasks', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.RunPython(mark_existing_matches, unmark_matches),
]
+74
View File
@@ -27,6 +27,17 @@ def build_search_tags(tags, e621_data):
class MediaItem(models.Model): class MediaItem(models.Model):
"""A logical media file, identified by its MD5 fingerprint.""" """A logical media file, identified by its MD5 fingerprint."""
E621_UNKNOWN = "unknown"
E621_MATCHED = "matched"
E621_NOT_FOUND = "not_found"
E621_DELETED = "deleted"
E621_STATUS_CHOICES = [
(E621_UNKNOWN, "Not checked"),
(E621_MATCHED, "Matched"),
(E621_NOT_FOUND, "Not found on e621"),
(E621_DELETED, "Post deleted on e621"),
]
md5 = models.CharField(max_length=32, unique=True) md5 = models.CharField(max_length=32, unique=True)
size = models.BigIntegerField(default=0) size = models.BigIntegerField(default=0)
# Custom rating: "s" (safe), "q" (questionable), "e" (explicit) or "" (unrated). # Custom rating: "s" (safe), "q" (questionable), "e" (explicit) or "" (unrated).
@@ -44,6 +55,14 @@ class MediaItem(models.Model):
hidden_from_guests = models.BooleanField(default=False, db_index=True) hidden_from_guests = models.BooleanField(default=False, db_index=True)
e621_post_id = models.IntegerField(null=True, blank=True, db_index=True) e621_post_id = models.IntegerField(null=True, blank=True, db_index=True)
e621_data = models.JSONField(null=True, blank=True) e621_data = models.JSONField(null=True, blank=True)
# Cached result of the last MD5/manual match against e621.
e621_match_status = models.CharField(
max_length=20,
choices=E621_STATUS_CHOICES,
default=E621_UNKNOWN,
db_index=True,
)
e621_checked_at = models.DateTimeField(null=True, blank=True)
# Perceptual hashes (hex strings) used by the duplicates engine. # Perceptual hashes (hex strings) used by the duplicates engine.
ahash = models.CharField(max_length=32, blank=True, default="", db_index=True) ahash = models.CharField(max_length=32, blank=True, default="", db_index=True)
dhash = models.CharField(max_length=32, blank=True, default="", db_index=True) dhash = models.CharField(max_length=32, blank=True, default="", db_index=True)
@@ -207,3 +226,58 @@ class DownloadTask(models.Model):
def __str__(self): def __str__(self):
return f"Download {self.id} ({self.status})" return f"Download {self.id} ({self.status})"
class MatchTask(models.Model):
"""A background library-wide e621 match scan with progress tracking."""
STATUS_PENDING = "pending"
STATUS_RUNNING = "running"
STATUS_COMPLETE = "complete"
STATUS_ERROR = "error"
STATUS_CANCELLED = "cancelled"
STATUS_CHOICES = [
(STATUS_PENDING, "Pending"),
(STATUS_RUNNING, "Running"),
(STATUS_COMPLETE, "Complete"),
(STATUS_ERROR, "Error"),
(STATUS_CANCELLED, "Cancelled"),
]
SCOPE_MISSING = "missing"
SCOPE_ALL = "all"
SCOPE_CHOICES = [
(SCOPE_MISSING, "Unchecked items"),
(SCOPE_ALL, "Whole library"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="match_tasks",
)
scope = models.CharField(
max_length=20, choices=SCOPE_CHOICES, default=SCOPE_MISSING
)
status = models.CharField(
max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING
)
total = models.IntegerField(default=0)
processed = models.IntegerField(default=0)
matched = models.IntegerField(default=0)
not_found = models.IntegerField(default=0)
deleted = models.IntegerField(default=0)
failed = models.IntegerField(default=0)
error = models.TextField(blank=True, default="")
cancelled = models.BooleanField(default=False)
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"Match scan {self.id} ({self.status})"
+27 -1
View File
@@ -4,7 +4,7 @@ from pathlib import Path
from django.core import signing from django.core import signing
from rest_framework import serializers from rest_framework import serializers
from .models import MediaItem, MediaLocation, TempUpload, DownloadTask from .models import MediaItem, MediaLocation, TempUpload, DownloadTask, MatchTask
from .services import ( from .services import (
MEDIA_FILE_SALT, MEDIA_FILE_SALT,
UPLOAD_FILE_SALT, UPLOAD_FILE_SALT,
@@ -55,6 +55,8 @@ class MediaItemSerializer(serializers.ModelSerializer):
"uploaded_by_id", "uploaded_by_id",
"e621_post_id", "e621_post_id",
"e621_data", "e621_data",
"e621_match_status",
"e621_checked_at",
"locations", "locations",
"created_at", "created_at",
"updated_at", "updated_at",
@@ -74,6 +76,8 @@ class MediaItemSerializer(serializers.ModelSerializer):
"uploaded_by_id", "uploaded_by_id",
"e621_post_id", "e621_post_id",
"e621_data", "e621_data",
"e621_match_status",
"e621_checked_at",
"locations", "locations",
"created_at", "created_at",
"updated_at", "updated_at",
@@ -220,3 +224,25 @@ class DownloadTaskSerializer(serializers.ModelSerializer):
def get_library_j_id(self, obj): def get_library_j_id(self, obj):
return f"J-{obj.library_item_id}" if obj.library_item_id else None return f"J-{obj.library_item_id}" if obj.library_item_id else None
class MatchTaskSerializer(serializers.ModelSerializer):
task_id = serializers.UUIDField(source="id", read_only=True)
class Meta:
model = MatchTask
fields = [
"task_id",
"scope",
"status",
"total",
"processed",
"matched",
"not_found",
"deleted",
"failed",
"error",
"created_at",
"updated_at",
]
read_only_fields = fields
+9 -1
View File
@@ -18,6 +18,7 @@ from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core import signing from django.core import signing
from django.http import Http404 from django.http import Http404
from django.utils import timezone
from rest_framework import mixins, status, viewsets from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
@@ -114,7 +115,14 @@ def complete_temp_upload(temp, download_url=None):
if temp.e621_post_id is not None: if temp.e621_post_id is not None:
item.e621_post_id = temp.e621_post_id item.e621_post_id = temp.e621_post_id
item.e621_data = temp.e621_data item.e621_data = temp.e621_data
update_fields += ["e621_post_id", "e621_data"] item.e621_match_status = MediaItem.E621_MATCHED
item.e621_checked_at = timezone.now()
update_fields += [
"e621_post_id",
"e621_data",
"e621_match_status",
"e621_checked_at",
]
if temp.custom_rating in {"s", "q", "e"} and not item.rating: if temp.custom_rating in {"s", "q", "e"} and not item.rating:
item.rating = temp.custom_rating item.rating = temp.custom_rating
update_fields.append("rating") update_fields.append("rating")
+7 -1
View File
@@ -11,12 +11,18 @@ from .tools import (
VisualMatchesView, VisualMatchesView,
) )
from .uploads import TempUploadViewSet from .uploads import TempUploadViewSet
from .views import ClientDownloadView, DownloadTaskViewSet, MediaItemViewSet from .views import (
ClientDownloadView,
DownloadTaskViewSet,
MatchTaskViewSet,
MediaItemViewSet,
)
router = DefaultRouter() router = DefaultRouter()
router.register("files", MediaItemViewSet, basename="file") router.register("files", MediaItemViewSet, basename="file")
router.register("uploads", TempUploadViewSet, basename="upload") router.register("uploads", TempUploadViewSet, basename="upload")
router.register("online/downloads", DownloadTaskViewSet, basename="download") router.register("online/downloads", DownloadTaskViewSet, basename="download")
router.register("matches", MatchTaskViewSet, basename="match")
urlpatterns = [ urlpatterns = [
path("", include(router.urls)), path("", include(router.urls)),
+116 -7
View File
@@ -15,11 +15,16 @@ from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from . import services from . import e621, matching, services
from .downloads import reap_stale_downloads, start_download_task from .downloads import reap_stale_downloads, start_download_task
from .models import DownloadTask, MediaItem from .matching import reap_stale_match_tasks, start_match_task
from .models import DownloadTask, MatchTask, MediaItem
from .permissions import CanUpload, IsUploaderOrStaffOrReadOnly from .permissions import CanUpload, IsUploaderOrStaffOrReadOnly
from .serializers import DownloadTaskSerializer, MediaItemSerializer from .serializers import (
DownloadTaskSerializer,
MatchTaskSerializer,
MediaItemSerializer,
)
LIST_ORDERINGS = {"name", "-name", "size", "-size", "created_at", "-created_at"} LIST_ORDERINGS = {"name", "-name", "size", "-size", "created_at", "-created_at"}
MD5_RE = re.compile(r"[0-9a-fA-F]{32}") MD5_RE = re.compile(r"[0-9a-fA-F]{32}")
@@ -83,12 +88,15 @@ class MediaItemViewSet(
if status_value == "custom": if status_value == "custom":
status_query |= Q(has_custom_data=True) status_query |= Q(has_custom_data=True)
elif status_value == "matched": elif status_value == "matched":
status_query |= Q( status_query |= Q(e621_match_status=MediaItem.E621_MATCHED)
has_custom_data=False, e621_post_id__isnull=False elif status_value == "not_found":
) status_query |= Q(e621_match_status=MediaItem.E621_NOT_FOUND)
elif status_value == "deleted":
status_query |= Q(e621_match_status=MediaItem.E621_DELETED)
elif status_value == "unknown": elif status_value == "unknown":
status_query |= Q( status_query |= Q(
has_custom_data=False, e621_post_id__isnull=True has_custom_data=False,
e621_match_status=MediaItem.E621_UNKNOWN,
) )
queryset = queryset.filter(status_query) queryset = queryset.filter(status_query)
ordering = self.request.query_params.get("ordering", "").strip() ordering = self.request.query_params.get("ordering", "").strip()
@@ -230,6 +238,64 @@ class MediaItemViewSet(
} }
) )
def _can_match(self, request, item):
user = request.user
return bool(
user.is_superuser
or user.role == user.ROLE_STAFF
or item.uploaded_by_id == user.id
)
@action(detail=True, methods=["post"], permission_classes=[CanUpload])
def match(self, request, pk=None):
"""Link this item to e621: MD5 lookup, or an explicit post id."""
item = self.get_object()
if not self._can_match(request, item):
return Response(
{"detail": "Only the uploader or staff can match this item."},
status=status.HTTP_403_FORBIDDEN,
)
extra = {}
post_id = request.data.get("post_id")
try:
if post_id not in (None, ""):
text = str(post_id).strip()
if not text.isdigit():
return Response(
{"detail": "Post ID must be a number."},
status=status.HTTP_400_BAD_REQUEST,
)
extra["md5_match"] = matching.link_item_to_post(
item, int(text), request.user
)
else:
matching.check_item(item, request.user)
except e621.E621NotFound:
return Response(
{"detail": "That post does not exist on e621."},
status=status.HTTP_404_NOT_FOUND,
)
except e621.E621Error as exc:
return Response(
{"detail": str(exc)}, status=status.HTTP_502_BAD_GATEWAY
)
item.refresh_from_db()
data = dict(self.get_serializer(item).data)
data.update(extra)
return Response(data)
@action(detail=True, methods=["post"], permission_classes=[CanUpload])
def unlink(self, request, pk=None):
"""Forget this item's e621 link so it can be matched again."""
item = self.get_object()
if not self._can_match(request, item):
return Response(
{"detail": "Only the uploader or staff can unlink this item."},
status=status.HTTP_403_FORBIDDEN,
)
matching.unlink_item(item)
return Response(self.get_serializer(item).data)
class DownloadTaskViewSet( class DownloadTaskViewSet(
mixins.ListModelMixin, mixins.ListModelMixin,
@@ -295,6 +361,49 @@ class DownloadTaskViewSet(
return Response({"success": True}) return Response({"success": True})
class MatchTaskViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
"""Background library-wide e621 match scans."""
serializer_class = MatchTaskSerializer
permission_classes = [CanUpload]
http_method_names = ["get", "post", "head", "options"]
def get_queryset(self):
reap_stale_match_tasks()
queryset = MatchTask.objects.all()
user = self.request.user
if not (user.is_staff or user.is_superuser):
queryset = queryset.filter(user=user)
return queryset
def create(self, request):
scope = str(request.data.get("scope") or MatchTask.SCOPE_MISSING).strip()
if scope not in {MatchTask.SCOPE_MISSING, MatchTask.SCOPE_ALL}:
scope = MatchTask.SCOPE_MISSING
task = MatchTask.objects.create(user=request.user, scope=scope)
start_match_task(task.id)
return Response(
self.get_serializer(task).data, status=status.HTTP_202_ACCEPTED
)
@action(detail=True, methods=["post"])
def cancel(self, request, pk=None):
task = self.get_object()
if task.status not in {MatchTask.STATUS_PENDING, MatchTask.STATUS_RUNNING}:
return Response(
{"detail": "Task is not running."},
status=status.HTTP_400_BAD_REQUEST,
)
MatchTask.objects.filter(id=task.id).update(
cancelled=True, updated_at=timezone.now()
)
return Response({"success": True})
class ClientDownloadView(APIView): class ClientDownloadView(APIView):
"""Stream an e621 file straight to the browser (no library write).""" """Stream an e621 file straight to the browser (no library write)."""
+197 -1
View File
@@ -12,7 +12,12 @@ import { isTypingTarget } from "@/lib/dom";
import { renderDText } from "@/lib/dtext"; import { renderDText } from "@/lib/dtext";
import { RATING_LABELS, TAG_CATEGORY_LABELS } from "@/lib/e621"; import { RATING_LABELS, TAG_CATEGORY_LABELS } from "@/lib/e621";
import { formatBytes, formatDate } from "@/lib/format"; import { formatBytes, formatDate } from "@/lib/format";
import type { FileNeighbors, MediaItem, Rating } from "@/lib/types"; import type {
E621MatchStatus,
FileNeighbors,
MediaItem,
Rating,
} from "@/lib/types";
import { useAuth } from "@/store/auth"; import { useAuth } from "@/store/auth";
import { useUi } from "@/store/ui"; import { useUi } from "@/store/ui";
@@ -45,6 +50,188 @@ const ratingLabel: Record<string, string> = {
e: "Explicit", e: "Explicit",
}; };
const matchStatusMeta: Record<
E621MatchStatus,
{ label: string; className: string }
> = {
unknown: {
label: "Not checked",
className: "border-ctp-surface2 bg-ctp-surface0 text-ctp-subtext1",
},
matched: {
label: "Matched",
className: "border-ctp-green/40 bg-ctp-green/15 text-ctp-green",
},
not_found: {
label: "Not found on e621",
className: "border-ctp-yellow/40 bg-ctp-yellow/15 text-ctp-yellow",
},
deleted: {
label: "Deleted on e621",
className: "border-ctp-red/40 bg-ctp-red/15 text-ctp-red",
},
};
function MatchCard({ item, canEdit }: { item: MediaItem; canEdit: boolean }) {
const queryClient = useQueryClient();
const [postId, setPostId] = useState("");
const [notice, setNotice] = useState<{
kind: "ok" | "warn" | "error";
text: string;
} | null>(null);
function refresh() {
void queryClient.invalidateQueries({ queryKey: ["file", item.j_id] });
void queryClient.invalidateQueries({ queryKey: ["files"] });
void queryClient.invalidateQueries({ queryKey: ["tag-cloud"] });
}
const checkMutation = useMutation({
mutationFn: () =>
api<MediaItem>(`/api/files/${item.j_id}/match/`, {
method: "POST",
json: {},
}),
onSuccess: (data) => {
refresh();
if (data.e621_match_status === "matched") {
setNotice({ kind: "ok", text: `Matched to post #${data.e621_post_id}.` });
} else if (data.e621_match_status === "deleted") {
setNotice({
kind: "error",
text: "The linked post no longer exists on e621.",
});
} else {
setNotice({
kind: "warn",
text: "No e621 post with this file's MD5 was found.",
});
}
},
onError: (error) =>
setNotice({ kind: "error", text: errorMessage(error) }),
});
const linkMutation = useMutation({
mutationFn: (id: number) =>
api<MediaItem & { md5_match?: boolean }>(`/api/files/${item.j_id}/match/`, {
method: "POST",
json: { post_id: id },
}),
onSuccess: (data) => {
refresh();
setPostId("");
setNotice(
data.md5_match
? {
kind: "ok",
text: `Linked to post #${data.e621_post_id} — MD5 matches.`,
}
: {
kind: "warn",
text: `Linked to post #${data.e621_post_id}, but its MD5 differs from this file.`,
},
);
},
onError: (error) =>
setNotice({ kind: "error", text: errorMessage(error) }),
});
const unlinkMutation = useMutation({
mutationFn: () =>
api<MediaItem>(`/api/files/${item.j_id}/unlink/`, { method: "POST" }),
onSuccess: () => {
refresh();
setNotice({ kind: "ok", text: "e621 link removed." });
},
onError: (error) =>
setNotice({ kind: "error", text: errorMessage(error) }),
});
const meta = matchStatusMeta[item.e621_match_status];
return (
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-ctp-subtext1">e621 match</h2>
<span
className={cn(
"rounded-full border px-2 py-0.5 text-[11px] font-medium",
meta.className,
)}
>
{meta.label}
</span>
</div>
<p className="mt-2 text-xs text-ctp-overlay0">
{item.e621_checked_at
? `Last checked ${formatDate(item.e621_checked_at)}`
: "This item has not been checked against e621 yet."}
</p>
{canEdit ? (
<div className="mt-3 flex flex-col gap-2">
<div className="flex gap-2">
<Button
variant="secondary"
disabled={checkMutation.isPending}
onClick={() => checkMutation.mutate()}
>
{checkMutation.isPending
? "Checking…"
: item.e621_post_id
? "Recheck"
: "Check MD5"}
</Button>
{item.e621_post_id ? (
<Button
variant="ghost"
disabled={unlinkMutation.isPending}
onClick={() => unlinkMutation.mutate()}
>
Unlink
</Button>
) : null}
</div>
<div className="flex gap-2">
<input
className={inputClass}
placeholder="Post ID"
inputMode="numeric"
value={postId}
onChange={(event) =>
setPostId(event.target.value.replace(/[^0-9]/g, ""))
}
/>
<Button
disabled={!postId || linkMutation.isPending}
onClick={() => linkMutation.mutate(Number(postId))}
>
Link
</Button>
</div>
</div>
) : null}
{notice ? (
<p
className={cn(
"mt-2 text-xs",
notice.kind === "ok"
? "text-ctp-green"
: notice.kind === "warn"
? "text-ctp-yellow"
: "text-ctp-red",
)}
>
{notice.text}
</p>
) : null}
</section>
);
}
function E621MetadataCard({ item }: { item: MediaItem }) { function E621MetadataCard({ item }: { item: MediaItem }) {
const data = item.e621_data; const data = item.e621_data;
if (!item.e621_post_id || !data) return null; if (!item.e621_post_id || !data) return null;
@@ -77,6 +264,13 @@ function E621MetadataCard({ item }: { item: MediaItem }) {
</span> </span>
</div> </div>
{item.e621_match_status === "deleted" ? (
<p className="mt-3 rounded border border-ctp-red/40 bg-ctp-red/10 px-2.5 py-1.5 text-xs text-ctp-red">
This post has been deleted on e621 — the metadata below is a cached
copy.
</p>
) : null}
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-4"> <dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-4">
<div> <div>
<dt className="text-ctp-overlay0">Rating</dt> <dt className="text-ctp-overlay0">Rating</dt>
@@ -501,6 +695,8 @@ export function LibraryDetail({ jId }: { jId: string }) {
</p> </p>
</section> </section>
<MatchCard key={item.j_id} item={item} canEdit={canEdit} />
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4"> <section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<h2 className="text-sm font-semibold text-ctp-subtext1"> <h2 className="text-sm font-semibold text-ctp-subtext1">
Locations ({item.locations.length}) Locations ({item.locations.length})
+19 -5
View File
@@ -5,6 +5,7 @@ import { useState, useEffect, type FormEvent } from "react";
import { CollapsibleSidebar } from "@/components/CollapsibleSidebar"; import { CollapsibleSidebar } from "@/components/CollapsibleSidebar";
import { MediaCard } from "@/components/MediaCard"; import { MediaCard } from "@/components/MediaCard";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui"; import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
import { MatchPanel } from "@/features/library/MatchPanel";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { isTypingTarget } from "@/lib/dom"; import { isTypingTarget } from "@/lib/dom";
@@ -47,6 +48,16 @@ const statusFilters = [
label: "Matched", label: "Matched",
active: "border-ctp-green/40 bg-ctp-green/15 text-ctp-green", active: "border-ctp-green/40 bg-ctp-green/15 text-ctp-green",
}, },
{
value: "not_found",
label: "Not found",
active: "border-ctp-yellow/40 bg-ctp-yellow/15 text-ctp-yellow",
},
{
value: "deleted",
label: "Deleted",
active: "border-ctp-red/40 bg-ctp-red/15 text-ctp-red",
},
{ {
value: "custom", value: "custom",
label: "Custom", label: "Custom",
@@ -323,11 +334,14 @@ export default function LibraryPage() {
? `${query.data.count} item${query.data.count === 1 ? "" : "s"}` ? `${query.data.count} item${query.data.count === 1 ? "" : "s"}`
: "…"} : "…"}
</p> </p>
{pageCount > 1 ? ( <div className="flex items-center gap-3">
<p className="font-mono text-xs text-ctp-overlay0"> {pageCount > 1 ? (
Page {page} / {pageCount} <p className="font-mono text-xs text-ctp-overlay0">
</p> Page {page} / {pageCount}
) : null} </p>
) : null}
{user?.can_upload ? <MatchPanel /> : null}
</div>
</div> </div>
{!user ? ( {!user ? (
@@ -0,0 +1,158 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { RefreshCw, X } from "lucide-react";
import { useEffect, useState } from "react";
import { Button, Spinner } from "@/components/ui";
import { api, errorMessage } from "@/lib/api";
import type { MatchTask } from "@/lib/types";
type Scope = "missing" | "all";
function summary(task: MatchTask): string {
const parts = [
`${task.matched} matched`,
`${task.not_found} not found`,
`${task.deleted} deleted`,
];
if (task.failed > 0) parts.push(`${task.failed} failed`);
return parts.join(" · ");
}
export function MatchPanel() {
const queryClient = useQueryClient();
const [taskId, setTaskId] = useState<string | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const taskQuery = useQuery({
queryKey: ["match-task", taskId],
queryFn: () => api<MatchTask>(`/api/matches/${taskId}/`),
enabled: Boolean(taskId),
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "pending" || status === "running" ? 1000 : false;
},
});
const task = taskQuery.data;
const running = task?.status === "pending" || task?.status === "running";
const finishedTaskId = task && !running ? task.task_id : null;
useEffect(() => {
if (!finishedTaskId) return;
void queryClient.invalidateQueries({ queryKey: ["files"] });
void queryClient.invalidateQueries({ queryKey: ["tag-cloud"] });
}, [finishedTaskId, queryClient]);
const startMutation = useMutation({
mutationFn: (scope: Scope) =>
api<MatchTask>("/api/matches/", { method: "POST", json: { scope } }),
onSuccess: (data) => {
setTaskId(data.task_id);
setMenuOpen(false);
},
});
const cancelMutation = useMutation({
mutationFn: () =>
api<{ success: boolean }>(`/api/matches/${taskId}/cancel/`, {
method: "POST",
}),
});
if (running && task) {
const percent = task.total
? Math.round((task.processed * 100) / task.total)
: 0;
return (
<div className="flex items-center gap-3">
<div className="w-36">
<div className="h-1.5 overflow-hidden rounded-full bg-ctp-surface0">
<div
className="h-full bg-ctp-mauve transition-all"
style={{ width: `${percent}%` }}
/>
</div>
</div>
<span className="font-mono text-xs text-ctp-subtext0">
{task.processed}/{task.total} · {task.matched} matched
</span>
<Button
variant="ghost"
disabled={cancelMutation.isPending}
onClick={() => cancelMutation.mutate()}
>
Cancel
</Button>
</div>
);
}
if (task) {
return (
<div className="flex items-center gap-2 text-xs">
{task.status === "complete" ? (
<span className="text-ctp-green">Scan complete — {summary(task)}</span>
) : task.status === "cancelled" ? (
<span className="text-ctp-subtext0">Scan cancelled.</span>
) : (
<span className="text-ctp-red">
{task.error || "The scan failed."}
</span>
)}
<button
type="button"
className="text-ctp-overlay0 transition hover:text-ctp-text"
onClick={() => setTaskId(null)}
title="Dismiss"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
}
return (
<div className="relative">
<Button variant="secondary" onClick={() => setMenuOpen((open) => !open)}>
{startMutation.isPending ? (
<Spinner className="h-3.5 w-3.5" />
) : (
<RefreshCw className="h-4 w-4" />
)}
Match e621
</Button>
{menuOpen ? (
<div className="absolute right-0 top-full z-20 mt-2 w-72 rounded-lg border border-ctp-surface1 bg-ctp-base p-1.5 shadow-lg">
<button
type="button"
className="flex w-full flex-col rounded-md px-2.5 py-2 text-left transition hover:bg-ctp-surface0"
onClick={() => startMutation.mutate("missing")}
>
<span className="text-sm text-ctp-text">
Check unchecked items
</span>
<span className="text-xs text-ctp-overlay0">
MD5 lookups for items never checked against e621.
</span>
</button>
<button
type="button"
className="flex w-full flex-col rounded-md px-2.5 py-2 text-left transition hover:bg-ctp-surface0"
onClick={() => startMutation.mutate("all")}
>
<span className="text-sm text-ctp-text">Recheck the library</span>
<span className="text-xs text-ctp-overlay0">
Refreshes matched metadata and flags deleted posts too.
</span>
</button>
{startMutation.isError ? (
<p className="px-2.5 py-1.5 text-xs text-ctp-red">
{errorMessage(startMutation.error)}
</p>
) : null}
</div>
) : null}
</div>
);
}
+19
View File
@@ -81,6 +81,8 @@ export interface MediaLocation {
export type Rating = "" | "s" | "q" | "e"; export type Rating = "" | "s" | "q" | "e";
export type E621MatchStatus = "unknown" | "matched" | "not_found" | "deleted";
export interface E621StoredPost { export interface E621StoredPost {
id: number | null; id: number | null;
created_at?: string | null; created_at?: string | null;
@@ -126,6 +128,8 @@ export interface MediaItem {
uploaded_by_id: number | null; uploaded_by_id: number | null;
e621_post_id: number | null; e621_post_id: number | null;
e621_data: E621StoredPost | null; e621_data: E621StoredPost | null;
e621_match_status: E621MatchStatus;
e621_checked_at: string | null;
locations: MediaLocation[]; locations: MediaLocation[];
raw_url: string; raw_url: string;
thumbnail_url: string; thumbnail_url: string;
@@ -185,6 +189,21 @@ export interface DownloadTask {
updated_at: string; updated_at: string;
} }
export interface MatchTask {
task_id: string;
scope: "missing" | "all";
status: "pending" | "running" | "complete" | "error" | "cancelled";
total: number;
processed: number;
matched: number;
not_found: number;
deleted: number;
failed: number;
error: string;
created_at: string;
updated_at: string;
}
export interface E621IqdbCandidate { export interface E621IqdbCandidate {
post_id: number | null; post_id: number | null;
score: number | null; score: number | null;