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 AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from . import services from .models import MediaItem from .serializers import MediaItemSerializer LIST_ORDERINGS = {"name", "-name", "size", "-size", "created_at", "-created_at"} class MediaItemViewSet( mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet, ): serializer_class = MediaItemSerializer lookup_field = "md5" http_method_names = ["get", "post", "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() queryset = queryset.order_by( ordering if ordering in LIST_ORDERINGS 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) @action(detail=False, methods=["post"], permission_classes=[AllowAny]) def lookup(self, request): """Return which of the given MD5s already 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} found = MediaItem.objects.filter(md5__in=wanted).values_list("md5", flat=True) return Response({"found": sorted(found)}) @action(detail=True, methods=["get"]) def neighbors(self, request, md5=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 { "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 = [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, )