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
+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)."""