Files
J621/backend/apps/library/views.py
T
JakeBreath 555c25d77b M0: scaffold SPA + API monorepo
Backend (Django 6.1 + DRF):
- Token auth with a custom User model (register/login/logout/me)
- Library models (MediaItem, MediaLocation) and REST endpoints
- File list/detail with search, rating filter, sorting, pagination
- Multipart upload with optional rating/tags/notes
- Range-aware media serving (video seeking) and ffmpeg thumbnails
- scan_files management command for the watched folder

Frontend (React 19 + Vite + TypeScript):
- Catppuccin Mocha design tokens from the design docs
- App shell, token persistence, protected routes
- Library grid with filters, file detail with custom data editor
- Upload page with per-file progress via XHR
- Dev proxy to the Django API
2026-09-17 07:56:47 -05:00

124 lines
4.5 KiB
Python

from pathlib import Path
from django.conf import settings
from django.db.models import Min
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 IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from . import services
from .models import MediaItem
from .serializers import MediaItemSerializer
class MediaItemViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
serializer_class = MediaItemSerializer
lookup_field = "md5"
http_method_names = ["get", "patch", "head", "options"]
def get_queryset(self):
queryset = MediaItem.objects.prefetch_related("locations").annotate(
name=Min("locations__rel_path")
)
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()
allowed = {"name", "-name", "size", "-size", "created_at", "-created_at"}
queryset = queryset.order_by(ordering if ordering in allowed else "-created_at")
return queryset.distinct()
@action(detail=True, methods=["get"])
def raw(self, request, md5=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, md5=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)
class UploadView(APIView):
permission_classes = [IsAuthenticated]
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, _, _ = 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 = []
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,
)