diff --git a/api/config/settings/local.py b/api/config/settings/local.py
index de390a814..70fb5a21e 100644
--- a/api/config/settings/local.py
+++ b/api/config/settings/local.py
@@ -141,3 +141,14 @@ MIDDLEWARE = (
"funkwhale_api.common.middleware.ProfilerMiddleware",
"funkwhale_api.common.middleware.PymallocMiddleware",
) + MIDDLEWARE
+
+REST_FRAMEWORK.update(
+ {
+ "TEST_REQUEST_RENDERER_CLASSES": [
+ "rest_framework.renderers.MultiPartRenderer",
+ "rest_framework.renderers.JSONRenderer",
+ "rest_framework.renderers.TemplateHTMLRenderer",
+ "funkwhale_api.playlists.renderers.PlaylistXspfRenderer",
+ ],
+ }
+)
diff --git a/api/config/urls/api_v2.py b/api/config/urls/api_v2.py
index 1a2c05625..ff94ff4b8 100644
--- a/api/config/urls/api_v2.py
+++ b/api/config/urls/api_v2.py
@@ -17,6 +17,12 @@ v2_patterns += [
r"^radios/",
include(("funkwhale_api.radios.urls_v2", "radios"), namespace="radios"),
),
+ re_path(
+ r"^playlists/",
+ include(
+ ("funkwhale_api.playlists.urls_v2", "playlists"), namespace="playlists"
+ ),
+ ),
]
v2_paths = {
diff --git a/api/funkwhale_api/playlists/parsers.py b/api/funkwhale_api/playlists/parsers.py
new file mode 100644
index 000000000..fba44d689
--- /dev/null
+++ b/api/funkwhale_api/playlists/parsers.py
@@ -0,0 +1,47 @@
+from defusedxml.ElementTree import parse
+from rest_framework.parsers import BaseParser
+
+
+# from https://github.com/jpadilla/django-rest-framework-xml/blob/master/rest_framework_xml/parsers.py
+class XspfParser(BaseParser):
+ """
+ Takes a xspf stream, validate it, and return an xspf json
+ """
+
+ media_type = "application/octet-stream"
+
+ def parse(self, stream, media_type=None, parser_context=None):
+ playlist = {"tracks": []}
+
+ tree = parse(stream, forbid_dtd=True)
+ root = tree.getroot()
+
+ # Extract playlist information
+ playlist_info = root.find(".")
+ if playlist_info is not None:
+ playlist["title"] = playlist_info.findtext(
+ "{http://xspf.org/ns/0/}title", default=""
+ )
+ playlist["creator"] = playlist_info.findtext(
+ "{http://xspf.org/ns/0/}creator", default=""
+ )
+ playlist["creation_date"] = playlist_info.findtext(
+ "{http://xspf.org/ns/0/}date", default=""
+ )
+ playlist["version"] = playlist_info.attrib.get("version", "")
+
+ # Extract track information
+ for track in root.findall(".//{http://xspf.org/ns/0/}track"):
+ track_info = {
+ "location": track.findtext(
+ "{http://xspf.org/ns/0/}location", default=""
+ ),
+ "title": track.findtext("{http://xspf.org/ns/0/}title", default=""),
+ "creator": track.findtext("{http://xspf.org/ns/0/}creator", default=""),
+ "album": track.findtext("{http://xspf.org/ns/0/}album", default=""),
+ "duration": track.findtext(
+ "{http://xspf.org/ns/0/}duration", default=""
+ ),
+ }
+ playlist["tracks"].append(track_info)
+ return playlist
diff --git a/api/funkwhale_api/playlists/renderers.py b/api/funkwhale_api/playlists/renderers.py
new file mode 100644
index 000000000..0abe6ed8b
--- /dev/null
+++ b/api/funkwhale_api/playlists/renderers.py
@@ -0,0 +1,57 @@
+import xml.etree.ElementTree as etree
+from xml.etree.ElementTree import Element, SubElement
+
+from defusedxml import minidom
+from rest_framework import renderers
+
+from funkwhale_api.playlists.models import Playlist
+
+
+class PlaylistXspfRenderer(renderers.BaseRenderer):
+ media_type = "application/octet-stream"
+ format = "xspf"
+
+ def render(self, data, accepted_media_type=None, renderer_context=None):
+ if isinstance(data, bytes):
+ return data
+
+ fw_playlist = Playlist.objects.get(id=data["id"])
+ plt_tracks = fw_playlist.playlist_tracks.prefetch_related("track")
+ top = Element("playlist", version="1", xmlns="http://xspf.org/ns/0/")
+ title_xspf = SubElement(top, "title")
+ title_xspf.text = fw_playlist.name
+ date_xspf = SubElement(top, "date")
+ date_xspf.text = fw_playlist.creation_date.isoformat()
+ trackList_xspf = SubElement(top, "trackList")
+
+ for plt_track in plt_tracks:
+ track = plt_track.track
+ write_xspf_track_data(track, trackList_xspf)
+ return prettify(top)
+
+
+def write_xspf_track_data(track, trackList_xspf):
+ """
+ Insert a track into the trackList subelement of a xspf file
+ """
+ track_xspf = SubElement(trackList_xspf, "track")
+ location_xspf = SubElement(track_xspf, "location")
+ location_xspf.text = "https://" + track.domain_name + track.listen_url
+ title_xspf = SubElement(track_xspf, "title")
+ title_xspf.text = str(track.title)
+ creator_xspf = SubElement(track_xspf, "creator")
+ creator_xspf.text = str(track.get_artist_credit_string)
+ if str(track.album) == "[non-album tracks]":
+ return
+ else:
+ album_xspf = SubElement(track_xspf, "album")
+ album_xspf.text = str(track.album)
+
+
+def prettify(elem):
+ """
+ Return a pretty-printed XML string for the Element.
+ """
+ rough_string = etree.tostring(elem, "utf-8")
+ reparsed = minidom.parseString(rough_string)
+ return reparsed.toprettyxml(indent=" ")
diff --git a/api/funkwhale_api/playlists/serializers.py b/api/funkwhale_api/playlists/serializers.py
index 4767f1d9a..edf9aa804 100644
--- a/api/funkwhale_api/playlists/serializers.py
+++ b/api/funkwhale_api/playlists/serializers.py
@@ -1,14 +1,20 @@
+import logging
+
+from django.core.exceptions import ObjectDoesNotExist, ValidationError
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from funkwhale_api.federation import serializers as federation_serializers
-from funkwhale_api.music.models import Track
+from funkwhale_api.music import tasks
+from funkwhale_api.music.models import Album, Artist, Track
from funkwhale_api.music.serializers import TrackSerializer
from funkwhale_api.users.serializers import UserBasicSerializer
from . import models
+logger = logging.getLogger(__name__)
+
class PlaylistTrackSerializer(serializers.ModelSerializer):
# track = TrackSerializer()
@@ -122,3 +128,60 @@ class PlaylistAddManySerializer(serializers.Serializer):
class Meta:
fields = "allow_duplicates"
+
+
+class XspfTrackSerializer(serializers.Serializer):
+ location = serializers.CharField(allow_blank=True, required=False)
+ title = serializers.CharField()
+ creator = serializers.CharField()
+ album = serializers.CharField(allow_blank=True, required=False)
+ duration = serializers.CharField(allow_blank=True, required=False)
+
+ def validate(self, data):
+ title = data["title"]
+ album = data.get("album", None)
+ acs_tuples = tasks.parse_credits(data["creator"], "", 0)
+ try:
+ artist_id = Artist.objects.get(name=acs_tuples[0][0])
+ except ObjectDoesNotExist:
+ raise ValidationError("Couldn't find artist in the database")
+ if album:
+ try:
+ album_id = Album.objects.get(title=album)
+ fw_track = Track.objects.get(
+ title=title, artist_credit__artist=artist_id, album=album_id
+ )
+ except ObjectDoesNotExist:
+ pass
+ try:
+ fw_track = Track.objects.get(title=title, artist_credit__artist=artist_id)
+ except ObjectDoesNotExist as e:
+ raise ValidationError(f"Couldn't find track in the database : {e!r}")
+
+ super().validate(data)
+ return fw_track
+
+
+class XspfSerializer(serializers.Serializer):
+ title = serializers.CharField()
+ creator = serializers.CharField(allow_blank=True, required=False)
+ creation_date = serializers.DateTimeField(required=False)
+ version = serializers.IntegerField(required=False)
+ tracks = XspfTrackSerializer(many=True, required=False)
+
+ def create(self, validated_data):
+ pl = models.Playlist.objects.create(
+ name=validated_data["title"],
+ privacy_level="private",
+ user=validated_data["request"].user,
+ )
+ pl.insert_many(validated_data["tracks"])
+
+ return pl
+
+ def update(self, instance, validated_data):
+ instance.name = validated_data["title"]
+ instance.playlist_tracks.all().delete()
+ instance.insert_many(validated_data["tracks"])
+ instance.save()
+ return instance
diff --git a/api/funkwhale_api/playlists/urls.py b/api/funkwhale_api/playlists/urls.py
new file mode 100644
index 000000000..f55d35bb3
--- /dev/null
+++ b/api/funkwhale_api/playlists/urls.py
@@ -0,0 +1,8 @@
+from funkwhale_api.common import routers
+
+from . import views
+
+router = routers.OptionalSlashRouter()
+router.register(r"playlists", views.PlaylistViewSet, "playlists")
+
+urlpatterns = router.urls
diff --git a/api/funkwhale_api/playlists/urls_v2.py b/api/funkwhale_api/playlists/urls_v2.py
new file mode 100644
index 000000000..36fd2ccea
--- /dev/null
+++ b/api/funkwhale_api/playlists/urls_v2.py
@@ -0,0 +1,9 @@
+from funkwhale_api.common import routers
+
+from . import views
+
+router = routers.OptionalSlashRouter()
+
+router.register(r"playlists", views.PlaylistViewSet, "playlists")
+
+urlpatterns = router.urls
diff --git a/api/funkwhale_api/playlists/views.py b/api/funkwhale_api/playlists/views.py
index 0b57a6ef9..ea8a6be29 100644
--- a/api/funkwhale_api/playlists/views.py
+++ b/api/funkwhale_api/playlists/views.py
@@ -1,15 +1,23 @@
+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, 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.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, serializers
+from . import filters, models, parsers, renderers, serializers
+
+logger = logging.getLogger(__name__)
class PlaylistViewSet(
@@ -37,6 +45,50 @@ class PlaylistViewSet(
owner_checks = ["write"]
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 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)
+ response = super().create(request, *args, **kwargs)
+ return response
+
+ 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()
+ return Response(serializers.PlaylistSerializer(pl).data, status=201)
+ return super().retrieve(request, *args, **kwargs)
@extend_schema(responses=serializers.PlaylistTrackSerializer(many=True))
@action(methods=["get"], detail=True)
@@ -140,3 +192,33 @@ class PlaylistViewSet(
return Response(status=404)
playlist.insert(plt, to_index)
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.SimpleArtistSerializer(artists, many=True)
+ return Response(serializer.data, status=200)
diff --git a/api/tests/playlists/test.xspf b/api/tests/playlists/test.xspf
new file mode 100644
index 000000000..aff98c634
--- /dev/null
+++ b/api/tests/playlists/test.xspf
@@ -0,0 +1,19 @@
+
+