Files
J621/backend/apps/library/views.py
T
JakeBreath e5cc63b0cc Phase 3: J-IDs, ownership, roles, guest safety, adaptive detail, download
Backend:
- User.role (user/uploader/staff) with can_upload; uploads and downloads
  gated to uploader+; owners and staff can edit their items
- MediaItem.uploaded_by plus J-<id> identity (serializer, admin,
  scan_files --user, first superuser as default owner)
- API resolves J-<id>, bare numeric ids and MD5s; neighbors and lookup
  return j_ids
- Guest safety: mirror e621's anonymous default blacklist into Redis
  (parses comments, negations and wildcards), flag hidden_from_guests
  and filter lists, details and lookups for anonymous users
- POST /api/online/downloads/ writes an e621 file into the watched
  folder and indexes it for the uploader
- MariaDB + Redis via docker compose (host ports 3307/6380), PyMySQL
  driver shim, Redis cache replacing the file cache; SQLite data
  dumped and loaded into MariaDB

Frontend:
- Single /detail/:itemId route with an adaptive shell: J-<id> renders
  the library item, bare numbers render the e621 post
- Legacy /view/<md5> and /online/view/<id> redirect to canonical URLs
- Cards expose J-IDs; library custom-data editor is read-only for
  non-owners
- Role gating: Upload hidden/blocked for regular users, account shows
  the role, guest hint on the library
2026-09-17 10:16:45 -05:00

254 lines
9.6 KiB
Python

