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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']})."
|
||||
)
|
||||
)
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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})"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user