diff --git a/api/funkwhale_api/subsonic/serializers.py b/api/funkwhale_api/subsonic/serializers.py index 329c644ee266adc4360059d45064c09cf274f292..cae99e242685c205ad794efbf5adf696ac7cc32e 100644 --- a/api/funkwhale_api/subsonic/serializers.py +++ b/api/funkwhale_api/subsonic/serializers.py @@ -8,6 +8,40 @@ from funkwhale_api.music import models as music_models from funkwhale_api.music import utils as music_utils +def to_subsonic_date(date): + """ + Subsonic expects this kind of date format: 2012-04-17T19:55:49.000Z + """ + + if not date: + return + + return date.strftime("%Y-%m-%dT%H:%M:%S.000Z") + + +def get_valid_filepart(s): + """ + Return a string suitable for use in a file path. Escape most non-ASCII + chars, and truncate the string to a suitable length too. + """ + max_length = 50 + keepcharacters = " ._()[]-+" + final = "".join( + c if c.isalnum() or c in keepcharacters else "_" for c in s + ).rstrip() + return final[:max_length] + + +def get_track_path(track, suffix): + artist_part = get_valid_filepart(track.artist.name) + album_part = get_valid_filepart(track.album.title) + track_part = get_valid_filepart(track.title) + "." + suffix + if track.position: + track_part = "{} - {}".format(track.position, track_part) + + return "/".join([artist_part, album_part, track_part]) + + def get_artist_data(artist_values): return { "id": artist_values["id"], @@ -52,7 +86,7 @@ class GetArtistSerializer(serializers.Serializer): "artistId": artist.id, "name": album.title, "artist": artist.name, - "created": album.creation_date, + "created": to_subsonic_date(album.creation_date), "songCount": len(album.tracks.all()), } if album.cover: @@ -81,8 +115,9 @@ def get_track_data(album, track, upload): else "audio/mpeg" ), "suffix": upload.extension or "", + "path": get_track_path(track, upload.extension or "mp3"), "duration": upload.duration or 0, - "created": track.creation_date, + "created": to_subsonic_date(track.creation_date), "albumId": album.pk, "artistId": album.artist.pk, "type": "music", @@ -104,7 +139,7 @@ def get_album2_data(album): "artistId": album.artist.id, "name": album.title, "artist": album.artist.name, - "created": album.creation_date, + "created": to_subsonic_date(album.creation_date), } if album.cover: payload["coverArt"] = "al-{}".format(album.id) @@ -162,7 +197,7 @@ def get_starred_tracks_data(favorites): except IndexError: continue td = get_track_data(t.album, t, uploads) - td["starred"] = by_track_id[t.pk].creation_date + td["starred"] = to_subsonic_date(by_track_id[t.pk].creation_date) data.append(td) return data @@ -179,7 +214,7 @@ def get_playlist_data(playlist): "public": "false", "songCount": playlist._tracks_count, "duration": 0, - "created": playlist.creation_date, + "created": to_subsonic_date(playlist.creation_date), } @@ -220,8 +255,9 @@ def get_music_directory_data(artist): "year": track.album.release_date.year if track.album.release_date else 0, "contentType": upload.mimetype, "suffix": upload.extension or "", + "path": get_track_path(track, upload.extension or "mp3"), "duration": upload.duration or 0, - "created": track.creation_date, + "created": to_subsonic_date(track.creation_date), "albumId": album.pk, "artistId": artist.pk, "parent": artist.id, @@ -259,7 +295,7 @@ def get_user_detail_data(user): "playlistRole": "true", "streamRole": "true", "jukeboxRole": "true", - "folder": [f["id"] for f in get_folders(user)], + "folder": [{"value": f["id"]} for f in get_folders(user)], } diff --git a/api/tests/subsonic/test_serializers.py b/api/tests/subsonic/test_serializers.py index d6025a90b9bfc158f269a9fe0bfbab6e330ad0b4..4da84ec3502cbacf6aa0e1cbb74307436e7b780d 100644 --- a/api/tests/subsonic/test_serializers.py +++ b/api/tests/subsonic/test_serializers.py @@ -1,9 +1,71 @@ +import datetime import pytest from funkwhale_api.music import models as music_models from funkwhale_api.subsonic import serializers +@pytest.mark.parametrize( + "date, expected", + [ + (datetime.datetime(2017, 1, 12, 9, 53, 12, 1890), "2017-01-12T09:53:12.000Z"), + (None, None), + ], +) +def test_to_subsonic_date(date, expected): + assert serializers.to_subsonic_date(date) == expected + + +@pytest.mark.parametrize( + "input, expected", + [ + ("AC/DC", "AC_DC"), + ("AC-DC", "AC-DC"), + ("A" * 100, "A" * 50), + ("Album (2019)", "Album (2019)"), + ("Haven't", "Haven_t"), + ], +) +def test_get_valid_filepart(input, expected): + assert serializers.get_valid_filepart(input) == expected + + +@pytest.mark.parametrize( + "factory_kwargs, suffix, expected", + [ + ( + { + "artist__name": "Hello", + "album__title": "World", + "title": "foo", + "position": None, + }, + "mp3", + "Hello/World/foo.mp3", + ), + ( + { + "artist__name": "AC/DC", + "album__title": "escape/my", + "title": "sla/sh", + "position": 12, + }, + "ogg", + "/".join( + [ + serializers.get_valid_filepart("AC/DC"), + serializers.get_valid_filepart("escape/my"), + ] + ) + + "/12 - {}.ogg".format(serializers.get_valid_filepart("sla/sh")), + ), + ], +) +def test_get_track_path(factory_kwargs, suffix, expected, factories): + track = factories["music.Track"](**factory_kwargs) + assert serializers.get_track_path(track, suffix) == expected + + def test_get_artists_serializer(factories): artist1 = factories["music.Artist"](name="eliot") artist2 = factories["music.Artist"](name="Ellena") @@ -54,7 +116,7 @@ def test_get_artist_serializer(factories): "name": album.title, "artist": artist.name, "songCount": len(tracks), - "created": album.creation_date, + "created": serializers.to_subsonic_date(album.creation_date), "year": album.release_date.year, } ], @@ -96,7 +158,7 @@ def test_get_album_serializer(factories): "name": album.title, "artist": artist.name, "songCount": 1, - "created": album.creation_date, + "created": serializers.to_subsonic_date(album.creation_date), "year": album.release_date.year, "coverArt": "al-{}".format(album.id), "song": [ @@ -112,10 +174,11 @@ def test_get_album_serializer(factories): "year": track.album.release_date.year, "contentType": upload.mimetype, "suffix": upload.extension or "", + "path": serializers.get_track_path(track, upload.extension), "bitrate": 42, "duration": 43, "size": 44, - "created": track.creation_date, + "created": serializers.to_subsonic_date(track.creation_date), "albumId": album.pk, "artistId": artist.pk, "type": "music", @@ -133,7 +196,7 @@ def test_starred_tracks2_serializer(factories): upload = factories["music.Upload"](track=track) favorite = factories["favorites.TrackFavorite"](track=track) expected = [serializers.get_track_data(album, track, upload)] - expected[0]["starred"] = favorite.creation_date + expected[0]["starred"] = serializers.to_subsonic_date(favorite.creation_date) data = serializers.get_starred_tracks_data([favorite]) assert data == expected @@ -162,7 +225,7 @@ def test_playlist_serializer(factories): "public": "false", "songCount": 1, "duration": 0, - "created": playlist.creation_date, + "created": serializers.to_subsonic_date(playlist.creation_date), } qs = playlist.__class__.objects.with_tracks_count() data = serializers.get_playlist_data(qs.first()) @@ -181,7 +244,7 @@ def test_playlist_detail_serializer(factories): "public": "false", "songCount": 1, "duration": 0, - "created": playlist.creation_date, + "created": serializers.to_subsonic_date(playlist.creation_date), "entry": [serializers.get_track_data(plt.track.album, plt.track, upload)], } qs = playlist.__class__.objects.with_tracks_count() @@ -210,10 +273,11 @@ def test_directory_serializer_artist(factories): "year": track.album.release_date.year, "contentType": upload.mimetype, "suffix": upload.extension or "", + "path": serializers.get_track_path(track, upload.extension), "bitrate": 42, "duration": 43, "size": 44, - "created": track.creation_date, + "created": serializers.to_subsonic_date(track.creation_date), "albumId": album.pk, "artistId": artist.pk, "parent": artist.pk, diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py index d58cc3932c61a4763db228716e7a418f417a3432..4f22b96ee6a291276d9703d9a87af57a81e391a4 100644 --- a/api/tests/subsonic/test_views.py +++ b/api/tests/subsonic/test_views.py @@ -762,7 +762,8 @@ def test_get_user(f, db, logged_in_api_client, factories): "coverArtRole": "false", "shareRole": "false", "folder": [ - f["id"] for f in serializers.get_folders(logged_in_api_client.user) + {"value": f["id"]} + for f in serializers.get_folders(logged_in_api_client.user) ], } }