From 6d84a814d98c33136e472c819d1550916b5d82f5 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Tue, 15 Oct 2019 15:46:48 +0200 Subject: [PATCH 001/322] Revert "Apply suggestion to api/funkwhale_api/common/filters.py" This reverts commit 4972d760e2809122af54060252be14a638cc87cc. --- api/funkwhale_api/common/filters.py | 34 ++++++++++++++++++ api/funkwhale_api/favorites/filters.py | 4 ++- api/funkwhale_api/history/filters.py | 4 ++- api/funkwhale_api/music/filters.py | 19 ++++++++-- api/funkwhale_api/playlists/filters.py | 3 ++ api/funkwhale_api/radios/filtersets.py | 8 ++++- api/tests/common/test_filters.py | 50 ++++++++++++++++++++++++++ 7 files changed, 117 insertions(+), 5 deletions(-) diff --git a/api/funkwhale_api/common/filters.py b/api/funkwhale_api/common/filters.py index feca948bb..953904bfa 100644 --- a/api/funkwhale_api/common/filters.py +++ b/api/funkwhale_api/common/filters.py @@ -168,3 +168,37 @@ class MutationFilter(filters.FilterSet): class Meta: model = models.Mutation fields = ["is_approved", "is_applied", "type"] + + +class ActorScopeFilter(filters.CharFilter): + def __init__(self, *args, **kwargs): + self.actor_field = kwargs.pop("actor_field") + super().__init__(*args, **kwargs) + + def filter(self, queryset, value): + if not value: + return queryset + + request = getattr(self.parent, "request", None) + if not request: + return queryset.none() + + user = getattr(request, "user", None) + qs = queryset + if value.lower() == "me": + qs = self.filter_me(user=user, queryset=queryset) + elif value.lower() == "all": + return queryset + else: + return queryset.none() + + if self.distinct: + qs = qs.distinct() + return qs + + def filter_me(self, user, queryset): + actor = getattr(user, "actor", None) + if not actor: + return queryset.none() + + return queryset.filter(**{self.actor_field: actor}) diff --git a/api/funkwhale_api/favorites/filters.py b/api/funkwhale_api/favorites/filters.py index cf8048b8d..8a4b91bb2 100644 --- a/api/funkwhale_api/favorites/filters.py +++ b/api/funkwhale_api/favorites/filters.py @@ -1,4 +1,5 @@ from funkwhale_api.common import fields +from funkwhale_api.common import filters as common_filters from funkwhale_api.moderation import filters as moderation_filters from . import models @@ -8,10 +9,11 @@ class TrackFavoriteFilter(moderation_filters.HiddenContentFilterSet): q = fields.SearchFilter( search_fields=["track__title", "track__artist__name", "track__album__title"] ) + scope = common_filters.ActorScopeFilter(actor_field="user__actor", distinct=True) class Meta: model = models.TrackFavorite - fields = ["user", "q"] + fields = ["user", "q", "scope"] hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG[ "TRACK_FAVORITE" ] diff --git a/api/funkwhale_api/history/filters.py b/api/funkwhale_api/history/filters.py index 02549b3b1..16a03204f 100644 --- a/api/funkwhale_api/history/filters.py +++ b/api/funkwhale_api/history/filters.py @@ -1,5 +1,6 @@ import django_filters +from funkwhale_api.common import filters as common_filters from funkwhale_api.moderation import filters as moderation_filters from . import models @@ -8,10 +9,11 @@ from . import models class ListeningFilter(moderation_filters.HiddenContentFilterSet): username = django_filters.CharFilter("user__username") domain = django_filters.CharFilter("user__actor__domain_id") + scope = common_filters.ActorScopeFilter(actor_field="user__actor", distinct=True) class Meta: model = models.Listening hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG[ "LISTENING" ] - fields = ["hidden"] + fields = ["hidden", "scope"] diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py index 44763b966..f5bd17e67 100644 --- a/api/funkwhale_api/music/filters.py +++ b/api/funkwhale_api/music/filters.py @@ -23,12 +23,17 @@ class ArtistFilter(moderation_filters.HiddenContentFilterSet): q = fields.SearchFilter(search_fields=["name"]) playable = filters.BooleanFilter(field_name="_", method="filter_playable") tag = TAG_FILTER + scope = common_filters.ActorScopeFilter( + actor_field="tracks__uploads__library__actor", distinct=True + ) class Meta: model = models.Artist fields = { "name": ["exact", "iexact", "startswith", "icontains"], - "playable": "exact", + "playable": ["exact"], + "scope": ["exact"], + "mbid": ["exact"], } hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ARTIST"] @@ -42,6 +47,9 @@ class TrackFilter(moderation_filters.HiddenContentFilterSet): playable = filters.BooleanFilter(field_name="_", method="filter_playable") tag = TAG_FILTER id = common_filters.MultipleQueryFilter(coerce=int) + scope = common_filters.ActorScopeFilter( + actor_field="uploads__library__actor", distinct=True + ) class Meta: model = models.Track @@ -52,6 +60,8 @@ class TrackFilter(moderation_filters.HiddenContentFilterSet): "artist": ["exact"], "album": ["exact"], "license": ["exact"], + "scope": ["exact"], + "mbid": ["exact"], } hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["TRACK"] @@ -67,6 +77,7 @@ class UploadFilter(filters.FilterSet): album_artist = filters.UUIDFilter("track__album__artist__uuid") library = filters.UUIDFilter("library__uuid") playable = filters.BooleanFilter(field_name="_", method="filter_playable") + scope = common_filters.ActorScopeFilter(actor_field="library__actor", distinct=True) q = fields.SmartSearchFilter( config=search.SearchConfig( search_fields={ @@ -96,6 +107,7 @@ class UploadFilter(filters.FilterSet): "album_artist", "library", "import_reference", + "scope", ] def filter_playable(self, queryset, name, value): @@ -107,10 +119,13 @@ class AlbumFilter(moderation_filters.HiddenContentFilterSet): playable = filters.BooleanFilter(field_name="_", method="filter_playable") q = fields.SearchFilter(search_fields=["title", "artist__name"]) tag = TAG_FILTER + scope = common_filters.ActorScopeFilter( + actor_field="tracks__uploads__library__actor", distinct=True + ) class Meta: model = models.Album - fields = ["playable", "q", "artist"] + fields = ["playable", "q", "artist", "scope", "mbid"] hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ALBUM"] def filter_playable(self, queryset, name, value): diff --git a/api/funkwhale_api/playlists/filters.py b/api/funkwhale_api/playlists/filters.py index b204df4b0..43029a360 100644 --- a/api/funkwhale_api/playlists/filters.py +++ b/api/funkwhale_api/playlists/filters.py @@ -1,6 +1,7 @@ from django.db.models import Count from django_filters import rest_framework as filters +from funkwhale_api.common import filters as common_filters from funkwhale_api.music import utils from . import models @@ -9,6 +10,7 @@ from . import models class PlaylistFilter(filters.FilterSet): q = filters.CharFilter(field_name="_", method="filter_q") playable = filters.BooleanFilter(field_name="_", method="filter_playable") + scope = common_filters.ActorScopeFilter(actor_field="user__actor", distinct=True) class Meta: model = models.Playlist @@ -17,6 +19,7 @@ class PlaylistFilter(filters.FilterSet): "name": ["exact", "icontains"], "q": "exact", "playable": "exact", + "scope": "exact", } def filter_playable(self, queryset, name, value): diff --git a/api/funkwhale_api/radios/filtersets.py b/api/funkwhale_api/radios/filtersets.py index d8d7c9ed0..6f548dbea 100644 --- a/api/funkwhale_api/radios/filtersets.py +++ b/api/funkwhale_api/radios/filtersets.py @@ -1,9 +1,15 @@ import django_filters +from funkwhale_api.common import filters as common_filters from . import models class RadioFilter(django_filters.FilterSet): + scope = common_filters.ActorScopeFilter(actor_field="user__actor", distinct=True) + class Meta: model = models.Radio - fields = {"name": ["exact", "iexact", "startswith", "icontains"]} + fields = { + "name": ["exact", "iexact", "startswith", "icontains"], + "scope": "exact", + } diff --git a/api/tests/common/test_filters.py b/api/tests/common/test_filters.py index 2e89dfa37..138f6ca5d 100644 --- a/api/tests/common/test_filters.py +++ b/api/tests/common/test_filters.py @@ -36,3 +36,53 @@ def test_mutation_filter_is_approved(value, expected, factories): ) assert list(filterset.qs) == [mutations[expected]] + + +@pytest.mark.parametrize( + "scope, user_index, expected_tracks", + [ + ("me", 0, [0]), + ("me", 1, [1]), + ("me", 2, []), + ("all", 0, [0, 1, 2]), + ("all", 1, [0, 1, 2]), + ("all", 2, [0, 1, 2]), + ("noop", 0, []), + ("noop", 1, []), + ("noop", 2, []), + ], +) +def test_actor_scope_filter( + scope, + user_index, + expected_tracks, + queryset_equal_list, + factories, + mocker, + anonymous_user, +): + actor1 = factories["users.User"]().create_actor() + actor2 = factories["users.User"]().create_actor() + users = [actor1.user, actor2.user, anonymous_user] + tracks = [ + factories["music.Upload"](library__actor=actor1, playable=True).track, + factories["music.Upload"](library__actor=actor2, playable=True).track, + factories["music.Upload"](playable=True).track, + ] + + class FS(filters.filters.FilterSet): + scope = filters.ActorScopeFilter( + actor_field="uploads__library__actor", distinct=True + ) + + class Meta: + model = tracks[0].__class__ + fields = ["scope"] + + queryset = tracks[0].__class__.objects.all() + request = mocker.Mock(user=users[user_index]) + filterset = FS({"scope": scope}, queryset=queryset.order_by("id"), request=request) + + expected = [tracks[i] for i in expected_tracks] + + assert filterset.qs == expected From 7253bba70c27a9568b6c621e6efd69c33eefbb21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Ainsworth?= Date: Wed, 16 Oct 2019 14:26:39 +0200 Subject: [PATCH 002/322] Resolve ""Recent" columns on front page no longer display any results" --- changes/changelog.d/948.bugfix | 1 + front/src/components/library/Home.vue | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 changes/changelog.d/948.bugfix diff --git a/changes/changelog.d/948.bugfix b/changes/changelog.d/948.bugfix new file mode 100644 index 000000000..f9deef2f8 --- /dev/null +++ b/changes/changelog.d/948.bugfix @@ -0,0 +1 @@ +Fixed issue with recent results not being loaded from the API (#948) \ No newline at end of file diff --git a/front/src/components/library/Home.vue b/front/src/components/library/Home.vue index eea8f22c5..9ebf84dc3 100644 --- a/front/src/components/library/Home.vue +++ b/front/src/components/library/Home.vue @@ -3,17 +3,17 @@
- +
- +
- +
From 9d26daaa45bbd1488c25fc327094562b4a7118e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Ainsworth?= Date: Thu, 17 Oct 2019 10:18:55 +0200 Subject: [PATCH 003/322] Add new whitlisted class for upward menus --- front/vue.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/vue.config.js b/front/vue.config.js index 7c3d47ef4..b4be29122 100644 --- a/front/vue.config.js +++ b/front/vue.config.js @@ -21,7 +21,7 @@ plugins.push( ]), whitelist: ['scale'], whitelistPatterns:[/plyr/], - whitelistPatternsChildren:[/plyr/] + whitelistPatternsChildren:[/plyr/,/dropdown/,/upward/] }), ) module.exports = { From 11d6c7cf1df0ca2866f1d9cd79da898cbe021b31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Ainsworth?= Date: Thu, 17 Oct 2019 14:15:33 +0200 Subject: [PATCH 004/322] Added placeholders across the application --- changes/changelog.d/750.enhancement | 1 + front/src/components/audio/album/Widget.vue | 9 +- front/src/components/audio/track/Widget.vue | 13 +- front/src/components/favorites/List.vue | 15 +- front/src/components/library/Albums.vue | 17 + front/src/components/library/Artists.vue | 17 + front/src/components/library/Radios.vue | 19 +- .../manage/moderation/DomainsTable.vue | 8 +- .../playlists/PlaceholderWidget.vue | 18 - .../components/playlists/PlaylistModal.vue | 12 + front/src/components/playlists/Widget.vue | 25 +- .../views/content/libraries/FilesTable.vue | 319 +++++++++++------- front/src/views/playlists/Detail.vue | 14 +- front/src/views/playlists/List.vue | 19 +- front/src/views/radios/Detail.vue | 17 +- 15 files changed, 369 insertions(+), 154 deletions(-) create mode 100644 changes/changelog.d/750.enhancement delete mode 100644 front/src/components/playlists/PlaceholderWidget.vue diff --git a/changes/changelog.d/750.enhancement b/changes/changelog.d/750.enhancement new file mode 100644 index 000000000..5c66ad162 --- /dev/null +++ b/changes/changelog.d/750.enhancement @@ -0,0 +1 @@ +Placeholders will now be shown if no content is available across the application (#750) \ No newline at end of file diff --git a/front/src/components/audio/album/Widget.vue b/front/src/components/audio/album/Widget.vue index c9e395f3c..e5ee7f742 100644 --- a/front/src/components/audio/album/Widget.vue +++ b/front/src/components/audio/album/Widget.vue @@ -35,7 +35,14 @@
-
No results matching your query.
+ diff --git a/front/src/components/audio/track/Widget.vue b/front/src/components/audio/track/Widget.vue index 788d279d0..235659564 100644 --- a/front/src/components/audio/track/Widget.vue +++ b/front/src/components/audio/track/Widget.vue @@ -7,7 +7,7 @@ -
+
@@ -51,6 +51,17 @@
+
+
+ + + Nothing found + +
+
+
+
+
diff --git a/front/src/components/favorites/List.vue b/front/src/components/favorites/List.vue index 6402d417f..f7d37369c 100644 --- a/front/src/components/favorites/List.vue +++ b/front/src/components/favorites/List.vue @@ -18,7 +18,7 @@
-
+
@@ -46,7 +46,6 @@
-
+
+
+ + No tracks have been added to your favorites yet +
+ + + Browse the library + +
diff --git a/front/src/components/library/Albums.vue b/front/src/components/library/Albums.vue index 7aad836f0..07e20b22e 100644 --- a/front/src/components/library/Albums.vue +++ b/front/src/components/library/Albums.vue @@ -59,6 +59,23 @@ :key="album.id" :album="album"> +
+
+ + + No results matching your query + +
+ + + + Add some music + + +
+
+
+ + + No results matching your query + +
+ + + + Add some music + + +
+
+
+ + + No results matching your query + +
+ + + + Create a radio + + +
-
+
-
+
@@ -90,6 +90,12 @@
+
+
+ + No interactions with other pods yet +
+
diff --git a/front/src/views/playlists/Detail.vue b/front/src/views/playlists/Detail.vue index 3b6b9000b..5dc99e78b 100644 --- a/front/src/views/playlists/Detail.vue +++ b/front/src/views/playlists/Detail.vue @@ -55,7 +55,6 @@
-
@@ -64,7 +63,6 @@
-
- diff --git a/front/src/views/playlists/List.vue b/front/src/views/playlists/List.vue index 1ff56b5d5..9fb53fd66 100644 --- a/front/src/views/playlists/List.vue +++ b/front/src/views/playlists/List.vue @@ -40,7 +40,24 @@ - + +
+
+ + + No results matching your query + +
+ +
-
+

Tracks

@@ -44,6 +44,21 @@ >
+
+
+ + No tracks have been added to this radio yet +
+ + + Edit… + +
From b9eedbf89f6905ae2d6af7de0724272dbd4c2aa0 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Fri, 25 Oct 2019 11:12:48 +0200 Subject: [PATCH 005/322] Ensure subsonic dates are formatted properly --- api/funkwhale_api/subsonic/serializers.py | 23 ++++++++++++++------ api/tests/subsonic/test_serializers.py | 26 +++++++++++++++++------ 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/api/funkwhale_api/subsonic/serializers.py b/api/funkwhale_api/subsonic/serializers.py index 329c644ee..5a8e7f37e 100644 --- a/api/funkwhale_api/subsonic/serializers.py +++ b/api/funkwhale_api/subsonic/serializers.py @@ -8,6 +8,17 @@ 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_artist_data(artist_values): return { "id": artist_values["id"], @@ -52,7 +63,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: @@ -82,7 +93,7 @@ def get_track_data(album, track, upload): ), "suffix": upload.extension or "", "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 +115,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 +173,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 +190,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), } @@ -221,7 +232,7 @@ def get_music_directory_data(artist): "contentType": upload.mimetype, "suffix": upload.extension or "", "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, diff --git a/api/tests/subsonic/test_serializers.py b/api/tests/subsonic/test_serializers.py index d6025a90b..9998e6ef2 100644 --- a/api/tests/subsonic/test_serializers.py +++ b/api/tests/subsonic/test_serializers.py @@ -1,9 +1,21 @@ +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 + + def test_get_artists_serializer(factories): artist1 = factories["music.Artist"](name="eliot") artist2 = factories["music.Artist"](name="Ellena") @@ -54,7 +66,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 +108,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": [ @@ -115,7 +127,7 @@ def test_get_album_serializer(factories): "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 +145,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 +174,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 +193,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() @@ -213,7 +225,7 @@ def test_directory_serializer_artist(factories): "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, From d1b1f116276e8de411f354523fe5a331b44068c8 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Fri, 25 Oct 2019 11:53:03 +0200 Subject: [PATCH 006/322] Added missing path attribute to subsonic payloads --- api/funkwhale_api/subsonic/serializers.py | 25 +++++++++++ api/tests/subsonic/test_serializers.py | 52 +++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/api/funkwhale_api/subsonic/serializers.py b/api/funkwhale_api/subsonic/serializers.py index 5a8e7f37e..8e881f24d 100644 --- a/api/funkwhale_api/subsonic/serializers.py +++ b/api/funkwhale_api/subsonic/serializers.py @@ -19,6 +19,29 @@ def to_subsonic_date(date): 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"], @@ -92,6 +115,7 @@ 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": to_subsonic_date(track.creation_date), "albumId": album.pk, @@ -231,6 +255,7 @@ 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": to_subsonic_date(track.creation_date), "albumId": album.pk, diff --git a/api/tests/subsonic/test_serializers.py b/api/tests/subsonic/test_serializers.py index 9998e6ef2..4da84ec35 100644 --- a/api/tests/subsonic/test_serializers.py +++ b/api/tests/subsonic/test_serializers.py @@ -16,6 +16,56 @@ 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") @@ -124,6 +174,7 @@ 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, @@ -222,6 +273,7 @@ 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, From 0ecdd7c0fba3a55575545a2a98b50a98d70a62d6 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Fri, 25 Oct 2019 11:58:58 +0200 Subject: [PATCH 007/322] Fixed broken getUser subsonic endpoint --- api/funkwhale_api/subsonic/serializers.py | 2 +- api/tests/subsonic/test_views.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/api/funkwhale_api/subsonic/serializers.py b/api/funkwhale_api/subsonic/serializers.py index 8e881f24d..cae99e242 100644 --- a/api/funkwhale_api/subsonic/serializers.py +++ b/api/funkwhale_api/subsonic/serializers.py @@ -295,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_views.py b/api/tests/subsonic/test_views.py index d58cc3932..4f22b96ee 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) ], } } From 72c7f9aa46125440e8c042874b54eae122a0203e Mon Sep 17 00:00:00 2001 From: Keunes Date: Wed, 16 Oct 2019 20:57:46 +0000 Subject: [PATCH 008/322] Translated using Weblate (Dutch) Currently translated at 78.6% (750 of 954 strings) Translation: Funkwhale/Funkwhale's server front-end Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/nl/ --- front/locales/nl/LC_MESSAGES/app.po | 140 +++++++++++----------------- 1 file changed, 56 insertions(+), 84 deletions(-) diff --git a/front/locales/nl/LC_MESSAGES/app.po b/front/locales/nl/LC_MESSAGES/app.po index 751fb7226..502956a5c 100644 --- a/front/locales/nl/LC_MESSAGES/app.po +++ b/front/locales/nl/LC_MESSAGES/app.po @@ -8,15 +8,15 @@ msgstr "" "Project-Id-Version: front 0.1.0\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2019-10-01 15:15+0200\n" -"PO-Revision-Date: 2019-10-14 10:14+0000\n" -"Last-Translator: Ezra \n" +"PO-Revision-Date: 2019-10-18 08:59+0000\n" +"Last-Translator: Keunes \n" "Language-Team: none\n" "Language: nl\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 3.7\n" +"X-Generator: Weblate 3.9\n" #: front/src/components/playlists/PlaylistModal.vue:9 msgctxt "Popup/Playlist/Paragraph" @@ -111,7 +111,6 @@ msgstr[0] "% { count } nummer komt overeen met de gecombineerde filters" msgstr[1] "% { count } nummers komen overeen met de gecombineerde filters" #: front/src/components/About.vue:185 -#, fuzzy msgctxt "Content/Home/Stat" msgid "%{ count } tracks" msgid_plural "%{ count } tracks" @@ -202,7 +201,6 @@ msgid "90 days" msgstr "90 dagen" #: front/src/components/library/FileUpload.vue:264 -#, fuzzy msgctxt "Content/Library/Help text" msgid "A network error occurred while uploading this file" msgstr "Er is een netwerkfout opgetreden tijdens het uploaden van dit bestand" @@ -621,7 +619,6 @@ msgstr "" "Er is een HTTP-fout opgetreden bij het contacteren van de externe server" #: front/src/components/library/ImportStatusModal.vue:145 -#, fuzzy msgctxt "Popup/Import/Error.Label" msgid "An unknown error occurred" msgstr "Er is een onbekende fout opgetreden" @@ -1491,7 +1488,6 @@ msgstr "Subsonic-API-toegang uitschakelen?" #: front/src/components/manage/moderation/InstancePolicyForm.vue:18 #: front/src/views/admin/moderation/AccountsDetail.vue:157 #: front/src/views/admin/moderation/AccountsDetail.vue:161 -#, fuzzy msgctxt "*/*/*/State of feature" msgid "Disabled" msgstr "Uitgeschakeld" @@ -1532,7 +1528,6 @@ msgid "Do not download any media file (audio, album cover, account avatar…) fr msgstr "Geen mediabestanden (audio, albumhoes, profielfoto…) downloaden van dit account of domein. Hiermee wordt ook bestaande content verwijderd." #: front/src/views/Notifications.vue:36 -#, fuzzy msgctxt "Content/Notifications/Header" msgid "Do you like Funkwhale?" msgstr "Vind je Funkwhale leuk?" @@ -1762,7 +1757,6 @@ msgstr "Uitgezonden berichten" #: front/src/components/manage/moderation/InstancePolicyForm.vue:17 #: front/src/views/admin/moderation/AccountsDetail.vue:156 #: front/src/views/admin/moderation/AccountsDetail.vue:160 -#, fuzzy msgctxt "*/*/*/State of feature" msgid "Enabled" msgstr "Ingeschakeld" @@ -1990,7 +1984,6 @@ msgid "Failed" msgstr "Mislukt" #: front/src/views/content/remote/Card.vue:75 -#, fuzzy msgctxt "Content/Library/Card.List item/Noun" msgid "Failed tracks:" msgstr "Mislukte nummers:" @@ -1998,7 +1991,6 @@ msgstr "Mislukte nummers:" #: front/src/views/admin/library/AlbumDetail.vue:171 #: front/src/views/admin/library/ArtistDetail.vue:159 #: front/src/views/admin/library/TrackDetail.vue:223 -#, fuzzy msgctxt "*/*/*" msgid "Favorited tracks" msgstr "Favoriete nummers" @@ -2027,7 +2019,6 @@ msgid "Field" msgstr "Veld" #: front/src/components/library/FileUpload.vue:93 -#, fuzzy msgctxt "Content/Library/Table.Label" msgid "Filename" msgstr "Bestandsnaam" @@ -2229,10 +2220,9 @@ msgstr "Als je applicaties van derden toegang hebt gegeven tot je data, vind je #: front/src/components/mixins/Translations.vue:46 #: front/src/components/mixins/Translations.vue:47 -#, fuzzy msgctxt "Content/Moderation/Dropdown" msgid "Illegal content" -msgstr "illegale inhoud" +msgstr "Illegale inhoud" #: front/src/components/library/ImportStatusModal.vue:3 msgctxt "Popup/Import/Title" @@ -2397,7 +2387,6 @@ msgid "Keyboard shortcuts" msgstr "Sneltoetsen" #: front/src/views/admin/moderation/DomainsDetail.vue:198 -#, fuzzy msgctxt "Content/Moderation/Table.Label.Link" msgid "Known accounts" msgstr "Bekende accounts" @@ -2458,7 +2447,6 @@ msgid "Leave empty for a random code" msgstr "Laat leeg om een willekeurig code te krijgen" #: front/src/components/audio/EmbedWizard.vue:17 -#, fuzzy msgctxt "Popup/Embed/Paragraph" msgid "Leave empty for a responsive widget" msgstr "Laat leeg voor een responsieve widget" @@ -2519,7 +2507,6 @@ msgid "Library deleted" msgstr "Bibliotheek verwijderd" #: front/src/views/admin/library/EditsList.vue:4 -#, fuzzy msgctxt "Content/Admin/Title/Noun" msgid "Library edits" msgstr "Bibliotheekbewerkingen" @@ -2939,13 +2926,12 @@ msgstr "Nee" #: front/src/components/audio/Search.vue:25 msgctxt "Content/Search/Paragraph" msgid "No album matched your query" -msgstr "Geen albums kwamen overeen met uw zoekopdracht" +msgstr "Geen albums gevonden voor je zoekopdracht" #: front/src/components/audio/Search.vue:16 -#, fuzzy msgctxt "Content/Search/Paragraph" msgid "No artist matched your query" -msgstr "Geen enkele artiest komt overeen met uw zoekopdracht" +msgstr "Geen artiesten gevonden voor je zoekopdracht" #: front/src/components/library/TrackDetail.vue:14 msgctxt "Content/Track/Table.Paragraph" @@ -3013,7 +2999,6 @@ msgstr "Meldingen" #: front/src/components/mixins/Translations.vue:47 #: front/src/components/mixins/Translations.vue:48 -#, fuzzy msgctxt "Content/Moderation/Dropdown" msgid "Offensive content" msgstr "Aanstootgevende inhoud" @@ -3044,11 +3029,12 @@ msgid "Open" msgstr "Open" #: front/src/components/library/ImportStatusModal.vue:56 +#, fuzzy msgctxt "Popup/Import/Table.Label/Value" msgid "Open a support thread (include the debug information below in your message)" msgstr "" -"Open een ondersteuningsthread (voeg de foutopsporingsinformatie vanonder toe " -"in uw bericht)" +"Open een ondersteuningsthread (voeg de foutopsporingsinformatie toe onderaan " +"je bericht)" #: front/src/components/library/AlbumBase.vue:87 #: front/src/components/library/ArtistBase.vue:99 @@ -3095,10 +3081,9 @@ msgid "Open website" msgstr "Open website" #: front/src/components/manage/moderation/InstancePolicyForm.vue:40 -#, fuzzy msgctxt "Content/Moderation/Card.Title" msgid "Or customize your rule" -msgstr "Of pas uw regel aan" +msgstr "Of pas de regel aan" #: front/src/components/favorites/List.vue:32 #: src/components/library/Radios.vue:41 @@ -3238,10 +3223,9 @@ msgid "Pending review" msgstr "Wachtend op beoordeling" #: front/src/components/Sidebar.vue:233 -#, fuzzy msgctxt "Sidebar/Moderation/Hidden text" msgid "Pending review edits" -msgstr "In behandeling zijnde verzoeken" +msgstr "Openstaande bewerkingen" #: front/src/components/auth/Settings.vue:176 #: front/src/components/manage/users/UsersTable.vue:42 @@ -3359,8 +3343,8 @@ msgstr "Afspeellijsten" msgctxt "Content/Embed/Message" msgid "Please contact your admins and ask them to update the corresponding setting." msgstr "" -"Gelieve uw beheerders te contacteren en hen te vragen om de bijbehorende " -"instelling bij te werken." +"Neem alsjeblieft contact op met de beheerders om te vragen de relevante " +"instellingen aan te passen." #: front/src/components/auth/Settings.vue:79 msgctxt "Content/Settings/Error message.List item/Call to action" @@ -3383,7 +3367,6 @@ msgid "Pod configuration" msgstr "Pod-configuratie" #: front/src/views/admin/library/TrackDetail.vue:143 src/edits.js:70 -#, fuzzy msgctxt "*/*/*/Short, Noun" msgid "Position" msgstr "Positie" @@ -3433,7 +3416,6 @@ msgstr "Aan het verwerken" #: front/src/components/mixins/Translations.vue:80 #: front/src/components/mixins/Translations.vue:81 -#, fuzzy msgctxt "Content/OAuth Scopes/Label" msgid "Profile" msgstr "Profiel" @@ -3518,7 +3500,7 @@ msgstr "Radio's" #: front/src/components/auth/ApplicationForm.vue:151 msgctxt "Content/OAuth Scopes/Label/Verb" msgid "Read" -msgstr "" +msgstr "Lezen" #: front/src/components/library/ImportStatusModal.vue:51 msgctxt "Popup/Import/Table.Label/Value" @@ -3545,25 +3527,24 @@ msgstr "Reden" #: front/src/views/admin/moderation/DomainsDetail.vue:216 msgctxt "Content/Moderation/Table.Label/Noun" msgid "Received library follows" -msgstr "" +msgstr "Bibliotheek-volgers" #: front/src/components/manage/moderation/DomainsTable.vue:48 #: front/src/components/mixins/Translations.vue:74 #: front/src/components/mixins/Translations.vue:75 msgctxt "Content/Moderation/*/Noun" msgid "Received messages" -msgstr "" +msgstr "Ontvangen berichten" #: front/src/components/library/EditForm.vue:27 -#, fuzzy msgctxt "Content/Library/Paragraph" msgid "Recent edits" -msgstr "Recent toegevoegd" +msgstr "Recente bewerkingen" #: front/src/components/library/EditForm.vue:17 msgctxt "Content/Library/Paragraph" msgid "Recent edits awaiting review" -msgstr "" +msgstr "Recente bewerking wachtend op een beoordeling" #: front/src/components/library/Home.vue:24 msgctxt "Content/Home/Title" @@ -3571,10 +3552,9 @@ msgid "Recently added" msgstr "Recent toegevoegd" #: front/src/components/Home.vue:167 -#, fuzzy msgctxt "Content/Home/Title" msgid "Recently added albums" -msgstr "Recent toegevoegd" +msgstr "Recent toegevoegde albums" #: front/src/components/library/Home.vue:11 msgctxt "Content/Home/Title" @@ -3587,9 +3567,10 @@ msgid "Recently listened" msgstr "Recent beluisterd" #: front/src/components/auth/ApplicationForm.vue:13 +#, fuzzy msgctxt "Content/Applications/Input.Label/Noun" msgid "Redirect URI" -msgstr "" +msgstr "Redirect URI" #: front/src/components/auth/Settings.vue:125 #: src/components/auth/Settings.vue:170 @@ -3600,28 +3581,26 @@ msgid "Refresh" msgstr "Verversen" #: front/src/components/federation/FetchButton.vue:20 -#, fuzzy msgctxt "Popup/*/Message.Title" msgid "Refresh error" -msgstr "Verversen" +msgstr "Fout bij vernieuwen" #: front/src/views/admin/library/AlbumDetail.vue:56 #: front/src/views/admin/library/ArtistDetail.vue:54 #: front/src/views/admin/library/TrackDetail.vue:55 msgctxt "Content/Moderation/Button/Verb" msgid "Refresh from remote server" -msgstr "" +msgstr "Vernieuwd door externe server" #: front/src/views/admin/moderation/DomainsDetail.vue:164 msgctxt "Content/Moderation/Button.Label/Verb" msgid "Refresh node info" -msgstr "" +msgstr "Vernieuw serverinformatie" #: front/src/components/federation/FetchButton.vue:79 -#, fuzzy msgctxt "Popup/*/Message.Title" msgid "Refresh pending" -msgstr "Aflopend" +msgstr "Vernieuwing in afwachting" #: front/src/components/federation/FetchButton.vue:80 msgctxt "Popup/*/Message.Content" @@ -3629,20 +3608,19 @@ msgid "Refresh request wasn't proceed in time by our server. It will be processe msgstr "" #: front/src/components/federation/FetchButton.vue:16 -#, fuzzy msgctxt "Popup/*/Message.Title" msgid "Refresh successful" -msgstr "Scannen afgerond" +msgstr "Vernieuwen afgerond" #: front/src/components/common/ActionTable.vue:275 msgctxt "Content/*/Button.Tooltip/Verb" msgid "Refresh table content" -msgstr "" +msgstr "Vernieuw inhoud tabel" #: front/src/components/federation/FetchButton.vue:12 msgctxt "Popup/*/Message.Title" msgid "Refresh was skipped" -msgstr "" +msgstr "Vernieuwen overgeslagen" #: front/src/components/federation/FetchButton.vue:7 msgctxt "Popup/*/Title" @@ -3656,10 +3634,9 @@ msgid "Registrations" msgstr "Administratie" #: front/src/components/manage/users/UsersTable.vue:72 -#, fuzzy msgctxt "Content/Admin/Table, User role" msgid "Regular user" -msgstr "standaard gebruiker" +msgstr "Standaardgebruiker" #: front/src/components/library/EditCard.vue:87 #: front/src/views/content/libraries/Detail.vue:51 @@ -3669,10 +3646,9 @@ msgstr "Afkeuren" #: front/src/components/manage/moderation/InstancePolicyCard.vue:32 #: front/src/components/manage/moderation/InstancePolicyForm.vue:123 -#, fuzzy msgctxt "Content/Moderation/*/Verb" msgid "Reject media" -msgstr "Afgekeurd" +msgstr "Media afkeuren" #: front/src/components/library/EditCard.vue:33 #: front/src/components/manage/library/EditsCardList.vue:24 @@ -3684,20 +3660,19 @@ msgstr "Afgekeurd" #: front/src/components/manage/library/AlbumsTable.vue:43 #: front/src/components/mixins/Translations.vue:54 src/edits.js:43 #: front/src/components/mixins/Translations.vue:55 -#, fuzzy msgctxt "Content/*/*/Noun" msgid "Release date" -msgstr "Benaderd op" +msgstr "Publicatiedatum" #: front/src/components/library/FileUpload.vue:63 msgctxt "Content/Library/Paragraph" msgid "Remaining storage space" -msgstr "" +msgstr "Resterende opslagruimte" #: front/src/views/Notifications.vue:18 src/views/Notifications.vue:52 msgctxt "Content/Notifications/Label" msgid "Remind me in:" -msgstr "" +msgstr "Herinner me over:" #: front/src/views/content/remote/Home.vue:6 msgctxt "Content/Library/Title/Noun" @@ -3720,17 +3695,15 @@ msgid "Remove avatar" msgstr "Gebruikersafbeelding verwijderen" #: front/src/components/library/ArtistDetail.vue:12 -#, fuzzy msgctxt "Content/Moderation/Button.Label" msgid "Remove filter" -msgstr "Gebruikersafbeelding verwijderen" +msgstr "Filter verwijderen" #: front/src/components/manage/moderation/DomainsTable.vue:198 #: front/src/views/admin/moderation/DomainsDetail.vue:39 -#, fuzzy msgctxt "Content/Moderation/Action/Verb" msgid "Remove from allow-list" -msgstr "Verwijderen uit favorieten" +msgstr "Verwijder van toelatingslijst" #: front/src/components/favorites/TrackFavoriteIcon.vue:26 msgctxt "Content/Track/Icon.Tooltip/Verb" @@ -3741,91 +3714,90 @@ msgstr "Verwijderen uit favorieten" msgctxt "Popup/Library/Paragraph" msgid "Removes uploaded but yet to be processed tracks completely, adding the corresponding data to your quota." msgstr "" +"Alle nummers die geüpload maar nog niet verwerkt zijn, worden verwijderd. De " +"bestanden worden gewist en je krijgt de bijbehorende opslagruimte terug." #: front/src/views/content/libraries/Quota.vue:64 -#, fuzzy msgctxt "Popup/Library/Paragraph" msgid "Removes uploaded tracks skipped during the import processes completely, adding the corresponding data to your quota." -msgstr "Alle nummers die bij het importeren zijn overgeslagen (om wat voor reden dan ook), worden verwijderd. De bestanden worden volledig verwijderd en je krijgt het bijbehorende quotum terug." +msgstr "" +"Alle nummers die bij het importeren zijn overgeslagen, worden verwijderd. De " +"bestanden worden gewist en je krijgt de bijbehorende opslagruimte terug." #: front/src/views/content/libraries/Quota.vue:90 -#, fuzzy msgctxt "Popup/Library/Paragraph" msgid "Removes uploaded tracks that could not be processed by the server completely, adding the corresponding data to your quota." -msgstr "Alle nummers die nog niet verwerkt zijn, worden verwijderd. De bestanden worden volledig verwijderd en je krijgt het bijbehorende quotum terug." +msgstr "" +"Alle nummers die zijn geüpload maar niet volledig door de server verwerkt " +"konden worden, worden verwijderd. De bestanden worden gewist en je krijgt de " +"bijbehorende opslagruimte terug." #: front/src/components/audio/PlayButton.vue:94 -#, fuzzy msgctxt "*/Queue/Dropdown/Button/Title" msgid "Replace current queue" -msgstr "Aan huidige wachtrij toevoegen" +msgstr "Huidige wachtrij vervangen" #: front/src/components/mixins/Report.vue:6 src/components/mixins/Report.vue:7 msgctxt "*/Moderation/*/Verb" msgid "Report @%{ username }…" -msgstr "" +msgstr "@%{ username } melden…" #: front/src/components/manage/moderation/ReportCard.vue:5 msgctxt "Content/Moderation/Card/Short" msgid "Report %{ id }" -msgstr "" +msgstr "Melding %{id}" #: front/src/components/moderation/ReportModal.vue:139 msgctxt "*/Moderation/Message" msgid "Report successfully submitted, thank you" -msgstr "" +msgstr "Melding succesvol ingediend, dankjewel" #: front/src/components/mixins/Report.vue:36 #: src/components/mixins/Report.vue:37 -#, fuzzy msgctxt "*/Moderation/*/Verb" msgid "Report this album…" -msgstr "Dit album bewerken" +msgstr "Dit album rapporteren…" #: front/src/components/mixins/Report.vue:50 #: src/components/mixins/Report.vue:51 -#, fuzzy msgctxt "*/Moderation/*/Verb" msgid "Report this artist…" -msgstr "Toevoegen aan deze afspeellijst" +msgstr "Deze artiest rapporteren…" #: front/src/components/mixins/Report.vue:72 #: src/components/mixins/Report.vue:73 -#, fuzzy msgctxt "*/Moderation/*/Verb" msgid "Report this library…" -msgstr "Deze bibliotheek verwijderen?" +msgstr "Deze bibliotheek rapporteren…" #: front/src/components/mixins/Report.vue:61 #: src/components/mixins/Report.vue:62 -#, fuzzy msgctxt "*/Moderation/*/Verb" msgid "Report this playlist…" -msgstr "Toevoegen aan deze afspeellijst" +msgstr "Deze afspeellijst rapporteren…" #: front/src/components/mixins/Report.vue:23 #: src/components/mixins/Report.vue:24 -#, fuzzy msgctxt "*/Moderation/*/Verb" msgid "Report this track…" -msgstr "Instantie-informatie bewerken" +msgstr "Dit nummer rapporteren…" #: front/src/components/audio/PlayButton.vue:95 msgctxt "*/Moderation/*/Button/Label,Verb" msgid "Report…" -msgstr "" +msgstr "Rapporteren…" #: front/src/components/manage/moderation/ReportCard.vue:117 msgctxt "Content/*/*/Short" msgid "Reported object" -msgstr "" +msgstr "Gemeldde object" #: front/src/views/admin/moderation/Base.vue:5 #: front/src/views/admin/moderation/ReportsList.vue:3 #: front/src/views/admin/moderation/ReportsList.vue:208 msgctxt "*/Moderation/*/Noun" msgid "Reports" -msgstr "" +msgstr "Meldingen" #: front/src/components/auth/SubsonicTokenForm.vue:38 #: front/src/components/auth/SubsonicTokenForm.vue:41 From 8d0e6a2918f8bcfb3817a3e22ce569e330aa4e8e Mon Sep 17 00:00:00 2001 From: Daniele Lira Mereb Date: Wed, 16 Oct 2019 20:20:12 +0000 Subject: [PATCH 009/322] Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (954 of 954 strings) Translation: Funkwhale/Funkwhale's server front-end Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/pt_BR/ --- front/locales/pt_BR/LC_MESSAGES/app.po | 197 +++++++++++++++---------- 1 file changed, 122 insertions(+), 75 deletions(-) diff --git a/front/locales/pt_BR/LC_MESSAGES/app.po b/front/locales/pt_BR/LC_MESSAGES/app.po index c8fc2c6ef..363d30e2c 100644 --- a/front/locales/pt_BR/LC_MESSAGES/app.po +++ b/front/locales/pt_BR/LC_MESSAGES/app.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: front 0.1.0\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2019-10-01 15:15+0200\n" -"PO-Revision-Date: 2019-10-15 08:43+0000\n" +"PO-Revision-Date: 2019-10-18 08:59+0000\n" "Last-Translator: Daniele Lira Mereb \n" "Language-Team: none\n" "Language: pt_BR\n" @@ -16,7 +16,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n > 1;\n" -"X-Generator: Weblate 3.7\n" +"X-Generator: Weblate 3.9\n" #: front/src/components/playlists/PlaylistModal.vue:9 msgctxt "Popup/Playlist/Paragraph" @@ -77,8 +77,8 @@ msgstr[1] "%{ count } horas de músicas" msgctxt "Content/Home/Stat" msgid "%{ count } listenings" msgid_plural "%{ count } listenings" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "%{ count } áudio" +msgstr[1] "%{ count } áudios" #: front/src/components/common/ActionTable.vue:68 msgctxt "Content/*/Paragraph" @@ -295,7 +295,7 @@ msgstr "Ver seguidos" #: front/src/components/mixins/Translations.vue:94 msgctxt "Content/OAuth Scopes/Paragraph" msgid "Access to listening history" -msgstr "Ver histórico de escutadas" +msgstr "Ver histórico de escutados" #: front/src/components/mixins/Translations.vue:113 #: front/src/components/mixins/Translations.vue:114 @@ -629,6 +629,7 @@ msgstr "Acesso anônimo" msgctxt "Popup/Moderation/Error message" msgid "Anonymous reports are disabled, please sign-in to submit a report." msgstr "" +"Denúncias anônimas estão desativadas, entre na sua conta para denunciar." #: front/src/components/auth/Settings.vue:175 #: src/components/auth/Settings.vue:225 @@ -752,7 +753,7 @@ msgstr "Solicitar redefinição de senha" #: front/src/components/manage/moderation/ReportCard.vue:72 msgctxt "Content/Moderation/*" msgid "Assigned to" -msgstr "" +msgstr "Atribuído a" #: front/src/views/admin/library/AlbumDetail.vue:214 #: front/src/views/admin/library/ArtistDetail.vue:202 @@ -926,7 +927,7 @@ msgstr "Não é possível enviar este arquivo, certifique-se de que não é gran #: front/src/components/mixins/Translations.vue:43 msgctxt "*/*/*" msgid "Category" -msgstr "" +msgstr "Categoria" #: front/src/components/Footer.vue:21 msgctxt "Footer/Settings/Dropdown.Label/Short, Verb" @@ -946,7 +947,7 @@ msgstr "Alterar senha" #: front/src/components/Footer.vue:37 msgctxt "Footer/Settings/Dropdown.Label/Short, Verb" msgid "Change theme" -msgstr "" +msgstr "Mudar tema" #: front/src/views/auth/PasswordResetConfirm.vue:62 msgctxt "*/Signup/Title" @@ -1075,7 +1076,7 @@ msgstr "Código de confirmação" #: front/src/components/About.vue:67 src/components/Home.vue:65 msgctxt "Content/Home/Header/Name" msgid "Contact" -msgstr "" +msgstr "Contato" #: front/src/components/moderation/FilterModal.vue:90 msgctxt "*/Moderation/Message" @@ -1253,7 +1254,7 @@ msgstr "Uso atual" #: front/src/components/Footer.vue:94 msgctxt "Footer/Settings/Dropdown.Label/Theme name" msgid "Dark" -msgstr "" +msgstr "Escuro" #: front/src/components/federation/FetchButton.vue:53 msgctxt "*/*/Error" @@ -1358,12 +1359,12 @@ msgstr "Excluir rádio" #: front/src/components/manage/moderation/ReportCard.vue:357 msgctxt "Content/Moderation/Button/Verb" msgid "Delete reported object" -msgstr "" +msgstr "Excluir objeto denunciado" #: front/src/components/manage/moderation/ReportCard.vue:358 msgctxt "Content/Moderation/Popup/Header" msgid "Delete reported object?" -msgstr "" +msgstr "Excluir objeto denunciado?" #: front/src/views/admin/library/AlbumDetail.vue:79 #: front/src/views/admin/library/TrackDetail.vue:78 @@ -1432,6 +1433,7 @@ msgstr "Descendente" msgctxt "Content/Moderation/Placeholder" msgid "Describe what actions have been taken, or any other related updates…" msgstr "" +"Descreva que ações foram tomadas, ou alguma outra atualização relacionada…" #: front/src/components/library/radios/Builder.vue:25 #: front/src/views/admin/library/LibraryDetail.vue:132 @@ -1491,6 +1493,7 @@ msgstr "Número do disco" msgctxt "Content/Home/Link" msgid "Discover everything you need to know about Funkwhale and its features" msgstr "" +"Descubra tudo o que você precisa saber sobre Funkwhale e suas funcionalidades" #: front/src/components/auth/SubsonicTokenForm.vue:13 msgctxt "Content/Settings/Link" @@ -1500,7 +1503,7 @@ msgstr "Aprenda a usar o Funkwhale em outros aplicativos" #: front/src/views/Notifications.vue:45 msgctxt "Content/Notifications/Button.Label/Verb" msgid "Discover other ways to help" -msgstr "" +msgstr "Descubra outras maneiras de ajudar" #: front/src/views/admin/moderation/AccountsDetail.vue:132 msgctxt "'Content/*/*/Noun'" @@ -1562,7 +1565,7 @@ msgstr[1] "Você deseja executar %{ action } em %{ count } elementos?" #: front/src/components/moderation/ReportModal.vue:3 msgctxt "Popup/Moderation/Title/Verb" msgid "Do you want to report this object?" -msgstr "Você deseja denunciar este elemento?" +msgstr "Você deseja denunciar este objeto?" #: front/src/components/Sidebar.vue:122 msgctxt "Sidebar/Queue/Message" @@ -1604,7 +1607,7 @@ msgstr "Domínios" #: front/src/views/Notifications.vue:42 msgctxt "Content/Notifications/Button.Label/Verb" msgid "Donate" -msgstr "" +msgstr "Doe" #: front/src/components/library/TrackBase.vue:37 #: front/src/views/admin/library/UploadDetail.vue:58 @@ -1784,7 +1787,7 @@ msgstr "Insira o nome da lista…" #: front/src/views/auth/PasswordReset.vue:54 msgctxt "Content/Signup/Input.Placeholder" msgid "Enter the email address linked to your account" -msgstr "Insira o e-mail associado à sua conta" +msgstr "Insira o e-mail vinculado à sua conta" #: front/src/components/auth/SignupForm.vue:96 msgctxt "Content/Signup/Form/Placeholder" @@ -1943,7 +1946,7 @@ msgstr "Excluir" #: front/src/components/common/CollapseLink.vue:2 msgctxt "*/*/Button,Label" msgid "Expand" -msgstr "" +msgstr "Expandir" #: front/src/components/manage/users/InvitationsTable.vue:41 #: front/src/components/mixins/Translations.vue:59 @@ -2053,7 +2056,7 @@ msgstr "Data do primeiro acesso" #: front/src/components/ShortcutsModal.vue:64 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Focus searchbar" -msgstr "" +msgstr "Foco na barra de pesquisa" #: front/src/views/content/remote/Card.vue:100 msgctxt "Content/Library/Card.Button.Label/Verb" @@ -2115,7 +2118,7 @@ msgstr "" #: front/src/components/About.vue:78 msgctxt "*/*/*" msgid "Funkwhale version" -msgstr "" +msgstr "Versão do Funkwhale" #: front/src/components/ShortcutsModal.vue:56 msgctxt "Popup/Keyboard shortcuts/Title" @@ -2152,7 +2155,7 @@ msgstr "Ir à página inicial" #: front/src/views/Notifications.vue:27 src/views/Notifications.vue:61 msgctxt "Content/Notifications/Button.Label" msgid "Got it!" -msgstr "" +msgstr "Entendi!" #: front/src/components/auth/Settings.vue:128 msgctxt "Content/Settings/Title" @@ -2162,7 +2165,7 @@ msgstr "Artistas ocultados" #: front/src/components/About.vue:155 msgctxt "*/*/*/Verb" msgid "Hide" -msgstr "" +msgstr "Ocultar" #: front/src/components/manage/moderation/InstancePolicyForm.vue:114 msgctxt "Content/Moderation/Help text" @@ -2198,7 +2201,7 @@ msgstr "Porém, para acessar o Funkwhale a partir desses clientes você precisa msgctxt "Content/Signup/Paragraph" msgid "If the email address provided in the previous step is valid and linked to a user account, you should receive an email with reset instructions in the next couple of minutes." msgstr "" -"Se o e-mail inserido anteriormente for válido e associado à uma conta de " +"Se o e-mail inserido anteriormente for válido e vinculado à uma conta de " "usuário, você receberá um e-mail com instruções de redefinição nos próximos " "minutos." @@ -2261,7 +2264,7 @@ msgstr "Nos favoritos" #: front/src/components/moderation/FilterModal.vue:25 msgctxt "Popup/Moderation/List item" msgid "In other users favorites and listening history" -msgstr "Nos favoritos e histórico de escutadas de outros usuários" +msgstr "Nos favoritos e histórico de escutados de outros usuários" #: front/src/components/moderation/FilterModal.vue:28 msgctxt "Popup/Moderation/List item" @@ -2320,7 +2323,7 @@ msgstr "Link da instância" #: front/src/components/manage/moderation/ReportCard.vue:203 msgctxt "Content/*/*/Noun" msgid "Internal notes" -msgstr "" +msgstr "Observações internas" #: front/src/components/library/FileUpload.vue:268 msgctxt "Content/Library/Help text" @@ -2366,7 +2369,7 @@ msgstr "Não foi possível entrar neste link" #: front/src/components/mixins/Translations.vue:66 msgctxt "*/*/*/Noun" msgid "Items" -msgstr "" +msgstr "Itens" #: front/src/components/Footer.vue:33 src/components/ShortcutsModal.vue:3 msgctxt "*/*/*/Noun" @@ -2442,7 +2445,7 @@ msgstr "Deixar vazio para um widget responsivo" #: front/src/components/mixins/Translations.vue:65 msgctxt "*/*/*/Noun" msgid "Length" -msgstr "" +msgstr "Tamanho" #: front/src/views/admin/library/AlbumDetail.vue:248 #: front/src/views/admin/library/ArtistDetail.vue:236 @@ -2514,7 +2517,7 @@ msgstr "Licença" #: front/src/components/Footer.vue:90 msgctxt "Footer/Settings/Dropdown.Label/Theme name" msgid "Light" -msgstr "" +msgstr "Claro" #: front/src/views/admin/library/AlbumDetail.vue:188 #: front/src/views/admin/library/ArtistDetail.vue:176 @@ -2523,12 +2526,12 @@ msgstr "" #: front/src/views/admin/moderation/AccountsDetail.vue:269 msgctxt "Content/Moderation/Table.Label/Noun" msgid "Linked reports" -msgstr "" +msgstr "Denúncias vinculadas" #: front/src/components/Home.vue:135 msgctxt "Content/Home/Link" msgid "Listen to public albums and playlists shared on this pod" -msgstr "" +msgstr "Ouça álbuns públicos e listas compartilhados neste pod" #: front/src/components/mixins/Translations.vue:92 #: front/src/views/admin/library/AlbumDetail.vue:163 @@ -2537,7 +2540,7 @@ msgstr "" #: front/src/components/mixins/Translations.vue:93 msgctxt "*/*/*/Noun" msgid "Listenings" -msgstr "Escutadas" +msgstr "Escutados" #: front/src/components/audio/track/Table.vue:25 #: front/src/components/library/ArtistDetail.vue:28 @@ -2724,7 +2727,7 @@ msgstr "Membro desde %{ date }" #: front/src/components/moderation/ReportModal.vue:41 msgctxt "*/*/Field.Label/Noun" msgid "Message" -msgstr "" +msgstr "Mensagem" #: front/src/components/Footer.vue:32 msgctxt "Footer/*/List item.Link" @@ -2879,7 +2882,7 @@ msgstr "Nome" #: front/src/views/Notifications.vue:24 src/views/Notifications.vue:58 msgctxt "*/*/*" msgid "Never" -msgstr "" +msgstr "Nunca" #: front/src/components/auth/Settings.vue:88 #: front/src/views/auth/PasswordResetConfirm.vue:14 @@ -2927,7 +2930,7 @@ msgstr "Sem informações de direitos autorais para esta faixa" #: front/src/components/About.vue:25 src/components/Home.vue:25 msgctxt "Content/Home/Paragraph" msgid "No description available." -msgstr "" +msgstr "Sem descrição." #: front/src/components/library/TrackDetail.vue:25 msgctxt "Content/Track/Table.Paragraph" @@ -2952,12 +2955,12 @@ msgstr "Sem resultados." #: front/src/components/About.vue:32 msgctxt "Content/Home/Paragraph" msgid "No rules available." -msgstr "" +msgstr "Sem regras." #: front/src/components/About.vue:39 msgctxt "Content/Home/Paragraph" msgid "No terms available." -msgstr "" +msgstr "Sem termos." #: front/src/components/mixins/Translations.vue:10 #: front/src/components/mixins/Translations.vue:11 @@ -3119,7 +3122,7 @@ msgstr "Direção da ordem" #: front/src/components/mixins/Translations.vue:49 msgctxt "Content/Moderation/Dropdown" msgid "Other" -msgstr "" +msgstr "Outro" #: front/src/components/manage/moderation/ReportCard.vue:143 #: front/src/components/manage/users/InvitationsTable.vue:38 @@ -3325,6 +3328,8 @@ msgstr "Listas de reprodução" msgctxt "Content/Embed/Message" msgid "Please contact your admins and ask them to update the corresponding setting." msgstr "" +"Entre em contato com seus administradores e peça que atualizem a " +"configuração correspondente." #: front/src/components/auth/Settings.vue:79 msgctxt "Content/Settings/Error message.List item/Call to action" @@ -3344,7 +3349,7 @@ msgstr "PNG, GIF ou JPG. No máximo 2MB. Será reduzido para 400x400px." #: front/src/components/About.vue:72 msgctxt "Content/About/Header/Name" msgid "Pod configuration" -msgstr "" +msgstr "Configuração do pod" #: front/src/views/admin/library/TrackDetail.vue:143 src/edits.js:70 msgctxt "*/*/*/Short, Noun" @@ -3649,7 +3654,7 @@ msgstr "Espaço de armazenamento restante" #: front/src/views/Notifications.vue:18 src/views/Notifications.vue:52 msgctxt "Content/Notifications/Label" msgid "Remind me in:" -msgstr "" +msgstr "Lembre-me em:" #: front/src/views/content/remote/Home.vue:6 msgctxt "Content/Library/Title/Noun" @@ -3710,12 +3715,12 @@ msgstr "Substituir a fila atual" #: front/src/components/mixins/Report.vue:6 src/components/mixins/Report.vue:7 msgctxt "*/Moderation/*/Verb" msgid "Report @%{ username }…" -msgstr "" +msgstr "Denunciar @%{ username }…" #: front/src/components/manage/moderation/ReportCard.vue:5 msgctxt "Content/Moderation/Card/Short" msgid "Report %{ id }" -msgstr "" +msgstr "Denunciar %{ id }" #: front/src/components/moderation/ReportModal.vue:139 msgctxt "*/Moderation/Message" @@ -3755,19 +3760,19 @@ msgstr "Denunciar esta faixa…" #: front/src/components/audio/PlayButton.vue:95 msgctxt "*/Moderation/*/Button/Label,Verb" msgid "Report…" -msgstr "" +msgstr "Denunciar…" #: front/src/components/manage/moderation/ReportCard.vue:117 msgctxt "Content/*/*/Short" msgid "Reported object" -msgstr "" +msgstr "Objeto denunciado" #: front/src/views/admin/moderation/Base.vue:5 #: front/src/views/admin/moderation/ReportsList.vue:3 #: front/src/views/admin/moderation/ReportsList.vue:208 msgctxt "*/Moderation/*/Noun" msgid "Reports" -msgstr "" +msgstr "Denúncias" #: front/src/components/auth/SubsonicTokenForm.vue:38 #: front/src/components/auth/SubsonicTokenForm.vue:41 @@ -3809,13 +3814,13 @@ msgstr "Data de resolução" #: front/src/components/manage/moderation/ReportCard.vue:218 msgctxt "Content/*/Button.Label/Verb" msgid "Resolve" -msgstr "" +msgstr "Resolver" #: front/src/components/manage/moderation/ReportCard.vue:62 #: front/src/views/admin/moderation/ReportsList.vue:20 msgctxt "Content/*/*/Short" msgid "Resolved" -msgstr "" +msgstr "Resolvido" #: front/src/views/content/libraries/FilesTable.vue:223 msgctxt "Content/Library/Dropdown/Verb" @@ -4049,22 +4054,22 @@ msgstr "Seções" #: front/src/components/ShortcutsModal.vue:108 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Seek backwards 30s" -msgstr "" +msgstr "Voltar 30s" #: front/src/components/ShortcutsModal.vue:100 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Seek backwards 5s" -msgstr "" +msgstr "Voltar 5s" #: front/src/components/ShortcutsModal.vue:112 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Seek forwards 30s" -msgstr "" +msgstr "Avançar 30s" #: front/src/components/ShortcutsModal.vue:104 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Seek forwards 5s" -msgstr "" +msgstr "Avançar 5s" #: front/src/components/library/radios/Builder.vue:46 msgctxt "Content/Radio/Dropdown.Placeholder/Verb" @@ -4086,7 +4091,7 @@ msgstr "Selecionar apenas a página atual" #: front/src/components/Home.vue:43 msgctxt "Content/Home/Link" msgid "Server rules" -msgstr "" +msgstr "Regras do servidor" #: front/src/components/Sidebar.vue:43 src/components/Sidebar.vue:112 #: front/src/components/manage/users/UsersTable.vue:182 @@ -4125,11 +4130,13 @@ msgstr "Link de compartilhamento" msgctxt "Content/Embed/Message" msgid "Sharing will not work because this pod doesn't allow anonymous users to access content." msgstr "" +"O compartilhamento não funcionará porque este pod não permite que usuários " +"anônimos vejam o conteúdo." #: front/src/components/About.vue:156 msgctxt "*/*/*/Verb" msgid "Show" -msgstr "" +msgstr "Mostrar" #: front/src/components/audio/album/Card.vue:38 msgctxt "Content/Album/Card.Link/Verb" @@ -4215,6 +4222,8 @@ msgstr "Criar conta" msgctxt "Content/Home/Paragraph" msgid "Sign up now to keep a track of your favorites, create playlists, discover new content and much more!" msgstr "" +"Crie uma conta agora para favoritar a faixa, criar listas, explorar novos " +"conteúdos e muito mais!" #: front/src/components/manage/users/UsersTable.vue:40 msgctxt "Content/Admin/Table.Label/Short, Noun (Value is a date)" @@ -4411,7 +4420,7 @@ msgstr "Fórum de suporte" #: front/src/views/Notifications.vue:10 msgctxt "Content/Notifications/Header" msgid "Support this Funkwhale pod" -msgstr "" +msgstr "Apoie este pod Funkwhale" #: front/src/components/library/FileUpload.vue:85 msgctxt "Content/Library/Paragraph" @@ -4426,7 +4435,7 @@ msgstr "Sincronizando alterações com o servidor…" #: front/src/components/audio/SearchBar.vue:35 msgctxt "*/*/*/Noun" msgid "Tag" -msgstr "" +msgstr "Tag" #: front/src/views/admin/library/TagDetail.vue:61 msgctxt "Content/Moderation/Title" @@ -4440,7 +4449,7 @@ msgstr "Dados da tag" #: src/edits.js:50 src/edits.js:91 src/entities.js:120 msgctxt "*/*/*/Noun" msgid "Tags" -msgstr "" +msgstr "Tags" #: front/src/components/mixins/Translations.vue:44 #: front/src/components/mixins/Translations.vue:45 @@ -4451,7 +4460,7 @@ msgstr "Solicitação de exclusão" #: front/src/components/About.vue:35 src/components/About.vue:61 msgctxt "Content/About/Header" msgid "Terms and privacy policy" -msgstr "" +msgstr "Política de Privacidade e Termos de Uso" #: front/src/components/audio/EmbedWizard.vue:35 #: front/src/components/common/CopyInput.vue:3 @@ -4462,7 +4471,9 @@ msgstr "Texto copiado para a área de transferência!" #: front/src/views/admin/library/AlbumDetail.vue:81 msgctxt "Content/Moderation/Paragraph" msgid "The album will be removed, as well as associated uploads, tracks, favorites and listening history. This action is irreversible." -msgstr "O álbum será permanentemente excluído, junto com os envios, faixas, favoritos e histórico de escutadas." +msgstr "" +"O álbum será permanentemente excluído, junto com os envios, faixas, " +"favoritos e histórico de escutados." #: front/src/components/auth/Authorize.vue:39 msgctxt "Content/Auth/Paragraph" @@ -4472,7 +4483,9 @@ msgstr "O aplicativo também está solicitando as seguintes permissões desconhe #: front/src/views/admin/library/ArtistDetail.vue:79 msgctxt "Content/Moderation/Paragraph" msgid "The artist will be removed, as well as associated uploads, tracks, albums, favorites and listening history. This action is irreversible." -msgstr "O(A) artista será permanentemente excluído(a), junto com os envios, faixas, álbuns, favoritos e histórico de escutadas." +msgstr "" +"O(A) artista será permanentemente excluído(a), junto com os envios, faixas, " +"álbuns, favoritos e histórico de escutados." #: front/src/components/Footer.vue:61 msgctxt "Footer/*/List item.Link" @@ -4512,12 +4525,12 @@ msgstr "A próxima faixa tocará automaticamente em alguns segundos…" #: front/src/components/manage/moderation/NotesThread.vue:27 msgctxt "Content/Moderation/Paragraph" msgid "The note will be removed. This action is irreversible." -msgstr "O comentário será permanentemente excluído. Esta ação é irreversível." +msgstr "O comentário será permanentemente excluído." #: front/src/components/manage/moderation/ReportCard.vue:120 msgctxt "Content/Moderation/Message" msgid "The object associated with this report was deleted." -msgstr "" +msgstr "O objeto associado à esta denúncia foi excluído." #: front/src/components/playlists/Form.vue:14 msgctxt "Content/Playlist/Error message.Title" @@ -4547,12 +4560,16 @@ msgstr "O servidor remoto retornou dados inválidos de JSON ou JSON-LD" #: front/src/components/manage/library/AlbumsTable.vue:189 msgctxt "Popup/*/Paragraph" msgid "The selected albums will be removed, as well as associated tracks, uploads, favorites and listening history. This action is irreversible." -msgstr "Os álbuns selecionados serão permanentemente excluídos, junto com as faixas, envios, favoritos e histórico de escutadas." +msgstr "" +"Os álbuns selecionados serão permanentemente excluídos, junto com as faixas, " +"envios, favoritos e histórico de escutados." #: front/src/components/manage/library/ArtistsTable.vue:179 msgctxt "Popup/*/Paragraph" msgid "The selected artist will be removed, as well as associated uploads, tracks, albums, favorites and listening history. This action is irreversible." -msgstr "O(A) artista selecionado(a) será permanentemente excluído(a), junto com as faixas, álbuns, favoritos e histórico de escutadas." +msgstr "" +"O(A) artista selecionado(a) será permanentemente excluído(a), junto com as " +"faixas, álbuns, favoritos e histórico de escutados." #: front/src/components/manage/library/LibrariesTable.vue:206 msgctxt "Popup/*/Paragraph" @@ -4563,13 +4580,15 @@ msgstr "A biblioteca selecionada será permanentemente excluída, junto com os e msgctxt "Popup/*/Paragraph" msgid "The selected tag will be removed and unlinked with existing content, if any. This action is irreversible." msgstr "" -"A tag selecionada será removida e desvinculada do conteúdo, se houver um. " -"Esta ação é irreversível." +"A tag selecionada será permanentemente removida e desvinculada do conteúdo, " +"se houver um." #: front/src/components/manage/library/TracksTable.vue:189 msgctxt "Popup/*/Paragraph" msgid "The selected tracks will be removed, as well as associated uploads, favorites and listening history. This action is irreversible." -msgstr "As faixas selecionadas serão permanentemente excluídas, junto com os envios, favoritos e histórico de escutadas." +msgstr "" +"As faixas selecionadas serão permanentemente excluídas, junto com os envios, " +"favoritos e histórico de escutados." #: front/src/components/manage/library/UploadsTable.vue:256 msgctxt "Popup/*/Paragraph" @@ -4595,8 +4614,8 @@ msgstr "A sugestão será permanentemente excluída." msgctxt "Content/Moderation/Paragraph" msgid "The tag will be removed and unlinked from any existing entity. This action is irreversible." msgstr "" -"A tag será removida e desvinculada da entidade, se houver uma. Esta ação é " -"irreversível." +"A tag será permanentemente removida e desvinculada da entidade, se houver " +"uma." #: front/src/components/playlists/PlaylistModal.vue:34 msgctxt "Popup/Playlist/Error message.Title" @@ -4611,7 +4630,9 @@ msgstr "Não foi possível carregar a faixa" #: front/src/views/admin/library/TrackDetail.vue:80 msgctxt "Content/Moderation/Paragraph" msgid "The track will be removed, as well as associated uploads, favorites and listening history. This action is irreversible." -msgstr "A faixa será permanentemente excluída, junto com os envios, favoritos e histórico de escutadas." +msgstr "" +"A faixa será permanentemente excluída, junto com os envios, favoritos e " +"histórico de escutados." #: front/src/views/admin/library/UploadDetail.vue:68 msgctxt "Content/Moderation/Paragraph" @@ -4668,6 +4689,8 @@ msgstr "Esta instância oferece até %{quota} de espaço de armazenamento por us msgctxt "Popup/Settings/Paragraph" msgid "This is irreversible and will permanently remove your data from our servers. You will we immediatly logged out." msgstr "" +"Esta ação é irreversível e excluirá permanentemente seus dados dos nossos " +"servidores. Você sairá da sua conta imediatamente." #: front/src/components/auth/Settings.vue:165 msgctxt "Content/Settings/Paragraph" @@ -4715,6 +4738,8 @@ msgstr "Não é possível editar este objeto, ele é gerenciado por outro servid msgctxt "Content/Home/Paragraph" msgid "This pod runs Funkwhale, a community-driven project that lets you listen and share music and audio within a decentralized, open network." msgstr "" +"Este pod roda Funkwhale, um projeto comunitário que permite compartilhar " +"músicas e áudios numa rede aberta e descentralizada." #: front/src/components/library/FileUpload.vue:51 msgctxt "Content/Library/Paragraph" @@ -4770,6 +4795,8 @@ msgstr "Isso vai desativar completamente o acesso á API Subsonic usada na conta msgctxt "Content/Moderation/Popup,Paragraph" msgid "This will delete the object associated with this report and mark the report as resolved. The deletion is irreversible." msgstr "" +"Isto irá excluir o objeto associado à esta denúncia e marcar a denúncia como " +"resolvida. A exclusão é irreversível." #: front/src/components/auth/SubsonicTokenForm.vue:40 msgctxt "Popup/Settings/Paragraph" @@ -4815,7 +4842,7 @@ msgstr "Alterar favorito" #: front/src/components/ShortcutsModal.vue:132 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Toggle mute" -msgstr "" +msgstr "Alterar mudo" #: front/src/components/ShortcutsModal.vue:136 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" @@ -4921,7 +4948,7 @@ msgstr "Sob regra de moderação" #: front/src/components/ShortcutsModal.vue:68 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Unfocus searchbar" -msgstr "" +msgstr "Desfocar a barra de pesquisa" #: front/src/views/content/remote/Card.vue:122 #: src/views/content/remote/Card.vue:127 @@ -4949,13 +4976,13 @@ msgstr "Desfazer silêncio" #: front/src/components/manage/moderation/ReportCard.vue:225 msgctxt "Content/*/Button.Label" msgid "Unresolve" -msgstr "" +msgstr "Desfazer resolução" #: front/src/components/manage/moderation/ReportCard.vue:67 #: front/src/views/admin/moderation/ReportsList.vue:23 msgctxt "Content/*/*/Short" msgid "Unresolved" -msgstr "" +msgstr "Resolução desfeita" #: front/src/components/manage/moderation/InstancePolicyForm.vue:57 msgctxt "Content/Moderation/Card.Button.Label/Verb" @@ -5103,12 +5130,14 @@ msgstr "Usar outra instância" #: front/src/components/Home.vue:146 msgctxt "Content/Home/Link" msgid "Use Funkwhale on other devices with our apps" -msgstr "" +msgstr "Use Funkwhale em outros dispositivos com nossos aplicativos" #: front/src/components/moderation/ReportModal.vue:44 msgctxt "*/*/Field,Help" msgid "Use this field to provide additional context to the moderator that will handle your report." msgstr "" +"Use este campo para fornecer contexto adicional para o moderador que irá " +"atender sua denúncia." #: front/src/views/auth/PasswordReset.vue:12 msgctxt "Content/Signup/Paragraph" @@ -5119,6 +5148,7 @@ msgstr "Use este formulário para solicitar redefinição de senha. Enviaremos u msgctxt "*/Moderation/Popup,Paragraph" msgid "Use this form to submit a report to our moderation team." msgstr "" +"Use este formulário para enviar uma denúncia para nossa equipe de moderação." #: front/src/components/manage/moderation/InstancePolicyForm.vue:111 msgctxt "Content/Moderation/Help text" @@ -5133,7 +5163,7 @@ msgstr "Usado" #: front/src/components/Home.vue:125 msgctxt "Content/Home/Header" msgid "Useful links" -msgstr "" +msgstr "Links úteis" #: front/src/views/content/libraries/Detail.vue:26 msgctxt "Content/Library/Table.Label" @@ -5190,6 +5220,8 @@ msgstr "Usuários" msgctxt "Content/Home/Paragraph" msgid "Users on this pod also get %{ quota } of free storage to upload their own content!" msgstr "" +"Os usuários deste pod também ganham %{ quota } de espaço grátis para " +"compartilhar o próprio conteúdo!" #: front/src/components/Footer.vue:29 msgctxt "Footer/*/Title" @@ -5243,7 +5275,7 @@ msgstr "Ver em MusicBrainz" #: front/src/components/manage/moderation/ReportCard.vue:124 msgctxt "Content/Moderation/Link" msgid "View public page" -msgstr "" +msgstr "Ver página pública" #: front/src/components/manage/library/LibrariesTable.vue:11 #: front/src/components/manage/library/LibrariesTable.vue:51 @@ -5290,6 +5322,8 @@ msgstr "Não foi possível salvar as alterações" msgctxt "Content/Notifications/Paragraph" msgid "We noticed you've been here for a while. If Funkwhale is useful to you, we could use your help to make it even better!" msgstr "" +"Notamos que você está aqui há um tempo. Se Funkwhale for útil para você, " +"adoraríamos ter sua ajuda para torná-lo ainda melhor!" #: front/src/components/library/FileUpload.vue:39 msgctxt "Content/Library/Link" @@ -5300,6 +5334,8 @@ msgstr "Recomendamos o uso do Picard para este propósito." msgctxt "*/*/Field,Help" msgid "We'll use this email if we need to contact you regarding this report." msgstr "" +"Usaremos este e-mail se precisarmos entrar em contato com você a respeito " +"desta denúncia." #: front/src/components/Home.vue:207 msgctxt "Head/Home/Title" @@ -5309,7 +5345,7 @@ msgstr "Bem-vindo(a)" #: front/src/components/Home.vue:5 msgctxt "Content/Home/Header" msgid "Welcome to %{ podName }!" -msgstr "" +msgstr "Bem-vindo(a) ao %{ podName }!" #: front/src/components/audio/EmbedWizard.vue:23 msgctxt "Popup/Embed/Input.Label" @@ -5409,6 +5445,8 @@ msgstr "Agora você pode usar o serviço sem limitações." msgctxt "Content/Settings/Paragraph'" msgid "You can permanently and irreversibly delete your account and all the associated data using the form below. You will be asked for confirmation." msgstr "" +"Você pode irreversivelmente excluir sua conta e todos os dados vinculados à " +"ela usando o formulário abaixo. Será solicitado uma confirmação." #: front/src/components/library/radios/Builder.vue:7 msgctxt "Content/Radio/Paragraph" @@ -5491,6 +5529,10 @@ msgstr "Não foi possível criar conta." msgctxt "Content/Settings/Paragraph'" msgid "Your account will be deleted from our servers within a few minutes. We will also notify other servers who may have a copy of some of your data so they can proceed to deletion. Please note that some of these servers may be offline or unwilling to comply though." msgstr "" +"Sua conta será excluída de nossos servidores em poucos minutos. Nós também " +"notificaremos outros servidores que podem ter uma cópia de algum dado seu " +"para que possam prosseguir com a exclusão. Por favor, note que alguns desses " +"servidores podem estar offline ou recusando fazê-lo." #: front/src/components/auth/Settings.vue:215 msgctxt "Content/Settings/Title/Noun" @@ -5506,6 +5548,8 @@ msgstr "Não foi possível salvar imagem de perfil" msgctxt "*/Auth/Message" msgid "Your deletion request was submitted, your account and content will be deleted shortly" msgstr "" +"Sua solicitação de exclusão foi enviada, logo mais sua conta e seus " +"conteúdos serão excluídos" #: front/src/components/library/EditForm.vue:3 msgctxt "Content/Library/Paragraph" @@ -5556,11 +5600,14 @@ msgstr "ID MusicBrainz" msgctxt "*/Error/Paragraph" msgid "You sent too many requests and have been rate limited, please try again in %{ delay }" msgstr "" +"Você foi limitado por enviar muitas solicitações, tente novamente em %{ " +"delay }" #: front/src/main.js:113 msgctxt "*/Error/Paragraph" msgid "You sent too many requests and have been rate limited, please try again later" msgstr "" +"Você foi limitado por enviar muitas solicitações, tente novamente mais tarde" #: front/src/components/library/AlbumBase.vue:208 msgctxt "Content/Album/Header.Title" From 0ef72baf12c7eab138609f48b9ca0bea80c6e42c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francesc=20Gal=C3=AD?= Date: Thu, 17 Oct 2019 08:45:27 +0000 Subject: [PATCH 010/322] Translated using Weblate (Catalan) Currently translated at 88.3% (842 of 954 strings) Translation: Funkwhale/Funkwhale's server front-end Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/ca/ --- front/locales/ca/LC_MESSAGES/app.po | 32 +++++++++-------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/front/locales/ca/LC_MESSAGES/app.po b/front/locales/ca/LC_MESSAGES/app.po index 4cda32b72..480c41048 100644 --- a/front/locales/ca/LC_MESSAGES/app.po +++ b/front/locales/ca/LC_MESSAGES/app.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: front 0.1.0\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2019-10-01 15:15+0200\n" -"PO-Revision-Date: 2019-10-14 10:14+0000\n" +"PO-Revision-Date: 2019-10-18 08:59+0000\n" "Last-Translator: Francesc Galí \n" "Language-Team: none\n" "Language: ca\n" @@ -16,7 +16,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 3.7\n" +"X-Generator: Weblate 3.9\n" #: front/src/components/playlists/PlaylistModal.vue:9 msgctxt "Popup/Playlist/Paragraph" @@ -74,7 +74,6 @@ msgstr[0] "%{ count } hora de música" msgstr[1] "%{ count } hores de música" #: front/src/components/About.vue:188 -#, fuzzy msgctxt "Content/Home/Stat" msgid "%{ count } listenings" msgid_plural "%{ count } listenings" @@ -112,7 +111,6 @@ msgstr[0] "%{ count } pista corresponent als filtres seleccionats" msgstr[1] "%{ count } pistes corresponents als filtres seleccionats" #: front/src/components/About.vue:185 -#, fuzzy msgctxt "Content/Home/Stat" msgid "%{ count } tracks" msgid_plural "%{ count } tracks" @@ -213,7 +211,6 @@ msgid "A short summary describing your changes." msgstr "Una breu resum descrivint els teus canvis." #: front/src/components/About.vue:5 -#, fuzzy msgctxt "Content/Home/Header" msgid "About %{ podName }" msgstr "Quant a %{ instance }" @@ -239,7 +236,6 @@ msgid "About this Funkwhale pod" msgstr "Quant a aquesta instància de Funkwhale" #: front/src/components/About.vue:21 src/components/About.vue:51 -#, fuzzy msgctxt "Content/About/Header" msgid "About this pod" msgstr "Quant a aquesta instància" @@ -1365,12 +1361,12 @@ msgstr "Suprimir la ràdio" #: front/src/components/manage/moderation/ReportCard.vue:357 msgctxt "Content/Moderation/Button/Verb" msgid "Delete reported object" -msgstr "Esborra l'objecte del que s'ha informat" +msgstr "Suprimeix l'objecte notificat" #: front/src/components/manage/moderation/ReportCard.vue:358 msgctxt "Content/Moderation/Popup/Header" msgid "Delete reported object?" -msgstr "Esborrar l'objecte del que s'ha informat?" +msgstr "Suprimir l'objecte notificat?" #: front/src/views/admin/library/AlbumDetail.vue:79 #: front/src/views/admin/library/TrackDetail.vue:78 @@ -1438,7 +1434,7 @@ msgstr "Descendent" #: front/src/components/manage/moderation/NoteForm.vue:37 msgctxt "Content/Moderation/Placeholder" msgid "Describe what actions have been taken, or any other related updates…" -msgstr "Descriu quines accions s'han dit a terme, o qualsevol altra novetat…" +msgstr "Descriu quines accions s'han dut a terme, o qualsevol altra novetat …" #: front/src/components/library/radios/Builder.vue:25 #: front/src/views/admin/library/LibraryDetail.vue:132 @@ -1485,7 +1481,6 @@ msgstr "Desactivar l'accés a l'API Subsonic?" #: front/src/components/manage/moderation/InstancePolicyForm.vue:18 #: front/src/views/admin/moderation/AccountsDetail.vue:157 #: front/src/views/admin/moderation/AccountsDetail.vue:161 -#, fuzzy msgctxt "*/*/*/State of feature" msgid "Disabled" msgstr "Desactivat" @@ -1756,7 +1751,6 @@ msgstr "Missatges emesos" #: front/src/components/manage/moderation/InstancePolicyForm.vue:17 #: front/src/views/admin/moderation/AccountsDetail.vue:156 #: front/src/views/admin/moderation/AccountsDetail.vue:160 -#, fuzzy msgctxt "*/*/*/State of feature" msgid "Enabled" msgstr "Activat" @@ -1792,7 +1786,6 @@ msgid "Enter playlist name…" msgstr "Introduir el nom de la llista de reproducció …" #: front/src/views/auth/PasswordReset.vue:54 -#, fuzzy msgctxt "Content/Signup/Input.Placeholder" msgid "Enter the email address linked to your account" msgstr "Introdueix el correu electrònic associat al vostre compte" @@ -2004,7 +1997,6 @@ msgid "Favorites" msgstr "Preferides" #: front/src/components/About.vue:110 src/views/admin/Settings.vue:84 -#, fuzzy msgctxt "*/*/*" msgid "Federation" msgstr "Federació" @@ -2436,10 +2428,9 @@ msgid "Launch" msgstr "Iniciar" #: front/src/components/Home.vue:35 -#, fuzzy msgctxt "Content/Home/Link" msgid "Learn more" -msgstr "Carrega'n més …" +msgstr "Aprèn més" #: front/src/components/manage/users/InvitationForm.vue:58 msgctxt "Content/Admin/Input.Placeholder" @@ -2487,7 +2478,6 @@ msgstr "Les biblioteques t'ajuden a organitzar i compartir la teva col·lecció #: front/src/views/admin/library/UploadDetail.vue:144 #: front/src/views/admin/moderation/AccountsDetail.vue:518 src/entities.js:132 #: front/src/components/mixins/Report.vue:78 -#, fuzzy msgctxt "*/*/*/Noun" msgid "Library" msgstr "Biblioteca" @@ -2685,10 +2675,9 @@ msgid "Manage library" msgstr "Gestionar la biblioteca" #: front/src/components/manage/moderation/InstancePolicyModal.vue:8 -#, fuzzy msgctxt "Popup/Moderation/Title/Verb" msgid "Manage moderation rules for %{ obj }" -msgstr "Sota la regla de moderació" +msgstr "Gestioneu les regles de moderació de %{ obj }" #: front/src/components/playlists/PlaylistModal.vue:3 msgctxt "Popup/Playlist/Title/Verb" @@ -2747,10 +2736,9 @@ msgid "Mobile and desktop apps" msgstr "Aplicacions mòbils i d'escriptori" #: front/src/components/Home.vue:143 -#, fuzzy msgctxt "Content/Home/Link" msgid "Mobile apps" -msgstr "Aplicacions mòbils i d'escriptori" +msgstr "Aplicacions mòbils" #: front/src/components/Sidebar.vue:96 #: src/components/manage/users/UsersTable.vue:178 @@ -2771,7 +2759,7 @@ msgstr "Les regles de moderació t'ajudaran a controlar com la vostra instància #, fuzzy msgctxt "Content/Moderation/Button.Label" msgid "Moderation rules…" -msgstr "Editar les regles de moderació" +msgstr "Regles de moderació …" #: front/src/components/library/EditCard.vue:5 msgctxt "Content/Library/Card/Short" @@ -3858,7 +3846,7 @@ msgstr "Rellançar la importació" #: front/src/components/library/EditForm.vue:31 msgctxt "Content/Library/Button.Label" msgid "Restrict to unreviewed edits" -msgstr "Restringir a edicions no revisades" +msgstr "Restringiu les edicions no revisades" #: front/src/components/favorites/List.vue:39 #: src/components/library/Albums.vue:34 From a4a3698f01fde3e737de5a4306fb895a47967a73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francesc=20Gal=C3=AD?= Date: Wed, 23 Oct 2019 13:44:28 +0000 Subject: [PATCH 011/322] Translated using Weblate (Catalan) Currently translated at 88.3% (842 of 954 strings) Translation: Funkwhale/Funkwhale's server front-end Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/ca/ --- front/locales/ca/LC_MESSAGES/app.po | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/front/locales/ca/LC_MESSAGES/app.po b/front/locales/ca/LC_MESSAGES/app.po index 480c41048..bd294b091 100644 --- a/front/locales/ca/LC_MESSAGES/app.po +++ b/front/locales/ca/LC_MESSAGES/app.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: front 0.1.0\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2019-10-01 15:15+0200\n" -"PO-Revision-Date: 2019-10-18 08:59+0000\n" +"PO-Revision-Date: 2019-10-24 02:30+0000\n" "Last-Translator: Francesc Galí \n" "Language-Team: none\n" "Language: ca\n" @@ -586,9 +586,10 @@ msgid "Allow application" msgstr "Autoritzar l'aplicació" #: front/src/components/About.vue:136 +#, fuzzy msgctxt "*/*/*" msgid "Allow-list" -msgstr "" +msgstr "Llista d'autorització" #: front/src/components/About.vue:149 msgctxt "*/*/*" From 0e14c37df44f207463e3cd61fbe81ab782f4691b Mon Sep 17 00:00:00 2001 From: Keunes Date: Fri, 25 Oct 2019 23:03:50 +0000 Subject: [PATCH 012/322] Translated using Weblate (Dutch) Currently translated at 80.1% (764 of 954 strings) Translation: Funkwhale/Funkwhale's server front-end Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/nl/ --- front/locales/nl/LC_MESSAGES/app.po | 39 ++++++++++++----------------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/front/locales/nl/LC_MESSAGES/app.po b/front/locales/nl/LC_MESSAGES/app.po index 502956a5c..3834701d4 100644 --- a/front/locales/nl/LC_MESSAGES/app.po +++ b/front/locales/nl/LC_MESSAGES/app.po @@ -8,8 +8,8 @@ msgstr "" "Project-Id-Version: front 0.1.0\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2019-10-01 15:15+0200\n" -"PO-Revision-Date: 2019-10-18 08:59+0000\n" -"Last-Translator: Keunes \n" +"PO-Revision-Date: 2019-10-26 11:47+0000\n" +"Last-Translator: Keunes \n" "Language-Team: none\n" "Language: nl\n" "MIME-Version: 1.0\n" @@ -3832,27 +3832,25 @@ msgid "Reset your password" msgstr "Wachtwoord opnieuw instellen" #: front/src/components/manage/moderation/ReportCard.vue:83 -#, fuzzy msgctxt "Content/*/*/Noun" msgid "Resolution date" -msgstr "Toegevoegd" +msgstr "Datum opgelost" #: front/src/components/manage/moderation/ReportCard.vue:218 msgctxt "Content/*/Button.Label/Verb" msgid "Resolve" -msgstr "" +msgstr "Oplossen" #: front/src/components/manage/moderation/ReportCard.vue:62 #: front/src/views/admin/moderation/ReportsList.vue:20 msgctxt "Content/*/*/Short" msgid "Resolved" -msgstr "" +msgstr "Opgelost" #: front/src/views/content/libraries/FilesTable.vue:223 -#, fuzzy msgctxt "Content/Library/Dropdown/Verb" msgid "Restart import" -msgstr "Importeren" +msgstr "Importeren herstarten" #: front/src/components/library/EditForm.vue:31 msgctxt "Content/Library/Button.Label" @@ -3868,41 +3866,39 @@ msgid "Results per page" msgstr "Aantal resultaten per pagina" #: front/src/views/auth/EmailConfirm.vue:17 -#, fuzzy msgctxt "Content/Signup/Link/Verb" msgid "Return to login" -msgstr "Doorgaan met inloggen" +msgstr "Terug naar inloggen" #: front/src/components/library/ArtistDetail.vue:9 -#, fuzzy msgctxt "Content/Moderation/Link" msgid "Review my filters" -msgstr "Bestanden bekijken" +msgstr "Mijn filters controleren" #: front/src/components/auth/Settings.vue:192 msgctxt "*/*/*/Verb" msgid "Revoke" -msgstr "" +msgstr "Intrekken" #: front/src/components/auth/Settings.vue:195 msgctxt "*/Settings/Button.Label/Verb" msgid "Revoke access" -msgstr "" +msgstr "Toestemming toegang intrekken" #: front/src/components/auth/Settings.vue:193 msgctxt "Popup/Settings/Title" msgid "Revoke access for application \"%{ application }\"?" -msgstr "" +msgstr "Toegang voor de service \"%{ application }\" intrekken?" #: front/src/components/manage/moderation/InstancePolicyCard.vue:16 msgctxt "Content/Moderation/Card.Title/Noun" msgid "Rule" -msgstr "" +msgstr "Regel" #: front/src/components/About.vue:28 src/components/About.vue:56 msgctxt "Content/About/Header" msgid "Rules" -msgstr "" +msgstr "Regels" #: front/src/components/admin/SettingsGroup.vue:75 #: front/src/components/library/radios/Builder.vue:34 @@ -3916,10 +3912,9 @@ msgid "Scan launched" msgstr "Scan begonnen" #: front/src/views/content/remote/Card.vue:80 -#, fuzzy msgctxt "Content/Library/Card.Button.Label/Verb" msgid "Scan now" -msgstr "Nu afspelen" +msgstr "Nu scannen" #: front/src/views/content/remote/Card.vue:48 msgctxt "Content/Library/Card.List item" @@ -3932,10 +3927,9 @@ msgid "Scan skipped (previous scan is too recent)" msgstr "Scan overgeslagen (vorige scan was zeer recent)" #: front/src/views/content/remote/Card.vue:60 -#, fuzzy msgctxt "Content/Library/Card.List item" msgid "Scanned" -msgstr "Scan begonnen" +msgstr "Gescand" #: front/src/views/content/remote/Card.vue:64 msgctxt "Content/Library/Card.List item" @@ -3943,10 +3937,9 @@ msgid "Scanned with errors" msgstr "Afgerond, maar met foutmeldingen" #: front/src/views/content/remote/Card.vue:52 -#, fuzzy msgctxt "Content/Library/Card.List item" msgid "Scanning… (%{ progress }%)" -msgstr "Bezig met scannen… (% {progress }%)" +msgstr "Aan het scannen… (% {progress }%)" #: front/src/components/auth/ApplicationForm.vue:22 #: front/src/components/auth/Settings.vue:226 From 65192b8318824f817a80741c32e53e024686bacd Mon Sep 17 00:00:00 2001 From: Keunes Date: Sun, 27 Oct 2019 12:14:55 +0000 Subject: [PATCH 013/322] Translated using Weblate (Dutch) Currently translated at 82.9% (791 of 954 strings) Translation: Funkwhale/Funkwhale's server front-end Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/nl/ --- front/locales/nl/LC_MESSAGES/app.po | 100 +++++++++++++++------------- 1 file changed, 52 insertions(+), 48 deletions(-) diff --git a/front/locales/nl/LC_MESSAGES/app.po b/front/locales/nl/LC_MESSAGES/app.po index 3834701d4..f9ef2ad08 100644 --- a/front/locales/nl/LC_MESSAGES/app.po +++ b/front/locales/nl/LC_MESSAGES/app.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: front 0.1.0\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2019-10-01 15:15+0200\n" -"PO-Revision-Date: 2019-10-26 11:47+0000\n" +"PO-Revision-Date: 2019-10-28 07:57+0000\n" "Last-Translator: Keunes \n" "Language-Team: none\n" "Language: nl\n" @@ -845,7 +845,7 @@ msgstr "Bibliotheek doorbladeren" #: front/src/components/Home.vue:132 msgctxt "Content/Home/Link" msgid "Browse public content" -msgstr "Doorblader openbare content" +msgstr "Publieke content doorbladeren" #: front/src/components/library/Albums.vue:4 msgctxt "Content/Album/Title" @@ -1520,7 +1520,7 @@ msgstr "Weergavenaam" #: front/src/components/library/radios/Builder.vue:30 msgctxt "Content/Radio/Checkbox.Label/Verb" msgid "Display publicly" -msgstr "Openbaren" +msgstr "Publiekelijk tonen" #: front/src/components/manage/moderation/InstancePolicyForm.vue:122 msgctxt "Content/Moderation/Help text" @@ -1943,7 +1943,7 @@ msgstr "Iedereen op deze server" #: front/src/components/mixins/Translations.vue:13 msgctxt "Content/Settings/Dropdown" msgid "Everyone, across all instances" -msgstr "Iedereen, overheen alle instanties" +msgstr "Iedereen, op alle servers" #: front/src/components/library/radios/Builder.vue:62 msgctxt "Content/Radio/Table.Label/Verb" @@ -2310,17 +2310,17 @@ msgstr "Servergegevens" #: front/src/views/admin/Settings.vue:80 msgctxt "Content/Admin/Menu" msgid "Instance information" -msgstr "Server-informatie" +msgstr "Serverinformatie" #: front/src/components/library/Radios.vue:9 msgctxt "Content/Radio/Title" msgid "Instance radios" -msgstr "Radio's van deze server" +msgstr "Radio's op deze server" #: front/src/views/admin/Settings.vue:75 msgctxt "Head/Admin/Title" msgid "Instance settings" -msgstr "Server-instellingen" +msgstr "Serverinstellingen" #: front/src/components/SetInstanceModal.vue:19 msgctxt "Popup/Instance/Input.Label/Noun" @@ -2541,8 +2541,7 @@ msgstr "Gelinkte meldingen" #: front/src/components/Home.vue:135 msgctxt "Content/Home/Link" msgid "Listen to public albums and playlists shared on this pod" -msgstr "" -"Luister naar openbare albums en afspeellijsten die zijn gedeeld op deze pod" +msgstr "Luister naar publiek toegankelijke albums en afspeellijsten op deze pod" #: front/src/components/mixins/Translations.vue:92 #: front/src/views/admin/library/AlbumDetail.vue:163 @@ -3424,7 +3423,7 @@ msgstr "Profiel" msgctxt "Content/Signup/Form/Paragraph" msgid "Public registrations are not possible on this instance. You will need an invitation code to sign up." msgstr "" -"Openbare registraties zijn niet mogelijk op deze server. Je zult een " +"Vrije registraties zijn niet mogelijk op deze server. Je zult een " "uitnodigingscode nodig hebben om te registreren." #: front/src/components/manage/moderation/AccountsTable.vue:188 @@ -3682,7 +3681,10 @@ msgstr "Externe verzamelingen" #: front/src/views/content/remote/Home.vue:7 msgctxt "Content/Library/Paragraph" msgid "Remote libraries are owned by other users on the network. You can access them as long as they are public or you are granted access." -msgstr "Externe verzamelingen worden beheerd door andere gebruikers op het netwerk. Je kunt ze gebruiken als ze openbaar zijn of als jou toegang is verleend." +msgstr "" +"Externe bibliotheken worden beheerd door andere gebruikers op het netwerk. " +"Je kunt ze gebruiken als ze publiek toegankelijk zijn of je toegang hebt " +"gekregen." #: front/src/components/library/radios/Filter.vue:59 msgctxt "Content/Radio/Button.Label/Verb" @@ -4060,10 +4062,9 @@ msgstr "Zoek artiesten, albums, nummers…" #: front/src/components/library/AlbumBase.vue:66 #: front/src/components/library/ArtistBase.vue:77 #: front/src/components/library/TrackBase.vue:82 -#, fuzzy msgctxt "Content/*/Button.Label/Verb" msgid "Search on Discogs" -msgstr "Zoeken naar muziek" +msgstr "Zoek bij Discogs" #: front/src/components/library/AlbumBase.vue:58 #: front/src/components/library/ArtistBase.vue:69 @@ -4078,7 +4079,7 @@ msgstr "Zoeken op Wikipedia" #: src/views/admin/users/Base.vue:21 front/src/views/content/Base.vue:19 msgctxt "Menu/*/Hidden text" msgid "Secondary menu" -msgstr "" +msgstr "Submenu" #: front/src/views/admin/Settings.vue:15 msgctxt "Content/Admin/Menu.Title" @@ -4088,22 +4089,22 @@ msgstr "Secties" #: front/src/components/ShortcutsModal.vue:108 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Seek backwards 30s" -msgstr "" +msgstr "Terugspoelen met 30s" #: front/src/components/ShortcutsModal.vue:100 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Seek backwards 5s" -msgstr "" +msgstr "Terugspoelen met 5s" #: front/src/components/ShortcutsModal.vue:112 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Seek forwards 30s" -msgstr "" +msgstr "Vooruitspoelen met 30s" #: front/src/components/ShortcutsModal.vue:104 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" msgid "Seek forwards 5s" -msgstr "" +msgstr "Vooruitspoelen met 5s" #: front/src/components/library/radios/Builder.vue:46 msgctxt "Content/Radio/Dropdown.Placeholder/Verb" @@ -4126,7 +4127,7 @@ msgstr "Alleen deze pagina kiezen" #: front/src/components/Home.vue:43 msgctxt "Content/Home/Link" msgid "Server rules" -msgstr "" +msgstr "Regels op deze server" #: front/src/components/Sidebar.vue:43 src/components/Sidebar.vue:112 #: front/src/components/manage/users/UsersTable.vue:182 @@ -4151,10 +4152,11 @@ msgid "Share link" msgstr "Link om te delen" #: front/src/views/content/libraries/Detail.vue:15 -#, fuzzy msgctxt "Content/Library/Paragraph" msgid "Share this link with other users so they can request access to your library." -msgstr "Deel deze link met anderen zodat ze toegang tot je verzamelingen kunnen aanvragen." +msgstr "" +"Deel deze link met anderen zodat ze toegang tot je bibliotheek kunnen " +"aanvragen." #: front/src/views/content/libraries/Detail.vue:14 #: front/src/views/content/remote/Card.vue:90 @@ -4166,14 +4168,15 @@ msgstr "Link om te delen" msgctxt "Content/Embed/Message" msgid "Sharing will not work because this pod doesn't allow anonymous users to access content." msgstr "" +"Delen werkt niet omdat anonieme gebruikers geen toegang hebben tot content " +"op deze pod." #: front/src/components/About.vue:156 msgctxt "*/*/*/Verb" msgid "Show" -msgstr "" +msgstr "Toon" #: front/src/components/audio/album/Card.vue:38 -#, fuzzy msgctxt "Content/Album/Card.Link/Verb" msgid "Show %{ count } more track" msgid_plural "Show %{ count } more tracks" @@ -4181,17 +4184,16 @@ msgstr[0] "Nog %{ count } nummer tonen" msgstr[1] "Nog %{ count } nummers tonen" #: front/src/components/tags/List.vue:11 -#, fuzzy msgctxt "Content/*/Button/Label/Verb" msgid "Show 1 more tag" msgid_plural "Show %{ count } more tags" -msgstr[0] "Nog %{ count } album tonen" -msgstr[1] "Nog %{ count } albums tonen" +msgstr[0] "Nog 1 tag tonen" +msgstr[1] "Nog %{ count } tags tonen" #: front/src/components/library/EditForm.vue:21 msgctxt "Content/Library/Button.Label" msgid "Show all edits" -msgstr "" +msgstr "Toon alle wijzigingen" #: front/src/components/ShortcutsModal.vue:60 msgctxt "Popup/Keyboard shortcuts/Table.Label/Verb" @@ -4201,13 +4203,12 @@ msgstr "Toon beschikbare sneltoetsen" #: front/src/components/common/ExpandableDiv.vue:7 msgctxt "*/*/Button,Label" msgid "Show less" -msgstr "" +msgstr "Toon minder" #: front/src/components/common/ExpandableDiv.vue:8 -#, fuzzy msgctxt "*/*/Button,Label" msgid "Show more" -msgstr "Nog %{ count } album tonen" +msgstr "Toon meer" #: front/src/views/Notifications.vue:72 msgctxt "Content/Notifications/Form.Label/Verb" @@ -4246,10 +4247,9 @@ msgid "Shuffle your queue" msgstr "Shuffel je wachtrij" #: front/src/components/Home.vue:103 -#, fuzzy msgctxt "*/Signup/Title" msgid "Sign up" -msgstr "Registreren" +msgstr "Aanmelden" #: front/src/views/auth/Signup.vue:37 msgctxt "*/Signup/Title" @@ -4260,6 +4260,8 @@ msgstr "Registreren" msgctxt "Content/Home/Paragraph" msgid "Sign up now to keep a track of your favorites, create playlists, discover new content and much more!" msgstr "" +"Maak nu een account aan om je favorieten op te slaan, afspeellijsten te " +"maken en nieuwe content te ontdekken!" #: front/src/components/manage/users/UsersTable.vue:40 msgctxt "Content/Admin/Table.Label/Short, Noun (Value is a date)" @@ -4305,13 +4307,12 @@ msgstr "" #: front/src/components/playlists/Editor.vue:21 msgctxt "Content/Playlist/Paragraph" msgid "Some tracks in your queue are already in this playlist:" -msgstr "" +msgstr "Een aantal nummers in je wachtrij staat al in deze afspeellijst:" #: front/src/components/PageNotFound.vue:10 -#, fuzzy msgctxt "Content/*/Paragraph" msgid "Sorry, the page you asked for does not exist:" -msgstr "Helaas, de opgevraagde pagina bestaat niet:" +msgstr "Sorry, de opgevraagde pagina bestaat niet:" #: front/src/components/Footer.vue:57 msgctxt "Footer/*/List item.Link" @@ -4326,14 +4327,12 @@ msgstr "Staflid" #: front/src/components/audio/PlayButton.vue:27 #: src/components/radios/Button.vue:4 -#, fuzzy msgctxt "*/Queue/Button.Label/Short, Verb" msgid "Start radio" -msgstr "Radio stoppen" +msgstr "Radio starten" #: front/src/components/About.vue:170 src/components/Home.vue:53 #: front/src/views/admin/Settings.vue:87 -#, fuzzy msgctxt "Content/Home/Header" msgid "Statistics" msgstr "Statistieken" @@ -4367,7 +4366,6 @@ msgstr "Statistieken worden berekend op basis van de activiteit en media op je s #: front/src/views/admin/moderation/DomainsDetail.vue:152 #: front/src/views/admin/moderation/ReportsList.vue:14 #: front/src/views/content/libraries/Detail.vue:28 -#, fuzzy msgctxt "*/*/*" msgid "Status" msgstr "Status" @@ -4385,13 +4383,12 @@ msgstr "Indienen" #: front/src/components/library/EditForm.vue:110 msgctxt "Content/Library/Button.Label/Verb" msgid "Submit and apply edit" -msgstr "" +msgstr "Bewerking verzenden en toepassen" #: front/src/components/library/EditForm.vue:7 -#, fuzzy msgctxt "Content/Library/Button.Label" msgid "Submit another edit" -msgstr "Nóg een verzoek indienen" +msgstr "Nog een bewerking verzenden" #: front/src/components/moderation/ReportModal.vue:64 #, fuzzy @@ -4537,7 +4534,7 @@ msgstr "Funkwhale-logo met liefde ontworpen door Francis Gading." #: front/src/components/SetInstanceModal.vue:8 msgctxt "Popup/Instance/Error message.List item" msgid "The given address is not a Funkwhale server" -msgstr "" +msgstr "Het opgegeven adres is geen Funkwhale-server" #: front/src/views/content/libraries/Form.vue:34 msgctxt "Popup/Library/Paragraph" @@ -4636,7 +4633,7 @@ msgstr "De geselecteerde content zal worden verwijderd. Dit kan niet ongedaan ge #: front/src/components/SetInstanceModal.vue:7 msgctxt "Popup/Instance/Error message.List item" msgid "The server might be down" -msgstr "" +msgstr "De server is mogelijk niet beschikbaar" #: front/src/components/auth/SubsonicTokenForm.vue:4 msgctxt "Content/Settings/Paragraph" @@ -4866,7 +4863,9 @@ msgstr "Titel" #: front/src/components/SetInstanceModal.vue:16 msgctxt "Popup/Instance/Paragraph" msgid "To continue, please select the Funkwhale instance you want to connect to. Enter the address directly, or select one of the suggested choices." -msgstr "Selecteer met welke Funkwhale-server je wil verbinden om verder te gaan. Voer zelf de url in, of kies een van de suggesties." +msgstr "" +"Selecteer met welke Funkwhale-server je wil verbinden. Voer zelf de URL in, " +"of kies een van de suggesties." #: front/src/components/ShortcutsModal.vue:148 #, fuzzy @@ -5445,7 +5444,10 @@ msgstr "Je staat op het punt muziek te uploaden. Controleer voordat je doorgaat: #: front/src/components/SetInstanceModal.vue:12 msgctxt "Popup/Login/Paragraph" msgid "You are currently connected to %{ hostname } . If you continue, you will be disconnected from your current instance and all your local data will be deleted." -msgstr "Je bent momenteel verbonden met %{ hostname } . Als je doorgaat, zal je worden ontkoppeld van de huidige server en zal al je lokale data worden gewist." +msgstr "" +"Je bent momenteel verbonden met %{ " +"hostname } . Als je doorgaat, word je " +"ontkoppeld van de huidige server en zal al je lokale data worden gewist." #: front/src/components/library/ArtistDetail.vue:6 msgctxt "Content/Artist/Paragraph" @@ -5468,10 +5470,12 @@ msgid "You are now using the Funkwhale instance at %{ url }" msgstr "Je gebruikt nu de Funkwhale-server op %{ url }" #: front/src/views/content/Home.vue:17 -#, fuzzy msgctxt "Content/Library/Paragraph" msgid "You can follow libraries from other users to get access to new music. Public libraries can be followed immediately, while following a private library requires approval from its owner." -msgstr "Je kunt andere verzamelingen volgen voor toegang tot nieuwe muziek. Openbare verzamelingen kun je meteen volgen, maar voor privéverzamelingen heb je toestemming nodig van de beheerder." +msgstr "" +"Je kunt andere verzamelingen volgen voor toegang tot nieuwe muziek. Publiek " +"toegankelijke verzamelingen kun je meteen volgen – voor privéverzamelingen " +"heb je toestemming nodig van de beheerder." #: front/src/components/moderation/FilterModal.vue:31 msgctxt "Popup/Moderation/Paragraph" From f259081b01afe1011d63ff13af0bfa6b6a497a95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francesc=20Gal=C3=AD?= Date: Sun, 27 Oct 2019 19:45:16 +0000 Subject: [PATCH 014/322] Translated using Weblate (Catalan) Currently translated at 89.3% (852 of 954 strings) Translation: Funkwhale/Funkwhale's server front-end Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/ca/ --- front/locales/ca/LC_MESSAGES/app.po | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/front/locales/ca/LC_MESSAGES/app.po b/front/locales/ca/LC_MESSAGES/app.po index bd294b091..52c460363 100644 --- a/front/locales/ca/LC_MESSAGES/app.po +++ b/front/locales/ca/LC_MESSAGES/app.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: front 0.1.0\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2019-10-01 15:15+0200\n" -"PO-Revision-Date: 2019-10-24 02:30+0000\n" +"PO-Revision-Date: 2019-10-28 07:57+0000\n" "Last-Translator: Francesc Galí \n" "Language-Team: none\n" "Language: ca\n" @@ -586,7 +586,6 @@ msgid "Allow application" msgstr "Autoritzar l'aplicació" #: front/src/components/About.vue:136 -#, fuzzy msgctxt "*/*/*" msgid "Allow-list" msgstr "Llista d'autorització" @@ -2757,7 +2756,6 @@ msgid "Moderation policies help you control how your instance interact with a gi msgstr "Les regles de moderació t'ajudaran a controlar com la vostra instància interactua amb un domini o un compte determinat." #: front/src/components/manage/moderation/InstancePolicyModal.vue:4 -#, fuzzy msgctxt "Content/Moderation/Button.Label" msgid "Moderation rules…" msgstr "Regles de moderació …" @@ -3012,7 +3010,6 @@ msgid "Old value" msgstr "Valor antic" #: front/src/components/About.vue:90 -#, fuzzy msgctxt "*/*/*/State of registrations" msgid "Open" msgstr "Obert" @@ -3131,7 +3128,6 @@ msgstr "" #: front/src/components/manage/moderation/ReportCard.vue:143 #: front/src/components/manage/users/InvitationsTable.vue:38 -#, fuzzy msgctxt "*/*/*" msgid "Owner" msgstr "Propietari" @@ -3154,7 +3150,6 @@ msgstr "Paginació" #: front/src/components/auth/LoginForm.vue:32 #: src/components/auth/Settings.vue:291 #: front/src/components/auth/SignupForm.vue:36 -#, fuzzy msgctxt "*/*/*" msgid "Password" msgstr "Contrasenya" @@ -3282,7 +3277,6 @@ msgstr "Reprodueix la pista" #: front/src/components/mixins/Report.vue:66 src/views/playlists/Detail.vue:121 #: front/src/components/mixins/Report.vue:67 -#, fuzzy msgctxt "*/*/*" msgid "Playlist" msgstr "Llista de reproducció" @@ -3411,10 +3405,11 @@ msgid "Profile" msgstr "Perfil" #: front/src/components/auth/SignupForm.vue:5 -#, fuzzy msgctxt "Content/Signup/Form/Paragraph" msgid "Public registrations are not possible on this instance. You will need an invitation code to sign up." -msgstr "Les inscripcions estan tancades en aquesta instància, necessites un codi d'inscripció per registrar-te." +msgstr "" +"Les inscripcions estan tancades en aquesta instància, necessites un codi " +"d'inscripció per registrar-te." #: front/src/components/manage/moderation/AccountsTable.vue:188 #: front/src/components/manage/moderation/DomainsTable.vue:186 @@ -3541,10 +3536,9 @@ msgid "Recently added" msgstr "Afegit recentment" #: front/src/components/Home.vue:167 -#, fuzzy msgctxt "Content/Home/Title" msgid "Recently added albums" -msgstr "Afegit recentment" +msgstr "Àlbums afegits recentment" #: front/src/components/library/Home.vue:11 msgctxt "Content/Home/Title" @@ -3617,10 +3611,9 @@ msgid "Refreshing object from remote…" msgstr "Actualització de l'objecte des del servidor remot …" #: front/src/components/About.vue:86 -#, fuzzy msgctxt "*/*/*" msgid "Registrations" -msgstr "Administració" +msgstr "Inscripcions" #: front/src/components/manage/users/UsersTable.vue:72 msgctxt "Content/Admin/Table, User role" @@ -3690,10 +3683,9 @@ msgstr "Suprimir vel filtre" #: front/src/components/manage/moderation/DomainsTable.vue:198 #: front/src/views/admin/moderation/DomainsDetail.vue:39 -#, fuzzy msgctxt "Content/Moderation/Action/Verb" msgid "Remove from allow-list" -msgstr "Elimina dels preferits" +msgstr "Eliminar de la llista d'autoritzacions" #: front/src/components/favorites/TrackFavoriteIcon.vue:26 msgctxt "Content/Track/Icon.Tooltip/Verb" From c9a2439ecc5f36deb2c03d0d0654dc79bdee0322 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Mon, 4 Nov 2019 11:16:58 +0100 Subject: [PATCH 015/322] Fixed broken linting --- api/funkwhale_api/federation/activity.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py index e3fb7be32..096fd767f 100644 --- a/api/funkwhale_api/federation/activity.py +++ b/api/funkwhale_api/federation/activity.py @@ -330,10 +330,18 @@ class OutboxRouter(Router): cc = activity_data["payload"].pop("cc", []) a = models.Activity(**activity_data) a.uuid = uuid.uuid4() - to_inbox_items, to_deliveries, new_to = prepare_deliveries_and_inbox_items( + ( + to_inbox_items, + to_deliveries, + new_to, + ) = prepare_deliveries_and_inbox_items( to, "to", allowed_domains=allowed_domains ) - cc_inbox_items, cc_deliveries, new_cc = prepare_deliveries_and_inbox_items( + ( + cc_inbox_items, + cc_deliveries, + new_cc, + ) = prepare_deliveries_and_inbox_items( cc, "cc", allowed_domains=allowed_domains ) if not any( From 1c215ac2f18a86f95d2876d1d9ac9ec3f64ffea6 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Mon, 4 Nov 2019 11:18:14 +0100 Subject: [PATCH 016/322] Updated Pillow to 6.2 to fix DoS vulnerability --- api/requirements/base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/requirements/base.txt b/api/requirements/base.txt index 4f87e1ba8..cc24b10f6 100644 --- a/api/requirements/base.txt +++ b/api/requirements/base.txt @@ -5,7 +5,7 @@ django>=2.2.4,<2.3 django-environ>=0.4,<0.5 # Images -Pillow>=5.4,<5.5 +Pillow>=6.2,<7 # For user registration, either via email or social # Well-built with regular release cycles! From 77ff3c2ff21a0330eae0bda81f386dd203b5f70b Mon Sep 17 00:00:00 2001 From: Morgan Kesler Date: Tue, 5 Nov 2019 11:59:36 +0100 Subject: [PATCH 017/322] Add direct bind option for LDAP authentication --- api/config/settings/common.py | 3 +++ docs/installation/ldap.rst | 1 + 2 files changed, 4 insertions(+) diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 7b391b552..e5ac5a344 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -460,6 +460,9 @@ if AUTH_LDAP_ENABLED: "%(user)s" ) AUTH_LDAP_START_TLS = env.bool("LDAP_START_TLS", default=False) + AUTH_LDAP_BIND_AS_AUTHENTICATING_USER = env( + "AUTH_LDAP_BIND_AS_AUTHENTICATING_USER", default=False + ) DEFAULT_USER_ATTR_MAP = [ "first_name:givenName", diff --git a/docs/installation/ldap.rst b/docs/installation/ldap.rst index a30bb5e6b..dc5582f7d 100644 --- a/docs/installation/ldap.rst +++ b/docs/installation/ldap.rst @@ -31,6 +31,7 @@ Basic features: * ``LDAP_START_TLS``: Set to ``True`` to enable LDAP StartTLS support. Default: ``False``. * ``LDAP_ROOT_DN``: The LDAP search root DN, e.g. ``dc=my,dc=domain,dc=com``; supports multiple entries in a space-delimited list, e.g. ``dc=users,dc=domain,dc=com dc=admins,dc=domain,dc=com``. * ``LDAP_USER_ATTR_MAP``: A mapping of Django user attributes to LDAP values, e.g. ``first_name:givenName, last_name:sn, username:cn, email:mail``. Default: ``first_name:givenName, last_name:sn, username:cn, email:mail``. +* ``AUTH_LDAP_BIND_AS_AUTHENTICATING_USER``: Controls whether direct binding is used. Default: ``False``. Group features: From 1152c9da97713c781bf80b05ee2f502bdba8b515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Ainsworth?= Date: Wed, 6 Nov 2019 10:22:46 +0100 Subject: [PATCH 018/322] Resolve "Wrong sort used when sorting favorites by album name" --- api/funkwhale_api/music/views.py | 1 + changes/changelog.d/960.bugfix | 1 + 2 files changed, 2 insertions(+) create mode 100644 changes/changelog.d/960.bugfix diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 580ac28b7..c451d6b76 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -269,6 +269,7 @@ class TrackViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelVi ordering_fields = ( "creation_date", "title", + "album__title", "album__release_date", "size", "position", diff --git a/changes/changelog.d/960.bugfix b/changes/changelog.d/960.bugfix new file mode 100644 index 000000000..a3082e85d --- /dev/null +++ b/changes/changelog.d/960.bugfix @@ -0,0 +1 @@ +Fixed issue with sorting by album name not working (#960) \ No newline at end of file From 8a37ac0f340380ec0aed935252ace3ea0e28cfd0 Mon Sep 17 00:00:00 2001 From: Renon Date: Mon, 11 Nov 2019 11:32:07 +0100 Subject: [PATCH 019/322] Resolve "No feedback when search does not raise any result" --- front/src/components/audio/SearchBar.vue | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/front/src/components/audio/SearchBar.vue b/front/src/components/audio/SearchBar.vue index d73571fcb..ae2ae08fb 100644 --- a/front/src/components/audio/SearchBar.vue +++ b/front/src/components/audio/SearchBar.vue @@ -49,12 +49,17 @@ export default { jQuery(this.$el).search({ type: 'category', minCharacters: 3, + showNoResults: true, + error: { + noResultsHeader: this.$pgettext('Sidebar/Search/Error', 'No matches found'), + noResults: this.$pgettext('Sidebar/Search/Error.Label', 'Sorry, there are no results for this search') + }, onSelect (result, response) { router.push(result.routerUrl) }, onSearchQuery (query) { self.$emit('search') - searchQuery = query; + searchQuery = query }, apiSettings: { beforeXHR: function (xhrObject) { @@ -65,7 +70,8 @@ export default { return xhrObject }, onResponse: function (initialResponse) { - var results = {} + let results = {} + let isEmptyResults = true let categories = [ { code: 'artists', @@ -130,6 +136,7 @@ export default { results: [] } initialResponse[category.code].forEach(result => { + isEmptyResults = false let id = category.getId(result) results[category.code].results.push({ title: category.getTitle(result), @@ -144,7 +151,9 @@ export default { }) }) }) - return {results: results} + return { + results: isEmptyResults ? {} : results + } }, url: this.$store.getters['instance/absoluteUrl']('api/v1/search?query={query}') } From 82744bf193f146412d5d272f36f87e59aff5d938 Mon Sep 17 00:00:00 2001 From: Renon Date: Tue, 12 Nov 2019 15:51:45 +0100 Subject: [PATCH 020/322] Resolve "Option to remember our display settings " --- changes/changelog.d/391.enhancement | 1 + .../changelog.d}/943.bugfix | 0 front/src/components/library/Albums.vue | 6 ---- front/src/components/library/Artists.vue | 6 ---- front/src/components/library/Radios.vue | 8 +---- front/src/components/mixins/Ordering.vue | 35 +++++++++++++++++++ front/src/store/index.js | 2 +- front/src/store/ui.js | 35 +++++++++++++++++-- front/src/views/playlists/List.vue | 6 ---- 9 files changed, 71 insertions(+), 28 deletions(-) create mode 100644 changes/changelog.d/391.enhancement rename {front/src/components/playlists => changes/changelog.d}/943.bugfix (100%) diff --git a/changes/changelog.d/391.enhancement b/changes/changelog.d/391.enhancement new file mode 100644 index 000000000..28c19ecf3 --- /dev/null +++ b/changes/changelog.d/391.enhancement @@ -0,0 +1 @@ +Remember display settings in Album, Artist, Radio and Playlist views (#391) diff --git a/front/src/components/playlists/943.bugfix b/changes/changelog.d/943.bugfix similarity index 100% rename from front/src/components/playlists/943.bugfix rename to changes/changelog.d/943.bugfix diff --git a/front/src/components/library/Albums.vue b/front/src/components/library/Albums.vue index 07e20b22e..5ce51be1b 100644 --- a/front/src/components/library/Albums.vue +++ b/front/src/components/library/Albums.vue @@ -119,18 +119,12 @@ export default { TagsSelector, }, data() { - let defaultOrdering = this.getOrderingFromString( - this.defaultOrdering || "-creation_date" - ) return { isLoading: true, result: null, page: parseInt(this.defaultPage), query: this.defaultQuery, tags: (this.defaultTags || []).filter((t) => { return t.length > 0 }), - paginateBy: parseInt(this.defaultPaginateBy || 25), - orderingDirection: defaultOrdering.direction || "+", - ordering: defaultOrdering.field, orderingOptions: [["creation_date", "creation_date"], ["title", "album_title"]] } }, diff --git a/front/src/components/library/Artists.vue b/front/src/components/library/Artists.vue index a56abac14..f0dcd8a11 100644 --- a/front/src/components/library/Artists.vue +++ b/front/src/components/library/Artists.vue @@ -107,18 +107,12 @@ export default { TagsSelector, }, data() { - let defaultOrdering = this.getOrderingFromString( - this.defaultOrdering || "-creation_date" - ) return { isLoading: true, result: null, page: parseInt(this.defaultPage), query: this.defaultQuery, tags: (this.defaultTags || []).filter((t) => { return t.length > 0 }), - paginateBy: parseInt(this.defaultPaginateBy || 30), - orderingDirection: defaultOrdering.direction || "+", - ordering: defaultOrdering.field, orderingOptions: [["creation_date", "creation_date"], ["name", "name"]] } }, diff --git a/front/src/components/library/Radios.vue b/front/src/components/library/Radios.vue index a646321bc..b03801d80 100644 --- a/front/src/components/library/Radios.vue +++ b/front/src/components/library/Radios.vue @@ -93,7 +93,7 @@ v-for="radio in result.results" :key="radio.id" :custom-radio="radio"> - +
{ @@ -103,7 +125,16 @@ export default { }, pageTitle: (state, value) => { state.pageTitle = value - } + }, + paginateBy: (state, {route, value}) => { + state.routePreferences[route].paginateBy = value + }, + ordering: (state, {route, value}) => { + state.routePreferences[route].ordering = value + }, + orderingDirection: (state, {route, value}) => { + state.routePreferences[route].orderingDirection = value + }, }, actions: { fetchUnreadNotifications ({commit}, payload) { diff --git a/front/src/views/playlists/List.vue b/front/src/views/playlists/List.vue index 9fb53fd66..dad029cf4 100644 --- a/front/src/views/playlists/List.vue +++ b/front/src/views/playlists/List.vue @@ -94,17 +94,11 @@ export default { Pagination }, data() { - let defaultOrdering = this.getOrderingFromString( - this.defaultOrdering || "-creation_date" - ) return { isLoading: true, result: null, page: parseInt(this.defaultPage), query: this.defaultQuery, - paginateBy: parseInt(this.defaultPaginateBy || 12), - orderingDirection: defaultOrdering.direction || "+", - ordering: defaultOrdering.field, orderingOptions: [ ["creation_date", "creation_date"], ["modification_date", "modification_date"], From 22240b9f945a88c836f8129d2909bfb3552ebe5b Mon Sep 17 00:00:00 2001 From: Renon Date: Wed, 13 Nov 2019 11:06:19 +0100 Subject: [PATCH 021/322] unset padding from playlist card cover bg --- changes/changelog.d/680.bugfix | 1 + front/src/components/playlists/Card.vue | 1 + 2 files changed, 2 insertions(+) create mode 100644 changes/changelog.d/680.bugfix diff --git a/changes/changelog.d/680.bugfix b/changes/changelog.d/680.bugfix new file mode 100644 index 000000000..ebe4636ee --- /dev/null +++ b/changes/changelog.d/680.bugfix @@ -0,0 +1 @@ +Set correct size for album covers in playlist cards (#680) diff --git a/front/src/components/playlists/Card.vue b/front/src/components/playlists/Card.vue index a8e5c149a..39b8a583f 100644 --- a/front/src/components/playlists/Card.vue +++ b/front/src/components/playlists/Card.vue @@ -88,5 +88,6 @@ export default { /* background-position: 0 0, 25% 0, 50% 0, 75% 0, 100% 0; */ font-size: 4em; box-shadow: 0px 0px 0px 1px rgba(34, 36, 38, 0.15) inset !important; + padding: unset; } From d94e5ab01352fb611303497a7cb31223ec7ce680 Mon Sep 17 00:00:00 2001 From: Renon Date: Thu, 14 Nov 2019 15:40:47 +0100 Subject: [PATCH 022/322] order playlists by modification date in browse tab --- changes/changelog.d/775.enhancement | 1 + front/src/components/library/Home.vue | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changes/changelog.d/775.enhancement diff --git a/changes/changelog.d/775.enhancement b/changes/changelog.d/775.enhancement new file mode 100644 index 000000000..c63ebec93 --- /dev/null +++ b/changes/changelog.d/775.enhancement @@ -0,0 +1 @@ +Order the playlist columns by modification date in the Browse tab (#775) diff --git a/front/src/components/library/Home.vue b/front/src/components/library/Home.vue index 9ebf84dc3..60c3d5394 100644 --- a/front/src/components/library/Home.vue +++ b/front/src/components/library/Home.vue @@ -13,7 +13,7 @@
- +
From 4dcdc93958507cb670621b0fbcf0aa13907a0def Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 14 Nov 2019 15:47:18 +0100 Subject: [PATCH 023/322] Fix #966: More robust importer against malformed dates --- api/funkwhale_api/music/metadata.py | 2 +- api/tests/music/test_metadata.py | 1 + changes/changelog.d/966.bugfix | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 changes/changelog.d/966.bugfix diff --git a/api/funkwhale_api/music/metadata.py b/api/funkwhale_api/music/metadata.py index d22f637fd..9b3b6a387 100644 --- a/api/funkwhale_api/music/metadata.py +++ b/api/funkwhale_api/music/metadata.py @@ -556,7 +556,7 @@ class PermissiveDateField(serializers.CharField): try: parsed = pendulum.parse(str(value)) return datetime.date(parsed.year, parsed.month, parsed.day) - except pendulum.exceptions.ParserError: + except (pendulum.exceptions.ParserError, ValueError): pass return None diff --git a/api/tests/music/test_metadata.py b/api/tests/music/test_metadata.py index 6c9b03846..f6aa513ab 100644 --- a/api/tests/music/test_metadata.py +++ b/api/tests/music/test_metadata.py @@ -239,6 +239,7 @@ def test_can_get_metadata_from_flac_file_not_crash_if_empty(): ("2017-12-31", datetime.date(2017, 12, 31)), ("2017-14-01 01:32", datetime.date(2017, 1, 14)), # deezer format ("2017-02", datetime.date(2017, 1, 1)), # weird format that exists + ("0000", None), ("nonsense", None), ], ) diff --git a/changes/changelog.d/966.bugfix b/changes/changelog.d/966.bugfix new file mode 100644 index 000000000..c3718fcb6 --- /dev/null +++ b/changes/changelog.d/966.bugfix @@ -0,0 +1 @@ +More robust importer against malformed dates (#966) From 654d206033c3e372fd65ce34fbe7a1d3878e1072 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Mon, 25 Nov 2019 09:45:53 +0100 Subject: [PATCH 024/322] Server CLI: user management --- api/funkwhale_api/cli/__init__.py | 0 api/funkwhale_api/cli/base.py | 65 +++++++ api/funkwhale_api/cli/main.py | 19 ++ api/funkwhale_api/cli/users.py | 234 +++++++++++++++++++++++++ api/funkwhale_api/cli/utils.py | 3 + api/manage.py | 9 +- api/requirements/base.txt | 2 + api/tests/cli/__init__.py | 0 api/tests/cli/test_main.py | 118 +++++++++++++ api/tests/cli/test_users.py | 147 ++++++++++++++++ changes/changelog.d/server-cli.feature | 1 + changes/notes.rst | 15 ++ docs/admin/commands.rst | 88 ++++++++++ 13 files changed, 700 insertions(+), 1 deletion(-) create mode 100644 api/funkwhale_api/cli/__init__.py create mode 100644 api/funkwhale_api/cli/base.py create mode 100644 api/funkwhale_api/cli/main.py create mode 100644 api/funkwhale_api/cli/users.py create mode 100644 api/funkwhale_api/cli/utils.py create mode 100644 api/tests/cli/__init__.py create mode 100644 api/tests/cli/test_main.py create mode 100644 api/tests/cli/test_users.py create mode 100644 changes/changelog.d/server-cli.feature diff --git a/api/funkwhale_api/cli/__init__.py b/api/funkwhale_api/cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/funkwhale_api/cli/base.py b/api/funkwhale_api/cli/base.py new file mode 100644 index 000000000..439d91ff6 --- /dev/null +++ b/api/funkwhale_api/cli/base.py @@ -0,0 +1,65 @@ +import click +import functools + + +@click.group() +def cli(): + pass + + +def confirm_action(f, id_var, message_template="Do you want to proceed?"): + @functools.wraps(f) + def action(*args, **kwargs): + if id_var: + id_value = kwargs[id_var] + message = message_template.format(len(id_value)) + else: + message = message_template + if not kwargs.pop("no_input", False) and not click.confirm(message, abort=True): + return + + return f(*args, **kwargs) + + return action + + +def delete_command( + group, + id_var="id", + name="rm", + message_template="Do you want to delete {} objects? This action is irreversible.", +): + """ + Wrap a command to ensure it asks for confirmation before deletion, unless the --no-input + flag is provided + """ + + def decorator(f): + decorated = click.option("--no-input", is_flag=True)(f) + decorated = confirm_action( + decorated, id_var=id_var, message_template=message_template + ) + return group.command(name)(decorated) + + return decorator + + +def update_command( + group, + id_var="id", + name="set", + message_template="Do you want to update {} objects? This action may have irreversible consequnces.", +): + """ + Wrap a command to ensure it asks for confirmation before deletion, unless the --no-input + flag is provided + """ + + def decorator(f): + decorated = click.option("--no-input", is_flag=True)(f) + decorated = confirm_action( + decorated, id_var=id_var, message_template=message_template + ) + return group.command(name)(decorated) + + return decorator diff --git a/api/funkwhale_api/cli/main.py b/api/funkwhale_api/cli/main.py new file mode 100644 index 000000000..a51cca1fd --- /dev/null +++ b/api/funkwhale_api/cli/main.py @@ -0,0 +1,19 @@ +import click +import sys + +from . import base +from . import users # noqa + +from rest_framework.exceptions import ValidationError + + +def invoke(): + try: + return base.cli() + except ValidationError as e: + click.secho("Invalid data:", fg="red") + for field, errors in e.detail.items(): + click.secho(" {}:".format(field), fg="red") + for error in errors: + click.secho(" - {}".format(error), fg="red") + sys.exit(1) diff --git a/api/funkwhale_api/cli/users.py b/api/funkwhale_api/cli/users.py new file mode 100644 index 000000000..678b19f81 --- /dev/null +++ b/api/funkwhale_api/cli/users.py @@ -0,0 +1,234 @@ +import click + +from django.db import transaction + +from funkwhale_api.federation import models as federation_models +from funkwhale_api.users import models +from funkwhale_api.users import serializers +from funkwhale_api.users import tasks + +from . import base +from . import utils + + +class FakeRequest(object): + def __init__(self, session={}): + self.session = session + + +@transaction.atomic +def handler_create_user( + username, + password, + email, + is_superuser=False, + is_staff=False, + permissions=[], + upload_quota=None, +): + serializer = serializers.RS( + data={ + "username": username, + "email": email, + "password1": password, + "password2": password, + } + ) + utils.logger.debug("Validating user data…") + serializer.is_valid(raise_exception=True) + + # Override email validation, we assume accounts created from CLI have a valid email + request = FakeRequest(session={"account_verified_email": email}) + utils.logger.debug("Creating user…") + user = serializer.save(request=request) + utils.logger.debug("Setting permissions and other attributes…") + user.is_staff = is_staff + user.upload_quota = upload_quota + user.is_superuser = is_superuser + for permission in permissions: + if permission in models.PERMISSIONS: + utils.logger.debug("Setting %s permission to True", permission) + setattr(user, "permission_{}".format(permission), True) + else: + utils.logger.warn("Unknown permission %s", permission) + utils.logger.debug("Creating actor…") + user.actor = models.create_actor(user) + user.save() + return user + + +@transaction.atomic +def handler_delete_user(usernames, soft=True): + for username in usernames: + click.echo("Deleting {}…".format(username)) + actor = None + user = None + try: + user = models.User.objects.get(username=username) + except models.User.DoesNotExist: + try: + actor = federation_models.Actor.objects.local().get( + preferred_username=username + ) + except federation_models.Actor.DoesNotExist: + click.echo(" Not found, skipping") + continue + + actor = actor or user.actor + if user: + tasks.delete_account(user_id=user.pk) + if not soft: + click.echo(" Hard delete, removing actor") + actor.delete() + click.echo(" Done") + + +@transaction.atomic +def handler_update_user(usernames, kwargs): + users = models.User.objects.filter(username__in=usernames) + total = users.count() + if not total: + click.echo("No matching users") + return + + final_kwargs = {} + supported_fields = [ + "is_active", + "permission_moderation", + "permission_library", + "permission_settings", + "is_staff", + "is_superuser", + "upload_quota", + "password", + ] + for field in supported_fields: + try: + value = kwargs[field] + except KeyError: + continue + final_kwargs[field] = value + + click.echo( + "Updating {} on {} matching users…".format( + ", ".join(final_kwargs.keys()), total + ) + ) + if "password" in final_kwargs: + new_password = final_kwargs.pop("password") + for user in users: + user.set_password(new_password) + models.User.objects.bulk_update(users, ["password"]) + if final_kwargs: + users.update(**final_kwargs) + click.echo("Done!") + + +@base.cli.group() +def users(): + """Manage users""" + pass + + +@users.command() +@click.option("--username", "-u", prompt=True, required=True) +@click.option( + "-p", + "--password", + prompt="Password (leave empty to have a random one generated)", + hide_input=True, + envvar="FUNKWHALE_CLI_USER_PASSWORD", + default="", + help="If empty, a random password will be generated and displayed in console output", +) +@click.option( + "-e", + "--email", + prompt=True, + help="Email address to associate with the account", + required=True, +) +@click.option( + "-q", + "--upload-quota", + help="Upload quota (leave empty to use default pod quota)", + required=False, + default=None, + type=click.INT, +) +@click.option( + "--superuser/--no-superuser", default=False, +) +@click.option( + "--staff/--no-staff", default=False, +) +@click.option( + "--permission", multiple=True, +) +def create(username, password, email, superuser, staff, permission, upload_quota): + """Create a new user""" + generated_password = None + if password == "": + generated_password = models.User.objects.make_random_password() + user = handler_create_user( + username=username, + password=password or generated_password, + email=email, + is_superuser=superuser, + is_staff=staff, + permissions=permission, + upload_quota=upload_quota, + ) + click.echo("User {} created!".format(user.username)) + if generated_password: + click.echo(" Generated password: {}".format(generated_password)) + + +@base.delete_command(group=users, id_var="username") +@click.argument("username", nargs=-1) +@click.option( + "--hard/--no-hard", + default=False, + help="Purge all user-related info (allow recreating a user with the same username)", +) +def delete(username, hard): + """Delete given users""" + handler_delete_user(usernames=username, soft=not hard) + + +@base.update_command(group=users, id_var="username") +@click.argument("username", nargs=-1) +@click.option( + "--active/--inactive", + help="Mark as active or inactive (inactive users cannot login or use the service)", + default=None, +) +@click.option("--superuser/--no-superuser", default=None) +@click.option("--staff/--no-staff", default=None) +@click.option("--permission-library/--no-permission-library", default=None) +@click.option("--permission-moderation/--no-permission-moderation", default=None) +@click.option("--permission-settings/--no-permission-settings", default=None) +@click.option("--password", default=None, envvar="FUNKWHALE_CLI_USER_UPDATE_PASSWORD") +@click.option( + "-q", "--upload-quota", type=click.INT, +) +def update(username, **kwargs): + """Update attributes for given users""" + field_mapping = { + "active": "is_active", + "superuser": "is_superuser", + "staff": "is_staff", + } + final_kwargs = {} + for cli_field, value in kwargs.items(): + if value is None: + continue + model_field = ( + field_mapping[cli_field] if cli_field in field_mapping else cli_field + ) + final_kwargs[model_field] = value + + if not final_kwargs: + raise click.BadArgumentUsage("You need to update at least one attribute") + + handler_update_user(usernames=username, kwargs=final_kwargs) diff --git a/api/funkwhale_api/cli/utils.py b/api/funkwhale_api/cli/utils.py new file mode 100644 index 000000000..08dd1a6fa --- /dev/null +++ b/api/funkwhale_api/cli/utils.py @@ -0,0 +1,3 @@ +import logging + +logger = logging.getLogger("funkwhale_api.cli") diff --git a/api/manage.py b/api/manage.py index c8db95ede..8331a33ec 100755 --- a/api/manage.py +++ b/api/manage.py @@ -17,4 +17,11 @@ if __name__ == "__main__": from django.core.management import execute_from_command_line - execute_from_command_line(sys.argv) + if len(sys.argv) > 1 and sys.argv[1] in ["fw", "funkwhale"]: + # trigger our own click-based cli + from funkwhale_api.cli import main + + sys.argv = sys.argv[1:] + main.invoke() + else: + execute_from_command_line(sys.argv) diff --git a/api/requirements/base.txt b/api/requirements/base.txt index cc24b10f6..b651d9c4d 100644 --- a/api/requirements/base.txt +++ b/api/requirements/base.txt @@ -73,3 +73,5 @@ django-storages==1.7.1 boto3<3 unicode-slugify django-cacheops==4.2 + +click>=7,<8 diff --git a/api/tests/cli/__init__.py b/api/tests/cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/tests/cli/test_main.py b/api/tests/cli/test_main.py new file mode 100644 index 000000000..4cea9e414 --- /dev/null +++ b/api/tests/cli/test_main.py @@ -0,0 +1,118 @@ +import pytest + +from click.testing import CliRunner + +from funkwhale_api.cli import main +from funkwhale_api.cli import users + + +@pytest.mark.parametrize( + "cmd, args, handlers", + [ + ( + ("users", "create"), + ( + "--username", + "testuser", + "--password", + "testpassword", + "--email", + "test@hello.com", + "--upload-quota", + "35", + "--permission", + "library", + "--permission", + "moderation", + "--staff", + "--superuser", + ), + [ + ( + users, + "handler_create_user", + { + "username": "testuser", + "password": "testpassword", + "email": "test@hello.com", + "upload_quota": 35, + "permissions": ("library", "moderation"), + "is_staff": True, + "is_superuser": True, + }, + ) + ], + ), + ( + ("users", "rm"), + ("testuser1", "testuser2", "--no-input"), + [ + ( + users, + "handler_delete_user", + {"usernames": ("testuser1", "testuser2"), "soft": True}, + ) + ], + ), + ( + ("users", "rm"), + ("testuser1", "testuser2", "--no-input", "--hard",), + [ + ( + users, + "handler_delete_user", + {"usernames": ("testuser1", "testuser2"), "soft": False}, + ) + ], + ), + ( + ("users", "set"), + ( + "testuser1", + "testuser2", + "--no-input", + "--inactive", + "--upload-quota", + "35", + "--no-staff", + "--superuser", + "--permission-library", + "--no-permission-moderation", + "--no-permission-settings", + "--password", + "newpassword", + ), + [ + ( + users, + "handler_update_user", + { + "usernames": ("testuser1", "testuser2"), + "kwargs": { + "is_active": False, + "upload_quota": 35, + "is_staff": False, + "is_superuser": True, + "permission_library": True, + "permission_moderation": False, + "permission_settings": False, + "password": "newpassword", + }, + }, + ) + ], + ), + ], +) +def test_cli(cmd, args, handlers, mocker): + patched_handlers = {} + for module, path, _ in handlers: + patched_handlers[(module, path)] = mocker.spy(module, path) + + runner = CliRunner() + result = runner.invoke(main.base.cli, cmd + args) + + assert result.exit_code == 0, result.output + + for module, path, expected_call in handlers: + patched_handlers[(module, path)].assert_called_once_with(**expected_call) diff --git a/api/tests/cli/test_users.py b/api/tests/cli/test_users.py new file mode 100644 index 000000000..5f0c63bdb --- /dev/null +++ b/api/tests/cli/test_users.py @@ -0,0 +1,147 @@ +import pytest + +from funkwhale_api.cli import users + + +def test_user_create_handler(factories, mocker, now): + kwargs = { + "username": "helloworld", + "password": "securepassword", + "is_superuser": False, + "is_staff": True, + "email": "hello@world.email", + "upload_quota": 35, + "permissions": ["moderation"], + } + set_password = mocker.spy(users.models.User, "set_password") + create_actor = mocker.spy(users.models, "create_actor") + user = users.handler_create_user(**kwargs) + + assert user.username == kwargs["username"] + assert user.is_superuser == kwargs["is_superuser"] + assert user.is_staff == kwargs["is_staff"] + assert user.date_joined >= now + assert user.upload_quota == kwargs["upload_quota"] + set_password.assert_called_once_with(user, kwargs["password"]) + create_actor.assert_called_once_with(user) + + expected_permissions = { + p: p in kwargs["permissions"] for p in users.models.PERMISSIONS + } + + assert user.all_permissions == expected_permissions + + +def test_user_delete_handler_soft(factories, mocker, now): + user1 = factories["federation.Actor"](local=True).user + actor1 = user1.actor + user2 = factories["federation.Actor"](local=True).user + actor2 = user2.actor + user3 = factories["federation.Actor"](local=True).user + delete_account = mocker.spy(users.tasks, "delete_account") + users.handler_delete_user([user1.username, user2.username, "unknown"]) + + assert delete_account.call_count == 2 + delete_account.assert_any_call(user_id=user1.pk) + with pytest.raises(user1.DoesNotExist): + user1.refresh_from_db() + + delete_account.assert_any_call(user_id=user2.pk) + with pytest.raises(user2.DoesNotExist): + user2.refresh_from_db() + + # soft delete, actor shouldn't be deleted + actor1.refresh_from_db() + actor2.refresh_from_db() + + # not deleted + user3.refresh_from_db() + + +def test_user_delete_handler_hard(factories, mocker, now): + user1 = factories["federation.Actor"](local=True).user + actor1 = user1.actor + user2 = factories["federation.Actor"](local=True).user + actor2 = user2.actor + user3 = factories["federation.Actor"](local=True).user + delete_account = mocker.spy(users.tasks, "delete_account") + users.handler_delete_user([user1.username, user2.username, "unknown"], soft=False) + + assert delete_account.call_count == 2 + delete_account.assert_any_call(user_id=user1.pk) + with pytest.raises(user1.DoesNotExist): + user1.refresh_from_db() + + delete_account.assert_any_call(user_id=user2.pk) + with pytest.raises(user2.DoesNotExist): + user2.refresh_from_db() + + # hard delete, actors are deleted as well + with pytest.raises(actor1.DoesNotExist): + actor1.refresh_from_db() + + with pytest.raises(actor2.DoesNotExist): + actor2.refresh_from_db() + + # not deleted + user3.refresh_from_db() + + +@pytest.mark.parametrize( + "params, expected", + [ + ({"is_active": False}, {"is_active": False}), + ( + {"is_staff": True, "is_superuser": True}, + {"is_staff": True, "is_superuser": True}, + ), + ({"upload_quota": 35}, {"upload_quota": 35}), + ( + { + "permission_library": True, + "permission_moderation": True, + "permission_settings": True, + }, + { + "all_permissions": { + "library": True, + "moderation": True, + "settings": True, + } + }, + ), + ], +) +def test_user_update_handler(params, expected, factories): + user1 = factories["federation.Actor"](local=True).user + user2 = factories["federation.Actor"](local=True).user + user3 = factories["federation.Actor"](local=True).user + + def get_field_values(user): + return {f: getattr(user, f) for f, v in expected.items()} + + unchanged = get_field_values(user3) + + users.handler_update_user([user1.username, user2.username, "unknown"], params) + + user1.refresh_from_db() + user2.refresh_from_db() + user3.refresh_from_db() + + assert get_field_values(user1) == expected + assert get_field_values(user2) == expected + assert get_field_values(user3) == unchanged + + +def test_user_update_handler_password(factories, mocker): + user = factories["federation.Actor"](local=True).user + current_password = user.password + + set_password = mocker.spy(users.models.User, "set_password") + + users.handler_update_user([user.username], {"password": "hello"}) + + user.refresh_from_db() + + set_password.assert_called_once_with(user, "hello") + assert user.password != current_password diff --git a/changes/changelog.d/server-cli.feature b/changes/changelog.d/server-cli.feature new file mode 100644 index 000000000..11eb9ec25 --- /dev/null +++ b/changes/changelog.d/server-cli.feature @@ -0,0 +1 @@ +User management through the server CLI diff --git a/changes/notes.rst b/changes/notes.rst index 96ac3d765..7a84fce9a 100644 --- a/changes/notes.rst +++ b/changes/notes.rst @@ -5,3 +5,18 @@ Next release notes Those release notes refer to the current development branch and are reset after each release. + +User management through the server CLI +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +We now support user creation (incl. non-admin accounts), update and removal directly +from the server CLI. Typical use cases include: + +- Changing a user password from the command line +- Creating or updating users from deployments scripts or playbooks +- Removing or granting permissions or upload quota to multiple users at once +- Marking multiple users as inactive + +All user-related commands are available under the ``python manage.py fw users`` namespace. +Please refer to the `Admin documentation `_ for +more information and instructions. diff --git a/docs/admin/commands.rst b/docs/admin/commands.rst index c30a67a99..b771e4fe9 100644 --- a/docs/admin/commands.rst +++ b/docs/admin/commands.rst @@ -1,6 +1,94 @@ Management commands =================== +User management +--------------- + +It's possible to create, remove and update users directly from the command line. + +This feature is useful if you want to experiment, automate or perform batch actions that +would be too repetitive through the web UI. + +All users-related commands are available under the ``python manage.py fw users`` namespace: + +.. code-block:: sh + + # print subcommands and help + python manage.py fw users --help + + +Creation +^^^^^^^^ + +.. code-block:: sh + + # print help + python manage.py fw users create --help + + # create a user interactively + python manage.py fw users create + + # create a user with a random password + python manage.py fw users create --username alice --email alice@email.host -p "" + + # create a user with password set from an environment variable + export FUNKWHALE_CLI_USER_PASSWORD=securepassword + python manage.py fw users create --username bob --email bob@email.host + +Additional options are available to further configure the user during creation, such as +setting permissions or user quota. Please refer to the command help. + + +Update +^^^^^^ + +.. code-block:: sh + + # print help + python manage.py fw users set --help + + # set upload quota to 500MB for alice + python manage.py fw users set --upload-quota 500 alice + + # disable confirmation prompt with --no-input + python manage.py fw users set --no-input --upload-quota 500 alice + + # make alice and bob staff members + python manage.py fw users set --staff --superuser alice bob + + # remove staff privileges from bob + python manage.py fw users set --no-staff --no-superuser bob + + # give bob moderation permission + python manage.py fw users set --permission-moderation bob + + # reset alice's password + python manage.py fw users set --password "securepassword" alice + + # reset bob's password through an environment variable + export FUNKWHALE_CLI_USER_UPDATE_PASSWORD=newsecurepassword + python manage.py fw users set bob + +Deletion +^^^^^^^^ + +.. code-block:: sh + + # print help + python manage.py fw users rm --help + + # delete bob's account, but keep a reference to their account in the database + # to prevent future signup with the same username + python manage.py fw users rm bob + + # delete alice's account, with no confirmation prompt + python manage.py fw users rm --no-input alice + + # delete alice and bob accounts, including all reference to their account + # (people will be able to signup again with their usernames) + python manage.py fw users rm --hard alice bob + + Pruning library --------------- From c84396e669ec3e4bb0c9e7e4c2d925facf6df25c Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Mon, 25 Nov 2019 09:49:06 +0100 Subject: [PATCH 025/322] Attachments --- api/config/api_urls.py | 1 + api/config/settings/common.py | 17 ++- api/funkwhale_api/common/admin.py | 17 +++ api/funkwhale_api/common/factories.py | 11 ++ api/funkwhale_api/common/middleware.py | 7 +- .../migrations/0004_auto_20191111_1338.py | 35 ++++++ api/funkwhale_api/common/models.py | 108 ++++++++++++++++++ .../common/scripts/create_image_variations.py | 7 +- api/funkwhale_api/common/serializers.py | 35 ++++++ api/funkwhale_api/common/session.py | 9 +- api/funkwhale_api/common/tasks.py | 43 ++++++- api/funkwhale_api/common/views.py | 39 +++++++ api/funkwhale_api/favorites/views.py | 4 +- api/funkwhale_api/federation/actors.py | 5 +- api/funkwhale_api/federation/library.py | 9 +- api/funkwhale_api/federation/models.py | 1 - api/funkwhale_api/federation/serializers.py | 11 +- api/funkwhale_api/federation/tasks.py | 14 +-- api/funkwhale_api/federation/utils.py | 2 - api/funkwhale_api/federation/views.py | 28 ++++- api/funkwhale_api/federation/webfinger.py | 4 +- api/funkwhale_api/manage/views.py | 12 +- .../management/commands/mrf_check.py | 7 +- api/funkwhale_api/music/factories.py | 3 +- .../migrations/0042_album_attachment_cover.py | 20 ++++ .../migrations/0043_album_cover_attachment.py | 41 +++++++ api/funkwhale_api/music/models.py | 73 ++++++------ api/funkwhale_api/music/serializers.py | 83 ++++++++------ api/funkwhale_api/music/spa_views.py | 28 ++--- api/funkwhale_api/music/tasks.py | 9 +- api/funkwhale_api/music/views.py | 6 +- api/funkwhale_api/playlists/models.py | 10 +- api/funkwhale_api/playlists/serializers.py | 2 +- api/funkwhale_api/subsonic/serializers.py | 6 +- api/funkwhale_api/subsonic/views.py | 16 ++- api/tests/common/test_models.py | 25 ++++ api/tests/common/test_serializers.py | 70 ++++++++++++ api/tests/common/test_tasks.py | 23 ++++ api/tests/common/test_views.py | 68 +++++++++++ api/tests/federation/test_serializers.py | 6 +- api/tests/manage/test_serializers.py | 17 +-- api/tests/music/test_models.py | 2 +- api/tests/music/test_music.py | 4 +- api/tests/music/test_serializers.py | 24 ++-- api/tests/music/test_spa_views.py | 16 +-- api/tests/music/test_tasks.py | 17 +-- api/tests/music/test_views.py | 28 +++-- api/tests/playlists/test_serializers.py | 12 +- api/tests/subsonic/test_views.py | 2 +- docs/swagger.yml | 103 +++++++++++++---- 50 files changed, 879 insertions(+), 261 deletions(-) create mode 100644 api/funkwhale_api/common/migrations/0004_auto_20191111_1338.py create mode 100644 api/funkwhale_api/music/migrations/0042_album_attachment_cover.py create mode 100644 api/funkwhale_api/music/migrations/0043_album_cover_attachment.py diff --git a/api/config/api_urls.py b/api/config/api_urls.py index cdcc74391..dc5ef22a3 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -28,6 +28,7 @@ router.register( r"playlist-tracks", playlists_views.PlaylistTrackViewSet, "playlist-tracks" ) router.register(r"mutations", common_views.MutationViewSet, "mutations") +router.register(r"attachments", common_views.AttachmentViewSet, "attachments") v1_patterns = router.urls subsonic_router = routers.SimpleRouter(trailing_slash=False) diff --git a/api/config/settings/common.py b/api/config/settings/common.py index e5ac5a344..1ec11e7ff 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -392,6 +392,11 @@ MEDIA_ROOT = env("MEDIA_ROOT", default=str(APPS_DIR("media"))) # See: https://docs.djangoproject.com/en/dev/ref/settings/#media-url MEDIA_URL = env("MEDIA_URL", default="/media/") FILE_UPLOAD_PERMISSIONS = 0o644 + +ATTACHMENTS_UNATTACHED_PRUNE_DELAY = env.int( + "ATTACHMENTS_UNATTACHED_PRUNE_DELAY", default=3600 * 24 +) + # URL Configuration # ------------------------------------------------------------------------------ ROOT_URLCONF = "config.urls" @@ -558,6 +563,11 @@ CELERY_BROKER_URL = env( CELERY_TASK_DEFAULT_RATE_LIMIT = 1 CELERY_TASK_TIME_LIMIT = 300 CELERY_BEAT_SCHEDULE = { + "common.prune_unattached_attachments": { + "task": "common.prune_unattached_attachments", + "schedule": crontab(minute="0", hour="*"), + "options": {"expires": 60 * 60}, + }, "federation.clean_music_cache": { "task": "federation.clean_music_cache", "schedule": crontab(minute="0", hour="*/2"), @@ -856,6 +866,7 @@ ACCOUNT_USERNAME_BLACKLIST = [ ] + env.list("ACCOUNT_USERNAME_BLACKLIST", default=[]) EXTERNAL_REQUESTS_VERIFY_SSL = env.bool("EXTERNAL_REQUESTS_VERIFY_SSL", default=True) +EXTERNAL_REQUESTS_TIMEOUT = env.int("EXTERNAL_REQUESTS_TIMEOUT", default=5) # XXX: deprecated, see #186 API_AUTHENTICATION_REQUIRED = env.bool("API_AUTHENTICATION_REQUIRED", True) @@ -878,7 +889,11 @@ VERSATILEIMAGEFIELD_RENDITION_KEY_SETS = { ("square_crop", "crop__400x400"), ("medium_square_crop", "crop__200x200"), ("small_square_crop", "crop__50x50"), - ] + ], + "attachment_square": [ + ("original", "url"), + ("medium_square_crop", "crop__200x200"), + ], } VERSATILEIMAGEFIELD_SETTINGS = {"create_images_on_demand": False} RSA_KEY_SIZE = 2048 diff --git a/api/funkwhale_api/common/admin.py b/api/funkwhale_api/common/admin.py index 3ec6f1f44..39a112531 100644 --- a/api/funkwhale_api/common/admin.py +++ b/api/funkwhale_api/common/admin.py @@ -45,3 +45,20 @@ class MutationAdmin(ModelAdmin): search_fields = ["created_by__preferred_username"] list_filter = ["type", "is_approved", "is_applied"] actions = [apply] + + +@register(models.Attachment) +class AttachmentAdmin(ModelAdmin): + list_display = [ + "uuid", + "actor", + "url", + "file", + "size", + "mimetype", + "creation_date", + "last_fetch_date", + ] + list_select_related = True + search_fields = ["actor__domain__name"] + list_filter = ["mimetype"] diff --git a/api/funkwhale_api/common/factories.py b/api/funkwhale_api/common/factories.py index 6919f9c37..85a441e85 100644 --- a/api/funkwhale_api/common/factories.py +++ b/api/funkwhale_api/common/factories.py @@ -23,3 +23,14 @@ class MutationFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): return self.target = extracted self.save() + + +@registry.register +class AttachmentFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): + url = factory.Faker("federation_url") + uuid = factory.Faker("uuid4") + actor = factory.SubFactory(federation_factories.ActorFactory) + file = factory.django.ImageField() + + class Meta: + model = "common.Attachment" diff --git a/api/funkwhale_api/common/middleware.py b/api/funkwhale_api/common/middleware.py index d8573baaa..3b9fc17e5 100644 --- a/api/funkwhale_api/common/middleware.py +++ b/api/funkwhale_api/common/middleware.py @@ -1,6 +1,5 @@ import html import io -import requests import time import xml.sax.saxutils @@ -11,6 +10,7 @@ from django import urls from rest_framework import views from . import preferences +from . import session from . import throttling from . import utils @@ -76,10 +76,7 @@ def get_spa_html(spa_url): if cached: return cached - response = requests.get( - utils.join_url(spa_url, "index.html"), - verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, - ) + response = session.get_session().get(utils.join_url(spa_url, "index.html"),) response.raise_for_status() content = response.text caches["local"].set(cache_key, content, settings.FUNKWHALE_SPA_HTML_CACHE_DURATION) diff --git a/api/funkwhale_api/common/migrations/0004_auto_20191111_1338.py b/api/funkwhale_api/common/migrations/0004_auto_20191111_1338.py new file mode 100644 index 000000000..f35c9e11f --- /dev/null +++ b/api/funkwhale_api/common/migrations/0004_auto_20191111_1338.py @@ -0,0 +1,35 @@ +# Generated by Django 2.2.6 on 2019-11-11 13:38 + +import django.contrib.postgres.fields.jsonb +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import funkwhale_api.common.models +import funkwhale_api.common.validators +import uuid +import versatileimagefield.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0003_cit_extension'), + ] + + operations = [ + migrations.CreateModel( + name='Attachment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('url', models.URLField(max_length=500, unique=True, null=True)), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)), + ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), + ('last_fetch_date', models.DateTimeField(blank=True, null=True)), + ('size', models.IntegerField(blank=True, null=True)), + ('mimetype', models.CharField(blank=True, max_length=200, null=True)), + ('file', versatileimagefield.fields.VersatileImageField(max_length=255, upload_to=funkwhale_api.common.models.get_file_path, validators=[funkwhale_api.common.validators.ImageDimensionsValidator(min_height=50, min_width=50), funkwhale_api.common.validators.FileValidator(allowed_extensions=['png', 'jpg', 'jpeg'], max_size=5242880)])), + ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='federation.Actor', null=True)), + ], + ), + ] diff --git a/api/funkwhale_api/common/models.py b/api/funkwhale_api/common/models.py index 9fd1a3c76..f764bf251 100644 --- a/api/funkwhale_api/common/models.py +++ b/api/funkwhale_api/common/models.py @@ -1,4 +1,6 @@ import uuid +import magic +import mimetypes from django.contrib.postgres.fields import JSONField from django.contrib.contenttypes.fields import GenericForeignKey @@ -9,11 +11,18 @@ from django.db import connections, models, transaction from django.db.models import Lookup from django.db.models.fields import Field from django.db.models.sql.compiler import SQLCompiler +from django.dispatch import receiver from django.utils import timezone from django.urls import reverse +from versatileimagefield.fields import VersatileImageField +from versatileimagefield.image_warmer import VersatileImageFieldWarmer + from funkwhale_api.federation import utils as federation_utils +from . import utils +from . import validators + @Field.register_lookup class NotEqual(Lookup): @@ -150,3 +159,102 @@ class Mutation(models.Model): self.applied_date = timezone.now() self.save(update_fields=["is_applied", "applied_date", "previous_state"]) return previous_state + + +def get_file_path(instance, filename): + return utils.ChunkedPath("attachments")(instance, filename) + + +class AttachmentQuerySet(models.QuerySet): + def attached(self, include=True): + related_fields = ["covered_album"] + query = None + for field in related_fields: + field_query = ~models.Q(**{field: None}) + query = query | field_query if query else field_query + + if include is False: + query = ~query + + return self.filter(query) + + +class Attachment(models.Model): + # Remote URL where the attachment can be fetched + url = models.URLField(max_length=500, unique=True, null=True) + uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4) + # Actor associated with the attachment + actor = models.ForeignKey( + "federation.Actor", + related_name="attachments", + on_delete=models.CASCADE, + null=True, + ) + creation_date = models.DateTimeField(default=timezone.now) + last_fetch_date = models.DateTimeField(null=True, blank=True) + # File size + size = models.IntegerField(null=True, blank=True) + mimetype = models.CharField(null=True, blank=True, max_length=200) + + file = VersatileImageField( + upload_to=get_file_path, + max_length=255, + validators=[ + validators.ImageDimensionsValidator(min_width=50, min_height=50), + validators.FileValidator( + allowed_extensions=["png", "jpg", "jpeg"], max_size=1024 * 1024 * 5, + ), + ], + ) + + objects = AttachmentQuerySet.as_manager() + + def save(self, **kwargs): + if self.file and not self.size: + self.size = self.file.size + + if self.file and not self.mimetype: + self.mimetype = self.guess_mimetype() + + return super().save() + + @property + def is_local(self): + return federation_utils.is_local(self.fid) + + def guess_mimetype(self): + f = self.file + b = min(1000000, f.size) + t = magic.from_buffer(f.read(b), mime=True) + if not t.startswith("image/"): + # failure, we try guessing by extension + mt, _ = mimetypes.guess_type(f.name) + if mt: + t = mt + return t + + @property + def download_url_original(self): + if self.file: + return federation_utils.full_url(self.file.url) + proxy_url = reverse("api:v1:attachments-proxy", kwargs={"uuid": self.uuid}) + return federation_utils.full_url(proxy_url + "?next=original") + + @property + def download_url_medium_square_crop(self): + if self.file: + return federation_utils.full_url(self.file.crop["200x200"].url) + proxy_url = reverse("api:v1:attachments-proxy", kwargs={"uuid": self.uuid}) + return federation_utils.full_url(proxy_url + "?next=medium_square_crop") + + +@receiver(models.signals.post_save, sender=Attachment) +def warm_attachment_thumbnails(sender, instance, **kwargs): + if not instance.file or not settings.CREATE_IMAGE_THUMBNAILS: + return + warmer = VersatileImageFieldWarmer( + instance_or_queryset=instance, + rendition_key_set="attachment_square", + image_attr="file", + ) + num_created, failed_to_create = warmer.warm() diff --git a/api/funkwhale_api/common/scripts/create_image_variations.py b/api/funkwhale_api/common/scripts/create_image_variations.py index 5e941ce1f..31bf0269c 100644 --- a/api/funkwhale_api/common/scripts/create_image_variations.py +++ b/api/funkwhale_api/common/scripts/create_image_variations.py @@ -4,11 +4,16 @@ Compute different sizes of image used for Album covers and User avatars from versatileimagefield.image_warmer import VersatileImageFieldWarmer +from funkwhale_api.common.models import Attachment from funkwhale_api.music.models import Album from funkwhale_api.users.models import User -MODELS = [(Album, "cover", "square"), (User, "avatar", "square")] +MODELS = [ + (Album, "cover", "square"), + (User, "avatar", "square"), + (Attachment, "file", "attachment_square"), +] def main(command, **kwargs): diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py index 59b513f37..3f128c18d 100644 --- a/api/funkwhale_api/common/serializers.py +++ b/api/funkwhale_api/common/serializers.py @@ -272,3 +272,38 @@ class APIMutationSerializer(serializers.ModelSerializer): if value not in self.context["registry"]: raise serializers.ValidationError("Invalid mutation type {}".format(value)) return value + + +class AttachmentSerializer(serializers.Serializer): + uuid = serializers.UUIDField(read_only=True) + size = serializers.IntegerField(read_only=True) + mimetype = serializers.CharField(read_only=True) + creation_date = serializers.DateTimeField(read_only=True) + file = StripExifImageField(write_only=True) + urls = serializers.SerializerMethodField() + + def get_urls(self, o): + urls = {} + urls["source"] = o.url + urls["original"] = o.download_url_original + urls["medium_square_crop"] = o.download_url_medium_square_crop + return urls + + def to_representation(self, o): + repr = super().to_representation(o) + # XXX: BACKWARD COMPATIBILITY + # having the attachment urls in a nested JSON obj is better, + # but we can't do this without breaking clients + # So we extract the urls and include these in the parent payload + repr.update({k: v for k, v in repr["urls"].items() if k != "source"}) + # also, our legacy images had lots of variations (400x400, 200x200, 50x50) + # but we removed some of these, so we emulate these by hand (by redirecting) + # to actual, existing attachment variations + repr["square_crop"] = repr["medium_square_crop"] + repr["small_square_crop"] = repr["medium_square_crop"] + return repr + + def create(self, validated_data): + return models.Attachment.objects.create( + file=validated_data["file"], actor=validated_data["actor"] + ) diff --git a/api/funkwhale_api/common/session.py b/api/funkwhale_api/common/session.py index 4d5d0bb60..23fd28306 100644 --- a/api/funkwhale_api/common/session.py +++ b/api/funkwhale_api/common/session.py @@ -4,6 +4,13 @@ from django.conf import settings import funkwhale_api +class FunkwhaleSession(requests.Session): + def request(self, *args, **kwargs): + kwargs.setdefault("verify", settings.EXTERNAL_REQUESTS_VERIFY_SSL) + kwargs.setdefault("timeout", settings.EXTERNAL_REQUESTS_TIMEOUT) + return super().request(*args, **kwargs) + + def get_user_agent(): return "python-requests (funkwhale/{}; +{})".format( funkwhale_api.__version__, settings.FUNKWHALE_URL @@ -11,6 +18,6 @@ def get_user_agent(): def get_session(): - s = requests.Session() + s = FunkwhaleSession() s.headers["User-Agent"] = get_user_agent() return s diff --git a/api/funkwhale_api/common/tasks.py b/api/funkwhale_api/common/tasks.py index 994b0bdff..c7deee7f5 100644 --- a/api/funkwhale_api/common/tasks.py +++ b/api/funkwhale_api/common/tasks.py @@ -1,14 +1,23 @@ +import datetime +import logging +import tempfile + +from django.conf import settings +from django.core.files import File from django.db import transaction from django.dispatch import receiver - +from django.utils import timezone from funkwhale_api.common import channels from funkwhale_api.taskapp import celery from . import models from . import serializers +from . import session from . import signals +logger = logging.getLogger(__name__) + @celery.app.task(name="common.apply_mutation") @transaction.atomic @@ -57,3 +66,35 @@ def broadcast_mutation_update(mutation, old_is_approved, new_is_approved, **kwar }, }, ) + + +def fetch_remote_attachment(attachment, filename=None, save=True): + if attachment.file: + # already there, no need to fetch + return + + s = session.get_session() + attachment.last_fetch_date = timezone.now() + with tempfile.TemporaryFile() as tf: + with s.get(attachment.url, timeout=5, stream=True) as r: + for chunk in r.iter_content(): + tf.write(chunk) + tf.seek(0) + attachment.file.save( + filename or attachment.url.split("/")[-1], File(tf), save=save + ) + + +@celery.app.task(name="common.prune_unattached_attachments") +def prune_unattached_attachments(): + limit = timezone.now() - datetime.timedelta( + seconds=settings.ATTACHMENTS_UNATTACHED_PRUNE_DELAY + ) + candidates = models.Attachment.objects.attached(False).filter( + creation_date__lte=limit + ) + + total = candidates.count() + logger.info("Deleting %s unattached attachments…", total) + result = candidates.delete() + logger.info("Deletion done: %s", result) diff --git a/api/funkwhale_api/common/views.py b/api/funkwhale_api/common/views.py index d197dbe90..1109589d0 100644 --- a/api/funkwhale_api/common/views.py +++ b/api/funkwhale_api/common/views.py @@ -11,6 +11,8 @@ from rest_framework import response from rest_framework import views from rest_framework import viewsets +from funkwhale_api.users.oauth import permissions as oauth_permissions + from . import filters from . import models from . import mutations @@ -140,3 +142,40 @@ class RateLimitView(views.APIView): "scopes": throttling.get_status(ident, time.time()), } return response.Response(data, status=200) + + +class AttachmentViewSet( + mixins.RetrieveModelMixin, + mixins.CreateModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + lookup_field = "uuid" + queryset = models.Attachment.objects.all() + serializer_class = serializers.AttachmentSerializer + permission_classes = [oauth_permissions.ScopePermission] + required_scope = "libraries" + anonymous_policy = "setting" + + @action(detail=True, methods=["get"]) + @transaction.atomic + def proxy(self, request, *args, **kwargs): + instance = self.get_object() + + size = request.GET.get("next", "original").lower() + if size not in ["original", "medium_square_crop"]: + size = "original" + + tasks.fetch_remote_attachment(instance) + data = self.serializer_class(instance).data + redirect = response.Response(status=302) + redirect["Location"] = data["urls"][size] + return redirect + + def perform_create(self, serializer): + return serializer.save(actor=self.request.user.actor) + + def perform_destroy(self, instance): + if instance.actor is None or instance.actor != self.request.user.actor: + raise exceptions.PermissionDenied() + instance.delete() diff --git a/api/funkwhale_api/favorites/views.py b/api/funkwhale_api/favorites/views.py index 7d1991bc6..3e1f33759 100644 --- a/api/funkwhale_api/favorites/views.py +++ b/api/funkwhale_api/favorites/views.py @@ -54,7 +54,9 @@ class TrackFavoriteViewSet( ) tracks = Track.objects.with_playable_uploads( music_utils.get_actor_from_request(self.request) - ).select_related("artist", "album__artist", "attributed_to") + ).select_related( + "artist", "album__artist", "attributed_to", "album__attachment_cover" + ) queryset = queryset.prefetch_related(Prefetch("track", queryset=tracks)) return queryset diff --git a/api/funkwhale_api/federation/actors.py b/api/funkwhale_api/federation/actors.py index 95997f952..39161a9cb 100644 --- a/api/funkwhale_api/federation/actors.py +++ b/api/funkwhale_api/federation/actors.py @@ -14,10 +14,7 @@ logger = logging.getLogger(__name__) def get_actor_data(actor_url): response = session.get_session().get( - actor_url, - timeout=5, - verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, - headers={"Accept": "application/activity+json"}, + actor_url, headers={"Accept": "application/activity+json"}, ) response.raise_for_status() try: diff --git a/api/funkwhale_api/federation/library.py b/api/funkwhale_api/federation/library.py index e7f8373fa..1fef43c22 100644 --- a/api/funkwhale_api/federation/library.py +++ b/api/funkwhale_api/federation/library.py @@ -1,5 +1,4 @@ import requests -from django.conf import settings from funkwhale_api.common import session @@ -12,8 +11,6 @@ def get_library_data(library_url, actor): response = session.get_session().get( library_url, auth=auth, - timeout=5, - verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, headers={"Content-Type": "application/activity+json"}, ) except requests.ConnectionError: @@ -35,11 +32,7 @@ def get_library_data(library_url, actor): def get_library_page(library, page_url, actor): auth = signing.get_auth(actor.private_key, actor.private_key_id) response = session.get_session().get( - page_url, - auth=auth, - timeout=5, - verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, - headers={"Content-Type": "application/activity+json"}, + page_url, auth=auth, headers={"Content-Type": "application/activity+json"}, ) serializer = serializers.CollectionPageSerializer( data=response.json(), diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index fd52e17c8..12057253f 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -541,7 +541,6 @@ class LibraryTrack(models.Model): auth=auth, stream=True, timeout=20, - verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, headers={"Content-Type": "application/activity+json"}, ) with remote_response as r: diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 697c2acc9..5a62f4ce3 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -824,8 +824,8 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer): def get_tags_repr(self, instance): return [ - {"type": "Hashtag", "name": "#{}".format(tag)} - for tag in sorted(instance.tagged_items.values_list("tag__name", flat=True)) + {"type": "Hashtag", "name": "#{}".format(item.tag.name)} + for item in sorted(instance.tagged_items.all(), key=lambda i: i.tag.name) ] @@ -902,12 +902,11 @@ class AlbumSerializer(MusicEntitySerializer): else None, "tag": self.get_tags_repr(instance), } - if instance.cover: + if instance.attachment_cover: d["cover"] = { "type": "Link", - "href": utils.full_url(instance.cover.url), - "mediaType": mimetypes.guess_type(instance.cover_path)[0] - or "image/jpeg", + "href": instance.attachment_cover.download_url_original, + "mediaType": instance.attachment_cover.mimetype or "image/jpeg", } if self.context.get("include_ap_context", self.parent is None): d["@context"] = jsonld.get_default_context() diff --git a/api/funkwhale_api/federation/tasks.py b/api/funkwhale_api/federation/tasks.py index 163ac7788..6a03438c4 100644 --- a/api/funkwhale_api/federation/tasks.py +++ b/api/funkwhale_api/federation/tasks.py @@ -88,7 +88,7 @@ def dispatch_inbox(activity, call_handlers=True): context={ "activity": activity, "actor": activity.actor, - "inbox_items": activity.inbox_items.filter(is_read=False), + "inbox_items": activity.inbox_items.filter(is_read=False).order_by("id"), }, call_handlers=call_handlers, ) @@ -142,8 +142,6 @@ def deliver_to_remote(delivery): auth=auth, json=delivery.activity.payload, url=delivery.inbox_url, - timeout=5, - verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, headers={"Content-Type": "application/activity+json"}, ) logger.debug("Remote answered with %s", response.status_code) @@ -163,9 +161,7 @@ def deliver_to_remote(delivery): def fetch_nodeinfo(domain_name): s = session.get_session() wellknown_url = "https://{}/.well-known/nodeinfo".format(domain_name) - response = s.get( - url=wellknown_url, timeout=5, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL - ) + response = s.get(url=wellknown_url) response.raise_for_status() serializer = serializers.NodeInfoSerializer(data=response.json()) serializer.is_valid(raise_exception=True) @@ -175,9 +171,7 @@ def fetch_nodeinfo(domain_name): nodeinfo_url = link["href"] break - response = s.get( - url=nodeinfo_url, timeout=5, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL - ) + response = s.get(url=nodeinfo_url) response.raise_for_status() return response.json() @@ -308,8 +302,6 @@ def fetch(fetch): response = session.get_session().get( auth=auth, url=fetch.url, - timeout=5, - verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, headers={"Content-Type": "application/activity+json"}, ) logger.debug("Remote answered with %s", response.status_code) diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py index c2eacfe9d..74e9fef69 100644 --- a/api/funkwhale_api/federation/utils.py +++ b/api/funkwhale_api/federation/utils.py @@ -84,8 +84,6 @@ def retrieve_ap_object( response = session.get_session().get( fid, auth=auth, - timeout=5, - verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, headers={ "Accept": "application/activity+json", "Content-Type": "application/activity+json", diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index 85594e02b..7aebc2971 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -1,5 +1,6 @@ from django import forms from django.core import paginator +from django.db.models import Prefetch from django.http import HttpResponse from django.urls import reverse from rest_framework import exceptions, mixins, permissions, response, viewsets @@ -163,7 +164,7 @@ class MusicLibraryViewSet( FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet ): authentication_classes = [authentication.SignatureAuthentication] - renderer_classes = renderers.get_ap_renderers() + # renderer_classes = renderers.get_ap_renderers() serializer_class = serializers.LibrarySerializer queryset = music_models.Library.objects.all().select_related("actor") lookup_field = "uuid" @@ -176,7 +177,25 @@ class MusicLibraryViewSet( "actor": lb.actor, "name": lb.name, "summary": lb.description, - "items": lb.uploads.for_federation().order_by("-creation_date"), + "items": lb.uploads.for_federation() + .order_by("-creation_date") + .prefetch_related( + Prefetch( + "track", + queryset=music_models.Track.objects.select_related( + "album__artist__attributed_to", + "artist__attributed_to", + "album__attributed_to", + "attributed_to", + "album__attachment_cover", + ).prefetch_related( + "tagged_items__tag", + "album__tagged_items__tag", + "album__artist__tagged_items__tag", + "artist__tagged_items__tag", + ), + ) + ), "item_serializer": serializers.UploadSerializer, } page = request.GET.get("page") @@ -219,7 +238,10 @@ class MusicUploadViewSet( authentication_classes = [authentication.SignatureAuthentication] renderer_classes = renderers.get_ap_renderers() queryset = music_models.Upload.objects.local().select_related( - "library__actor", "track__artist", "track__album__artist" + "library__actor", + "track__artist", + "track__album__artist", + "track__album__attachment_cover", ) serializer_class = serializers.UploadSerializer lookup_field = "uuid" diff --git a/api/funkwhale_api/federation/webfinger.py b/api/funkwhale_api/federation/webfinger.py index 874b3c158..765c5e535 100644 --- a/api/funkwhale_api/federation/webfinger.py +++ b/api/funkwhale_api/federation/webfinger.py @@ -41,9 +41,7 @@ def get_resource(resource_string): url = "https://{}/.well-known/webfinger?resource={}".format( hostname, resource_string ) - response = session.get_session().get( - url, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, timeout=5 - ) + response = session.get_session().get(url) response.raise_for_status() serializer = serializers.ActorWebfingerSerializer(data=response.json()) serializer.is_valid(raise_exception=True) diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index 200dccf1f..c946c37e2 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -69,9 +69,9 @@ class ManageArtistViewSet( "tracks", Prefetch( "albums", - queryset=music_models.Album.objects.annotate( - tracks_count=Count("tracks") - ), + queryset=music_models.Album.objects.select_related( + "attachment_cover" + ).annotate(tracks_count=Count("tracks")), ), music_views.TAG_PREFETCH, ) @@ -110,7 +110,7 @@ class ManageAlbumViewSet( queryset = ( music_models.Album.objects.all() .order_by("-id") - .select_related("attributed_to", "artist") + .select_related("attributed_to", "artist", "attachment_cover") .prefetch_related("tracks", music_views.TAG_PREFETCH) ) serializer_class = serializers.ManageAlbumSerializer @@ -153,7 +153,9 @@ class ManageTrackViewSet( queryset = ( music_models.Track.objects.all() .order_by("-id") - .select_related("attributed_to", "artist", "album__artist") + .select_related( + "attributed_to", "artist", "album__artist", "album__attachment_cover" + ) .annotate(uploads_count=Coalesce(Subquery(uploads_subquery), 0)) .prefetch_related(music_views.TAG_PREFETCH) ) diff --git a/api/funkwhale_api/moderation/management/commands/mrf_check.py b/api/funkwhale_api/moderation/management/commands/mrf_check.py index b518daa08..6462bd9a0 100644 --- a/api/funkwhale_api/moderation/management/commands/mrf_check.py +++ b/api/funkwhale_api/moderation/management/commands/mrf_check.py @@ -6,8 +6,6 @@ import logging from django.core.management.base import BaseCommand, CommandError from django.core import validators -from django.conf import settings - from funkwhale_api.common import session from funkwhale_api.federation import models from funkwhale_api.moderation import mrf @@ -84,10 +82,7 @@ class Command(BaseCommand): content = models.Activity.objects.get(uuid=input).payload elif is_url(input): response = session.get_session().get( - input, - timeout=5, - verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, - headers={"Content-Type": "application/activity+json"}, + input, headers={"Content-Type": "application/activity+json"}, ) response.raise_for_status() content = response.json() diff --git a/api/funkwhale_api/music/factories.py b/api/funkwhale_api/music/factories.py index 52e5020bb..07f0d9aa3 100644 --- a/api/funkwhale_api/music/factories.py +++ b/api/funkwhale_api/music/factories.py @@ -4,6 +4,7 @@ import factory from funkwhale_api.factories import registry, NoUpdateOnCreate +from funkwhale_api.common import factories as common_factories from funkwhale_api.federation import factories as federation_factories from funkwhale_api.music import licenses from funkwhale_api.tags import factories as tags_factories @@ -81,7 +82,7 @@ class AlbumFactory( title = factory.Faker("sentence", nb_words=3) mbid = factory.Faker("uuid4") release_date = factory.Faker("date_object") - cover = factory.django.ImageField() + attachment_cover = factory.SubFactory(common_factories.AttachmentFactory) artist = factory.SubFactory(ArtistFactory) release_group_id = factory.Faker("uuid4") fid = factory.Faker("federation_url") diff --git a/api/funkwhale_api/music/migrations/0042_album_attachment_cover.py b/api/funkwhale_api/music/migrations/0042_album_attachment_cover.py new file mode 100644 index 000000000..b27dc03eb --- /dev/null +++ b/api/funkwhale_api/music/migrations/0042_album_attachment_cover.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.6 on 2019-11-12 09:56 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0004_auto_20191111_1338'), + ('music', '0041_auto_20191021_1705'), + ] + + operations = [ + migrations.AddField( + model_name='album', + name='attachment_cover', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.Attachment', blank=True), + ), + ] diff --git a/api/funkwhale_api/music/migrations/0043_album_cover_attachment.py b/api/funkwhale_api/music/migrations/0043_album_cover_attachment.py new file mode 100644 index 000000000..f5da4072a --- /dev/null +++ b/api/funkwhale_api/music/migrations/0043_album_cover_attachment.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations + + +def create_attachments(apps, schema_editor): + Album = apps.get_model("music", "Album") + Attachment = apps.get_model("common", "Attachment") + + album_attachment_mapping = {} + def get_mimetype(path): + if path.lower().endswith('.png'): + return "image/png" + return "image/jpeg" + + for album in Album.objects.filter(attachment_cover=None).exclude(cover="").exclude(cover=None): + album_attachment_mapping[album] = Attachment( + file=album.cover, + size=album.cover.size, + mimetype=get_mimetype(album.cover.path), + ) + + Attachment.objects.bulk_create(album_attachment_mapping.values(), batch_size=2000) + # map each attachment to the corresponding album + # and bulk save + for album, attachment in album_attachment_mapping.items(): + album.attachment_cover = attachment + + Album.objects.bulk_update(album_attachment_mapping.keys(), fields=['attachment_cover'], batch_size=2000) + + +def rewind(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [("music", "0042_album_attachment_cover")] + + operations = [migrations.RunPython(create_attachments, rewind)] diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index f40a4b186..a1f4a7c64 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -20,7 +20,6 @@ from django.urls import reverse from django.utils import timezone from versatileimagefield.fields import VersatileImageField -from versatileimagefield.image_warmer import VersatileImageFieldWarmer from funkwhale_api import musicbrainz from funkwhale_api.common import fields @@ -286,9 +285,17 @@ class Album(APIModelMixin): artist = models.ForeignKey(Artist, related_name="albums", on_delete=models.CASCADE) release_date = models.DateField(null=True, blank=True, db_index=True) release_group_id = models.UUIDField(null=True, blank=True) + # XXX: 1.0 clean this uneeded field in favor of attachment_cover cover = VersatileImageField( upload_to="albums/covers/%Y/%m/%d", null=True, blank=True ) + attachment_cover = models.ForeignKey( + "common.Attachment", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="covered_album", + ) TYPE_CHOICES = (("album", "Album"),) type = models.CharField(choices=TYPE_CHOICES, max_length=30, default="album") @@ -334,40 +341,46 @@ class Album(APIModelMixin): objects = AlbumQuerySet.as_manager() def get_image(self, data=None): + from funkwhale_api.common import tasks as common_tasks + + attachment = None if data: extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"} extension = extensions.get(data["mimetype"], "jpg") + attachment = common_models.Attachment(mimetype=data["mimetype"]) f = None + filename = "{}.{}".format(self.uuid, extension) if data.get("content"): # we have to cover itself f = ContentFile(data["content"]) + attachment.file.save(filename, f, save=False) elif data.get("url"): + attachment.url = data.get("url") # we can fetch from a url try: - response = session.get_session().get( - data.get("url"), - timeout=3, - verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, + common_tasks.fetch_remote_attachment( + attachment, filename=filename, save=False ) - response.raise_for_status() except Exception as e: logger.warn( "Cannot download cover at url %s: %s", data.get("url"), e ) return - else: - f = ContentFile(response.content) - if f: - self.cover.save("{}.{}".format(self.uuid, extension), f, save=False) - self.save(update_fields=["cover"]) - return self.cover.file - if self.mbid: + + elif self.mbid: image_data = musicbrainz.api.images.get_front(str(self.mbid)) f = ContentFile(image_data) - self.cover.save("{0}.jpg".format(self.mbid), f, save=False) - self.save(update_fields=["cover"]) - if self.cover: - return self.cover.file + attachment = common_models.Attachment(mimetype="image/jpeg") + attachment.file.save("{0}.jpg".format(self.mbid), f, save=False) + if attachment and attachment.file: + attachment.save() + self.attachment_cover = attachment + self.save(update_fields=["attachment_cover"]) + return self.attachment_cover.file + + @property + def cover(self): + return self.attachment_cover def __str__(self): return self.title @@ -378,16 +391,6 @@ class Album(APIModelMixin): def get_moderation_url(self): return "/manage/library/albums/{}".format(self.pk) - @property - def cover_path(self): - if not self.cover: - return None - try: - return self.cover.path - except NotImplementedError: - # external storage - return self.cover.name - @classmethod def get_or_create_from_title(cls, title, **kwargs): kwargs.update({"title": title}) @@ -415,7 +418,9 @@ def import_album(v): class TrackQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet): def for_nested_serialization(self): - return self.prefetch_related("artist", "album__artist") + return self.prefetch_related( + "artist", "album__artist", "album__attachment_cover" + ) def annotate_playable_by_actor(self, actor): @@ -729,7 +734,6 @@ class Upload(models.Model): return parsed.hostname def download_audio_from_remote(self, actor): - from funkwhale_api.common import session from funkwhale_api.federation import signing if actor: @@ -743,7 +747,6 @@ class Upload(models.Model): stream=True, timeout=20, headers={"Content-Type": "application/octet-stream"}, - verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, ) with remote_response as r: remote_response.raise_for_status() @@ -1307,13 +1310,3 @@ def update_request_status(sender, instance, created, **kwargs): # let's mark the request as imported since the import is over instance.import_request.status = "imported" return instance.import_request.save(update_fields=["status"]) - - -@receiver(models.signals.post_save, sender=Album) -def warm_album_covers(sender, instance, **kwargs): - if not instance.cover or not settings.CREATE_IMAGE_THUMBNAILS: - return - album_covers_warmer = VersatileImageFieldWarmer( - instance_or_queryset=instance, rendition_key_set="square", image_attr="cover" - ) - num_created, failed_to_create = album_covers_warmer.warm() diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 9ea2dd2cb..66790cf6d 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -4,7 +4,6 @@ from django.db import transaction from django import urls from django.conf import settings from rest_framework import serializers -from versatileimagefield.serializers import VersatileImageFieldSerializer from funkwhale_api.activity import serializers as activity_serializers from funkwhale_api.common import serializers as common_serializers @@ -17,7 +16,25 @@ from funkwhale_api.tags.models import Tag from . import filters, models, tasks -cover_field = VersatileImageFieldSerializer(allow_null=True, sizes="square") +class NullToEmptDict(object): + def get_attribute(self, o): + attr = super().get_attribute(o) + if attr is None: + return {} + return attr + + def to_representation(self, v): + if not v: + return v + return super().to_representation(v) + + +class CoverField(NullToEmptDict, common_serializers.AttachmentSerializer): + # XXX: BACKWARD COMPATIBILITY + pass + + +cover_field = CoverField() def serialize_attributed_to(self, obj): @@ -450,12 +467,12 @@ class OembedSerializer(serializers.Serializer): embed_type = "track" embed_id = track.pk data["title"] = "{} by {}".format(track.title, track.artist.name) - if track.album.cover: - data["thumbnail_url"] = federation_utils.full_url( - track.album.cover.crop["400x400"].url - ) - data["thumbnail_width"] = 400 - data["thumbnail_height"] = 400 + if track.album.attachment_cover: + data[ + "thumbnail_url" + ] = track.album.attachment_cover.download_url_medium_square_crop + data["thumbnail_width"] = 200 + data["thumbnail_height"] = 200 data["description"] = track.full_name data["author_name"] = track.artist.name data["height"] = 150 @@ -476,12 +493,12 @@ class OembedSerializer(serializers.Serializer): ) embed_type = "album" embed_id = album.pk - if album.cover: - data["thumbnail_url"] = federation_utils.full_url( - album.cover.crop["400x400"].url - ) - data["thumbnail_width"] = 400 - data["thumbnail_height"] = 400 + if album.attachment_cover: + data[ + "thumbnail_url" + ] = album.attachment_cover.download_url_medium_square_crop + data["thumbnail_width"] = 200 + data["thumbnail_height"] = 200 data["title"] = "{} by {}".format(album.title, album.artist.name) data["description"] = "{} by {}".format(album.title, album.artist.name) data["author_name"] = album.artist.name @@ -501,19 +518,14 @@ class OembedSerializer(serializers.Serializer): ) embed_type = "artist" embed_id = artist.pk - album = ( - artist.albums.filter(cover__isnull=False) - .exclude(cover="") - .order_by("-id") - .first() - ) + album = artist.albums.exclude(attachment_cover=None).order_by("-id").first() - if album and album.cover: - data["thumbnail_url"] = federation_utils.full_url( - album.cover.crop["400x400"].url - ) - data["thumbnail_width"] = 400 - data["thumbnail_height"] = 400 + if album and album.attachment_cover: + data[ + "thumbnail_url" + ] = album.attachment_cover.download_url_medium_square_crop + data["thumbnail_width"] = 200 + data["thumbnail_height"] = 200 data["title"] = artist.name data["description"] = artist.name data["author_name"] = artist.name @@ -533,19 +545,22 @@ class OembedSerializer(serializers.Serializer): ) embed_type = "playlist" embed_id = obj.pk - playlist_tracks = obj.playlist_tracks.exclude(track__album__cover="") - playlist_tracks = playlist_tracks.exclude(track__album__cover=None) - playlist_tracks = playlist_tracks.select_related("track__album").order_by( - "index" + playlist_tracks = obj.playlist_tracks.exclude( + track__album__attachment_cover=None ) + playlist_tracks = playlist_tracks.select_related( + "track__album__attachment_cover" + ).order_by("index") first_playlist_track = playlist_tracks.first() if first_playlist_track: - data["thumbnail_url"] = federation_utils.full_url( - first_playlist_track.track.album.cover.crop["400x400"].url + data[ + "thumbnail_url" + ] = ( + first_playlist_track.track.album.attachment_cover.download_url_medium_square_crop ) - data["thumbnail_width"] = 400 - data["thumbnail_height"] = 400 + data["thumbnail_width"] = 200 + data["thumbnail_height"] = 200 data["title"] = obj.name data["description"] = obj.name data["author_name"] = obj.name diff --git a/api/funkwhale_api/music/spa_views.py b/api/funkwhale_api/music/spa_views.py index 5215dcdd8..cbf143162 100644 --- a/api/funkwhale_api/music/spa_views.py +++ b/api/funkwhale_api/music/spa_views.py @@ -57,14 +57,12 @@ def library_track(request, pk): ), }, ] - if obj.album.cover: + if obj.album.attachment_cover: metas.append( { "tag": "meta", "property": "og:image", - "content": utils.join_url( - settings.FUNKWHALE_URL, obj.album.cover.crop["400x400"].url - ), + "content": obj.album.attachment_cover.download_url_medium_square_crop, } ) @@ -126,14 +124,12 @@ def library_album(request, pk): } ) - if obj.cover: + if obj.attachment_cover: metas.append( { "tag": "meta", "property": "og:image", - "content": utils.join_url( - settings.FUNKWHALE_URL, obj.cover.crop["400x400"].url - ), + "content": obj.attachment_cover.download_url_medium_square_crop, } ) @@ -166,7 +162,7 @@ def library_artist(request, pk): ) # we use latest album's cover as artist image latest_album = ( - obj.albums.exclude(cover="").exclude(cover=None).order_by("release_date").last() + obj.albums.exclude(attachment_cover=None).order_by("release_date").last() ) metas = [ {"tag": "meta", "property": "og:url", "content": artist_url}, @@ -174,14 +170,12 @@ def library_artist(request, pk): {"tag": "meta", "property": "og:type", "content": "profile"}, ] - if latest_album and latest_album.cover: + if latest_album and latest_album.attachment_cover: metas.append( { "tag": "meta", "property": "og:image", - "content": utils.join_url( - settings.FUNKWHALE_URL, latest_album.cover.crop["400x400"].url - ), + "content": latest_album.attachment_cover.download_url_medium_square_crop, } ) @@ -217,8 +211,7 @@ def library_playlist(request, pk): utils.spa_reverse("library_playlist", kwargs={"pk": obj.pk}), ) # we use the first playlist track's album's cover as image - playlist_tracks = obj.playlist_tracks.exclude(track__album__cover="") - playlist_tracks = playlist_tracks.exclude(track__album__cover=None) + playlist_tracks = obj.playlist_tracks.exclude(track__album__attachment_cover=None) playlist_tracks = playlist_tracks.select_related("track__album").order_by("index") first_playlist_track = playlist_tracks.first() metas = [ @@ -232,10 +225,7 @@ def library_playlist(request, pk): { "tag": "meta", "property": "og:image", - "content": utils.join_url( - settings.FUNKWHALE_URL, - first_playlist_track.track.album.cover.crop["400x400"].url, - ), + "content": first_playlist_track.track.album.attachment_cover.download_url_medium_square_crop, } ) diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py index 20265b320..1bf0f823e 100644 --- a/api/funkwhale_api/music/tasks.py +++ b/api/funkwhale_api/music/tasks.py @@ -21,7 +21,6 @@ from . import licenses from . import models from . import metadata from . import signals -from . import serializers logger = logging.getLogger(__name__) @@ -29,7 +28,7 @@ logger = logging.getLogger(__name__) def update_album_cover( album, source=None, cover_data=None, musicbrainz=True, replace=False ): - if album.cover and not replace: + if album.attachment_cover and not replace: return if cover_data: return album.get_image(data=cover_data) @@ -257,7 +256,7 @@ def process_upload(upload, update_denormalization=True): ) # update album cover, if needed - if not track.album.cover: + if not track.album.attachment_cover: update_album_cover( track.album, source=final_metadata.get("upload_source"), @@ -404,7 +403,7 @@ def sort_candidates(candidates, important_fields): @transaction.atomic def get_track_from_import_metadata(data, update_cover=False, attributed_to=None): track = _get_track(data, attributed_to=attributed_to) - if update_cover and track and not track.album.cover: + if update_cover and track and not track.album.attachment_cover: update_album_cover( track.album, source=data.get("upload_source"), @@ -584,6 +583,8 @@ def broadcast_import_status_update_to_owner(old_status, new_status, upload, **kw if not user: return + from . import serializers + group = "user.{}.imports".format(user.pk) channels.group_send( group, diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index c451d6b76..2260d9268 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -128,7 +128,9 @@ class ArtistViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelV def get_queryset(self): queryset = super().get_queryset() - albums = models.Album.objects.with_tracks_count() + albums = models.Album.objects.with_tracks_count().select_related( + "attachment_cover" + ) albums = albums.annotate_playable_by_actor( utils.get_actor_from_request(self.request) ) @@ -149,7 +151,7 @@ class AlbumViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelVi queryset = ( models.Album.objects.all() .order_by("-creation_date") - .prefetch_related("artist", "attributed_to") + .prefetch_related("artist", "attributed_to", "attachment_cover") ) serializer_class = serializers.AlbumSerializer permission_classes = [oauth_permissions.ScopePermission] diff --git a/api/funkwhale_api/playlists/models.py b/api/funkwhale_api/playlists/models.py index 37d498c4f..affa86ea5 100644 --- a/api/funkwhale_api/playlists/models.py +++ b/api/funkwhale_api/playlists/models.py @@ -17,7 +17,8 @@ class PlaylistQuerySet(models.QuerySet): def with_covers(self): album_prefetch = models.Prefetch( - "album", queryset=music_models.Album.objects.only("cover", "artist_id") + "album", + queryset=music_models.Album.objects.select_related("attachment_cover"), ) track_prefetch = models.Prefetch( "track", @@ -29,8 +30,7 @@ class PlaylistQuerySet(models.QuerySet): plt_prefetch = models.Prefetch( "playlist_tracks", queryset=PlaylistTrack.objects.all() - .exclude(track__album__cover=None) - .exclude(track__album__cover="") + .exclude(track__album__attachment_cover=None) .order_by("index") .only("id", "playlist_id", "track_id") .prefetch_related(track_prefetch), @@ -179,7 +179,9 @@ class Playlist(models.Model): class PlaylistTrackQuerySet(models.QuerySet): def for_nested_serialization(self, actor=None): tracks = music_models.Track.objects.with_playable_uploads(actor) - tracks = tracks.select_related("artist", "album__artist") + tracks = tracks.select_related( + "artist", "album__artist", "album__attachment_cover", "attributed_to" + ) return self.prefetch_related( models.Prefetch("track", queryset=tracks, to_attr="_prefetched_track") ) diff --git a/api/funkwhale_api/playlists/serializers.py b/api/funkwhale_api/playlists/serializers.py index dc61950dd..727edd525 100644 --- a/api/funkwhale_api/playlists/serializers.py +++ b/api/funkwhale_api/playlists/serializers.py @@ -145,7 +145,7 @@ class PlaylistSerializer(serializers.ModelSerializer): for plt in plts: if plt.track.album.artist_id in excluded_artists: continue - url = plt.track.album.cover.crop["200x200"].url + url = plt.track.album.attachment_cover.download_url_medium_square_crop if url in covers: continue covers.append(url) diff --git a/api/funkwhale_api/subsonic/serializers.py b/api/funkwhale_api/subsonic/serializers.py index cae99e242..7b6e37686 100644 --- a/api/funkwhale_api/subsonic/serializers.py +++ b/api/funkwhale_api/subsonic/serializers.py @@ -89,7 +89,7 @@ class GetArtistSerializer(serializers.Serializer): "created": to_subsonic_date(album.creation_date), "songCount": len(album.tracks.all()), } - if album.cover: + if album.attachment_cover_id: album_data["coverArt"] = "al-{}".format(album.id) if album.release_date: album_data["year"] = album.release_date.year @@ -122,7 +122,7 @@ def get_track_data(album, track, upload): "artistId": album.artist.pk, "type": "music", } - if track.album.cover: + if track.album.attachment_cover_id: data["coverArt"] = "al-{}".format(track.album.id) if upload.bitrate: data["bitrate"] = int(upload.bitrate / 1000) @@ -141,7 +141,7 @@ def get_album2_data(album): "artist": album.artist.name, "created": to_subsonic_date(album.creation_date), } - if album.cover: + if album.attachment_cover_id: payload["coverArt"] = "al-{}".format(album.id) try: diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py index db2620100..53861572a 100644 --- a/api/funkwhale_api/subsonic/views.py +++ b/api/funkwhale_api/subsonic/views.py @@ -16,7 +16,12 @@ from rest_framework.serializers import ValidationError import funkwhale_api from funkwhale_api.activity import record -from funkwhale_api.common import fields, preferences, utils as common_utils +from funkwhale_api.common import ( + fields, + preferences, + utils as common_utils, + tasks as common_tasks, +) from funkwhale_api.favorites.models import TrackFavorite from funkwhale_api.moderation import filters as moderation_filters from funkwhale_api.music import models as music_models @@ -732,20 +737,23 @@ class SubsonicViewSet(viewsets.GenericViewSet): try: album_id = int(id.replace("al-", "")) album = ( - music_models.Album.objects.exclude(cover__isnull=True) - .exclude(cover="") + music_models.Album.objects.exclude(attachment_cover=None) + .select_related("attachment_cover") .get(pk=album_id) ) except (TypeError, ValueError, music_models.Album.DoesNotExist): return response.Response( {"error": {"code": 70, "message": "cover art not found."}} ) - cover = album.cover + attachment = album.attachment_cover else: return response.Response( {"error": {"code": 70, "message": "cover art not found."}} ) + if not attachment.file: + common_tasks.fetch_remote_attachment(attachment) + cover = attachment.file mapping = {"nginx": "X-Accel-Redirect", "apache2": "X-Sendfile"} path = music_views.get_file_path(cover) file_header = mapping[settings.REVERSE_PROXY_TYPE] diff --git a/api/tests/common/test_models.py b/api/tests/common/test_models.py index a2ea89ef2..b5a56614d 100644 --- a/api/tests/common/test_models.py +++ b/api/tests/common/test_models.py @@ -46,3 +46,28 @@ def test_get_moderation_url(factory_name, factories, expected): obj = factories[factory_name]() assert obj.get_moderation_url() == expected.format(obj=obj) + + +def test_attachment(factories, now): + attachment = factories["common.Attachment"]() + + assert attachment.uuid is not None + assert attachment.mimetype == "image/jpeg" + assert attachment.file is not None + assert attachment.url is not None + assert attachment.actor is not None + assert attachment.creation_date > now + assert attachment.last_fetch_date is None + assert attachment.size > 0 + + +@pytest.mark.parametrize("args, expected", [([], [0]), ([True], [0]), ([False], [1])]) +def test_attachment_queryset_attached(args, expected, factories, queryset_equal_list): + attachments = [ + factories["music.Album"]().attachment_cover, + factories["common.Attachment"](), + ] + + queryset = attachments[0].__class__.objects.attached(*args).order_by("id") + expected_objs = [attachments[i] for i in expected] + assert queryset == expected_objs diff --git a/api/tests/common/test_serializers.py b/api/tests/common/test_serializers.py index 6d20443af..067a9e4a2 100644 --- a/api/tests/common/test_serializers.py +++ b/api/tests/common/test_serializers.py @@ -2,11 +2,13 @@ import os import PIL from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse import django_filters from funkwhale_api.common import serializers from funkwhale_api.users import models +from funkwhale_api.federation import utils as federation_utils class TestActionFilterSet(django_filters.FilterSet): @@ -182,3 +184,71 @@ def test_strip_exif_field(): cleaned = PIL.Image.open(field.to_internal_value(uploaded)) assert cleaned._getexif() is None + + +def test_attachment_serializer_existing_file(factories, to_api_date): + attachment = factories["common.Attachment"]() + expected = { + "uuid": str(attachment.uuid), + "size": attachment.size, + "mimetype": attachment.mimetype, + "creation_date": to_api_date(attachment.creation_date), + "urls": { + "source": attachment.url, + "original": federation_utils.full_url(attachment.file.url), + "medium_square_crop": federation_utils.full_url( + attachment.file.crop["200x200"].url + ), + }, + # XXX: BACKWARD COMPATIBILITY + "original": federation_utils.full_url(attachment.file.url), + "medium_square_crop": federation_utils.full_url( + attachment.file.crop["200x200"].url + ), + "small_square_crop": federation_utils.full_url( + attachment.file.crop["200x200"].url + ), + "square_crop": federation_utils.full_url(attachment.file.crop["200x200"].url), + } + + serializer = serializers.AttachmentSerializer(attachment) + + assert serializer.data == expected + + +def test_attachment_serializer_remote_file(factories, to_api_date): + attachment = factories["common.Attachment"](file=None) + proxy_url = reverse("api:v1:attachments-proxy", kwargs={"uuid": attachment.uuid}) + expected = { + "uuid": str(attachment.uuid), + "size": attachment.size, + "mimetype": attachment.mimetype, + "creation_date": to_api_date(attachment.creation_date), + # everything is the same, except for the urls field because: + # - the file isn't available on the local pod + # - we need to return different URLs so that the client can trigger + # a fetch and get redirected to the desired version + # + "urls": { + "source": attachment.url, + "original": federation_utils.full_url(proxy_url + "?next=original"), + "medium_square_crop": federation_utils.full_url( + proxy_url + "?next=medium_square_crop" + ), + }, + # XXX: BACKWARD COMPATIBILITY + "original": federation_utils.full_url(proxy_url + "?next=original"), + "medium_square_crop": federation_utils.full_url( + proxy_url + "?next=medium_square_crop" + ), + "square_crop": federation_utils.full_url( + proxy_url + "?next=medium_square_crop" + ), + "small_square_crop": federation_utils.full_url( + proxy_url + "?next=medium_square_crop" + ), + } + + serializer = serializers.AttachmentSerializer(attachment) + + assert serializer.data == expected diff --git a/api/tests/common/test_tasks.py b/api/tests/common/test_tasks.py index f097c4423..fc62d901b 100644 --- a/api/tests/common/test_tasks.py +++ b/api/tests/common/test_tasks.py @@ -1,4 +1,5 @@ import pytest +import datetime from funkwhale_api.common import serializers from funkwhale_api.common import signals @@ -63,3 +64,25 @@ def test_cannot_apply_already_applied_migration(factories): mutation = factories["common.Mutation"](payload={}, is_applied=True) with pytest.raises(mutation.__class__.DoesNotExist): tasks.apply_mutation(mutation_id=mutation.pk) + + +def test_prune_unattached_attachments(factories, settings, now): + settings.ATTACHMENTS_UNATTACHED_PRUNE_DELAY = 5 + attachments = [ + # attached, kept + factories["music.Album"]().attachment_cover, + # recent, kept + factories["common.Attachment"](), + # too old, pruned + factories["common.Attachment"]( + creation_date=now + - datetime.timedelta(seconds=settings.ATTACHMENTS_UNATTACHED_PRUNE_DELAY) + ), + ] + + tasks.prune_unattached_attachments() + + attachments[0].refresh_from_db() + attachments[1].refresh_from_db() + with pytest.raises(attachments[2].DoesNotExist): + attachments[2].refresh_from_db() diff --git a/api/tests/common/test_views.py b/api/tests/common/test_views.py index c1cbfd761..0ca7cbfd9 100644 --- a/api/tests/common/test_views.py +++ b/api/tests/common/test_views.py @@ -1,4 +1,6 @@ +import io import pytest + from django.urls import reverse from funkwhale_api.common import serializers @@ -181,3 +183,69 @@ def test_rate_limit(logged_in_api_client, now_time, settings, mocker): assert response.status_code == 200 assert response.data == expected get_status.assert_called_once_with(expected_ident, now_time) + + +@pytest.mark.parametrize( + "next, expected", + [ + ("original", "original"), + ("medium_square_crop", "medium_square_crop"), + ("unknown", "original"), + ], +) +def test_attachment_proxy_redirects_original( + next, expected, factories, logged_in_api_client, mocker, avatar, r_mock, now +): + attachment = factories["common.Attachment"](file=None) + + avatar_content = avatar.read() + fetch_remote_attachment = mocker.spy(tasks, "fetch_remote_attachment") + m = r_mock.get(attachment.url, body=io.BytesIO(avatar_content)) + proxy_url = reverse("api:v1:attachments-proxy", kwargs={"uuid": attachment.uuid}) + + response = logged_in_api_client.get(proxy_url, {"next": next}) + attachment.refresh_from_db() + + urls = serializers.AttachmentSerializer(attachment).data["urls"] + + assert attachment.file.read() == avatar_content + assert attachment.last_fetch_date == now + fetch_remote_attachment.assert_called_once_with(attachment) + assert len(m.request_history) == 1 + assert response.status_code == 302 + assert response["Location"] == urls[expected] + + +def test_attachment_create(logged_in_api_client, avatar): + actor = logged_in_api_client.user.create_actor() + url = reverse("api:v1:attachments-list") + content = avatar.read() + avatar.seek(0) + payload = {"file": avatar} + response = logged_in_api_client.post(url, payload) + + assert response.status_code == 201 + attachment = actor.attachments.latest("id") + assert attachment.file.read() == content + assert attachment.file.size == len(content) + + +def test_attachment_destroy(factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + attachment = factories["common.Attachment"](actor=actor) + url = reverse("api:v1:attachments-detail", kwargs={"uuid": attachment.uuid}) + response = logged_in_api_client.delete(url) + + assert response.status_code == 204 + with pytest.raises(attachment.DoesNotExist): + attachment.refresh_from_db() + + +def test_attachment_destroy_not_owner(factories, logged_in_api_client): + logged_in_api_client.user.create_actor() + attachment = factories["common.Attachment"]() + url = reverse("api:v1:attachments-detail", kwargs={"uuid": attachment.uuid}) + response = logged_in_api_client.delete(url) + + assert response.status_code == 403 + attachment.refresh_from_db() diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index a0b773fb4..34460a5a6 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -589,7 +589,7 @@ def test_activity_pub_album_serializer_to_ap(factories): "cover": { "type": "Link", "mediaType": "image/jpeg", - "href": utils.full_url(album.cover.url), + "href": utils.full_url(album.attachment_cover.file.url), }, "musicbrainzId": album.mbid, "published": album.creation_date.isoformat(), @@ -729,8 +729,8 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker): assert str(track.mbid) == data["musicbrainzId"] assert album.from_activity == activity - assert album.cover.read() == b"coucou" - assert album.cover_path.endswith(".png") + assert album.attachment_cover.file.read() == b"coucou" + assert album.attachment_cover.file.path.endswith(".png") assert album.title == data["album"]["name"] assert album.fid == data["album"]["id"] assert str(album.mbid) == data["album"]["musicbrainzId"] diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index d8a2ee8f9..37490383a 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -1,7 +1,8 @@ import pytest -from funkwhale_api.manage import serializers +from funkwhale_api.common import serializers as common_serializers from funkwhale_api.federation import tasks as federation_tasks +from funkwhale_api.manage import serializers def test_manage_upload_action_delete(factories): @@ -339,12 +340,7 @@ def test_manage_nested_album_serializer(factories, now, to_api_date): "mbid": album.mbid, "creation_date": to_api_date(album.creation_date), "release_date": album.release_date.isoformat(), - "cover": { - "original": album.cover.url, - "square_crop": album.cover.crop["400x400"].url, - "medium_square_crop": album.cover.crop["200x200"].url, - "small_square_crop": album.cover.crop["50x50"].url, - }, + "cover": common_serializers.AttachmentSerializer(album.attachment_cover).data, "tracks_count": 44, } s = serializers.ManageNestedAlbumSerializer(album) @@ -380,12 +376,7 @@ def test_manage_album_serializer(factories, now, to_api_date): "mbid": album.mbid, "creation_date": to_api_date(album.creation_date), "release_date": album.release_date.isoformat(), - "cover": { - "original": album.cover.url, - "square_crop": album.cover.crop["400x400"].url, - "medium_square_crop": album.cover.crop["200x200"].url, - "small_square_crop": album.cover.crop["50x50"].url, - }, + "cover": common_serializers.AttachmentSerializer(album.attachment_cover).data, "artist": serializers.ManageNestedArtistSerializer(album.artist).data, "tracks": [serializers.ManageNestedTrackSerializer(track).data], "attributed_to": serializers.ManageBaseActorSerializer( diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py index a5090e89a..c723b0104 100644 --- a/api/tests/music/test_models.py +++ b/api/tests/music/test_models.py @@ -192,7 +192,7 @@ def test_album_get_image_content(factories): album.get_image(data={"content": b"test", "mimetype": "image/jpeg"}) album.refresh_from_db() - assert album.cover.read() == b"test" + assert album.attachment_cover.file.read() == b"test" def test_library(factories): diff --git a/api/tests/music/test_music.py b/api/tests/music/test_music.py index 727214af5..0709ab340 100644 --- a/api/tests/music/test_music.py +++ b/api/tests/music/test_music.py @@ -132,11 +132,11 @@ def test_can_download_image_file_for_album(binary_cover, mocker, factories): album.get_image() album.save() - assert album.cover.file.read() == binary_cover + assert album.attachment_cover.file.read() == binary_cover def test_album_get_image_doesnt_crash_with_empty_data(mocker, factories): - album = factories["music.Album"](mbid=None, cover=None) + album = factories["music.Album"](mbid=None, attachment_cover=None) assert ( album.get_image(data={"content": "", "url": "", "mimetype": "image/png"}) is None diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py index 4eaf54ad5..a3e94e768 100644 --- a/api/tests/music/test_serializers.py +++ b/api/tests/music/test_serializers.py @@ -1,5 +1,6 @@ import pytest +from funkwhale_api.common import serializers as common_serializers from funkwhale_api.federation import serializers as federation_serializers from funkwhale_api.music import licenses from funkwhale_api.music import models @@ -42,12 +43,7 @@ def test_artist_album_serializer(factories, to_api_date): "creation_date": to_api_date(album.creation_date), "tracks_count": 1, "is_playable": None, - "cover": { - "original": album.cover.url, - "square_crop": album.cover.crop["400x400"].url, - "medium_square_crop": album.cover.crop["200x200"].url, - "small_square_crop": album.cover.crop["50x50"].url, - }, + "cover": common_serializers.AttachmentSerializer(album.attachment_cover).data, "release_date": to_api_date(album.release_date), "is_local": album.is_local, } @@ -172,12 +168,7 @@ def test_album_serializer(factories, to_api_date): "artist": serializers.serialize_artist_simple(album.artist), "creation_date": to_api_date(album.creation_date), "is_playable": False, - "cover": { - "original": album.cover.url, - "square_crop": album.cover.crop["400x400"].url, - "medium_square_crop": album.cover.crop["200x200"].url, - "small_square_crop": album.cover.crop["50x50"].url, - }, + "cover": common_serializers.AttachmentSerializer(album.attachment_cover).data, "release_date": to_api_date(album.release_date), "tracks": [serializers.serialize_album_track(t) for t in [track2, track1]], "is_local": album.is_local, @@ -189,6 +180,15 @@ def test_album_serializer(factories, to_api_date): assert serializer.data == expected +def test_album_serializer_empty_cover(factories, to_api_date): + # XXX: BACKWARD COMPATIBILITY + album = factories["music.Album"](attachment_cover=None) + + serializer = serializers.AlbumSerializer(album) + + assert serializer.data["cover"] == {} + + def test_track_serializer(factories, to_api_date): actor = factories["federation.Actor"]() upload = factories["music.Upload"]( diff --git a/api/tests/music/test_spa_views.py b/api/tests/music/test_spa_views.py index bf85ab888..42f723460 100644 --- a/api/tests/music/test_spa_views.py +++ b/api/tests/music/test_spa_views.py @@ -49,9 +49,7 @@ def test_library_track(spa_html, no_api_auth, client, factories, settings): { "tag": "meta", "property": "og:image", - "content": utils.join_url( - settings.FUNKWHALE_URL, track.album.cover.crop["400x400"].url - ), + "content": track.album.attachment_cover.download_url_medium_square_crop, }, { "tag": "meta", @@ -116,9 +114,7 @@ def test_library_album(spa_html, no_api_auth, client, factories, settings): { "tag": "meta", "property": "og:image", - "content": utils.join_url( - settings.FUNKWHALE_URL, album.cover.crop["400x400"].url - ), + "content": album.attachment_cover.download_url_medium_square_crop, }, { "tag": "link", @@ -166,9 +162,7 @@ def test_library_artist(spa_html, no_api_auth, client, factories, settings): { "tag": "meta", "property": "og:image", - "content": utils.join_url( - settings.FUNKWHALE_URL, album.cover.crop["400x400"].url - ), + "content": album.attachment_cover.download_url_medium_square_crop, }, { "tag": "link", @@ -217,9 +211,7 @@ def test_library_playlist(spa_html, no_api_auth, client, factories, settings): { "tag": "meta", "property": "og:image", - "content": utils.join_url( - settings.FUNKWHALE_URL, track.album.cover.crop["400x400"].url - ), + "content": track.album.attachment_cover.download_url_medium_square_crop, }, { "tag": "link", diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py index fbbb0197a..65ed3f9b9 100644 --- a/api/tests/music/test_tasks.py +++ b/api/tests/music/test_tasks.py @@ -285,8 +285,11 @@ def test_can_create_track_from_file_metadata_federation(factories, mocker, r_moc assert track.fid == metadata["fid"] assert track.creation_date == metadata["fdate"] assert track.position == 4 - assert track.album.cover.read() == b"coucou" - assert track.album.cover_path.endswith(".png") + assert track.album.attachment_cover.file.read() == b"coucou" + assert track.album.attachment_cover.file.path.endswith(".png") + assert track.album.attachment_cover.url == metadata["cover_data"]["url"] + assert track.album.attachment_cover.mimetype == metadata["cover_data"]["mimetype"] + assert track.album.fid == metadata["album"]["fid"] assert track.album.title == metadata["album"]["title"] assert track.album.creation_date == metadata["album"]["fdate"] @@ -312,7 +315,7 @@ def test_upload_import(now, factories, temp_signal, mocker): update_album_cover = mocker.patch("funkwhale_api.music.tasks.update_album_cover") get_picture = mocker.patch("funkwhale_api.music.metadata.Metadata.get_picture") get_track_from_import_metadata = mocker.spy(tasks, "get_track_from_import_metadata") - track = factories["music.Track"](album__cover="") + track = factories["music.Track"](album__attachment_cover=None) upload = factories["music.Upload"]( track=None, import_metadata={"funkwhale": {"track": {"uuid": str(track.uuid)}}} ) @@ -531,7 +534,7 @@ def test_upload_import_error_metadata(factories, now, temp_signal, mocker): def test_upload_import_updates_cover_if_no_cover(factories, mocker, now): mocked_update = mocker.patch("funkwhale_api.music.tasks.update_album_cover") - album = factories["music.Album"](cover="") + album = factories["music.Album"](attachment_cover=None) track = factories["music.Track"](album=album) upload = factories["music.Upload"]( track=None, import_metadata={"funkwhale": {"track": {"uuid": track.uuid}}} @@ -541,7 +544,7 @@ def test_upload_import_updates_cover_if_no_cover(factories, mocker, now): def test_update_album_cover_mbid(factories, mocker): - album = factories["music.Album"](cover="") + album = factories["music.Album"](attachment_cover=None) mocked_get = mocker.patch("funkwhale_api.music.models.Album.get_image") tasks.update_album_cover(album=album) @@ -550,7 +553,7 @@ def test_update_album_cover_mbid(factories, mocker): def test_update_album_cover_file_data(factories, mocker): - album = factories["music.Album"](cover="", mbid=None) + album = factories["music.Album"](attachment_cover=None, mbid=None) mocked_get = mocker.patch("funkwhale_api.music.models.Album.get_image") tasks.update_album_cover(album=album, cover_data={"hello": "world"}) @@ -563,7 +566,7 @@ def test_update_album_cover_file_cover_separate_file(ext, mimetype, factories, m image_path = os.path.join(DATA_DIR, "cover.{}".format(ext)) with open(image_path, "rb") as f: image_content = f.read() - album = factories["music.Album"](cover="", mbid=None) + album = factories["music.Album"](attachment_cover=None, mbid=None) mocked_get = mocker.patch("funkwhale_api.music.models.Album.get_image") mocker.patch("funkwhale_api.music.metadata.Metadata.get_picture", return_value=None) diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 22f7da635..ad3bf0413 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -780,10 +780,10 @@ def test_oembed_track(factories, no_api_auth, api_client, settings): "title": "{} by {}".format(track.title, track.artist.name), "description": track.full_name, "thumbnail_url": federation_utils.full_url( - track.album.cover.crop["400x400"].url + track.album.attachment_cover.file.crop["200x200"].url ), - "thumbnail_height": 400, - "thumbnail_width": 400, + "thumbnail_height": 200, + "thumbnail_width": 200, "html": ''.format( iframe_src ), @@ -815,9 +815,11 @@ def test_oembed_album(factories, no_api_auth, api_client, settings): "width": 600, "title": "{} by {}".format(album.title, album.artist.name), "description": "{} by {}".format(album.title, album.artist.name), - "thumbnail_url": federation_utils.full_url(album.cover.crop["400x400"].url), - "thumbnail_height": 400, - "thumbnail_width": 400, + "thumbnail_url": federation_utils.full_url( + album.attachment_cover.file.crop["200x200"].url + ), + "thumbnail_height": 200, + "thumbnail_width": 200, "html": ''.format( iframe_src ), @@ -850,9 +852,11 @@ def test_oembed_artist(factories, no_api_auth, api_client, settings): "width": 600, "title": artist.name, "description": artist.name, - "thumbnail_url": federation_utils.full_url(album.cover.crop["400x400"].url), - "thumbnail_height": 400, - "thumbnail_width": 400, + "thumbnail_url": federation_utils.full_url( + album.attachment_cover.file.crop["200x200"].url + ), + "thumbnail_height": 200, + "thumbnail_width": 200, "html": ''.format( iframe_src ), @@ -886,10 +890,10 @@ def test_oembed_playlist(factories, no_api_auth, api_client, settings): "title": playlist.name, "description": playlist.name, "thumbnail_url": federation_utils.full_url( - track.album.cover.crop["400x400"].url + track.album.attachment_cover.file.crop["200x200"].url ), - "thumbnail_height": 400, - "thumbnail_width": 400, + "thumbnail_height": 200, + "thumbnail_width": 200, "html": ''.format( iframe_src ), diff --git a/api/tests/playlists/test_serializers.py b/api/tests/playlists/test_serializers.py index f84df4bb2..79105d3cc 100644 --- a/api/tests/playlists/test_serializers.py +++ b/api/tests/playlists/test_serializers.py @@ -95,7 +95,7 @@ def test_playlist_serializer_include_covers(factories, api_request): playlist = factories["playlists.Playlist"]() t1 = factories["music.Track"]() t2 = factories["music.Track"]() - t3 = factories["music.Track"](album__cover=None) + t3 = factories["music.Track"](album__attachment_cover=None) t4 = factories["music.Track"]() t5 = factories["music.Track"]() t6 = factories["music.Track"]() @@ -106,11 +106,11 @@ def test_playlist_serializer_include_covers(factories, api_request): qs = playlist.__class__.objects.with_covers().with_tracks_count() expected = [ - request.build_absolute_uri(t1.album.cover.crop["200x200"].url), - request.build_absolute_uri(t2.album.cover.crop["200x200"].url), - request.build_absolute_uri(t4.album.cover.crop["200x200"].url), - request.build_absolute_uri(t5.album.cover.crop["200x200"].url), - request.build_absolute_uri(t6.album.cover.crop["200x200"].url), + t1.album.attachment_cover.download_url_medium_square_crop, + t2.album.attachment_cover.download_url_medium_square_crop, + t4.album.attachment_cover.download_url_medium_square_crop, + t5.album.attachment_cover.download_url_medium_square_crop, + t6.album.attachment_cover.download_url_medium_square_crop, ] serializer = serializers.PlaylistSerializer(qs.get(), context={"request": request}) diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py index 3dd9f6cc6..0e7acfd66 100644 --- a/api/tests/subsonic/test_views.py +++ b/api/tests/subsonic/test_views.py @@ -724,7 +724,7 @@ def test_get_cover_art_album(factories, logged_in_api_client): assert response.status_code == 200 assert response["Content-Type"] == "" assert response["X-Accel-Redirect"] == music_views.get_file_path( - album.cover + album.attachment_cover.file ).decode("utf-8") diff --git a/docs/swagger.yml b/docs/swagger.yml index a6952098b..eff119aa5 100644 --- a/docs/swagger.yml +++ b/docs/swagger.yml @@ -185,6 +185,8 @@ tags: url: https://docs.funkwhale.audio/users/managing.html - name: Content curation description: Favorites, playlists, radios + - name: Other + description: Other endpoints that don't fit in the categories above paths: /api/v1/oauth/apps/: @@ -1022,6 +1024,54 @@ paths: 204: $ref: "#/responses/204" + /api/v1/attachments/: + post: + tags: + - "Other" + description: + Upload a new file as an attachment that can be later associated with other objects. + responses: + 201: + $ref: "#/responses/201" + 400: + $ref: "#/responses/400" + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + + /api/v1/attachments/{uuid}/: + parameters: + - name: uuid + in: path + required: true + schema: + type: "string" + format: "uuid" + get: + summary: Retrieve an attachment + tags: + - "Other" + responses: + 200: + content: + application/json: + schema: + $ref: "#/definitions/Attachment" + delete: + summary: Delete an attachment + tags: + - "Other" + responses: + 204: + $ref: "#/responses/204" + parameters: ObjectId: name: id @@ -1114,6 +1164,12 @@ properties: - "audio/mpeg" - "audio/x-flac" - "audio/flac" + image_mimetype: + type: string + example: "image/png" + enum: + - "image/png" + - "image/jpeg" import_status: type: string example: "finished" @@ -1180,28 +1236,33 @@ definitions: format: "uri" description: "Link to the previous page of results" - - Image: + Attachment: type: "object" properties: - original: + uuid: + type: string + format: uuid + size: + type: "integer" + format: "int64" + example: 2787000 + description: "Size of the file, in bytes" + mimetype: + $ref: "#/properties/image_mimetype" + creation_date: type: "string" - description: "URL to the original image" - example: "https://mydomain/media/albums/covers/ec2c53aeaac6.jpg" - small_square_crop: - type: "string" - description: "URL to a small, squared thumbnail of the image" - example: "https://mydomain/media/__sized__/albums/covers/ec2c53aeaac6-crop-c0-5__0-5-50x50-70.jpg" - - medium_square_crop: - type: "string" - description: "URL to a medium, squared thumbnail of the image" - example: "https://mydomain/media/__sized__/albums/covers/ec2c53aeaac6-crop-c0-5__0-5-200x200-70.jpg" - - square_crop: - type: "string" - description: "URL to a large, squared thumbnail of the image" - example: "https://mydomain/media/__sized__/albums/covers/ec2c53aeaac6-crop-c0-5__0-5-400x400-70.jpg" + format: "date-time" + urls: + type: "object" + properties: + original: + type: "string" + description: "URL to the original image" + example: "https://mydomain/media/attachments/ec2c53aeaac6.jpg" + medium_square_crop: + type: "string" + description: "URL to a medium, squared thumbnail of the image" + example: "https://mydomain/media/__sized__/attachments/ec2c53aeaac6-crop-c0-5__0-5-200x200-70.jpg" Actor: type: object @@ -1317,7 +1378,7 @@ definitions: is_playable: type: "boolean" cover: - $ref: "#/definitions/Image" + $ref: "#/definitions/Attachment" is_local: type: "boolean" description: "Indicates if the object was initally created locally or on another server" @@ -1508,7 +1569,7 @@ definitions: uuid: type: string format: uuid - size: + size:size: type: "integer" format: "int64" example: 278987000 From 6bbe48598ee02a048ad3b94a935b25e0c29f4a6d Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Mon, 25 Nov 2019 09:49:49 +0100 Subject: [PATCH 026/322] See #170: exclude by default all channels-related entities from /artists, /albums and /tracks endpoints results, for backward compatibility --- api/config/api_urls.py | 2 + api/config/settings/common.py | 1 + api/funkwhale_api/audio/__init__.py | 0 api/funkwhale_api/audio/admin.py | 15 ++ .../audio/dynamic_preferences_registry.py | 16 +++ api/funkwhale_api/audio/factories.py | 32 +++++ api/funkwhale_api/audio/filters.py | 65 +++++++++ .../audio/migrations/0001_initial.py | 31 +++++ .../audio/migrations/__init__.py | 0 api/funkwhale_api/audio/models.py | 39 ++++++ api/funkwhale_api/audio/serializers.py | 88 ++++++++++++ api/funkwhale_api/audio/views.py | 54 ++++++++ api/funkwhale_api/common/permissions.py | 11 +- .../migrations/0021_auto_20191029_1257.py | 22 +++ api/funkwhale_api/moderation/filters.py | 1 + api/funkwhale_api/music/filters.py | 19 ++- api/funkwhale_api/users/models.py | 3 +- api/tests/audio/__init__.py | 0 api/tests/audio/test_models.py | 7 + api/tests/audio/test_serializers.py | 74 ++++++++++ api/tests/audio/test_views.py | 128 ++++++++++++++++++ api/tests/music/test_filters.py | 4 +- api/tests/music/test_views.py | 46 +++++++ 23 files changed, 649 insertions(+), 9 deletions(-) create mode 100644 api/funkwhale_api/audio/__init__.py create mode 100644 api/funkwhale_api/audio/admin.py create mode 100644 api/funkwhale_api/audio/dynamic_preferences_registry.py create mode 100644 api/funkwhale_api/audio/factories.py create mode 100644 api/funkwhale_api/audio/filters.py create mode 100644 api/funkwhale_api/audio/migrations/0001_initial.py create mode 100644 api/funkwhale_api/audio/migrations/__init__.py create mode 100644 api/funkwhale_api/audio/models.py create mode 100644 api/funkwhale_api/audio/serializers.py create mode 100644 api/funkwhale_api/audio/views.py create mode 100644 api/funkwhale_api/federation/migrations/0021_auto_20191029_1257.py create mode 100644 api/tests/audio/__init__.py create mode 100644 api/tests/audio/test_models.py create mode 100644 api/tests/audio/test_serializers.py create mode 100644 api/tests/audio/test_views.py diff --git a/api/config/api_urls.py b/api/config/api_urls.py index dc5ef22a3..b0d7eaaff 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -4,6 +4,7 @@ from rest_framework import routers from rest_framework.urlpatterns import format_suffix_patterns from funkwhale_api.activity import views as activity_views +from funkwhale_api.audio import views as audio_views from funkwhale_api.common import views as common_views from funkwhale_api.common import routers as common_routers from funkwhale_api.music import views @@ -21,6 +22,7 @@ router.register(r"uploads", views.UploadViewSet, "uploads") router.register(r"libraries", views.LibraryViewSet, "libraries") router.register(r"listen", views.ListenViewSet, "listen") router.register(r"artists", views.ArtistViewSet, "artists") +router.register(r"channels", audio_views.ChannelViewSet, "channels") router.register(r"albums", views.AlbumViewSet, "albums") router.register(r"licenses", views.LicenseViewSet, "licenses") router.register(r"playlists", playlists_views.PlaylistViewSet, "playlists") diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 1ec11e7ff..7c03b89dc 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -191,6 +191,7 @@ LOCAL_APPS = ( "funkwhale_api.users.oauth", # Your stuff: custom apps go here "funkwhale_api.instance", + "funkwhale_api.audio", "funkwhale_api.music", "funkwhale_api.requests", "funkwhale_api.favorites", diff --git a/api/funkwhale_api/audio/__init__.py b/api/funkwhale_api/audio/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/funkwhale_api/audio/admin.py b/api/funkwhale_api/audio/admin.py new file mode 100644 index 000000000..5cde8cc87 --- /dev/null +++ b/api/funkwhale_api/audio/admin.py @@ -0,0 +1,15 @@ +from funkwhale_api.common import admin + +from . import models + + +@admin.register(models.Channel) +class ChannelAdmin(admin.ModelAdmin): + list_display = [ + "uuid", + "artist", + "attributed_to", + "actor", + "library", + "creation_date", + ] diff --git a/api/funkwhale_api/audio/dynamic_preferences_registry.py b/api/funkwhale_api/audio/dynamic_preferences_registry.py new file mode 100644 index 000000000..8f9b096b0 --- /dev/null +++ b/api/funkwhale_api/audio/dynamic_preferences_registry.py @@ -0,0 +1,16 @@ +from dynamic_preferences import types +from dynamic_preferences.registries import global_preferences_registry + +audio = types.Section("audio") + + +@global_preferences_registry.register +class ChannelsEnabled(types.BooleanPreference): + section = audio + name = "channels_enabled" + default = True + verbose_name = "Enable channels" + help_text = ( + "If disabled, the channels feature will be completely switched off, " + "and users won't be able to create channels or subscribe to them." + ) diff --git a/api/funkwhale_api/audio/factories.py b/api/funkwhale_api/audio/factories.py new file mode 100644 index 000000000..0c57eeb2e --- /dev/null +++ b/api/funkwhale_api/audio/factories.py @@ -0,0 +1,32 @@ +import factory + +from funkwhale_api.factories import registry, NoUpdateOnCreate +from funkwhale_api.federation import factories as federation_factories +from funkwhale_api.music import factories as music_factories + +from . import models + + +def set_actor(o): + return models.generate_actor(str(o.uuid)) + + +@registry.register +class ChannelFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): + uuid = factory.Faker("uuid4") + attributed_to = factory.SubFactory(federation_factories.ActorFactory) + library = factory.SubFactory( + federation_factories.MusicLibraryFactory, + actor=factory.SelfAttribute("..attributed_to"), + ) + actor = factory.LazyAttribute(set_actor) + artist = factory.SubFactory(music_factories.ArtistFactory) + + class Meta: + model = "audio.Channel" + + class Params: + local = factory.Trait( + attributed_to__fid=factory.Faker("federation_url", local=True), + artist__local=True, + ) diff --git a/api/funkwhale_api/audio/filters.py b/api/funkwhale_api/audio/filters.py new file mode 100644 index 000000000..02776e032 --- /dev/null +++ b/api/funkwhale_api/audio/filters.py @@ -0,0 +1,65 @@ +import django_filters + +from funkwhale_api.common import fields +from funkwhale_api.common import filters as common_filters +from funkwhale_api.moderation import filters as moderation_filters + +from . import models + + +def filter_tags(queryset, name, value): + non_empty_tags = [v.lower() for v in value if v] + for tag in non_empty_tags: + queryset = queryset.filter(artist__tagged_items__tag__name=tag).distinct() + return queryset + + +TAG_FILTER = common_filters.MultipleQueryFilter(method=filter_tags) + + +class ChannelFilter(moderation_filters.HiddenContentFilterSet): + q = fields.SearchFilter( + search_fields=["artist__name", "actor__summary", "actor__preferred_username"] + ) + tag = TAG_FILTER + scope = common_filters.ActorScopeFilter(actor_field="attributed_to", distinct=True) + + class Meta: + model = models.Channel + fields = ["q", "scope", "tag"] + hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["CHANNEL"] + + +class IncludeChannelsFilterSet(django_filters.FilterSet): + """ + + A filterset that include a "include_channels" param. Meant for compatibility + with clients that don't support channels yet: + + - include_channels=false : exclude objects associated with a channel + - include_channels=true : don't exclude objects associated with a channel + - not specified: include_channels=false + + Usage: + + class MyFilterSet(IncludeChannelsFilterSet): + class Meta: + include_channels_field = "album__artist__channel" + + """ + + include_channels = django_filters.BooleanFilter( + field_name="_", method="filter_include_channels" + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.data = self.data.copy() + self.data.setdefault("include_channels", False) + + def filter_include_channels(self, queryset, name, value): + if value is True: + return queryset + else: + params = {self.__class__.Meta.include_channels_field: None} + return queryset.filter(**params) diff --git a/api/funkwhale_api/audio/migrations/0001_initial.py b/api/funkwhale_api/audio/migrations/0001_initial.py new file mode 100644 index 000000000..62c271b26 --- /dev/null +++ b/api/funkwhale_api/audio/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 2.2.6 on 2019-10-29 12:57 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('federation', '0021_auto_20191029_1257'), + ('music', '0041_auto_20191021_1705'), + ] + + operations = [ + migrations.CreateModel( + name='Channel', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), + ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), + ('actor', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='channel', to='federation.Actor')), + ('artist', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='channel', to='music.Artist')), + ('attributed_to', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_channels', to='federation.Actor')), + ('library', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='channel', to='music.Library')), + ], + ), + ] diff --git a/api/funkwhale_api/audio/migrations/__init__.py b/api/funkwhale_api/audio/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/funkwhale_api/audio/models.py b/api/funkwhale_api/audio/models.py new file mode 100644 index 000000000..f3f9db896 --- /dev/null +++ b/api/funkwhale_api/audio/models.py @@ -0,0 +1,39 @@ +import uuid + + +from django.db import models +from django.utils import timezone + +from funkwhale_api.federation import keys +from funkwhale_api.federation import models as federation_models +from funkwhale_api.users import models as user_models + + +class Channel(models.Model): + uuid = models.UUIDField(default=uuid.uuid4, unique=True) + artist = models.OneToOneField( + "music.Artist", on_delete=models.CASCADE, related_name="channel" + ) + # the owner of the channel + attributed_to = models.ForeignKey( + "federation.Actor", on_delete=models.CASCADE, related_name="owned_channels" + ) + # the federation actor created for the channel + # (the one people can follow to receive updates) + actor = models.OneToOneField( + "federation.Actor", on_delete=models.CASCADE, related_name="channel" + ) + + library = models.OneToOneField( + "music.Library", on_delete=models.CASCADE, related_name="channel" + ) + creation_date = models.DateTimeField(default=timezone.now) + + +def generate_actor(username, **kwargs): + actor_data = user_models.get_actor_data(username, **kwargs) + private, public = keys.get_key_pair() + actor_data["private_key"] = private.decode("utf-8") + actor_data["public_key"] = public.decode("utf-8") + + return federation_models.Actor.objects.create(**actor_data) diff --git a/api/funkwhale_api/audio/serializers.py b/api/funkwhale_api/audio/serializers.py new file mode 100644 index 000000000..e2e469b7e --- /dev/null +++ b/api/funkwhale_api/audio/serializers.py @@ -0,0 +1,88 @@ +from django.db import transaction + +from rest_framework import serializers + +from funkwhale_api.federation import serializers as federation_serializers +from funkwhale_api.music import models as music_models +from funkwhale_api.music import serializers as music_serializers +from funkwhale_api.tags import models as tags_models +from funkwhale_api.tags import serializers as tags_serializers + +from . import models + + +class ChannelCreateSerializer(serializers.Serializer): + name = serializers.CharField(max_length=music_models.MAX_LENGTHS["ARTIST_NAME"]) + username = serializers.CharField(max_length=music_models.MAX_LENGTHS["ARTIST_NAME"]) + summary = serializers.CharField(max_length=500, allow_blank=True, allow_null=True) + tags = tags_serializers.TagsListField() + + @transaction.atomic + def create(self, validated_data): + artist = music_models.Artist.objects.create( + attributed_to=validated_data["attributed_to"], name=validated_data["name"] + ) + if validated_data.get("tags", []): + tags_models.set_tags(artist, *validated_data["tags"]) + + channel = models.Channel( + artist=artist, attributed_to=validated_data["attributed_to"] + ) + + channel.actor = models.generate_actor( + validated_data["username"], + summary=validated_data["summary"], + name=validated_data["name"], + ) + + channel.library = music_models.Library.objects.create( + name=channel.actor.preferred_username, + privacy_level="public", + actor=validated_data["attributed_to"], + ) + channel.save() + return channel + + def to_representation(self, obj): + return ChannelSerializer(obj).data + + +class ChannelUpdateSerializer(serializers.Serializer): + name = serializers.CharField(max_length=music_models.MAX_LENGTHS["ARTIST_NAME"]) + summary = serializers.CharField(max_length=500, allow_blank=True, allow_null=True) + tags = tags_serializers.TagsListField() + + @transaction.atomic + def update(self, obj, validated_data): + if validated_data.get("tags") is not None: + tags_models.set_tags(obj.artist, *validated_data["tags"]) + actor_update_fields = [] + + if "summary" in validated_data: + actor_update_fields.append(("summary", validated_data["summary"])) + if "name" in validated_data: + obj.artist.name = validated_data["name"] + obj.artist.save(update_fields=["name"]) + actor_update_fields.append(("name", validated_data["name"])) + + if actor_update_fields: + for field, value in actor_update_fields: + setattr(obj.actor, field, value) + obj.actor.save(update_fields=[f for f, _ in actor_update_fields]) + return obj + + def to_representation(self, obj): + return ChannelSerializer(obj).data + + +class ChannelSerializer(serializers.ModelSerializer): + artist = serializers.SerializerMethodField() + actor = federation_serializers.APIActorSerializer() + attributed_to = federation_serializers.APIActorSerializer() + + class Meta: + model = models.Channel + fields = ["uuid", "artist", "attributed_to", "actor", "creation_date"] + + def get_artist(self, obj): + return music_serializers.serialize_artist_simple(obj.artist) diff --git a/api/funkwhale_api/audio/views.py b/api/funkwhale_api/audio/views.py new file mode 100644 index 000000000..856c6b050 --- /dev/null +++ b/api/funkwhale_api/audio/views.py @@ -0,0 +1,54 @@ +from rest_framework import exceptions, mixins, viewsets + +from django import http + +from funkwhale_api.common import permissions +from funkwhale_api.common import preferences +from funkwhale_api.users.oauth import permissions as oauth_permissions + +from . import filters, models, serializers + + +class ChannelsMixin(object): + def dispatch(self, request, *args, **kwargs): + if not preferences.get("audio__channels_enabled"): + return http.HttpResponse(status=405) + return super().dispatch(request, *args, **kwargs) + + +class ChannelViewSet( + ChannelsMixin, + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.ListModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + lookup_field = "uuid" + filterset_class = filters.ChannelFilter + serializer_class = serializers.ChannelSerializer + queryset = ( + models.Channel.objects.all() + .prefetch_related("library", "attributed_to", "artist", "actor") + .order_by("-creation_date") + ) + permission_classes = [ + oauth_permissions.ScopePermission, + permissions.OwnerPermission, + ] + required_scope = "libraries" + anonymous_policy = "setting" + owner_checks = ["write"] + owner_field = "attributed_to.user" + owner_exception = exceptions.PermissionDenied + + def get_serializer_class(self): + if self.request.method.lower() in ["head", "get", "options"]: + return serializers.ChannelSerializer + elif self.action in ["update", "partial_update"]: + return serializers.ChannelUpdateSerializer + return serializers.ChannelCreateSerializer + + def perform_create(self, serializer): + return serializer.save(attributed_to=self.request.user.actor) diff --git a/api/funkwhale_api/common/permissions.py b/api/funkwhale_api/common/permissions.py index 237fc4ae4..76d8a7ff3 100644 --- a/api/funkwhale_api/common/permissions.py +++ b/api/funkwhale_api/common/permissions.py @@ -1,6 +1,8 @@ import operator +from django.core.exceptions import ObjectDoesNotExist from django.http import Http404 + from rest_framework.permissions import BasePermission from funkwhale_api.common import preferences @@ -46,7 +48,12 @@ class OwnerPermission(BasePermission): return True owner_field = getattr(view, "owner_field", "user") - owner = operator.attrgetter(owner_field)(obj) + owner_exception = getattr(view, "owner_exception", Http404) + try: + owner = operator.attrgetter(owner_field)(obj) + except ObjectDoesNotExist: + raise owner_exception + if not owner or not request.user.is_authenticated or owner != request.user: - raise Http404 + raise owner_exception return True diff --git a/api/funkwhale_api/federation/migrations/0021_auto_20191029_1257.py b/api/funkwhale_api/federation/migrations/0021_auto_20191029_1257.py new file mode 100644 index 000000000..2aad0df28 --- /dev/null +++ b/api/funkwhale_api/federation/migrations/0021_auto_20191029_1257.py @@ -0,0 +1,22 @@ +# Generated by Django 2.2.6 on 2019-10-29 12:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('federation', '0020_auto_20190730_0846'), + ] + + operations = [ + migrations.AlterModelOptions( + name='actor', + options={'verbose_name': 'Account'}, + ), + migrations.AlterField( + model_name='actor', + name='type', + field=models.CharField(choices=[('Person', 'Person'), ('Tombstone', 'Tombstone'), ('Application', 'Application'), ('Group', 'Group'), ('Organization', 'Organization'), ('Service', 'Service')], default='Person', max_length=25), + ), + ] diff --git a/api/funkwhale_api/moderation/filters.py b/api/funkwhale_api/moderation/filters.py index ddf183045..629ae685f 100644 --- a/api/funkwhale_api/moderation/filters.py +++ b/api/funkwhale_api/moderation/filters.py @@ -5,6 +5,7 @@ from django_filters import rest_framework as filters USER_FILTER_CONFIG = { "ARTIST": {"target_artist": ["pk"]}, + "CHANNEL": {"target_artist": ["artist__pk"]}, "ALBUM": {"target_artist": ["artist__pk"]}, "TRACK": {"target_artist": ["artist__pk", "album__artist__pk"]}, "LISTENING": {"target_artist": ["track__album__artist__pk", "track__artist__pk"]}, diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py index f5bd17e67..75ce9ec85 100644 --- a/api/funkwhale_api/music/filters.py +++ b/api/funkwhale_api/music/filters.py @@ -1,5 +1,6 @@ from django_filters import rest_framework as filters +from funkwhale_api.audio import filters as audio_filters from funkwhale_api.common import fields from funkwhale_api.common import filters as common_filters from funkwhale_api.common import search @@ -19,7 +20,9 @@ def filter_tags(queryset, name, value): TAG_FILTER = common_filters.MultipleQueryFilter(method=filter_tags) -class ArtistFilter(moderation_filters.HiddenContentFilterSet): +class ArtistFilter( + audio_filters.IncludeChannelsFilterSet, moderation_filters.HiddenContentFilterSet +): q = fields.SearchFilter(search_fields=["name"]) playable = filters.BooleanFilter(field_name="_", method="filter_playable") tag = TAG_FILTER @@ -36,13 +39,16 @@ class ArtistFilter(moderation_filters.HiddenContentFilterSet): "mbid": ["exact"], } hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ARTIST"] + include_channels_field = "channel" def filter_playable(self, queryset, name, value): actor = utils.get_actor_from_request(self.request) return queryset.playable_by(actor, value) -class TrackFilter(moderation_filters.HiddenContentFilterSet): +class TrackFilter( + audio_filters.IncludeChannelsFilterSet, moderation_filters.HiddenContentFilterSet +): q = fields.SearchFilter(search_fields=["title", "album__title", "artist__name"]) playable = filters.BooleanFilter(field_name="_", method="filter_playable") tag = TAG_FILTER @@ -64,13 +70,14 @@ class TrackFilter(moderation_filters.HiddenContentFilterSet): "mbid": ["exact"], } hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["TRACK"] + include_channels_field = "artist__channel" def filter_playable(self, queryset, name, value): actor = utils.get_actor_from_request(self.request) return queryset.playable_by(actor, value) -class UploadFilter(filters.FilterSet): +class UploadFilter(audio_filters.IncludeChannelsFilterSet): library = filters.CharFilter("library__uuid") track = filters.UUIDFilter("track__uuid") track_artist = filters.UUIDFilter("track__artist__uuid") @@ -109,13 +116,16 @@ class UploadFilter(filters.FilterSet): "import_reference", "scope", ] + include_channels_field = "track__artist__channel" def filter_playable(self, queryset, name, value): actor = utils.get_actor_from_request(self.request) return queryset.playable_by(actor, value) -class AlbumFilter(moderation_filters.HiddenContentFilterSet): +class AlbumFilter( + audio_filters.IncludeChannelsFilterSet, moderation_filters.HiddenContentFilterSet +): playable = filters.BooleanFilter(field_name="_", method="filter_playable") q = fields.SearchFilter(search_fields=["title", "artist__name"]) tag = TAG_FILTER @@ -127,6 +137,7 @@ class AlbumFilter(moderation_filters.HiddenContentFilterSet): model = models.Album fields = ["playable", "q", "artist", "scope", "mbid"] hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ALBUM"] + include_channels_field = "artist__channel" def filter_playable(self, queryset, name, value): actor = utils.get_actor_from_request(self.request) diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index 2a2f875a6..ca50c047f 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -362,7 +362,8 @@ def get_actor_data(username, **kwargs): "preferred_username": slugified_username, "domain": domain, "type": "Person", - "name": username, + "name": kwargs.get("name", username), + "summary": kwargs.get("summary"), "manually_approves_followers": False, "fid": federation_utils.full_url( reverse( diff --git a/api/tests/audio/__init__.py b/api/tests/audio/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/tests/audio/test_models.py b/api/tests/audio/test_models.py new file mode 100644 index 000000000..5992e41ed --- /dev/null +++ b/api/tests/audio/test_models.py @@ -0,0 +1,7 @@ +def test_channel(factories, now): + channel = factories["audio.Channel"]() + assert channel.artist is not None + assert channel.actor is not None + assert channel.attributed_to is not None + assert channel.library is not None + assert channel.creation_date >= now diff --git a/api/tests/audio/test_serializers.py b/api/tests/audio/test_serializers.py new file mode 100644 index 000000000..02737a852 --- /dev/null +++ b/api/tests/audio/test_serializers.py @@ -0,0 +1,74 @@ +from funkwhale_api.audio import serializers +from funkwhale_api.federation import serializers as federation_serializers +from funkwhale_api.music import serializers as music_serializers + + +def test_channel_serializer_create(factories): + attributed_to = factories["federation.Actor"](local=True) + + data = { + # TODO: cover + "name": "My channel", + "username": "mychannel", + "summary": "This is my channel", + "tags": ["hello", "world"], + } + + serializer = serializers.ChannelCreateSerializer(data=data) + assert serializer.is_valid(raise_exception=True) is True + + channel = serializer.save(attributed_to=attributed_to) + + assert channel.artist.name == data["name"] + assert channel.artist.attributed_to == attributed_to + assert ( + sorted(channel.artist.tagged_items.values_list("tag__name", flat=True)) + == data["tags"] + ) + assert channel.attributed_to == attributed_to + assert channel.actor.summary == data["summary"] + assert channel.actor.preferred_username == data["username"] + assert channel.actor.name == data["name"] + assert channel.library.privacy_level == "public" + assert channel.library.actor == attributed_to + + +def test_channel_serializer_update(factories): + channel = factories["audio.Channel"](artist__set_tags=["rock"]) + + data = { + # TODO: cover + "name": "My channel", + "summary": "This is my channel", + "tags": ["hello", "world"], + } + + serializer = serializers.ChannelUpdateSerializer(channel, data=data) + assert serializer.is_valid(raise_exception=True) is True + + serializer.save() + channel.refresh_from_db() + + assert channel.artist.name == data["name"] + assert ( + sorted(channel.artist.tagged_items.values_list("tag__name", flat=True)) + == data["tags"] + ) + assert channel.actor.summary == data["summary"] + assert channel.actor.name == data["name"] + + +def test_channel_serializer_representation(factories, to_api_date): + channel = factories["audio.Channel"]() + + expected = { + "artist": music_serializers.serialize_artist_simple(channel.artist), + "uuid": str(channel.uuid), + "creation_date": to_api_date(channel.creation_date), + "actor": federation_serializers.APIActorSerializer(channel.actor).data, + "attributed_to": federation_serializers.APIActorSerializer( + channel.attributed_to + ).data, + } + + assert serializers.ChannelSerializer(channel).data == expected diff --git a/api/tests/audio/test_views.py b/api/tests/audio/test_views.py new file mode 100644 index 000000000..d12caa3dd --- /dev/null +++ b/api/tests/audio/test_views.py @@ -0,0 +1,128 @@ +import pytest + +from django.urls import reverse + +from funkwhale_api.audio import serializers + + +def test_channel_create(logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + + data = { + # TODO: cover + "name": "My channel", + "username": "mychannel", + "summary": "This is my channel", + "tags": ["hello", "world"], + } + + url = reverse("api:v1:channels-list") + response = logged_in_api_client.post(url, data) + + assert response.status_code == 201 + + channel = actor.owned_channels.latest("id") + expected = serializers.ChannelSerializer(channel).data + + assert response.data == expected + assert channel.artist.name == data["name"] + assert channel.artist.attributed_to == actor + assert ( + sorted(channel.artist.tagged_items.values_list("tag__name", flat=True)) + == data["tags"] + ) + assert channel.attributed_to == actor + assert channel.actor.summary == data["summary"] + assert channel.actor.preferred_username == data["username"] + assert channel.library.privacy_level == "public" + assert channel.library.actor == actor + + +def test_channel_detail(factories, logged_in_api_client): + channel = factories["audio.Channel"]() + url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) + expected = serializers.ChannelSerializer(channel).data + response = logged_in_api_client.get(url) + + assert response.status_code == 200 + assert response.data == expected + + +def test_channel_list(factories, logged_in_api_client): + channel = factories["audio.Channel"]() + url = reverse("api:v1:channels-list") + expected = serializers.ChannelSerializer(channel).data + response = logged_in_api_client.get(url) + + assert response.status_code == 200 + assert response.data == { + "results": [expected], + "count": 1, + "next": None, + "previous": None, + } + + +def test_channel_update(logged_in_api_client, factories): + actor = logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"](attributed_to=actor) + + data = { + # TODO: cover + "name": "new name" + } + + url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) + response = logged_in_api_client.patch(url, data) + + assert response.status_code == 200 + + channel.refresh_from_db() + + assert channel.artist.name == data["name"] + + +def test_channel_update_permission(logged_in_api_client, factories): + logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"]() + + data = {"name": "new name"} + + url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) + response = logged_in_api_client.patch(url, data) + + assert response.status_code == 403 + + +def test_channel_delete(logged_in_api_client, factories): + actor = logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"](attributed_to=actor) + + url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) + response = logged_in_api_client.delete(url) + + assert response.status_code == 204 + + with pytest.raises(channel.DoesNotExist): + channel.refresh_from_db() + + +def test_channel_delete_permission(logged_in_api_client, factories): + logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"]() + + url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) + response = logged_in_api_client.patch(url) + + assert response.status_code == 403 + channel.refresh_from_db() + + +@pytest.mark.parametrize("url_name", ["api:v1:channels-list"]) +def test_channel_views_disabled_via_feature_flag( + url_name, logged_in_api_client, preferences +): + preferences["audio__channels_enabled"] = False + url = reverse(url_name) + response = logged_in_api_client.get(url) + assert response.status_code == 405 diff --git a/api/tests/music/test_filters.py b/api/tests/music/test_filters.py index f3ff13e77..7ce7e16a0 100644 --- a/api/tests/music/test_filters.py +++ b/api/tests/music/test_filters.py @@ -60,8 +60,8 @@ def test_artist_filter_track_album_artist(factories, mocker, queryset_equal_list "factory_name, filterset_class", [ ("music.Track", filters.TrackFilter), - ("music.Artist", filters.TrackFilter), - ("music.Album", filters.TrackFilter), + ("music.Artist", filters.ArtistFilter), + ("music.Album", filters.AlbumFilter), ], ) def test_track_filter_tag_single( diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index ad3bf0413..f0cc68f41 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -997,3 +997,49 @@ def test_refetch_obj(mocker, factories, settings, service_actor): views.refetch_obj(obj, obj.__class__.objects.all()) fetch = obj.fetches.filter(actor=service_actor).order_by("-creation_date").first() fetch_task.assert_called_once_with(fetch_id=fetch.pk) + + +@pytest.mark.parametrize( + "params, expected", + [({}, 0), ({"include_channels": "false"}, 0), ({"include_channels": "true"}, 1)], +) +def test_artist_list_exclude_channels( + params, expected, factories, logged_in_api_client +): + factories["audio.Channel"]() + + url = reverse("api:v1:artists-list") + response = logged_in_api_client.get(url, params) + + assert response.status_code == 200 + assert response.data["count"] == expected + + +@pytest.mark.parametrize( + "params, expected", + [({}, 0), ({"include_channels": "false"}, 0), ({"include_channels": "true"}, 1)], +) +def test_album_list_exclude_channels(params, expected, factories, logged_in_api_client): + channel_artist = factories["audio.Channel"]().artist + factories["music.Album"](artist=channel_artist) + + url = reverse("api:v1:albums-list") + response = logged_in_api_client.get(url, params) + + assert response.status_code == 200 + assert response.data["count"] == expected + + +@pytest.mark.parametrize( + "params, expected", + [({}, 0), ({"include_channels": "false"}, 0), ({"include_channels": "true"}, 1)], +) +def test_track_list_exclude_channels(params, expected, factories, logged_in_api_client): + channel_artist = factories["audio.Channel"]().artist + factories["music.Track"](artist=channel_artist) + + url = reverse("api:v1:tracks-list") + response = logged_in_api_client.get(url, params) + + assert response.status_code == 200 + assert response.data["count"] == expected From 11a533fa923634df50e11e1227dd302e798d9654 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Wed, 27 Nov 2019 12:26:12 +0100 Subject: [PATCH 027/322] Resolve "Adding cover art to my albums" --- api/funkwhale_api/common/factories.py | 8 -- .../migrations/0005_auto_20191125_1421.py | 37 +++++++ api/funkwhale_api/common/models.py | 46 ++++++++- api/funkwhale_api/common/serializers.py | 7 +- api/funkwhale_api/common/views.py | 4 +- api/funkwhale_api/federation/serializers.py | 30 ++++++ api/funkwhale_api/music/mutations.py | 41 +++++++- api/tests/common/test_tasks.py | 15 ++- api/tests/conftest.py | 5 + api/tests/federation/test_routes.py | 2 +- api/tests/federation/test_serializers.py | 41 ++++++++ api/tests/music/test_mutations.py | 19 ++++ changes/changelog.d/588.enhancement | 1 + .../src/components/common/AttachmentInput.vue | 99 +++++++++++++++++++ front/src/components/library/EditCard.vue | 32 ++++-- front/src/components/library/EditForm.vue | 15 ++- front/src/edits.js | 13 +++ 17 files changed, 388 insertions(+), 27 deletions(-) create mode 100644 api/funkwhale_api/common/migrations/0005_auto_20191125_1421.py create mode 100644 changes/changelog.d/588.enhancement create mode 100644 front/src/components/common/AttachmentInput.vue diff --git a/api/funkwhale_api/common/factories.py b/api/funkwhale_api/common/factories.py index 85a441e85..d6a063603 100644 --- a/api/funkwhale_api/common/factories.py +++ b/api/funkwhale_api/common/factories.py @@ -16,14 +16,6 @@ class MutationFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): class Meta: model = "common.Mutation" - @factory.post_generation - def target(self, create, extracted, **kwargs): - if not create: - # Simple build, do nothing. - return - self.target = extracted - self.save() - @registry.register class AttachmentFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): diff --git a/api/funkwhale_api/common/migrations/0005_auto_20191125_1421.py b/api/funkwhale_api/common/migrations/0005_auto_20191125_1421.py new file mode 100644 index 000000000..b0984c14e --- /dev/null +++ b/api/funkwhale_api/common/migrations/0005_auto_20191125_1421.py @@ -0,0 +1,37 @@ +# Generated by Django 2.2.7 on 2019-11-25 14:21 + +import django.contrib.postgres.fields.jsonb +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0004_auto_20191111_1338'), + ] + + operations = [ + migrations.AlterField( + model_name='mutation', + name='payload', + field=django.contrib.postgres.fields.jsonb.JSONField(encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='mutation', + name='previous_state', + field=django.contrib.postgres.fields.jsonb.JSONField(default=None, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True), + ), + migrations.CreateModel( + name='MutationAttachment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('attachment', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='mutation_attachment', to='common.Attachment')), + ('mutation', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='mutation_attachment', to='common.Mutation')), + ], + options={ + 'unique_together': {('attachment', 'mutation')}, + }, + ), + ] diff --git a/api/funkwhale_api/common/models.py b/api/funkwhale_api/common/models.py index f764bf251..4e4fc14dd 100644 --- a/api/funkwhale_api/common/models.py +++ b/api/funkwhale_api/common/models.py @@ -167,7 +167,7 @@ def get_file_path(instance, filename): class AttachmentQuerySet(models.QuerySet): def attached(self, include=True): - related_fields = ["covered_album"] + related_fields = ["covered_album", "mutation_attachment"] query = None for field in related_fields: field_query = ~models.Q(**{field: None}) @@ -178,6 +178,12 @@ class AttachmentQuerySet(models.QuerySet): return self.filter(query) + def local(self, include=True): + if include: + return self.filter(actor__domain_id=settings.FEDERATION_HOSTNAME) + else: + return self.exclude(actor__domain_id=settings.FEDERATION_HOSTNAME) + class Attachment(models.Model): # Remote URL where the attachment can be fetched @@ -248,6 +254,25 @@ class Attachment(models.Model): return federation_utils.full_url(proxy_url + "?next=medium_square_crop") +class MutationAttachment(models.Model): + """ + When using attachments in mutations, we need to keep a reference to + the attachment to ensure it is not pruned by common/tasks.py. + + This is what this model does. + """ + + attachment = models.OneToOneField( + Attachment, related_name="mutation_attachment", on_delete=models.CASCADE + ) + mutation = models.OneToOneField( + Mutation, related_name="mutation_attachment", on_delete=models.CASCADE + ) + + class Meta: + unique_together = ("attachment", "mutation") + + @receiver(models.signals.post_save, sender=Attachment) def warm_attachment_thumbnails(sender, instance, **kwargs): if not instance.file or not settings.CREATE_IMAGE_THUMBNAILS: @@ -258,3 +283,22 @@ def warm_attachment_thumbnails(sender, instance, **kwargs): image_attr="file", ) num_created, failed_to_create = warmer.warm() + + +@receiver(models.signals.post_save, sender=Mutation) +def trigger_mutation_post_init(sender, instance, created, **kwargs): + if not created: + return + + from . import mutations + + try: + conf = mutations.registry.get_conf(instance.type, instance.target) + except mutations.ConfNotFound: + return + serializer = conf["serializer_class"]() + try: + handler = serializer.mutation_post_init + except AttributeError: + return + handler(instance) diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py index 3f128c18d..fa889f9e8 100644 --- a/api/funkwhale_api/common/serializers.py +++ b/api/funkwhale_api/common/serializers.py @@ -23,9 +23,10 @@ class RelatedField(serializers.RelatedField): self.related_field_name = related_field_name self.serializer = serializer self.filters = kwargs.pop("filters", None) - kwargs["queryset"] = kwargs.pop( - "queryset", self.serializer.Meta.model.objects.all() - ) + try: + kwargs["queryset"] = kwargs.pop("queryset") + except KeyError: + kwargs["queryset"] = self.serializer.Meta.model.objects.all() super().__init__(**kwargs) def get_filters(self, data): diff --git a/api/funkwhale_api/common/views.py b/api/funkwhale_api/common/views.py index 1109589d0..b6f8d5a0a 100644 --- a/api/funkwhale_api/common/views.py +++ b/api/funkwhale_api/common/views.py @@ -157,7 +157,9 @@ class AttachmentViewSet( required_scope = "libraries" anonymous_policy = "setting" - @action(detail=True, methods=["get"]) + @action( + detail=True, methods=["get"], permission_classes=[], authentication_classes=[] + ) @transaction.atomic def proxy(self, request, *args, **kwargs): instance = self.get_object() diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 5a62f4ce3..977ab0bb1 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -5,9 +5,12 @@ import uuid from django.core.exceptions import ObjectDoesNotExist from django.core.paginator import Paginator +from django.db import transaction + from rest_framework import serializers from funkwhale_api.common import utils as funkwhale_utils +from funkwhale_api.common import models as common_models from funkwhale_api.music import licenses from funkwhale_api.music import models as music_models from funkwhale_api.music import tasks as music_tasks @@ -808,6 +811,7 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer): child=TagSerializer(), min_length=0, required=False, allow_null=True ) + @transaction.atomic def update(self, instance, validated_data): attributed_to_fid = validated_data.get("attributedTo") if attributed_to_fid: @@ -815,6 +819,8 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer): updated_fields = funkwhale_utils.get_updated_fields( self.updateable_fields, validated_data, instance ) + updated_fields = self.validate_updated_data(instance, updated_fields) + if updated_fields: music_tasks.update_library_entity(instance, updated_fields) @@ -828,6 +834,9 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer): for item in sorted(instance.tagged_items.all(), key=lambda i: i.tag.name) ] + def validate_updated_data(self, instance, validated_data): + return validated_data + class ArtistSerializer(MusicEntitySerializer): updateable_fields = [ @@ -869,6 +878,7 @@ class AlbumSerializer(MusicEntitySerializer): ("musicbrainzId", "mbid"), ("attributedTo", "attributed_to"), ("released", "release_date"), + ("cover", "attachment_cover"), ] class Meta: @@ -912,6 +922,26 @@ class AlbumSerializer(MusicEntitySerializer): d["@context"] = jsonld.get_default_context() return d + def validate_updated_data(self, instance, validated_data): + try: + attachment_cover = validated_data.pop("attachment_cover") + except KeyError: + return validated_data + + if ( + instance.attachment_cover + and instance.attachment_cover.url == attachment_cover["href"] + ): + # we already have the proper attachment + return validated_data + # create the attachment by hand so it can be attached as the album cover + validated_data["attachment_cover"] = common_models.Attachment.objects.create( + mimetype=attachment_cover["mediaType"], + url=attachment_cover["href"], + actor=instance.attributed_to, + ) + return validated_data + class TrackSerializer(MusicEntitySerializer): position = serializers.IntegerField(min_value=0, allow_null=True, required=False) diff --git a/api/funkwhale_api/music/mutations.py b/api/funkwhale_api/music/mutations.py index b149f1963..aad3b0e96 100644 --- a/api/funkwhale_api/music/mutations.py +++ b/api/funkwhale_api/music/mutations.py @@ -1,4 +1,6 @@ +from funkwhale_api.common import models as common_models from funkwhale_api.common import mutations +from funkwhale_api.common import serializers as common_serializers from funkwhale_api.federation import routes from funkwhale_api.tags import models as tags_models from funkwhale_api.tags import serializers as tags_serializers @@ -74,11 +76,48 @@ class ArtistMutationSerializer(TagMutation): perm_checkers={"suggest": can_suggest, "approve": can_approve}, ) class AlbumMutationSerializer(TagMutation): + cover = common_serializers.RelatedField( + "uuid", queryset=common_models.Attachment.objects.all().local(), serializer=None + ) + + serialized_relations = {"cover": "uuid"} + previous_state_handlers = dict( + list(TagMutation.previous_state_handlers.items()) + + [ + ( + "cover", + lambda obj: str(obj.attachment_cover.uuid) + if obj.attachment_cover + else None, + ), + ] + ) + class Meta: model = models.Album - fields = ["title", "release_date", "tags"] + fields = ["title", "release_date", "tags", "cover"] def post_apply(self, obj, validated_data): routes.outbox.dispatch( {"type": "Update", "object": {"type": "Album"}}, context={"album": obj} ) + + def update(self, instance, validated_data): + if "cover" in validated_data: + validated_data["attachment_cover"] = validated_data.pop("cover") + return super().update(instance, validated_data) + + def mutation_post_init(self, mutation): + # link cover_attachment (if any) to mutation + if "cover" not in mutation.payload: + return + try: + attachment = common_models.Attachment.objects.get( + uuid=mutation.payload["cover"] + ) + except common_models.Attachment.DoesNotExist: + return + + common_models.MutationAttachment.objects.create( + attachment=attachment, mutation=mutation + ) diff --git a/api/tests/common/test_tasks.py b/api/tests/common/test_tasks.py index fc62d901b..cfb91470f 100644 --- a/api/tests/common/test_tasks.py +++ b/api/tests/common/test_tasks.py @@ -1,6 +1,7 @@ import pytest import datetime +from funkwhale_api.common import models from funkwhale_api.common import serializers from funkwhale_api.common import signals from funkwhale_api.common import tasks @@ -68,21 +69,27 @@ def test_cannot_apply_already_applied_migration(factories): def test_prune_unattached_attachments(factories, settings, now): settings.ATTACHMENTS_UNATTACHED_PRUNE_DELAY = 5 + prunable_date = now - datetime.timedelta( + seconds=settings.ATTACHMENTS_UNATTACHED_PRUNE_DELAY + ) attachments = [ # attached, kept factories["music.Album"]().attachment_cover, # recent, kept factories["common.Attachment"](), # too old, pruned - factories["common.Attachment"]( - creation_date=now - - datetime.timedelta(seconds=settings.ATTACHMENTS_UNATTACHED_PRUNE_DELAY) - ), + factories["common.Attachment"](creation_date=prunable_date), + # attached to a mutation, kept even if old + models.MutationAttachment.objects.create( + mutation=factories["common.Mutation"](payload={}), + attachment=factories["common.Attachment"](creation_date=prunable_date), + ).attachment, ] tasks.prune_unattached_attachments() attachments[0].refresh_from_db() attachments[1].refresh_from_db() + attachments[3].refresh_from_db() with pytest.raises(attachments[2].DoesNotExist): attachments[2].refresh_from_db() diff --git a/api/tests/conftest.py b/api/tests/conftest.py index a7fa02cc0..2d32f42cf 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -422,3 +422,8 @@ def clear_license_cache(db): licenses._cache = None yield licenses._cache = None + + +@pytest.fixture +def faker(): + return factory.Faker._get_faker() diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py index 3bbdd4868..a632c5153 100644 --- a/api/tests/federation/test_routes.py +++ b/api/tests/federation/test_routes.py @@ -537,7 +537,7 @@ def test_inbox_update_album(factories, mocker): "funkwhale_api.music.tasks.update_library_entity" ) activity = factories["federation.Activity"]() - obj = factories["music.Album"](attributed=True) + obj = factories["music.Album"](attributed=True, attachment_cover=None) actor = obj.attributed_to data = serializers.AlbumSerializer(obj).data data["name"] = "New title" diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index 34460a5a6..b7c4c4a9e 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -610,6 +610,47 @@ def test_activity_pub_album_serializer_to_ap(factories): assert serializer.data == expected +def test_activity_pub_album_serializer_from_ap_update(factories, faker): + album = factories["music.Album"](attributed=True) + released = faker.date_object() + payload = { + "@context": jsonld.get_default_context(), + "type": "Album", + "id": album.fid, + "name": faker.sentence(), + "cover": {"type": "Link", "mediaType": "image/jpeg", "href": faker.url()}, + "musicbrainzId": faker.uuid4(), + "published": album.creation_date.isoformat(), + "released": released.isoformat(), + "artists": [ + serializers.ArtistSerializer( + album.artist, context={"include_ap_context": False} + ).data + ], + "attributedTo": album.attributed_to.fid, + "tag": [ + {"type": "Hashtag", "name": "#Punk"}, + {"type": "Hashtag", "name": "#Rock"}, + ], + } + serializer = serializers.AlbumSerializer(album, data=payload) + assert serializer.is_valid(raise_exception=True) is True + + serializer.save() + + album.refresh_from_db() + + assert album.title == payload["name"] + assert str(album.mbid) == payload["musicbrainzId"] + assert album.release_date == released + assert album.attachment_cover.url == payload["cover"]["href"] + assert album.attachment_cover.mimetype == payload["cover"]["mediaType"] + assert sorted(album.tagged_items.values_list("tag__name", flat=True)) == [ + "Punk", + "Rock", + ] + + def test_activity_pub_track_serializer_to_ap(factories): track = factories["music.Track"]( license="cc-by-4.0", diff --git a/api/tests/music/test_mutations.py b/api/tests/music/test_mutations.py index 3a86f3bf8..ff2982dff 100644 --- a/api/tests/music/test_mutations.py +++ b/api/tests/music/test_mutations.py @@ -176,3 +176,22 @@ def test_perm_checkers_can_approve( obj = factories["music.Track"](**obj_kwargs) assert mutations.can_approve(obj, actor=actor) is expected + + +def test_mutation_set_attachment_cover(factories, now, mocker): + new_attachment = factories["common.Attachment"](actor__local=True) + obj = factories["music.Album"]() + old_attachment = obj.attachment_cover + mutation = factories["common.Mutation"]( + type="update", target=obj, payload={"cover": new_attachment.uuid} + ) + + # new attachment should be linked to mutation, to avoid being pruned + # before being applied + assert new_attachment.mutation_attachment.mutation == mutation + + mutation.apply() + obj.refresh_from_db() + + assert obj.attachment_cover == new_attachment + assert mutation.previous_state["cover"] == old_attachment.uuid diff --git a/changes/changelog.d/588.enhancement b/changes/changelog.d/588.enhancement new file mode 100644 index 000000000..1ce18953d --- /dev/null +++ b/changes/changelog.d/588.enhancement @@ -0,0 +1 @@ +Support modifying album cover art through the web UI (#588) diff --git a/front/src/components/common/AttachmentInput.vue b/front/src/components/common/AttachmentInput.vue new file mode 100644 index 000000000..47b3253a2 --- /dev/null +++ b/front/src/components/common/AttachmentInput.vue @@ -0,0 +1,99 @@ + + diff --git a/front/src/components/library/EditCard.vue b/front/src/components/library/EditCard.vue index fc5efea55..20435a039 100644 --- a/front/src/components/library/EditCard.vue +++ b/front/src/components/library/EditCard.vue @@ -53,20 +53,37 @@ {{ field.id }} - - {{ part.value }} - + + N/A - - {{ part.value }} - + + + + + + - {{ field.newRepr }} @@ -171,6 +188,7 @@ export default { let getValueRepr = fieldConfig.getValueRepr || dummyRepr let d = { id: f, + config: fieldConfig } if (previousState && previousState[f]) { d.old = previousState[f] diff --git a/front/src/components/library/EditForm.vue b/front/src/components/library/EditForm.vue index ea9fc7696..6f4afccac 100644 --- a/front/src/components/library/EditForm.vue +++ b/front/src/components/library/EditForm.vue @@ -76,6 +76,17 @@ Clear + + + diff --git a/front/src/components/ShortcutsModal.vue b/front/src/components/ShortcutsModal.vue index 999d24dd3..097672f2c 100644 --- a/front/src/components/ShortcutsModal.vue +++ b/front/src/components/ShortcutsModal.vue @@ -42,12 +42,11 @@ @@ -331,16 +339,24 @@ export default { diff --git a/front/src/components/audio/SearchBar.vue b/front/src/components/audio/SearchBar.vue index ae2ae08fb..ed18805aa 100644 --- a/front/src/components/audio/SearchBar.vue +++ b/front/src/components/audio/SearchBar.vue @@ -1,7 +1,7 @@ diff --git a/front/src/components/audio/artist/Card.vue b/front/src/components/audio/artist/Card.vue index 6fe70e266..f0435940d 100644 --- a/front/src/components/audio/artist/Card.vue +++ b/front/src/components/audio/artist/Card.vue @@ -1,28 +1,20 @@ @@ -72,24 +64,4 @@ export default { .default-cover { background-image: url("../../../assets/audio/default-cover.png") !important; } - -.play-button { - position: absolute; - right: 0; - bottom: 40%; -} - -.with-overlay { - background-size: cover !important; - background-position: center !important; - height: 8em; - width: 8em; - display: flex !important; - justify-content: center !important; - align-items: center !important; -} -.flat.card .with-overlay.image { - border-radius: 50% !important; - margin: 0 auto; -} diff --git a/front/src/components/library/Artists.vue b/front/src/components/library/Artists.vue index 7f83fb0a0..10ef24f3b 100644 --- a/front/src/components/library/Artists.vue +++ b/front/src/components/library/Artists.vue @@ -42,7 +42,7 @@ -
+
diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss index db2f0701a..6c8ca4584 100644 --- a/front/src/style/_main.scss +++ b/front/src/style/_main.scss @@ -354,20 +354,6 @@ td.align.right { word-wrap: break-word; } -.ui.cards > .flat.card, .flat.card { - box-shadow: none; - .content { - border: none; - } -} - -.ui.cards > .inline.card { - flex-direction: row; - .content { - padding: 0.5em 0.75em; - } -} - .ui.checkbox label { cursor: pointer; } @@ -439,21 +425,30 @@ input + .help { } } .ui.cards.app-cards { - > .app-card { - display: inline-block; - width: 15em; - > .image { + $card-width: 14em; + $card-hight: 22em; + .app-card { + display: flex; + width: $card-width; + height: $card-hight; + .head-image { + height: $card-width; background-size: cover !important; background-position: center !important; - height: 15em; - width: 15em; display: flex !important; justify-content: flex-end !important; align-items: flex-end !important; - padding: 0.5em; .button { margin: 0; } + &.circular { + overflow: visible; + border-radius: 50% !important; + height: $card-width - 1em; + width: $card-width - 1em; + margin: 0.5em; + + } } .extra { border-top: 0 !important; @@ -462,6 +457,7 @@ input + .help { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + padding-bottom: 0; } .floating.dropdown > .icon { margin-right: 0; From f0b72c82043b38c79493f5481bfd56da008896e4 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Fri, 27 Dec 2019 10:22:39 +0100 Subject: [PATCH 080/322] WIP --- front/src/components/audio/artist/Card.vue | 4 +- front/src/components/playlists/Card.vue | 86 +++++---------------- front/src/components/playlists/CardList.vue | 11 +-- front/src/style/_main.scss | 16 ++++ 4 files changed, 42 insertions(+), 75 deletions(-) diff --git a/front/src/components/audio/artist/Card.vue b/front/src/components/audio/artist/Card.vue index f0435940d..71192914e 100644 --- a/front/src/components/audio/artist/Card.vue +++ b/front/src/components/audio/artist/Card.vue @@ -1,6 +1,8 @@ @@ -68,7 +71,7 @@ export default { self.previousPage = response.data.previous self.nextPage = response.data.next self.isLoading = false - self.albums = response.data.results + self.albums = [...self.albums, ...response.data.results] self.count = response.data.count }, error => { self.isLoading = false diff --git a/front/src/components/audio/artist/Card.vue b/front/src/components/audio/artist/Card.vue index bcb66e95f..f73b7dbb1 100644 --- a/front/src/components/audio/artist/Card.vue +++ b/front/src/components/audio/artist/Card.vue @@ -22,7 +22,6 @@ - - - diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue index 269e7ac29..ff1e78b3d 100644 --- a/front/src/components/auth/Settings.vue +++ b/front/src/components/auth/Settings.vue @@ -23,6 +23,7 @@ +
- + {{ remainingChars }}

@@ -49,7 +56,12 @@ export default { props: { value: {type: String, default: ""}, fieldId: {type: String, default: "change-content"}, + placeholder: {type: String, default: null}, autofocus: {type: Boolean, default: false}, + charLimit: {type: Number, default: 5000, required: false}, + rows: {type: Number, default: 5, required: false}, + permissive: {type: Boolean, default: false}, + required: {type: Boolean, default: false}, }, data () { return { @@ -57,7 +69,6 @@ export default { preview: null, newValue: this.value, isLoadingPreview: false, - charLimit: 5000, } }, mounted () { @@ -71,7 +82,7 @@ export default { async loadPreview () { this.isLoadingPreview = true try { - let response = await axios.post('text-preview/', {text: this.value}) + let response = await axios.post('text-preview/', {text: this.newValue, permissive: this.permissive}) this.preview = response.data.rendered } catch { @@ -86,11 +97,12 @@ export default { } }, remainingChars () { - return this.charLimit - this.value.length + return this.charLimit - (this.value || "").length } }, watch: { newValue (v) { + this.preview = null this.$emit('input', v) }, value: { @@ -104,7 +116,7 @@ export default { immediate: true, }, async isPreviewing (v) { - if (v && !!this.value && this.preview === null) { + if (v && !!this.value && this.preview === null && !this.isLoadingPreview) { await this.loadPreview() } if (!v) { diff --git a/front/src/components/library/EditForm.vue b/front/src/components/library/EditForm.vue index 70f83d049..17b781e59 100644 --- a/front/src/components/library/EditForm.vue +++ b/front/src/components/library/EditForm.vue @@ -79,7 +79,7 @@ diff --git a/front/src/components/ShortcutsModal.vue b/front/src/components/ShortcutsModal.vue index 097672f2c..8f3f41bab 100644 --- a/front/src/components/ShortcutsModal.vue +++ b/front/src/components/ShortcutsModal.vue @@ -36,7 +36,7 @@

-
Close
+
Close
diff --git a/front/src/components/audio/ChannelCard.vue b/front/src/components/audio/ChannelCard.vue index 6cb1ae416..8adbb9d2c 100644 --- a/front/src/components/audio/ChannelCard.vue +++ b/front/src/components/audio/ChannelCard.vue @@ -1,13 +1,13 @@