diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 5c92ad2b1eee0dbcdf07da798664106ac9927c5c..871dfc920fc052e9e05a85b30c66796ba629a1c2 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -3,7 +3,7 @@ import urllib from django.conf import settings from django.db import transaction -from django.db.models import Count, Prefetch, Sum, F +from django.db.models import Count, Prefetch, Sum, F, Q from django.db.models.functions import Length from django.utils import timezone @@ -26,6 +26,28 @@ from . import filters, models, serializers, tasks, utils logger = logging.getLogger(__name__) +def get_libraries(filter_uploads): + def view(self, request, *args, **kwargs): + obj = self.get_object() + actor = utils.get_actor_from_request(request) + uploads = models.Upload.objects.all() + uploads = filter_uploads(obj, uploads) + uploads = uploads.playable_by(actor) + libraries = models.Library.objects.filter( + pk__in=uploads.values_list("library", flat=True) + ) + libraries = libraries.select_related("actor") + page = self.paginate_queryset(libraries) + if page is not None: + serializer = federation_api_serializers.LibrarySerializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = federation_api_serializers.LibrarySerializer(libraries, many=True) + return Response(serializer.data) + + return view + + class TagViewSetMixin(object): def get_queryset(self): queryset = super().get_queryset() @@ -50,6 +72,14 @@ class ArtistViewSet(viewsets.ReadOnlyModelViewSet): ) return queryset.prefetch_related(Prefetch("albums", queryset=albums)).distinct() + libraries = detail_route(methods=["get"])( + get_libraries( + filter_uploads=lambda o, uploads: uploads.filter( + Q(track__artist=o) | Q(track__album__artist=o) + ) + ) + ) + class AlbumViewSet(viewsets.ReadOnlyModelViewSet): queryset = ( @@ -76,6 +106,10 @@ class AlbumViewSet(viewsets.ReadOnlyModelViewSet): qs = queryset.prefetch_related(Prefetch("tracks", queryset=tracks)) return qs.distinct() + libraries = detail_route(methods=["get"])( + get_libraries(filter_uploads=lambda o, uploads: uploads.filter(track__album=o)) + ) + class LibraryViewSet( mixins.CreateModelMixin, @@ -197,6 +231,10 @@ class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet): serializer = serializers.LyricsSerializer(lyrics) return Response(serializer.data) + libraries = detail_route(methods=["get"])( + get_libraries(filter_uploads=lambda o, uploads: uploads.filter(track=o)) + ) + def get_file_path(audio_file): serve_path = settings.MUSIC_DIRECTORY_SERVE_PATH diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 691fa049e75e0e058643ab6209a01d25a8a87be8..2f9d66e5b00d81fb56871e344df4df456ebf6a67 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -449,3 +449,34 @@ def test_user_can_list_own_library_follows(factories, logged_in_api_client): "previous": None, "results": [federation_api_serializers.LibraryFollowSerializer(follow).data], } + + +@pytest.mark.parametrize("entity", ["artist", "album", "track"]) +def test_can_get_libraries_for_music_entities( + factories, api_client, entity, preferences +): + preferences["common__api_authentication_required"] = False + upload = factories["music.Upload"](playable=True) + # another private library that should not appear + factories["music.Upload"]( + import_status="finished", library__privacy_level="me", track=upload.track + ).library + library = upload.library + data = { + "artist": upload.track.artist, + "album": upload.track.album, + "track": upload.track, + } + + url = reverse("api:v1:{}s-libraries".format(entity), kwargs={"pk": data[entity].pk}) + + response = api_client.get(url) + expected = federation_api_serializers.LibrarySerializer(library).data + + assert response.status_code == 200 + assert response.data == { + "count": 1, + "next": None, + "previous": None, + "results": [expected], + } diff --git a/changes/changelog.d/551.enhancement b/changes/changelog.d/551.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..267dba9f73783da87813472ec3743005191b3b1b --- /dev/null +++ b/changes/changelog.d/551.enhancement @@ -0,0 +1 @@ +Added a library widget to display libraries associated with a track, album and artist (#551) diff --git a/front/src/components/common/CopyInput.vue b/front/src/components/common/CopyInput.vue index c2db315bd1e11e5b0c0b1910f08d637c4596f481..af82f2c661aec207bbae6208d192ca4376dc02b6 100644 --- a/front/src/components/common/CopyInput.vue +++ b/front/src/components/common/CopyInput.vue @@ -4,7 +4,7 @@ <translate>Text copied to clipboard!</translate> </p> <input ref="input" :value="value" type="text"> - <button @click="copy" class="ui teal right labeled icon button"> + <button @click="copy" :class="['ui', buttonClasses, 'right', 'labeled', 'icon', 'button']"> <i class="copy icon"></i> <translate>Copy</translate> </button> @@ -12,7 +12,10 @@ </template> <script> export default { - props: ['value'], + props: { + value: {type: String}, + buttonClasses: {type: String, default: 'teal'} + }, data () { return { copied: false, diff --git a/front/src/components/federation/LibraryWidget.vue b/front/src/components/federation/LibraryWidget.vue new file mode 100644 index 0000000000000000000000000000000000000000..ff73bb7a892d77d09969100e54e7137b6d15e0c4 --- /dev/null +++ b/front/src/components/federation/LibraryWidget.vue @@ -0,0 +1,84 @@ +<template> + <div class="wrapper"> + <h3 class="ui header"> + <slot name="title"></slot> + </h3> + <p v-if="!isLoading && libraries.length > 0" class="ui subtitle"><slot name="subtitle"></slot></p> + <p v-if="!isLoading && libraries.length === 0" class="ui subtitle"><translate>No matching library.</translate></p> + <i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'medium', 'angle left', 'icon']"> + </i> + <i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'medium', 'angle right', 'icon']"> + </i> + <div class="ui hidden divider"></div> + <div class="ui three cards"> + <div v-if="isLoading" class="ui inverted active dimmer"> + <div class="ui loader"></div> + </div> + <library-card + :display-scan="false" + :display-follow="$store.state.auth.authenticated" + :library="library" + :display-copy-fid="true" + v-for="library in libraries" + :key="library.uuid"></library-card> + </div> + </div> +</template> + +<script> +import _ from 'lodash' +import axios from 'axios' +import LibraryCard from '@/views/content/remote/Card' + +export default { + props: { + url: {type: String, required: true} + }, + components: { + LibraryCard + }, + data () { + return { + libraries: [], + limit: 6, + isLoading: false, + errors: null, + previousPage: null, + nextPage: null + } + }, + created () { + this.fetchData() + }, + methods: { + fetchData () { + this.isLoading = true + let self = this + let params = _.clone({}) + params.page_size = this.limit + params.offset = this.offset + axios.get(this.url, {params: params}).then((response) => { + self.previousPage = response.data.previous + self.nextPage = response.data.next + self.isLoading = false + self.libraries = response.data.results + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + updateOffset (increment) { + if (increment) { + this.offset += this.limit + } else { + this.offset = Math.max(this.offset - this.limit, 0) + } + } + }, + watch: { + offset () { + this.fetchData() + } + } +} +</script> diff --git a/front/src/components/library/Album.vue b/front/src/components/library/Album.vue index 7ee5ae068833d592961e67c2442f6136b5cf7b8a..03d83e064a509203515c5ec998b746c985ebe989 100644 --- a/front/src/components/library/Album.vue +++ b/front/src/components/library/Album.vue @@ -45,6 +45,14 @@ </h2> <track-table v-if="album" :artist="album.artist" :display-position="true" :tracks="album.tracks"></track-table> </div> + <div class="ui vertical stripe segment"> + <h2> + <translate>User libraries</translate> + </h2> + <library-widget :url="'albums/' + id + '/libraries/'"> + <translate slot="subtitle">This album is present in the following libraries:</translate> + </library-widget> + </div> </template> </div> </template> @@ -55,6 +63,7 @@ import logger from '@/logging' import backend from '@/audio/backend' import PlayButton from '@/components/audio/PlayButton' import TrackTable from '@/components/audio/track/Table' +import LibraryWidget from '@/components/federation/LibraryWidget' const FETCH_URL = 'albums/' @@ -62,7 +71,8 @@ export default { props: ['id'], components: { PlayButton, - TrackTable + TrackTable, + LibraryWidget }, data () { return { diff --git a/front/src/components/library/Artist.vue b/front/src/components/library/Artist.vue index ee9c625e10a8519b86df3ba32316087767c175de..fe74f6f17c717bb46be209590fb46ef428c59d8a 100644 --- a/front/src/components/library/Artist.vue +++ b/front/src/components/library/Artist.vue @@ -56,6 +56,14 @@ </h2> <track-table :display-position="true" :tracks="tracks"></track-table> </div> + <div class="ui vertical stripe segment"> + <h2> + <translate>User libraries</translate> + </h2> + <library-widget :url="'artists/' + id + '/libraries/'"> + <translate slot="subtitle">This artist is present in the following libraries:</translate> + </library-widget> + </div> </template> </div> </template> @@ -69,6 +77,7 @@ import AlbumCard from '@/components/audio/album/Card' import RadioButton from '@/components/radios/Button' import PlayButton from '@/components/audio/PlayButton' import TrackTable from '@/components/audio/track/Table' +import LibraryWidget from '@/components/federation/LibraryWidget' export default { props: ['id'], @@ -76,7 +85,8 @@ export default { AlbumCard, RadioButton, PlayButton, - TrackTable + TrackTable, + LibraryWidget }, data () { return { diff --git a/front/src/components/library/Track.vue b/front/src/components/library/Track.vue index 1ede2218bf3131403b298555606d247f01801620..75d5f650d4cd3d1b04a702cb6136ae749f783a6f 100644 --- a/front/src/components/library/Track.vue +++ b/front/src/components/library/Track.vue @@ -118,6 +118,14 @@ </a> </template> </div> + <div class="ui vertical stripe segment"> + <h2> + <translate>User libraries</translate> + </h2> + <library-widget :url="'tracks/' + id + '/libraries/'"> + <translate slot="subtitle">This track is present in the following libraries:</translate> + </library-widget> + </div> </template> </div> </template> @@ -131,6 +139,7 @@ import logger from '@/logging' import PlayButton from '@/components/audio/PlayButton' import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon' +import LibraryWidget from '@/components/federation/LibraryWidget' const FETCH_URL = 'tracks/' @@ -139,7 +148,8 @@ export default { components: { PlayButton, TrackPlaylistIcon, - TrackFavoriteIcon + TrackFavoriteIcon, + LibraryWidget }, data () { return { diff --git a/front/src/views/content/remote/Card.vue b/front/src/views/content/remote/Card.vue index 862a9a69a4ae8c105f0c7cbb6c8bab1d147b8de1..9cbaf857fd0e7068bf5347be9a2e3b5953cdd462 100644 --- a/front/src/views/content/remote/Card.vue +++ b/front/src/views/content/remote/Card.vue @@ -26,7 +26,7 @@ <i class="music icon"></i> <translate :translate-params="{count: library.uploads_count}" :translate-n="library.uploads_count" translate-plural="%{ count } tracks">%{ count } tracks</translate> </div> - <div v-if="latestScan" class="meta"> + <div v-if="displayScan && latestScan" class="meta"> <template v-if="latestScan.status === 'pending'"> <i class="hourglass icon"></i> <translate>Scan pending</translate> @@ -59,7 +59,7 @@ <translate>Errored tracks:</translate> {{ latestScan.errored_files }} </div> </div> - <div v-if="canLaunchScan" class="clearfix"> + <div v-if="displayScan && canLaunchScan" class="clearfix"> <span class="right floated link" @click="launchScan"> <translate>Launch scan</translate> <i class="paper plane icon" /> </span> @@ -68,7 +68,15 @@ <div class="extra content"> <actor-link :actor="library.actor" /> </div> - <div class="ui bottom attached buttons"> + <div v-if="displayCopyFid" class="extra content"> + <div class="ui form"> + <div class="field"> + <label><translate>Sharing link</translate></label> + <copy-input :button-classes="'basic'" :value="library.fid" /> + </div> + </div> + </div> + <div v-if="displayFollow" class="ui bottom attached buttons"> <button v-if="!library.follow" @click="follow()" @@ -104,7 +112,12 @@ import axios from 'axios' export default { - props: ['library'], + props: { + library: {type: Object, required: true}, + displayFollow: {type: Boolean, default: true}, + displayScan: {type: Boolean, default: true}, + displayCopyFid: {type: Boolean, default: false}, + }, data () { return { isLoadingFollow: false,