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
+5 -5
View File
@@ -9,15 +9,15 @@ from .system_info import get_os_info
def _worker_counts():
"""Active/queued background jobs (download tasks for now)."""
from apps.library.models import DownloadTask
"""Active/queued background jobs (downloads and e621 match scans)."""
from apps.library.models import DownloadTask, MatchTask
active = DownloadTask.objects.filter(
status=DownloadTask.STATUS_DOWNLOADING
).count()
).count() + MatchTask.objects.filter(status=MatchTask.STATUS_RUNNING).count()
queued = DownloadTask.objects.filter(
status=DownloadTask.STATUS_PENDING
).count()
).count() + MatchTask.objects.filter(status=MatchTask.STATUS_PENDING).count()
return {"active": active, "queued": queued}
@@ -35,7 +35,7 @@ class StatusView(APIView):
"version": settings.APP_VERSION,
"os": get_os_info(),
"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(),
}
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:
item.e621_post_id = task.post_id
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")
if not item.rating and rating in {"s", "q", "e"}:
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):
"""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)
size = models.BigIntegerField(default=0)
# 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)
e621_post_id = models.IntegerField(null=True, blank=True, db_index=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.
ahash = 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):
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 rest_framework import serializers
from .models import MediaItem, MediaLocation, TempUpload, DownloadTask
from .models import MediaItem, MediaLocation, TempUpload, DownloadTask, MatchTask
from .services import (
MEDIA_FILE_SALT,
UPLOAD_FILE_SALT,
@@ -55,6 +55,8 @@ class MediaItemSerializer(serializers.ModelSerializer):
"uploaded_by_id",
"e621_post_id",
"e621_data",
"e621_match_status",
"e621_checked_at",
"locations",
"created_at",
"updated_at",
@@ -74,6 +76,8 @@ class MediaItemSerializer(serializers.ModelSerializer):
"uploaded_by_id",
"e621_post_id",
"e621_data",
"e621_match_status",
"e621_checked_at",
"locations",
"created_at",
"updated_at",
@@ -220,3 +224,25 @@ class DownloadTaskSerializer(serializers.ModelSerializer):
def get_library_j_id(self, obj):
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.core import signing
from django.http import Http404
from django.utils import timezone
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
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:
item.e621_post_id = temp.e621_post_id
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:
item.rating = temp.custom_rating
update_fields.append("rating")
+7 -1
View File
@@ -11,12 +11,18 @@ from .tools import (
VisualMatchesView,
)
from .uploads import TempUploadViewSet
from .views import ClientDownloadView, DownloadTaskViewSet, MediaItemViewSet
from .views import (
ClientDownloadView,
DownloadTaskViewSet,
MatchTaskViewSet,
MediaItemViewSet,
)
router = DefaultRouter()
router.register("files", MediaItemViewSet, basename="file")
router.register("uploads", TempUploadViewSet, basename="upload")
router.register("online/downloads", DownloadTaskViewSet, basename="download")
router.register("matches", MatchTaskViewSet, basename="match")
urlpatterns = [
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.views import APIView
from . import services
from . import e621, matching, services
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 .serializers import DownloadTaskSerializer, MediaItemSerializer
from .serializers import (
DownloadTaskSerializer,
MatchTaskSerializer,
MediaItemSerializer,
)
LIST_ORDERINGS = {"name", "-name", "size", "-size", "created_at", "-created_at"}
MD5_RE = re.compile(r"[0-9a-fA-F]{32}")
@@ -83,12 +88,15 @@ class MediaItemViewSet(
if status_value == "custom":
status_query |= Q(has_custom_data=True)
elif status_value == "matched":
status_query |= Q(
has_custom_data=False, e621_post_id__isnull=False
)
status_query |= Q(e621_match_status=MediaItem.E621_MATCHED)
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":
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)
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(
mixins.ListModelMixin,
@@ -295,6 +361,49 @@ class DownloadTaskViewSet(
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):
"""Stream an e621 file straight to the browser (no library write)."""