import logging from django.db import transaction from django.db.models import Count from drf_spectacular.utils import extend_schema from rest_framework import exceptions, mixins, status, viewsets from rest_framework.decorators import action from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.renderers import JSONRenderer from rest_framework.response import Response from funkwhale_api.common import fields, permissions from funkwhale_api.federation import routes from funkwhale_api.music import models as music_models from funkwhale_api.music import serializers as music_serializers from funkwhale_api.music import utils as music_utils from funkwhale_api.users.oauth import permissions as oauth_permissions from . import filters, models, parsers, renderers, serializers logger = logging.getLogger(__name__) class PlaylistViewSet( mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin, mixins.DestroyModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet, ): serializer_class = serializers.PlaylistSerializer queryset = ( models.Playlist.objects.all() .select_related("actor__attachment_icon") .annotate(tracks_count=Count("playlist_tracks", distinct=True)) .with_covers() .with_duration() ) permission_classes = [ oauth_permissions.ScopePermission, permissions.OwnerPermission, ] required_scope = "playlists" anonymous_policy = "setting" owner_checks = ["write"] owner_field = "actor.user" filterset_class = filters.PlaylistFilter ordering_fields = ("id", "name", "creation_date", "modification_date") parser_classes = [parsers.XspfParser, JSONParser, FormParser, MultiPartParser] renderer_classes = [JSONRenderer, renderers.PlaylistXspfRenderer] def update(self, request, *args, **kwargs): playlist = self.get_object() content_type = request.headers.get("Content-Type") if content_type and "application/octet-stream" in content_type: tracks = [] for track_data in request.data.get("tracks", []): track_serializer = serializers.XspfTrackSerializer(data=track_data) if track_serializer.is_valid(): tracks.append(track_serializer.validated_data) else: request.data["tracks"].remove(track_data) logger.info( f"Removing track {track_data} because we didn't find a match in db" ) serializer = serializers.XspfSerializer( playlist, data=request.data, partial=True ) serializer.is_valid(raise_exception=True) pl = serializer.save() routes.outbox.dispatch( {"type": "Update", "object": {"type": "Playlist"}}, context={"playlist": pl, "actor": playlist.actor}, ) return Response(serializers.PlaylistSerializer(pl).data, status=201) response = super().update(request, *args, **kwargs) routes.outbox.dispatch( {"type": "Update", "object": {"type": "Playlist"}}, context={"playlist": self.get_object(), "actor": playlist.actor}, ) return response def create(self, request, *args, **kwargs): content_type = request.headers.get("Content-Type") if content_type and "application/octet-stream" in content_type: # We check if tracks are in the db, and exclude the ones we don't find for track_data in list(request.data.get("tracks", [])): track_serializer = serializers.XspfTrackSerializer(data=track_data) if not track_serializer.is_valid(): request.data["tracks"].remove(track_data) logger.info( f"Removing track {track_data} because we didn't find a match in db" ) serializer = serializers.XspfSerializer(data=request.data) serializer.is_valid(raise_exception=True) pl = serializer.save(request=request) return Response(serializers.PlaylistSerializer(pl).data, status=201) serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) playlist = self.perform_create(serializer) headers = self.get_success_headers(serializer.data) routes.outbox.dispatch( {"type": "Create", "object": {"type": "Playlist"}}, context={"playlist": playlist, "actor": playlist.actor}, ) return Response( serializer.data, status=status.HTTP_201_CREATED, headers=headers ) def destroy(self, request, *args, **kwargs): playlist = self.get_object() self.perform_destroy(playlist) routes.outbox.dispatch( {"type": "Delete", "object": {"type": "Playlist"}}, context={"playlist": playlist, "actor": playlist.actor}, ) return Response(status=status.HTTP_204_NO_CONTENT) @extend_schema(responses=serializers.PlaylistTrackSerializer(many=True)) @action(methods=["get"], detail=True) def tracks(self, request, *args, **kwargs): playlist = self.get_object() plts = playlist.playlist_tracks.all().for_nested_serialization( music_utils.get_actor_from_request(request) ) serializer = serializers.PlaylistTrackSerializer(plts, many=True) data = {"count": len(plts), "results": serializer.data} return Response(data, status=200) @extend_schema( operation_id="add_to_playlist", request=serializers.PlaylistAddManySerializer ) @action(methods=["post"], detail=True) @transaction.atomic def add(self, request, *args, **kwargs): playlist = self.get_object() serializer = serializers.PlaylistAddManySerializer(data=request.data) serializer.is_valid(raise_exception=True) try: plts = playlist.insert_many( serializer.validated_data["tracks"], serializer.validated_data["allow_duplicates"], ) except exceptions.ValidationError as e: payload = {"playlist": e.detail} return Response(payload, status=400) ids = [p.id for p in plts] plts = ( models.PlaylistTrack.objects.filter(pk__in=ids) .order_by("index") .for_nested_serialization(music_utils.get_actor_from_request(request)) ) serializer = serializers.PlaylistTrackSerializer(plts, many=True) data = {"count": len(plts), "results": serializer.data} playlist.schedule_scan(playlist.actor, force=True) return Response(data, status=201) @extend_schema(operation_id="clear_playlist") @action(methods=["delete"], detail=True) @transaction.atomic def clear(self, request, *args, **kwargs): playlist = self.get_object() playlist.playlist_tracks.all().delete() playlist.save(update_fields=["modification_date"]) playlist.schedule_scan(playlist.actor) return Response(status=204) def get_queryset(self): return self.queryset.filter( fields.privacy_level_query( self.request.user, "privacy_level", "actor__user" ) ).with_playable_plts(music_utils.get_actor_from_request(self.request)) def perform_create(self, serializer): return serializer.save( actor=self.request.user.actor, privacy_level=serializer.validated_data.get( "privacy_level", self.request.user.privacy_level ), ) @extend_schema(operation_id="remove_from_playlist") @action(methods=["post", "delete"], detail=True) @transaction.atomic def remove(self, request, *args, **kwargs): playlist = self.get_object() try: index = int(request.data["index"]) assert index >= 0 except (KeyError, ValueError, AssertionError, TypeError): return Response(status=400) try: plt = playlist.playlist_tracks.by_index(index) except models.PlaylistTrack.DoesNotExist: return Response(status=404) plt.delete(update_indexes=True) plt.playlist.schedule_scan(playlist.actor) return Response(status=204) @extend_schema(operation_id="reorder_track_in_playlist") @action(methods=["post"], detail=True) @transaction.atomic def move(self, request, *args, **kwargs): playlist = self.get_object() try: from_index = int(request.data["from"]) assert from_index >= 0 except (KeyError, ValueError, AssertionError, TypeError): return Response({"detail": "invalid from index"}, status=400) try: to_index = int(request.data["to"]) assert to_index >= 0 except (KeyError, ValueError, AssertionError, TypeError): return Response({"detail": "invalid to index"}, status=400) try: plt = playlist.playlist_tracks.by_index(from_index) except models.PlaylistTrack.DoesNotExist: return Response(status=404) playlist.insert(plt, to_index) plt.playlist.schedule_scan(playlist.actor) return Response(status=204) @extend_schema(operation_id="get_playlist_albums") @action(methods=["get"], detail=True) @transaction.atomic def albums(self, request, *args, **kwargs): playlist = self.get_object() try: albums_pks = playlist.playlist_tracks.values_list( "track__album__pk", flat=True ).distinct() except models.PlaylistTrack.DoesNotExist: return Response(status=404) releases = music_models.Album.objects.filter(pk__in=albums_pks) serializer = music_serializers.AlbumSerializer(releases, many=True) return Response(serializer.data, status=200) @extend_schema(operation_id="get_playlist_artits") @action(methods=["get"], detail=True) @transaction.atomic def artists(self, request, *args, **kwargs): playlist = self.get_object() try: artists_pks = playlist.playlist_tracks.values_list( "track__artist_credit__artist__pk", flat=True ).distinct() except models.PlaylistTrack.DoesNotExist: return Response(status=404) artists = music_models.Artist.objects.filter(pk__in=artists_pks) serializer = music_serializers.ArtistSerializer(artists, many=True) return Response(serializer.data, status=200)