diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 977ab0bb1dddd0363d02c5be389dce28b91444b2..76c2514698b4319a6b01642a695745c2a587a1fe 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -204,6 +204,7 @@ class APIActorSerializer(serializers.ModelSerializer): "type", "manually_approves_followers", "full_username", + "is_local", ] diff --git a/api/funkwhale_api/radios/radios.py b/api/funkwhale_api/radios/radios.py index 01a242216085e799cfef1ae8a29765f448d2aee1..08329c6ead3ec09761864beeecede740911fa8b4 100644 --- a/api/funkwhale_api/radios/radios.py +++ b/api/funkwhale_api/radios/radios.py @@ -5,10 +5,11 @@ from django.db import connection from django.db.models import Q from rest_framework import serializers +from funkwhale_api.federation import models as federation_models +from funkwhale_api.federation import fields as federation_fields from funkwhale_api.moderation import filters as moderation_filters -from funkwhale_api.music.models import Artist, Track +from funkwhale_api.music.models import Artist, Library, Track, Upload from funkwhale_api.tags.models import Tag - from . import filters, models from .registries import registry @@ -271,3 +272,47 @@ class LessListenedRadio(SessionRadio): qs = super().get_queryset(**kwargs) listened = self.session.user.listenings.all().values_list("track", flat=True) return qs.exclude(pk__in=listened).order_by("?") + + +@registry.register(name="actor_content") +class ActorContentRadio(RelatedObjectRadio): + """ + Play content from given actor libraries + """ + + model = federation_models.Actor + related_object_field = federation_fields.ActorRelatedField(required=True) + + def get_related_object(self, value): + return value + + def get_queryset(self, **kwargs): + qs = super().get_queryset(**kwargs) + actor_uploads = Upload.objects.filter( + library__actor=self.session.related_object, + ) + return qs.filter(pk__in=actor_uploads.values("track")) + + def get_related_object_id_repr(self, obj): + return obj.full_username + + +@registry.register(name="library") +class LibraryRadio(RelatedObjectRadio): + """ + Play content from a given library + """ + + model = Library + related_object_field = serializers.UUIDField(required=True) + + def get_related_object(self, value): + return Library.objects.get(uuid=value) + + def get_queryset(self, **kwargs): + qs = super().get_queryset(**kwargs) + actor_uploads = Upload.objects.filter(library=self.session.related_object,) + return qs.filter(pk__in=actor_uploads.values("track")) + + def get_related_object_id_repr(self, obj): + return obj.uuid diff --git a/api/tests/radios/test_radios.py b/api/tests/radios/test_radios.py index 2aa60c36a9ac54a08773d0343fedcbc96d21272b..38a1ac831eb1af6b0a5668a9571debe0aace2b3a 100644 --- a/api/tests/radios/test_radios.py +++ b/api/tests/radios/test_radios.py @@ -47,6 +47,28 @@ def test_can_pick_by_weight(): assert picks[2] > picks[1] +def test_session_radio_excludes_previous_picks(factories): + tracks = factories["music.Track"].create_batch(5) + user = factories["users.User"]() + previous_choices = [] + for i in range(5): + TrackFavorite.add(track=random.choice(tracks), user=user) + + radio = radios.SessionRadio() + radio.radio_type = "favorites" + radio.start_session(user) + + for i in range(5): + pick = radio.pick(user=user, filter_playable=False) + assert pick in tracks + assert pick not in previous_choices + previous_choices.append(pick) + + with pytest.raises(ValueError): + # no more picks available + radio.pick(user=user, filter_playable=False) + + def test_can_get_choices_for_favorites_radio(factories): files = factories["music.Upload"].create_batch(10) tracks = [f.track for f in files] @@ -213,6 +235,77 @@ def test_can_start_tag_radio(factories): assert radio.pick(filter_playable=False) in good_tracks +def test_can_start_actor_content_radio(factories): + actor_library = factories["music.Library"](actor__local=True) + good_tracks = [ + factories["music.Upload"](playable=True, library=actor_library).track, + factories["music.Upload"](playable=True, library=actor_library).track, + factories["music.Upload"](playable=True, library=actor_library).track, + ] + factories["music.Upload"].create_batch(3, playable=True) + + radio = radios.ActorContentRadio() + session = radio.start_session( + actor_library.actor.user, related_object=actor_library.actor + ) + assert session.radio_type == "actor_content" + + for i in range(3): + assert radio.pick() in good_tracks + + +def test_can_start_actor_content_radio_from_api( + logged_in_api_client, preferences, factories +): + actor = factories["federation.Actor"]() + url = reverse("api:v1:radios:sessions-list") + + response = logged_in_api_client.post( + url, {"radio_type": "actor_content", "related_object_id": actor.full_username} + ) + + assert response.status_code == 201 + + session = models.RadioSession.objects.latest("id") + + assert session.radio_type == "actor_content" + assert session.related_object == actor + + +def test_can_start_library_radio(factories): + user = factories["users.User"]() + library = factories["music.Library"]() + good_tracks = [ + factories["music.Upload"](library=library).track, + factories["music.Upload"](library=library).track, + factories["music.Upload"](library=library).track, + ] + factories["music.Upload"].create_batch(3) + + radio = radios.LibraryRadio() + session = radio.start_session(user, related_object=library) + assert session.radio_type == "library" + + for i in range(3): + assert radio.pick(filter_playable=False) in good_tracks + + +def test_can_start_library_radio_from_api(logged_in_api_client, preferences, factories): + library = factories["music.Library"]() + url = reverse("api:v1:radios:sessions-list") + + response = logged_in_api_client.post( + url, {"radio_type": "library", "related_object_id": library.uuid} + ) + + assert response.status_code == 201 + + session = models.RadioSession.objects.latest("id") + + assert session.radio_type == "library" + assert session.related_object == library + + def test_can_start_artist_radio_from_api(logged_in_api_client, preferences, factories): artist = factories["music.Artist"]() url = reverse("api:v1:radios:sessions-list") diff --git a/changes/changelog.d/radio.enhancement b/changes/changelog.d/radio.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..a46a9cd92be55908debdfed26e52f77c874de384 --- /dev/null +++ b/changes/changelog.d/radio.enhancement @@ -0,0 +1 @@ +Added two new radios to play your own content or a given library tracks diff --git a/front/src/components/federation/LibraryWidget.vue b/front/src/components/federation/LibraryWidget.vue index 7d24180879a70222cfefd18d4789953270af0f22..3bdc5208937819ff97d851be32476db1bcd65522 100644 --- a/front/src/components/federation/LibraryWidget.vue +++ b/front/src/components/federation/LibraryWidget.vue @@ -16,7 +16,7 @@ </div> <library-card :display-scan="false" - :display-follow="$store.state.auth.authenticated" + :display-follow="$store.state.auth.authenticated && library.actor.full_username != $store.state.auth.fullUsername" :library="library" :display-copy-fid="true" v-for="library in libraries" @@ -48,16 +48,16 @@ export default { } }, created () { - this.fetchData() + this.fetchData(this.url) }, methods: { - fetchData () { + fetchData (url) { 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) => { + axios.get(url, {params: params}).then((response) => { self.previousPage = response.data.previous self.nextPage = response.data.next self.isLoading = false diff --git a/front/src/components/library/Radios.vue b/front/src/components/library/Radios.vue index b03801d807bc7fc164681345d319a483240315a4..fcc14b807efae7fc764870cc525152f76be6f30f 100644 --- a/front/src/components/library/Radios.vue +++ b/front/src/components/library/Radios.vue @@ -10,6 +10,7 @@ <translate translate-context="Content/Radio/Title">Instance radios</translate> </h3> <div class="ui cards"> + <radio-card v-if="isAuthenticated" :type="'actor_content'" :object-id="$store.state.auth.fullUsername"></radio-card> <radio-card v-if="isAuthenticated && hasFavorites" :type="'favorites'"></radio-card> <radio-card :type="'random'"></radio-card> <radio-card v-if="$store.state.auth.authenticated" :type="'less-listened'"></radio-card> diff --git a/front/src/components/radios/Card.vue b/front/src/components/radios/Card.vue index e72b9f1c1ca65cf6fd10020fe1b0b720de5a49f9..55ccb4005616d8795b2153edf579366d1b4dca51 100644 --- a/front/src/components/radios/Card.vue +++ b/front/src/components/radios/Card.vue @@ -16,7 +16,7 @@ <div class="extra content"> <user-link v-if="radio.user" :user="radio.user" class="left floated" /> <div class="ui hidden divider"></div> - <radio-button class="right floated button" :type="type" :custom-radio-id="customRadioId"></radio-button> + <radio-button class="right floated button" :type="type" :custom-radio-id="customRadioId" :object-id="objectId"></radio-button> <router-link class="ui basic yellow button right floated" v-if="$store.state.auth.authenticated && type === 'custom' && radio.user.id === $store.state.auth.profile.id" @@ -33,7 +33,8 @@ import RadioButton from './Button' export default { props: { type: {type: String, required: true}, - customRadio: {required: false} + customRadio: {required: false}, + objectId: {required: false}, }, components: { RadioButton diff --git a/front/src/store/radios.js b/front/src/store/radios.js index c27421ed06ca4fbbd6b8c0926c226e7099550165..6eb06566ff21b7533c840e4c0c9974ae551ab8f3 100644 --- a/front/src/store/radios.js +++ b/front/src/store/radios.js @@ -10,6 +10,10 @@ export default { getters: { types: state => { return { + actor_content: { + name: 'Your content', + description: "Picks from your own libraries" + }, random: { name: 'Random', description: "Totally random picks, maybe you'll discover new things?" diff --git a/front/src/views/content/libraries/DetailArea.vue b/front/src/views/content/libraries/DetailArea.vue index 0a73c90b927589c55bd1353a399febd67bb2309e..62928c05c1123bb2413047f01807275849e0fcbb 100644 --- a/front/src/views/content/libraries/DetailArea.vue +++ b/front/src/views/content/libraries/DetailArea.vue @@ -4,6 +4,7 @@ <div class="column"> <h3 class="ui header"><translate translate-context="Content/Library/Title">Current library</translate></h3> <library-card :library="library" /> + <radio-button :type="'library'" :object-id="library.uuid"></radio-button> </div> </div> <div class="ui hidden divider"></div> @@ -12,12 +13,14 @@ </template> <script> +import RadioButton from '@/components/radios/Button' import LibraryCard from './Card' export default { props: ['library'], components: { - LibraryCard + LibraryCard, + RadioButton, }, computed: { links () { diff --git a/front/src/views/content/remote/Card.vue b/front/src/views/content/remote/Card.vue index d87b7f3b45231b0cddb08cd4984030ea183e3b66..1676c073b82457ce390866712ffedba1539f4fb3 100644 --- a/front/src/views/content/remote/Card.vue +++ b/front/src/views/content/remote/Card.vue @@ -93,40 +93,39 @@ </div> </div> </div> - <div v-if="displayFollow" :class="['ui', 'bottom', {two: library.follow}, 'attached', 'buttons']"> - <button - v-if="!library.follow" - @click="follow()" - :class="['ui', 'green', {'loading': isLoadingFollow}, 'button']"> - <translate translate-context="Content/Library/Card.Button.Label/Verb">Follow</translate> - </button> - <template v-else-if="!library.follow.approved"> + <div v-if="displayFollow || radioPlayable" :class="['ui', {two: displayFollow && radioPlayable}, 'bottom', 'attached', 'buttons']"> + <radio-button v-if="radioPlayable" :type="'library'" :object-id="library.uuid"></radio-button> + <template v-if="displayFollow"> <button - class="ui disabled button"><i class="hourglass icon"></i> - <translate translate-context="Content/Library/Card.Paragraph">Follow request pending approval</translate> + v-if="!library.follow" + @click="follow()" + :class="['ui', 'green', {'loading': isLoadingFollow}, 'button']"> + <translate translate-context="Content/Library/Card.Button.Label/Verb">Follow</translate> </button> - <button - @click="unfollow" - class="ui button"> - <translate translate-context="Content/Library/Card.Paragraph">Cancel follow request</translate> - </button> - </template> - <template v-else-if="library.follow.approved"> - <button - class="ui disabled button"><i class="check icon"></i> - <translate translate-context="Content/Library/Card.Paragraph">Following</translate> - </button> - <dangerous-button - color="" - :class="['ui', 'button']" - :action="unfollow"> - <translate translate-context="*/Library/Button.Label/Verb">Unfollow</translate> - <p slot="modal-header"><translate translate-context="Popup/Library/Title">Unfollow this library?</translate></p> - <div slot="modal-content"> - <p><translate translate-context="Popup/Library/Paragraph">By unfollowing this library, you loose access to its content.</translate></p> - </div> - <div slot="modal-confirm"><translate translate-context="*/Library/Button.Label/Verb">Unfollow</translate></div> - </dangerous-button> + <template v-else-if="!library.follow.approved"> + <button + class="ui disabled button"><i class="hourglass icon"></i> + <translate translate-context="Content/Library/Card.Paragraph">Follow request pending approval</translate> + </button> + <button + @click="unfollow" + class="ui button"> + <translate translate-context="Content/Library/Card.Paragraph">Cancel follow request</translate> + </button> + </template> + <template v-else-if="library.follow.approved"> + <dangerous-button + color="" + :class="['ui', 'button']" + :action="unfollow"> + <translate translate-context="*/Library/Button.Label/Verb">Unfollow</translate> + <p slot="modal-header"><translate translate-context="Popup/Library/Title">Unfollow this library?</translate></p> + <div slot="modal-content"> + <p><translate translate-context="Popup/Library/Paragraph">By unfollowing this library, you loose access to its content.</translate></p> + </div> + <div slot="modal-confirm"><translate translate-context="*/Library/Button.Label/Verb">Unfollow</translate></div> + </dangerous-button> + </template> </template> </div> </div> @@ -134,6 +133,7 @@ <script> import axios from 'axios' import ReportMixin from '@/components/mixins/Report' +import RadioButton from '@/components/radios/Button' import jQuery from 'jquery' export default { @@ -144,6 +144,9 @@ export default { displayScan: {type: Boolean, default: true}, displayCopyFid: {type: Boolean, default: true}, }, + components: { + RadioButton + }, data () { return { isLoadingFollow: false, @@ -195,7 +198,13 @@ export default { return false } return true - } + }, + radioPlayable () { + return ( + (this.library.actor.is_local || this.scanStatus === 'finished') && + (this.library.privacy_level === 'everyone' || (this.library.follow && this.library.follow.is_approved)) + ) + }, }, methods: { launchScan () {