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 @@ + + + Test + 1312-01-08T17:10:47-05:00 + + + https://maldonado-boyd.com/api/v1/listen/c5a0f3b9-1866-4258-98bc-1c6867d0b125/ + Opinel 12 + Davinhor + Racisme en pls + + + https://wilson.com/api/v1/listen/9fb8ee3f-caa3-4c7f-b7c1-2fd645f738f4/ + lettre a la republique + Kery James + Racisme en pls + + + diff --git a/api/tests/playlists/test_renderers.py b/api/tests/playlists/test_renderers.py new file mode 100644 index 000000000..4c05885c0 --- /dev/null +++ b/api/tests/playlists/test_renderers.py @@ -0,0 +1,18 @@ +from defusedxml import ElementTree as etree + +from funkwhale_api.playlists import renderers, serializers + + +def test_generate_xspf_from_playlist(factories): + playlist_track = factories["playlists.PlaylistTrack"]() + playlist = playlist_track.playlist + xspf_test = renderers.PlaylistXspfRenderer().render( + serializers.PlaylistSerializer(playlist).data + ) + tree = etree.fromstring(xspf_test) + track1_title = playlist_track.track.title + ns = {"xspf": "http://xspf.org/ns/0/"} + assert playlist.name == tree.findtext("./xspf:title", namespaces=ns) + assert track1_title == tree.findtext( + "./xspf:trackList/xspf:track/xspf:title", namespaces=ns + ) diff --git a/api/tests/playlists/test_urls_v2.py b/api/tests/playlists/test_urls_v2.py new file mode 100644 index 000000000..fdc20df23 --- /dev/null +++ b/api/tests/playlists/test_urls_v2.py @@ -0,0 +1,137 @@ +import json + +from defusedxml import ElementTree as etree +from django.shortcuts import resolve_url +from django.urls import reverse + + +def test_can_get_playlist_list(factories, logged_in_api_client): + factories["playlists.Playlist"].create_batch(5) + url = reverse("api:v2:playlists:playlists-list") + headers = {"Content-Type": "application/json"} + response = logged_in_api_client.get(url, headers=headers) + data = json.loads(response.content) + + assert response.status_code == 200 + assert data["count"] == 5 + + +def test_can_get_playlists_octet_stream(factories, logged_in_api_client): + pl = factories["playlists.Playlist"]() + factories["playlists.PlaylistTrack"](playlist=pl) + factories["playlists.PlaylistTrack"](playlist=pl) + factories["playlists.PlaylistTrack"](playlist=pl) + + url = reverse("api:v2:playlists:playlists-detail", kwargs={"pk": pl.pk}) + headers = {"Accept": "application/octet-stream"} + response = logged_in_api_client.get(url, headers=headers) + el = etree.fromstring(response.content) + ns = {"xspf": "http://xspf.org/ns/0/"} + assert response.status_code == 200 + assert el.findtext("./xspf:title", namespaces=ns) == pl.name + + +def test_can_get_playlists_json(factories, logged_in_api_client): + pl = factories["playlists.Playlist"]() + url = reverse("api:v2:playlists:playlists-detail", kwargs={"pk": pl.pk}) + response = logged_in_api_client.get(url, format="json") + assert response.status_code == 200 + assert response.data["name"] == pl.name + + +def test_can_get_user_playlists_list(factories, logged_in_api_client): + user = factories["users.User"]() + factories["playlists.Playlist"](user=user) + + url = reverse("api:v2:playlists:playlists-list") + url = resolve_url(url) + "?user=me" + response = logged_in_api_client.get(url) + data = json.loads(response.content.decode("utf-8")) + + assert response.status_code == 200 + assert data["count"] == 1 + + +def test_can_post_user_playlists(factories, logged_in_api_client): + playlist = {"name": "Les chiennes de l'hexagone", "privacy_level": "me"} + url = reverse("api:v2:playlists:playlists-list") + + response = logged_in_api_client.post(url, playlist, format="json") + data = json.loads(response.content.decode("utf-8")) + assert response.status_code == 201 + assert data["name"] == "Les chiennes de l'hexagone" + assert data["privacy_level"] == "me" + + +def test_can_post_playlists_octet_stream(factories, logged_in_api_client): + artist = factories["music.Artist"](name="Davinhor") + album = factories["music.Album"]( + title="Racisme en pls", artist_credit__artist=artist + ) + factories["music.Track"]( + title="Opinel 12", artist_credit__artist=artist, album=album + ) + url = reverse("api:v2:playlists:playlists-list") + data = open("./tests/playlists/test.xspf", "rb").read() + response = logged_in_api_client.post(url, data=data, format="xspf") + data = json.loads(response.content) + assert response.status_code == 201 + assert data["name"] == "Test" + + +def test_can_post_playlists_octet_stream_invalid_track(factories, logged_in_api_client): + url = reverse("api:v2:playlists:playlists-list") + data = open("./tests/playlists/test.xspf", "rb").read() + response = logged_in_api_client.post(url, data=data, format="xspf") + data = json.loads(response.content) + assert response.status_code == 201 + assert data["name"] == "Test" + + +def test_can_patch_playlists_octet_stream(factories, logged_in_api_client): + pl = factories["playlists.Playlist"](user=logged_in_api_client.user) + artist = factories["music.Artist"](name="Davinhor") + album = factories["music.Album"]( + title="Racisme en pls", artist_credit__artist=artist + ) + track = factories["music.Track"]( + title="Opinel 12", artist_credit__artist=artist, album=album + ) + url = reverse("api:v2:playlists:playlists-detail", kwargs={"pk": pl.pk}) + data = open("./tests/playlists/test.xspf", "rb").read() + response = logged_in_api_client.patch(url, data=data, format="xspf") + pl.refresh_from_db() + assert response.status_code == 201 + assert pl.name == "Test" + assert pl.playlist_tracks.all()[0].track.title == track.title + + +def test_can_get_playlists_track(factories, logged_in_api_client): + pl = factories["playlists.Playlist"]() + plt = factories["playlists.PlaylistTrack"](playlist=pl) + url = reverse("api:v2:playlists:playlists-tracks", kwargs={"pk": pl.pk}) + response = logged_in_api_client.get(url) + data = json.loads(response.content.decode("utf-8")) + assert response.status_code == 200 + assert data["count"] == 1 + assert data["results"][0]["track"]["title"] == plt.track.title + + +def test_can_get_playlists_releases(factories, logged_in_api_client): + playlist = factories["playlists.Playlist"]() + plt = factories["playlists.PlaylistTrack"](playlist=playlist) + url = reverse("api:v2:playlists:playlists-albums", kwargs={"pk": playlist.pk}) + response = logged_in_api_client.get(url) + data = json.loads(response.content) + assert response.status_code == 200 + assert data[0]["title"] == plt.track.album.title + + +def test_can_get_playlists_artists(factories, logged_in_api_client): + playlist = factories["playlists.Playlist"]() + plt = factories["playlists.PlaylistTrack"](playlist=playlist) + url = reverse("api:v2:playlists:playlists-artists", kwargs={"pk": playlist.pk}) + response = logged_in_api_client.get(url) + data = json.loads(response.content) + assert response.status_code == 200 + assert data[0]["name"] == plt.track.get_artist_credit_string diff --git a/api/tests/playlists/test_views.py b/api/tests/playlists/test_views.py index 5b9cf4202..4803378d5 100644 --- a/api/tests/playlists/test_views.py +++ b/api/tests/playlists/test_views.py @@ -20,7 +20,7 @@ def test_serializer_includes_tracks_count(factories, logged_in_api_client): factories["playlists.PlaylistTrack"](playlist=playlist) url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk}) - response = logged_in_api_client.get(url) + response = logged_in_api_client.get(url, content_type="application/json") assert response.data["tracks_count"] == 1 @@ -32,7 +32,7 @@ def test_serializer_includes_tracks_count_986(factories, logged_in_api_client): 3, track=plt.track, library__privacy_level="everyone", import_status="finished" ) url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk}) - response = logged_in_api_client.get(url) + response = logged_in_api_client.get(url, content_type="application/json") assert response.data["tracks_count"] == 1 @@ -42,7 +42,7 @@ def test_serializer_includes_is_playable(factories, logged_in_api_client): factories["playlists.PlaylistTrack"](playlist=playlist) url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk}) - response = logged_in_api_client.get(url) + response = logged_in_api_client.get(url, content_type="application/json") assert response.data["is_playable"] is False @@ -78,7 +78,7 @@ def test_only_can_add_track_on_own_playlist_via_api(factories, logged_in_api_cli url = reverse("api:v1:playlists-add", kwargs={"pk": playlist.pk}) data = {"tracks": [track.pk]} - response = logged_in_api_client.post(url, data, format="json") + response = logged_in_api_client.post(url, data, content_type="application/json") assert response.status_code == 404 assert playlist.playlist_tracks.count() == 0 diff --git a/changes/changelog.d/2317-newfeature b/changes/changelog.d/2317-newfeature new file mode 100644 index 000000000..f6603b60f --- /dev/null +++ b/changes/changelog.d/2317-newfeature @@ -0,0 +1 @@ +Add backend logic to handle xspf file to import/export playlist (#836) diff --git a/docs/specs/playlist-import-export/index.md b/docs/specs/playlist-import-export/index.md new file mode 100644 index 000000000..9a6f083d1 --- /dev/null +++ b/docs/specs/playlist-import-export/index.md @@ -0,0 +1,38 @@ +## Playlist Import export + +### The Issue + +Playlists cannot be imported/exported. + +### Proposed Solution + +Add endpoints to allow import/export. + +### Feature Behavior + +Users will be able to click on a "Download playlist" or "Rebuild playlist" button. The playlist content and the playlist itself will be added to the user's library section ("My library"). + +#### Backend + +GET from the `/api/v2/playlists/{guid}` endpoint and receive either: + +- `application/json`: a summary of the playlist +- `application/octet-stream`: the full exported XSPF file + +PATCH the `/api/v2/playlists/{guid}` endpoint using either: + +- `application/json`: updated fields for the playlist metadata +- `application/octet-stream`: an updated XSPF file containing updated information about the playlist7 + +POST to the `/api/v2/playlists` endpoint and post either: + +- `application/json`: a small amount of metadata about the created playlist +- `application/octet-stream`: an XSPF file containing the playlist data + +GET from the `/api/v2/playlists/{guid}/artists` endpoint and receive either: + +- `application/json`: a list a artist present in the playlist + +GET from the `/api/v2/playlists/{guid}/albums` endpoint and receive either: + +- `application/json`: a list a albums present in the playlist diff --git a/docs/user/playlists/content/add.md b/docs/user/playlists/content/add.md index d04236b45..49fcdad62 100644 --- a/docs/user/playlists/content/add.md +++ b/docs/user/playlists/content/add.md @@ -84,3 +84,15 @@ You can add tracks from any page with a track table. This includes artist pages, 4. Select {guilabel}`+ Add track` next to the playlist you want to add the track to. Well done! You've added the track to your playlist. + +## Rebuild the playlist with xspf file + +```{warning} +This will delete all content from the playlist and replace it with the one from the xspf file. +``` + +You can import an xspf file. Funkwhale will try to match the metadata from the file to Funkwhale database. If the match didn't succeeded the Track will be ignored : + +1. Go to the playlist page +2. Click on the kebab menu ({fa}`ellipsis-v`) +3. click on "Rebuild playlist" diff --git a/docs/user/playlists/content/download_import.md b/docs/user/playlists/content/download_import.md new file mode 100644 index 000000000..c780f1ca7 --- /dev/null +++ b/docs/user/playlists/content/download_import.md @@ -0,0 +1,7 @@ +# Download your playlist data + +You can download playlist data in xspf format : + +1. Go to the playlist page +2. Click on the kebab menu ({fa}`ellipsis-v`) +3. click on "Download playlist" diff --git a/front/src/App.vue b/front/src/App.vue index aa6c0b3fe..cec2b1960 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -10,6 +10,8 @@ import { useStore } from '~/store' import onKeyboardShortcut from '~/composables/onKeyboardShortcut' import useLogger from '~/composables/useLogger' +import { generateTrackCreditStringFromQueue } from '~/utils/utils' + const ChannelUploadModal = defineAsyncComponent(() => import('~/components/channels/UploadModal.vue')) const PlaylistModal = defineAsyncComponent(() => import('~/components/playlists/PlaylistModal.vue')) const FilterModal = defineAsyncComponent(() => import('~/components/moderation/FilterModal.vue')) @@ -32,7 +34,7 @@ const getTrackInformationText = (track: QueueTrack | undefined) => { return null } - return `♫ ${track.title} – ${track.artistCredit} ♫` + return `♫ ${track.title} – ${generateTrackCreditStringFromQueue(track)} ♫` } // Update title diff --git a/front/src/components/playlists/Editor.vue b/front/src/components/playlists/Editor.vue index 231e6c56d..cb8f12ac0 100644 --- a/front/src/components/playlists/Editor.vue +++ b/front/src/components/playlists/Editor.vue @@ -1,4 +1,6 @@ + diff --git a/front/src/locales/en_US.json b/front/src/locales/en_US.json index 8a828a149..6793123f7 100644 --- a/front/src/locales/en_US.json +++ b/front/src/locales/en_US.json @@ -2896,6 +2896,19 @@ "placeholder": { "noPlaylists": "No playlists have been created yet" } + }, + "PlaylistDropdown": { + "button": { + "import": { + "header": "Rebuild playlist", + "description": "This will update the playlist with the content of the xspf file. Existing playlist tracks will be deleted" + }, + "export": { + "header": "Download playlist", + "description": "This will provide an xspf file with the playlist data" + } + }, + "more": "More" } }, "radios": { diff --git a/front/src/utils/utils.ts b/front/src/utils/utils.ts index e616890f8..d11fbb921 100644 --- a/front/src/utils/utils.ts +++ b/front/src/utils/utils.ts @@ -1,5 +1,6 @@ import type { Track, Album, ArtistCredit, QueueItemSource } from '~/types' import { useStore } from '~/store' +import type { QueueTrack } from '~/composables/audio/queue' const store = useStore() @@ -15,7 +16,7 @@ export function generateTrackCreditString (track: Track | Album | null): string return artistCredits.join('') } -export function generateTrackCreditStringFromQueue (track: QueueItemSource | null): string | null { +export function generateTrackCreditStringFromQueue (track: QueueTrack | QueueItemSource | null): string | null { if (!track || !track.artistCredit || track.artistCredit.length === 0) { return null } diff --git a/front/src/views/playlists/Detail.vue b/front/src/views/playlists/Detail.vue index b80183be2..29a60531a 100644 --- a/front/src/views/playlists/Detail.vue +++ b/front/src/views/playlists/Detail.vue @@ -14,6 +14,8 @@ import SemanticModal from '~/components/semantic/Modal.vue' import TrackTable from '~/components/audio/track/Table.vue' import PlayButton from '~/components/audio/PlayButton.vue' +import PlaylistDropdown from '~/components/playlists/PlaylistDropdown.vue' + import useErrorHandler from '~/composables/useErrorHandler' interface Props { @@ -157,6 +159,11 @@ const deletePlaylist = async () => { +