kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
Merge branch '836-playlist-import-export-backend' into 'develop'
Import/export xspf playlist (#836). Closes #2340 See merge request funkwhale/funkwhale!2317merge-requests/2317/merge
commit
592900ac21
|
@ -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",
|
||||
],
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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
|
|
@ -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=" ")
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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,35 @@ class PlaylistViewSet(
|
|||
return Response(status=404)
|
||||
playlist.insert(plt, to_index)
|
||||
return Response(status=204)
|
||||
|
||||
@extend_schema(operation_id="get_playlist_releases")
|
||||
@action(methods=["get"], detail=True)
|
||||
@transaction.atomic
|
||||
def releases(self, request, *args, **kwargs):
|
||||
playlist = self.get_object()
|
||||
try:
|
||||
releases_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=releases_pks)
|
||||
serializer = music_serializers.AlbumSerializer(data=releases, many=True)
|
||||
serializer.is_valid()
|
||||
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(data=artists, many=True)
|
||||
serializer.is_valid()
|
||||
return Response(serializer.data, status=200)
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" ?>
|
||||
<playlist version="1" xmlns="http://xspf.org/ns/0/">
|
||||
<title>Test</title>
|
||||
<date>1312-01-08T17:10:47-05:00</date>
|
||||
<trackList>
|
||||
<track>
|
||||
<location>https://maldonado-boyd.com/api/v1/listen/c5a0f3b9-1866-4258-98bc-1c6867d0b125/</location>
|
||||
<title>Opinel 12</title>
|
||||
<creator>Davinhor</creator>
|
||||
<album>Racisme en pls</album>
|
||||
</track>
|
||||
<track>
|
||||
<location>https://wilson.com/api/v1/listen/9fb8ee3f-caa3-4c7f-b7c1-2fd645f738f4/</location>
|
||||
<title>lettre a la republique</title>
|
||||
<creator>Kery James</creator>
|
||||
<album>Racisme en pls</album>
|
||||
</track>
|
||||
</trackList>
|
||||
</playlist>
|
|
@ -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
|
||||
)
|
|
@ -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_playlists_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-releases", 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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
Add backend logic to handle xspf file to import/export playlist (#836)
|
|
@ -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"
|
|
@ -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
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { generateTrackCreditString } from '~/utils/utils'
|
||||
|
||||
import type { Playlist, PlaylistTrack, BackendError, APIErrorResponse } from '~/types'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
@ -289,7 +291,7 @@ const insertMany = async (insertedTracks: number[], allowDuplicates: boolean) =>
|
|||
</td>
|
||||
<td colspan="4">
|
||||
<strong>{{ plt.track.title }}</strong><br>
|
||||
{{ plt.track.artist.name }}
|
||||
{{ generateTrackCreditString(plt.track) }}
|
||||
</td>
|
||||
<td class="right aligned">
|
||||
<button
|
||||
|
|
|
@ -0,0 +1,122 @@
|
|||
<script setup lang="ts">
|
||||
import type { Playlist } from '~/types'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useStore } from '~/store'
|
||||
import axios from 'axios'
|
||||
|
||||
interface Events {
|
||||
(e: 'import'): void
|
||||
(e: 'export'): void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
playlist: Playlist
|
||||
}
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const props = defineProps<Props>()
|
||||
const store = useStore()
|
||||
const currentDate = new Date()
|
||||
const formattedDate = currentDate.toISOString().split('T')[0]
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const labels = computed(() => ({
|
||||
import: t('components.playlists.PlaylistDropdown.button.import.header'),
|
||||
export: t('components.playlists.PlaylistDropdown.button.export.header'),
|
||||
more: t('components.playlists.PlaylistDropdown.more')
|
||||
|
||||
}))
|
||||
|
||||
const exportUrl = computed(() => store.getters['instance/absoluteUrl'](`/api/v2/playlists/${props.playlist.id}`))
|
||||
const exportPlaylist = async () => {
|
||||
const url = exportUrl.value
|
||||
const authToken = store.state.auth.oauth.accessToken
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
Accept: 'application/octet-stream',
|
||||
Authorization: `Bearer ${authToken}`
|
||||
}
|
||||
|
||||
const response = await axios.get(url, { headers })
|
||||
const blob = new Blob([response.data])
|
||||
const downloadUrl = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = downloadUrl
|
||||
a.download = `${props.playlist.name}_${formattedDate}.xspf`
|
||||
a.click()
|
||||
a.remove()
|
||||
}
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const patchPlaylist = async () => {
|
||||
const url = exportUrl.value
|
||||
|
||||
if (!fileInputRef.value || !fileInputRef.value.files || fileInputRef.value.files.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const file = fileInputRef.value.files[0]
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/octet-stream'
|
||||
}
|
||||
|
||||
// if (store.state.auth.oauth.accessToken) {
|
||||
// headers.Authorization = store.getters['auth/header']
|
||||
// }
|
||||
|
||||
await axios.patch(url, file, { headers })
|
||||
emit('import')
|
||||
}
|
||||
|
||||
// Function to trigger file input when clicking import
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<span>
|
||||
<button
|
||||
v-dropdown="{direction: 'downward'}"
|
||||
class="ui floating dropdown circular icon basic button"
|
||||
:title="labels.more"
|
||||
>
|
||||
<i class="ellipsis vertical icon" />
|
||||
<div class="menu">
|
||||
<div
|
||||
role="button"
|
||||
class="basic item"
|
||||
:title="t('components.playlists.PlaylistDropdown.button.export.description')"
|
||||
@click="exportPlaylist"
|
||||
>
|
||||
<i class="upload icon" />
|
||||
{{ labels.export }}
|
||||
</div>
|
||||
<div
|
||||
v-if="$store.state.auth.profile && playlist.user.id === $store.state.auth.profile.id"
|
||||
role="button"
|
||||
class="basic item"
|
||||
:title="t('components.playlists.PlaylistDropdown.button.import.description')"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<i class="download icon" />
|
||||
{{ labels.import }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Hidden file input, triggered by the button click -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
style="display: none"
|
||||
@change="patchPlaylist"
|
||||
>
|
||||
</span>
|
||||
</template>
|
|
@ -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": {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 () => {
|
|||
</div>
|
||||
</template>
|
||||
</dangerous-button>
|
||||
<div class="ui hidden horizontal divider" />
|
||||
<playlist-dropdown
|
||||
:playlist="playlist"
|
||||
@import="fetchData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<semantic-modal
|
||||
|
|
Ładowanie…
Reference in New Issue