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:
@@ -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