import re
from pathlib import Path
from urllib.parse import urlparse
from django.conf import settings
from django.db.models import Min
from django.http import Http404
from django.shortcuts import get_object_or_404
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly
from rest_framework.response import Response
from rest_framework.views import APIView
from . import services
from .models import MediaItem
from .permissions import CanUpload, IsUploaderOrStaffOrReadOnly
from .serializers import MediaItemSerializer
LIST_ORDERINGS = {"name", "-name", "size", "-size", "created_at", "-created_at"}
MD5_RE = re.compile(r"[0-9a-fA-F]{32}")
class MediaItemViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
serializer_class = MediaItemSerializer
permission_classes = [IsAuthenticatedOrReadOnly, IsUploaderOrStaffOrReadOnly]
http_method_names = ["get", "post", "patch", "head", "options"]
def get_queryset(self):
queryset = MediaItem.objects.prefetch_related("locations").annotate(
name=Min("locations__rel_path")
)
if not self.request.user.is_authenticated:
queryset = queryset.filter(hidden_from_guests=False)
search = self.request.query_params.get("search", "").strip()
if search:
queryset = queryset.filter(locations__rel_path__icontains=search)
ratings = [
value
for value in self.request.query_params.get("rating", "").split(",")
if value in {"s", "q", "e"}
]
if ratings:
queryset = queryset.filter(rating__in=ratings)
ordering = self.request.query_params.get("ordering", "").strip()
queryset = queryset.order_by(
ordering if ordering in LIST_ORDERINGS else "-created_at"
)
return queryset.distinct()
def get_object(self):
"""Resolve J-<id>, a bare numeric id, or an MD5 fingerprint."""
value = str(self.kwargs.get("pk", "")).strip()
queryset = self.get_queryset()
if MD5_RE.fullmatch(value):
obj = get_object_or_404(queryset, md5=value.lower())
else:
numeric = value[2:] if value.upper().startswith("J-") else value
if not numeric.isdigit():
raise Http404
obj = get_object_or_404(queryset, pk=int(numeric))
self.check_object_permissions(self.request, obj)
return obj
@action(detail=True, methods=["get"])
def raw(self, request, pk=None):
item = self.get_object()
location = item.locations.first()
if location is None:
return Response(
{"detail": "No file for this item."},
status=status.HTTP_404_NOT_FOUND,
)
return services.serve_file(
request, location.path, download=request.query_params.get("download") == "1"
)
@action(detail=True, methods=["get"])
def thumbnail(self, request, pk=None):
item = self.get_object()
location = item.locations.first()
if location is None:
return Response(
{"detail": "No file for this item."},
status=status.HTTP_404_NOT_FOUND,
)
path = Path(location.path)
if path.suffix.lower() in services.VIDEO_EXTENSIONS:
thumbnail = services.generate_video_thumbnail(item.md5, path)
if thumbnail is None:
return Response(
{"detail": "Thumbnail unavailable."},
status=status.HTTP_404_NOT_FOUND,
)
return services.serve_file(request, thumbnail)
return services.serve_file(request, path)
@action(detail=False, methods=["post"], permission_classes=[AllowAny])
def lookup(self, request):
"""Report which of the given MD5s exist in the library."""
md5s = request.data.get("md5s")
if not isinstance(md5s, list):
return Response(
{"detail": "md5s must be a list."},
status=status.HTTP_400_BAD_REQUEST,
)
wanted = {str(value).strip().lower()[:32] for value in md5s if value}
queryset = MediaItem.objects.filter(md5__in=wanted)
if not request.user.is_authenticated:
queryset = queryset.filter(hidden_from_guests=False)
rows = list(queryset.values_list("md5", "id"))
return Response(
{
"found": sorted(md5 for md5, _ in rows),
"j_ids": {md5: f"J-{item_id}" for md5, item_id in rows},
}
)
@action(detail=True, methods=["get"])
def neighbors(self, request, pk=None):
"""Previous/next items in the ordered list, for keyboard navigation."""
item = self.get_object()
ordering = request.query_params.get("ordering", "-created_at").strip()
if ordering not in LIST_ORDERINGS:
ordering = "-created_at"
field = ordering.lstrip("-")
descending = ordering.startswith("-")
value = {
"size": item.size,
"created_at": item.created_at,
"name": item.name,
}[field]
queryset = self.get_queryset().exclude(pk=item.pk)
if descending:
previous_query = queryset.filter(**{f"{field}__gt": value}).order_by(field)
next_query = queryset.filter(**{f"{field}__lt": value}).order_by(f"-{field}")
else:
previous_query = queryset.filter(**{f"{field}__lt": value}).order_by(
f"-{field}"
)
next_query = queryset.filter(**{f"{field}__gt": value}).order_by(field)
def brief(obj):
location = obj.locations.first()
return {
"j_id": f"J-{obj.id}",
"md5": obj.md5,
"filename": Path(location.rel_path).name if location else obj.md5,
}
previous = previous_query.first()
next_item = next_query.first()
return Response(
{
"previous": brief(previous) if previous else None,
"next": brief(next_item) if next_item else None,
}
)
class UploadView(APIView):
permission_classes = [CanUpload]
parser_classes = [MultiPartParser, FormParser]
def post(self, request):
upload = request.FILES.get("file")
if upload is None:
return Response(
{"detail": "A file is required."}, status=status.HTTP_400_BAD_REQUEST
)
folder = Path(settings.WATCHED_FOLDER)
destination = services.unique_destination(folder, upload.name)
with open(destination, "wb") as output:
for chunk in upload.chunks():
output.write(chunk)
try:
item, created_item, _ = services.index_file(destination, folder)
except Exception as exc: # noqa: BLE001 - report indexing failures
destination.unlink(missing_ok=True)
return Response(
{"detail": f"Could not index upload: {exc}"},
status=status.HTTP_400_BAD_REQUEST,
)
update_fields = []
if created_item and item.uploaded_by_id is None:
item.uploaded_by = request.user
update_fields.append("uploaded_by")
rating = request.data.get("rating") or ""
if rating in {"s", "q", "e"} and item.rating != rating:
item.rating = rating
update_fields.append("rating")
tags = services.parse_tags(request.data.get("tags"))
if tags is not None:
item.tags = tags
update_fields.append("tags")
notes = request.data.get("notes")
if notes is not None:
item.notes = str(notes)
update_fields.append("notes")
if update_fields:
item.save(update_fields=update_fields + ["updated_at"])
return Response(
MediaItemSerializer(item, context={"request": request}).data,
status=status.HTTP_201_CREATED,
)
class DownloadToLibraryView(APIView):
"""Fetch an e621 file into the watched folder and index it."""
permission_classes = [CanUpload]
def post(self, request):
url = str(request.data.get("url") or "").strip()
post_id = request.data.get("post_id")
filename = str(request.data.get("filename") or "").strip()
if not url.startswith(("http://", "https://")):
return Response(
{"detail": "A valid file URL is required."},
status=status.HTTP_400_BAD_REQUEST,
)
folder = Path(settings.WATCHED_FOLDER)
name = (
filename
or Path(urlparse(url).path).name
or f"post-{post_id or 'download'}"
)
destination = services.unique_destination(folder, name)
try:
services.download_file(url, destination)
item, _, _ = services.index_file(destination, folder)
except Exception as exc: # noqa: BLE001 - report download failures
destination.unlink(missing_ok=True)
return Response(
{"detail": f"Download failed: {exc}"},
status=status.HTTP_400_BAD_REQUEST,
)
if item.uploaded_by_id is None:
item.uploaded_by = request.user
item.save(update_fields=["uploaded_by", "updated_at"])
return Response(
MediaItemSerializer(item, context={"request": request}).data,
status=status.HTTP_201_CREATED,
)