diff --git a/api/config/spa_urls.py b/api/config/spa_urls.py index 730fcc298..8c9fe5149 100644 --- a/api/config/spa_urls.py +++ b/api/config/spa_urls.py @@ -21,6 +21,11 @@ urlpatterns = [ spa_views.library_playlist, name="library_playlist", ), + urls.re_path( + r"^library/(?P[0-9a-f-]+)/?$", + spa_views.library_library, + name="library_library", + ), urls.re_path( r"^channels/(?P[0-9a-f-]+)/?$", audio_spa_views.channel_detail_uuid, diff --git a/api/funkwhale_api/federation/api_views.py b/api/funkwhale_api/federation/api_views.py index db06e3197..39e6e584a 100644 --- a/api/funkwhale_api/federation/api_views.py +++ b/api/funkwhale_api/federation/api_views.py @@ -98,6 +98,26 @@ class LibraryFollowViewSet( update_follow(follow, approved=False) return response.Response(status=204) + @decorators.action(methods=["get"], detail=False) + def all(self, request, *args, **kwargs): + """ + Return all the subscriptions of the current user, with only limited data + to have a performant endpoint and avoid lots of queries just to display + subscription status in the UI + """ + follows = list( + self.get_queryset().values_list("uuid", "target__uuid", "approved") + ) + + payload = { + "results": [ + {"uuid": str(u[0]), "library": str(u[1]), "approved": u[2]} + for u in follows + ], + "count": len(follows), + } + return response.Response(payload, status=200) + class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): lookup_field = "uuid" diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py index cab3baf6d..8070b1310 100644 --- a/api/funkwhale_api/federation/utils.py +++ b/api/funkwhale_api/federation/utils.py @@ -201,3 +201,26 @@ def find_alternate(response_text): parser.feed(response_text) except StopParsing: return parser.result + + +def should_redirect_ap_to_html(accept_header): + if not accept_header: + return False + + redirect_headers = [ + "text/html", + ] + no_redirect_headers = [ + "application/json", + "application/activity+json", + "application/ld+json", + ] + + parsed_header = [ct.lower().strip() for ct in accept_header.split(",")] + for ct in parsed_header: + if ct in redirect_headers: + return True + if ct in no_redirect_headers: + return False + + return True diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index c20d792bc..5efebf1ec 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -1,4 +1,5 @@ from django import forms +from django.conf import settings from django.core import paginator from django.db.models import Prefetch from django.http import HttpResponse @@ -7,6 +8,7 @@ from rest_framework import exceptions, mixins, permissions, response, viewsets from rest_framework.decorators import action from funkwhale_api.common import preferences +from funkwhale_api.common import utils as common_utils from funkwhale_api.moderation import models as moderation_models from funkwhale_api.music import models as music_models from funkwhale_api.music import utils as music_utils @@ -14,6 +16,12 @@ from funkwhale_api.music import utils as music_utils from . import activity, authentication, models, renderers, serializers, utils, webfinger +def redirect_to_html(public_url): + response = HttpResponse(status=302) + response["Location"] = common_utils.join_url(settings.FUNKWHALE_URL, public_url) + return response + + class AuthenticatedIfAllowListEnabled(permissions.BasePermission): def has_permission(self, request, view): allow_list_enabled = preferences.get("moderation__allow_list_enabled") @@ -204,13 +212,18 @@ class MusicLibraryViewSet( renderer_classes = renderers.get_ap_renderers() serializer_class = serializers.LibrarySerializer queryset = ( - music_models.Library.objects.all().select_related("actor").filter(channel=None) + music_models.Library.objects.all() + .local() + .select_related("actor") + .filter(channel=None) ) lookup_field = "uuid" def retrieve(self, request, *args, **kwargs): lb = self.get_object() - + if utils.should_redirect_ap_to_html(request.headers.get("accept")): + # XXX: implement this for actors, albums, tracks, artists + return redirect_to_html(lb.get_absolute_url()) conf = { "id": lb.get_federation_id(), "actor": lb.actor, diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py index 2395a8381..60a9e5f86 100644 --- a/api/funkwhale_api/music/filters.py +++ b/api/funkwhale_api/music/filters.py @@ -41,8 +41,30 @@ class ChannelFilterSet(filters.FilterSet): return queryset.filter(pk__in=ids) +class LibraryFilterSet(filters.FilterSet): + + library = filters.CharFilter(field_name="_", method="filter_library") + + def filter_library(self, queryset, name, value): + if not value: + return queryset + + actor = utils.get_actor_from_request(self.request) + library = models.Library.objects.filter(uuid=value).viewable_by(actor).first() + + if not library: + return queryset.none() + + uploads = models.Upload.objects.filter(library=library) + uploads = uploads.playable_by(actor) + ids = uploads.values_list(self.Meta.library_filter_field, flat=True) + return queryset.filter(pk__in=ids) + + class ArtistFilter( - audio_filters.IncludeChannelsFilterSet, moderation_filters.HiddenContentFilterSet + LibraryFilterSet, + audio_filters.IncludeChannelsFilterSet, + moderation_filters.HiddenContentFilterSet, ): q = fields.SearchFilter(search_fields=["name"], fts_search_fields=["body_text"]) @@ -62,6 +84,7 @@ class ArtistFilter( } hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ARTIST"] include_channels_field = "channel" + library_filter_field = "track__artist" def filter_playable(self, queryset, name, value): actor = utils.get_actor_from_request(self.request) @@ -70,6 +93,7 @@ class ArtistFilter( class TrackFilter( ChannelFilterSet, + LibraryFilterSet, audio_filters.IncludeChannelsFilterSet, moderation_filters.HiddenContentFilterSet, ): @@ -99,6 +123,7 @@ class TrackFilter( hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["TRACK"] include_channels_field = "artist__channel" channel_filter_field = "track" + library_filter_field = "track" def filter_playable(self, queryset, name, value): actor = utils.get_actor_from_request(self.request) @@ -156,6 +181,7 @@ class UploadFilter(audio_filters.IncludeChannelsFilterSet): class AlbumFilter( ChannelFilterSet, + LibraryFilterSet, audio_filters.IncludeChannelsFilterSet, moderation_filters.HiddenContentFilterSet, ): @@ -175,6 +201,7 @@ class AlbumFilter( hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ALBUM"] include_channels_field = "artist__channel" channel_filter_field = "track__album" + library_filter_field = "track__album" def filter_playable(self, queryset, name, value): actor = utils.get_actor_from_request(self.request) diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index f27ed3d50..e0adfe86b 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -1110,6 +1110,12 @@ LIBRARY_PRIVACY_LEVEL_CHOICES = [ class LibraryQuerySet(models.QuerySet): + def local(self, include=True): + query = models.Q(actor__domain_id=settings.FEDERATION_HOSTNAME) + if not include: + query = ~query + return self.filter(query) + def with_follows(self, actor): return self.prefetch_related( models.Prefetch( @@ -1123,14 +1129,14 @@ class LibraryQuerySet(models.QuerySet): from funkwhale_api.federation.models import LibraryFollow if actor is None: - return Library.objects.filter(privacy_level="everyone") + return self.filter(privacy_level="everyone") me_query = models.Q(privacy_level="me", actor=actor) instance_query = models.Q(privacy_level="instance", actor__domain=actor.domain) followed_libraries = LibraryFollow.objects.filter( actor=actor, approved=True ).values_list("target", flat=True) - return Library.objects.filter( + return self.filter( me_query | instance_query | models.Q(privacy_level="everyone") @@ -1164,6 +1170,9 @@ class Library(federation_models.FederationMixin): reverse("federation:music:libraries-detail", kwargs={"uuid": self.uuid}) ) + def get_absolute_url(self): + return "/library/{}".format(self.uuid) + def save(self, **kwargs): if not self.pk and not self.fid and self.actor.get_user(): self.fid = self.get_federation_id() diff --git a/api/funkwhale_api/music/spa_views.py b/api/funkwhale_api/music/spa_views.py index 0619166a6..073f5bb96 100644 --- a/api/funkwhale_api/music/spa_views.py +++ b/api/funkwhale_api/music/spa_views.py @@ -292,3 +292,33 @@ def library_playlist(request, pk): # twitter player is also supported in various software metas += get_twitter_card_metas(type="playlist", id=obj.pk) return metas + + +def library_library(request, uuid): + queryset = models.Library.objects.filter(uuid=uuid) + try: + obj = queryset.get() + except models.Library.DoesNotExist: + return [] + library_url = utils.join_url( + settings.FUNKWHALE_URL, + utils.spa_reverse("library_library", kwargs={"uuid": obj.uuid}), + ) + metas = [ + {"tag": "meta", "property": "og:url", "content": library_url}, + {"tag": "meta", "property": "og:type", "content": "website"}, + {"tag": "meta", "property": "og:title", "content": obj.name}, + {"tag": "meta", "property": "og:description", "content": obj.description}, + ] + + if preferences.get("federation__enabled"): + metas.append( + { + "tag": "link", + "rel": "alternate", + "type": "application/activity+json", + "href": obj.fid, + } + ) + + return metas diff --git a/api/tests/federation/test_api_views.py b/api/tests/federation/test_api_views.py index ab74689bf..e3f76e849 100644 --- a/api/tests/federation/test_api_views.py +++ b/api/tests/federation/test_api_views.py @@ -286,3 +286,25 @@ def test_fetch_duplicate_bypass_with_force( assert response.status_code == 201 assert response.data == api_serializers.FetchSerializer(fetch).data fetch_task.assert_called_once_with(fetch_id=fetch.pk) + + +def test_library_follow_get_all(factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + library = factories["music.Library"]() + follow = factories["federation.LibraryFollow"](target=library, actor=actor) + factories["federation.LibraryFollow"]() + factories["music.Library"]() + url = reverse("api:v1:federation:library-follows-all") + response = logged_in_api_client.get(url) + + assert response.status_code == 200 + assert response.data == { + "results": [ + { + "uuid": str(follow.uuid), + "library": str(library.uuid), + "approved": follow.approved, + } + ], + "count": 1, + } diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py index 800b73789..0b5759937 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -2,7 +2,14 @@ import pytest from django.core.paginator import Paginator from django.urls import reverse -from funkwhale_api.federation import actors, serializers, webfinger +from funkwhale_api.common import utils + +from funkwhale_api.federation import ( + actors, + serializers, + webfinger, + utils as federation_utils, +) def test_authenticate_skips_anonymous_fetch_when_allow_list_enabled( @@ -159,7 +166,7 @@ def test_wellknown_webfinger_local(factories, api_client, settings, mocker): @pytest.mark.parametrize("privacy_level", ["me", "instance", "everyone"]) def test_music_library_retrieve(factories, api_client, privacy_level): - library = factories["music.Library"](privacy_level=privacy_level) + library = factories["music.Library"](privacy_level=privacy_level, actor__local=True) expected = serializers.LibrarySerializer(library).data url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) @@ -170,7 +177,7 @@ def test_music_library_retrieve(factories, api_client, privacy_level): def test_music_library_retrieve_excludes_channel_libraries(factories, api_client): - channel = factories["audio.Channel"]() + channel = factories["audio.Channel"](local=True) library = channel.library url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) @@ -180,7 +187,7 @@ def test_music_library_retrieve_excludes_channel_libraries(factories, api_client def test_music_library_retrieve_page_public(factories, api_client): - library = factories["music.Library"](privacy_level="everyone") + library = factories["music.Library"](privacy_level="everyone", actor__local=True) upload = factories["music.Upload"](library=library, import_status="finished") id = library.get_federation_id() expected = serializers.CollectionPageSerializer( @@ -253,7 +260,7 @@ def test_channel_upload_retrieve(factories, api_client): @pytest.mark.parametrize("privacy_level", ["me", "instance"]) def test_music_library_retrieve_page_private(factories, api_client, privacy_level): - library = factories["music.Library"](privacy_level=privacy_level) + library = factories["music.Library"](privacy_level=privacy_level, actor__local=True) url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) response = api_client.get(url, {"page": 1}) @@ -264,7 +271,7 @@ def test_music_library_retrieve_page_private(factories, api_client, privacy_leve def test_music_library_retrieve_page_follow( factories, api_client, authenticated_actor, approved, expected ): - library = factories["music.Library"](privacy_level="me") + library = factories["music.Library"](privacy_level="me", actor__local=True) factories["federation.LibraryFollow"]( actor=authenticated_actor, target=library, approved=approved ) @@ -344,3 +351,35 @@ def test_music_upload_detail_private_approved_follow( response = api_client.get(url) assert response.status_code == 200 + + +@pytest.mark.parametrize( + "accept_header,expected", + [ + ("text/html,application/xhtml+xml", True), + ("text/html,application/json", True), + ("", False), + (None, False), + ("application/json", False), + ("application/activity+json", False), + ("application/json,text/html", False), + ("application/activity+json,text/html", False), + ], +) +def test_should_redirect_ap_to_html(accept_header, expected): + assert federation_utils.should_redirect_ap_to_html(accept_header) is expected + + +def test_music_library_retrieve_redirects_to_html_if_header_set( + factories, api_client, settings +): + library = factories["music.Library"](actor__local=True) + + url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) + response = api_client.get(url, HTTP_ACCEPT="text/html") + expected_url = utils.join_url( + settings.FUNKWHALE_URL, + utils.spa_reverse("library_library", kwargs={"uuid": library.uuid}), + ) + assert response.status_code == 302 + assert response["Location"] == expected_url diff --git a/api/tests/music/test_filters.py b/api/tests/music/test_filters.py index ea4543ddb..30f8f58b9 100644 --- a/api/tests/music/test_filters.py +++ b/api/tests/music/test_filters.py @@ -142,3 +142,45 @@ def test_channel_filter_album(factories, queryset_equal_list, mocker, anonymous_ ) assert filterset.qs == [upload.track.album] + + +def test_library_filter_track(factories, queryset_equal_list, mocker, anonymous_user): + library = factories["music.Library"](privacy_level="everyone") + upload = factories["music.Upload"](library=library, playable=True) + factories["music.Track"]() + qs = upload.track.__class__.objects.all() + filterset = filters.TrackFilter( + {"library": library.uuid}, + request=mocker.Mock(user=anonymous_user, actor=None), + queryset=qs, + ) + + assert filterset.qs == [upload.track] + + +def test_library_filter_album(factories, queryset_equal_list, mocker, anonymous_user): + library = factories["music.Library"](privacy_level="everyone") + upload = factories["music.Upload"](library=library, playable=True) + factories["music.Album"]() + qs = upload.track.album.__class__.objects.all() + filterset = filters.AlbumFilter( + {"library": library.uuid}, + request=mocker.Mock(user=anonymous_user, actor=None), + queryset=qs, + ) + + assert filterset.qs == [upload.track.album] + + +def test_library_filter_artist(factories, queryset_equal_list, mocker, anonymous_user): + library = factories["music.Library"](privacy_level="everyone") + upload = factories["music.Upload"](library=library, playable=True) + factories["music.Artist"]() + qs = upload.track.artist.__class__.objects.all() + filterset = filters.ArtistFilter( + {"library": library.uuid}, + request=mocker.Mock(user=anonymous_user, actor=None), + queryset=qs, + ) + + assert filterset.qs == [upload.track.artist] diff --git a/api/tests/music/test_spa_views.py b/api/tests/music/test_spa_views.py index d5deb8ce6..ffb763a1b 100644 --- a/api/tests/music/test_spa_views.py +++ b/api/tests/music/test_spa_views.py @@ -282,3 +282,32 @@ def test_library_playlist_empty(spa_html, no_api_auth, client, factories, settin # we only test our custom metas, not the default ones assert metas[: len(expected_metas)] == expected_metas + + +def test_library_library(spa_html, no_api_auth, client, factories, settings): + library = factories["music.Library"]() + url = "/library/{}".format(library.uuid) + + response = client.get(url) + + expected_metas = [ + { + "tag": "meta", + "property": "og:url", + "content": utils.join_url(settings.FUNKWHALE_URL, url), + }, + {"tag": "meta", "property": "og:type", "content": "website"}, + {"tag": "meta", "property": "og:title", "content": library.name}, + {"tag": "meta", "property": "og:description", "content": library.description}, + { + "tag": "link", + "rel": "alternate", + "type": "application/activity+json", + "href": library.fid, + }, + ] + + metas = utils.parse_meta(response.content.decode()) + + # we only test our custom metas, not the default ones + assert metas[: len(expected_metas)] == expected_metas diff --git a/changes/changelog.d/926.feature b/changes/changelog.d/926.feature new file mode 100644 index 000000000..00d3f7073 --- /dev/null +++ b/changes/changelog.d/926.feature @@ -0,0 +1 @@ +Can now browse a library content through the UI (#926) diff --git a/front/src/components/audio/LibraryFollowButton.vue b/front/src/components/audio/LibraryFollowButton.vue new file mode 100644 index 000000000..2b418edc4 --- /dev/null +++ b/front/src/components/audio/LibraryFollowButton.vue @@ -0,0 +1,43 @@ + + + + + + diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index a4bb95692..b8c1e42fe 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -68,6 +68,7 @@ export default { iconOnly: {type: Boolean, default: false}, artist: {type: Object, required: false}, album: {type: Object, required: false}, + library: {type: Object, required: false}, isPlayable: {type: Boolean, required: false, default: null} }, data () { @@ -196,6 +197,9 @@ export default { } else if (self.album) { let params = {'album': self.album.id, include_channels: 'true', 'ordering': 'disc_number,position'} self.getTracksPage(1, params, resolve) + } else if (self.library) { + let params = {'library': self.library.uuid, 'ordering': '-creation_date'} + self.getTracksPage(1, params, resolve) } }) return getTracks.then((tracks) => { diff --git a/front/src/components/audio/album/Widget.vue b/front/src/components/audio/album/Widget.vue index fc9cebaeb..33bc44732 100644 --- a/front/src/components/audio/album/Widget.vue +++ b/front/src/components/audio/album/Widget.vue @@ -5,6 +5,7 @@ {{ count }} +
@@ -12,14 +13,9 @@
- + + + diff --git a/front/src/components/globals.js b/front/src/components/globals.js index 289fd625e..e3eac9abb 100644 --- a/front/src/components/globals.js +++ b/front/src/components/globals.js @@ -18,5 +18,6 @@ Vue.component('collapse-link', () => import(/* webpackChunkName: "common" */ "@/ Vue.component('action-feedback', () => import(/* webpackChunkName: "common" */ "@/components/common/ActionFeedback")) Vue.component('rendered-description', () => import(/* webpackChunkName: "common" */ "@/components/common/RenderedDescription")) Vue.component('content-form', () => import(/* webpackChunkName: "common" */ "@/components/common/ContentForm")) +Vue.component('inline-search-bar', () => import(/* webpackChunkName: "common" */ "@/components/common/InlineSearchBar")) export default {} diff --git a/front/src/components/library/FileUpload.vue b/front/src/components/library/FileUpload.vue index 7aed6ade5..2086f6291 100644 --- a/front/src/components/library/FileUpload.vue +++ b/front/src/components/library/FileUpload.vue @@ -45,17 +45,17 @@ -
+
-
+

This reference will be used to group imported files together.

-
-
Proceed
+ +
@@ -149,6 +149,7 @@
@@ -253,14 +254,6 @@ export default { }); }); }, - updateProgressBar() { - $(this.$el) - .find(".progress") - .progress({ - total: this.uploads.length * 2, - value: this.uploadedFilesCount + this.finishedJobs - }); - }, handleImportEvent(event) { let self = this; if (event.upload.import_reference != self.importReference) { @@ -387,12 +380,6 @@ export default { } }, watch: { - uploadedFilesCount() { - this.updateProgressBar(); - }, - finishedJobs() { - this.updateProgressBar(); - }, importReference: _.debounce(function() { this.$router.replace({ query: { import: this.importReference } }); }, 500), @@ -400,6 +387,11 @@ export default { if (newValue <= 0) { this.$refs.upload.active = false; } + }, + 'uploads.finished' (v, o) { + if (v > o) { + this.$emit('uploads-finished', v - o) + } } } }; diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue index ed528cd96..2b22a4ca6 100644 --- a/front/src/components/library/Library.vue +++ b/front/src/components/library/Library.vue @@ -1,6 +1,6 @@ diff --git a/front/src/components/mixins/Ordering.vue b/front/src/components/mixins/Ordering.vue index ec11b0b00..d91f28db5 100644 --- a/front/src/components/mixins/Ordering.vue +++ b/front/src/components/mixins/Ordering.vue @@ -1,11 +1,12 @@ diff --git a/front/src/views/content/libraries/DetailArea.vue b/front/src/views/content/libraries/DetailArea.vue deleted file mode 100644 index 62928c05c..000000000 --- a/front/src/views/content/libraries/DetailArea.vue +++ /dev/null @@ -1,37 +0,0 @@ - - - diff --git a/front/src/views/content/libraries/DetailMixin.vue b/front/src/views/content/libraries/DetailMixin.vue deleted file mode 100644 index 92ff8452d..000000000 --- a/front/src/views/content/libraries/DetailMixin.vue +++ /dev/null @@ -1,26 +0,0 @@ - diff --git a/front/src/views/content/libraries/Home.vue b/front/src/views/content/libraries/Home.vue index 69b4ce3a2..2e5e39498 100644 --- a/front/src/views/content/libraries/Home.vue +++ b/front/src/views/content/libraries/Home.vue @@ -62,8 +62,7 @@ export default { }) }, libraryCreated(library) { - this.hiddenForm = true - this.libraries.unshift(library) + this.$router.push({name: 'library.detail', params: {id: library.uuid}}) } } } diff --git a/front/src/views/content/libraries/Upload.vue b/front/src/views/content/libraries/Upload.vue deleted file mode 100644 index 5fc7234ec..000000000 --- a/front/src/views/content/libraries/Upload.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - diff --git a/front/src/views/content/remote/Card.vue b/front/src/views/content/remote/Card.vue index 2ad16c03f..decddc8fc 100644 --- a/front/src/views/content/remote/Card.vue +++ b/front/src/views/content/remote/Card.vue @@ -2,7 +2,9 @@
- {{ library.name }} + + {{ library.name }} +