Porównaj commity

...

33 Commity

Autor SHA1 Wiadomość Data
petitminion 592900ac21 Merge branch '836-playlist-import-export-backend' into 'develop'
Import/export  xspf playlist (#836).

Closes #2340

See merge request funkwhale/funkwhale!2317
2024-11-23 21:56:10 +00:00
Eric Lemesre bf2670519c Translated using Weblate (French)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/fr/
2024-11-23 08:23:11 +00:00
Weblate Admin 1e71b868f6 Translated using Weblate (Spanish)
Currently translated at 88.2% (1925 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/es/
2024-11-21 16:24:32 +00:00
Petitminion 2b66127537 Mathieux review 1 2024-11-20 18:36:01 +01:00
Petitminion 31330fed3e Display v2 endpoints to swagger (#2352) 2024-11-18 20:26:04 +01:00
Petitminion d2ac7bf84a Display v2 endpoints to swagger (#2352) 2024-11-18 17:54:54 +00:00
Petitminion 1f699423cd trigger event on import to reload the playlist detail component 2024-10-23 16:43:14 +02:00
Petitminion 48ec453feb delete TEST_REQUEST_RENDERER_CLASSES from testing.py and moove it to local.py 2024-10-22 15:20:04 +02:00
Petitminion e82c795c4f add a parsing for multiple artists 2024-10-22 14:50:21 +02:00
Petitminion e15bb9d41c Add user documentation 2024-10-22 14:17:16 +02:00
Petitminion b7b42bba84 resolve backend bug on xspf export format 2024-10-22 14:04:11 +02:00
Petitminion 544335ed74 Add frontend playlist-dropdown 2024-10-22 13:49:58 +02:00
Petitminion ab6fba72e7 fix:permet ulti_artist utils to handle queutracks 2024-10-21 18:10:42 +02:00
Petitminion c794575d6d fix: playlist edition didn't manage artist_credit obj 2024-10-21 18:09:08 +02:00
Petitminion 4f7f8e770a make sure page name is a string 2024-10-21 18:08:23 +02:00
Petitminion b7ea241623 resolve wrong api path 2024-10-21 16:01:02 +02:00
Petitminion a07196d618 add raise_exception=True 2024-10-21 11:07:42 +02:00
Petitminion 8fa75a000c update for multi_artist support (partial) 2024-10-21 11:07:42 +02:00
Petitminion 3a7cf49834 rebase and some test update 2024-10-21 11:07:41 +02:00
Petitminion e9d9b72824 move TEST_REQUEST_RENDERER_CLASSES 2024-10-21 11:06:34 +02:00
Petitminion fde303653d review 2024-10-21 11:06:34 +02:00
Petitminion 585f28bd5e simplify test 2024-10-21 11:06:34 +02:00
Petitminion fc4c58b62c change api endpoint to V2 spec 2024-10-21 11:06:34 +02:00
Petitminion 1b689fbc1b changelog 2024-10-21 11:06:34 +02:00
Petitminion 908c54ef46 changelog 2024-10-21 11:06:34 +02:00
Petitminion 3e0bb38ff3 lint 2024-10-21 11:06:34 +02:00
Petitminion 547dbfb286 lint 2024-10-21 11:06:34 +02:00
Petitminion ba8a25628d lint 2024-10-21 11:06:34 +02:00
Petitminion d326b636d6 adding views and changing file handeling to var handeling 2024-10-21 11:06:34 +02:00
Petitminion 4a5e477209 adding tests 2024-10-21 11:06:34 +02:00
petitminion 9da7874eb5 Update api/funkwhale_api/playlists/utils.py 2024-10-21 11:06:34 +02:00
Petitminion b93bf06bad adding tests 2024-10-21 11:06:34 +02:00
Petitminion 687903359d Add utils to playlist module to handle xspf file (#836). The clean_namespace_xspf function need to be update to handle the input format of the xspf file when the front-end art will be done. 2024-10-21 11:06:34 +02:00
23 zmienionych plików z 652 dodań i 37 usunięć

Wyświetl plik

@ -1,5 +1,3 @@
import os
from drf_spectacular.contrib.django_oauth_toolkit import OpenApiAuthenticationExtension
from drf_spectacular.plumbing import build_bearer_security_scheme_object
@ -44,7 +42,6 @@ def custom_preprocessing_hook(endpoints):
filtered = []
# your modifications to the list of operations that are exposed in the schema
api_type = os.environ.get("API_TYPE", "v1")
for path, path_regex, method, callback in endpoints:
if path.startswith("/api/v1/providers"):
@ -56,7 +53,7 @@ def custom_preprocessing_hook(endpoints):
if path.startswith("/api/v1/oauth/authorize"):
continue
if path.startswith(f"/api/{api_type}"):
if path.startswith("/api/v1") or path.startswith("/api/v2"):
filtered.append((path, path_regex, method, callback))
return filtered

Wyświetl plik

@ -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",
],
}
)

Wyświetl plik

@ -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 = {

Wyświetl plik

@ -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

Wyświetl plik

@ -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=" ")

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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)

Wyświetl plik

@ -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>

Wyświetl plik

@ -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
)

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -0,0 +1 @@
Add backend logic to handle xspf file to import/export playlist (#836)

Wyświetl plik

@ -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"

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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>

Wyświetl plik

@ -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": {

Wyświetl plik

@ -166,6 +166,7 @@
"label": {
"addArtistContentFilter": "Ocultar contenido de este artista…",
"duration": "Duración",
"enterFullscreen": "Entrar en modo de pantalla completa",
"exitFullscreen": "Entrar en modo pantalla completa",
"favorite": "Pista favorita",
"next": "Próxima canción",
@ -346,7 +347,8 @@
"true": "Sí"
},
"type": {
"long": "Texto largo"
"long": "Texto largo",
"short": "Texto corto"
}
}
}
@ -371,7 +373,8 @@
},
"help": {
"discography": "Publica la música que haces como una bonita discografía de álbumes y sencillos.",
"podcast": "Aloja tus episodios y mantén a tu comunidad actualizada."
"podcast": "Aloja tus episodios y mantén a tu comunidad actualizada.",
"podcastFields": "Usado para el campo itunes:email y itunes:name requerido por ciertas plataformas como Spotify o iTunes"
},
"label": {
"category": "Categoría",

Wyświetl plik

@ -3215,6 +3215,28 @@
}
},
"views": {
"ChooseInstance": {
"button": {
"submit": "Valider"
},
"header": {
"chooseInstance": "Choisissez votre instance",
"failure": "Impossible de se connecter à l'URL renseignée",
"suggestions": "Suggestions"
},
"help": {
"notFunkwhaleServer": "L'adresse fournie n'est pas un serveur Funkwhale",
"selectPod": "Pour continuer, sélectionnez le pod Funkwhale auquel vous souhaitez vous connecter. Entrez l'adresse directement, ou sélectionnez l'un des choix suggérés.",
"serverDown": "Le serveur est peut-être hors-service"
},
"label": {
"url": "Adresse de l'instance"
},
"message": {
"currentConnection": "Vous êtes actuellement connecté à { 0 }. Si vous continuez, vous serez déconnecté de linstance actuelle et toutes vos données locales seront supprimées.",
"newUrl": "Vous utilisez maintenant linstance Funkwhale sur { url }"
}
},
"Notifications": {
"button": {
"read": "Tout marquer comme lu",
@ -3352,6 +3374,7 @@
"moderation": "Modération",
"music": "Musique",
"playlists": "Listes de lecture",
"qualityFilters": "Explorer la pages des filtres de qualité",
"sections": "Sections",
"security": "Sécurité",
"settings": "Paramètres de l'instance",
@ -4588,28 +4611,6 @@
},
"title": "Radio"
}
},
"ChooseInstance": {
"button": {
"submit": "Valider"
},
"header": {
"chooseInstance": "Choisissez votre instance",
"failure": "Impossible de se connecter à l'URL renseignée",
"suggestions": "Suggestions"
},
"help": {
"notFunkwhaleServer": "L'adresse fournie n'est pas un serveur Funkwhale",
"selectPod": "Pour continuer, sélectionnez le pod Funkwhale auquel vous souhaitez vous connecter. Entrez l'adresse directement, ou sélectionnez l'un des choix suggérés.",
"serverDown": "Le serveur est peut-être hors-service"
},
"label": {
"url": "Adresse de l'instance"
},
"message": {
"currentConnection": "Vous êtes actuellement connecté à { 0 }. Si vous continuez, vous serez déconnecté de linstance actuelle et toutes vos données locales seront supprimées.",
"newUrl": "Vous utilisez maintenant linstance Funkwhale sur { url }"
}
}
}
}

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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