diff --git a/api/funkwhale_api/radios/radios.py b/api/funkwhale_api/radios/radios.py index 8d9eb816a192e6f6d5d3b72f04e1c14e4abb4da1..9ccb94e4f283c1187eba38a4e353903af8ac51c3 100644 --- a/api/funkwhale_api/radios/radios.py +++ b/api/funkwhale_api/radios/radios.py @@ -1,7 +1,7 @@ import random from django.core.exceptions import ValidationError -from django.db.models import Count +from django.db import connection from rest_framework import serializers from taggit.models import Tag @@ -43,8 +43,7 @@ class SessionRadio(SimpleRadio): return self.session def get_queryset(self, **kwargs): - qs = Track.objects.annotate(uploads_count=Count("uploads")) - return qs.filter(uploads_count__gt=0) + return Track.objects.all() def get_queryset_kwargs(self): return {} @@ -56,6 +55,10 @@ class SessionRadio(SimpleRadio): queryset = self.filter_from_session(queryset) if kwargs.pop("filter_playable", True): queryset = queryset.playable_by(self.session.user.actor) + queryset = self.filter_queryset(queryset) + return queryset + + def filter_queryset(self, queryset): return queryset def filter_from_session(self, queryset): @@ -153,6 +156,74 @@ class TagRadio(RelatedObjectRadio): return qs.filter(tags__in=[self.session.related_object]) +def weighted_choice(choices): + total = sum(w for c, w in choices) + r = random.uniform(0, total) + upto = 0 + for c, w in choices: + if upto + w >= r: + return c + upto += w + assert False, "Shouldn't get here" + + +class NextNotFound(Exception): + pass + + +@registry.register(name="similar") +class SimilarRadio(RelatedObjectRadio): + model = Track + + def filter_queryset(self, queryset): + queryset = super().filter_queryset(queryset) + seeds = list( + self.session.session_tracks.all() + .values_list("track_id", flat=True) + .order_by("-id")[:3] + ) + [self.session.related_object.pk] + for seed in seeds: + try: + return queryset.filter(pk=self.find_next_id(queryset, seed)) + except NextNotFound: + continue + + return queryset.none() + + def find_next_id(self, queryset, seed): + with connection.cursor() as cursor: + query = """ + SELECT next, count(next) AS c + FROM ( + SELECT + track_id, + creation_date, + LEAD(track_id) OVER ( + PARTITION by user_id order by creation_date asc + ) AS next + FROM history_listening + INNER JOIN users_user ON (users_user.id = user_id) + WHERE users_user.privacy_level = 'instance' OR users_user.privacy_level = 'everyone' OR user_id = %s + ORDER BY creation_date ASC + ) t WHERE track_id = %s AND next != %s GROUP BY next ORDER BY c DESC; + """ + cursor.execute(query, [self.session.user_id, seed, seed]) + next_candidates = list(cursor.fetchall()) + + if not next_candidates: + raise NextNotFound() + + matching_tracks = list( + queryset.filter(pk__in=[c[0] for c in next_candidates]).values_list( + "id", flat=True + ) + ) + next_candidates = [n for n in next_candidates if n[0] in matching_tracks] + if not next_candidates: + raise NextNotFound() + return weighted_choice(next_candidates) + + @registry.register(name="artist") class ArtistRadio(RelatedObjectRadio): model = Artist diff --git a/api/tests/radios/test_radios.py b/api/tests/radios/test_radios.py index 7e8f260d0338d2ef71b3b51fb11c5acde917789a..cedb6bd7f856afefe6c9de617a32e70a3b0b83b1 100644 --- a/api/tests/radios/test_radios.py +++ b/api/tests/radios/test_radios.py @@ -237,3 +237,20 @@ def test_can_start_less_listened_radio(factories): for i in range(5): assert radio.pick(filter_playable=False) in good_tracks + + +def test_similar_radio_track(factories): + user = factories["users.User"]() + seed = factories["music.Track"]() + radio = radios.SimilarRadio() + radio.start_session(user, related_object=seed) + + factories["music.Track"].create_batch(5) + + # one user listened to this track + l1 = factories["history.Listening"](track=seed) + + expected_next = factories["music.Track"]() + factories["history.Listening"](track=expected_next, user=l1.user) + + assert radio.pick(filter_playable=False) == expected_next diff --git a/changes/changelog.d/similar-radio.enhancement b/changes/changelog.d/similar-radio.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..d4c0a58de6adcb2fb789953dc8da9b59d60841a8 --- /dev/null +++ b/changes/changelog.d/similar-radio.enhancement @@ -0,0 +1 @@ +[Experimental] Added a new "Similar" radio based on users history (suggested by @gordon) diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index d438a14a02b412156accdf2a45c385defa0e42f3..07cb1f585afee112ade1fc1e64ed484bc7106a07 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -15,6 +15,7 @@ <button class="item basic" ref="add" data-ref="add" :disabled="!playable" @click.stop.prevent="add" :title="labels.addToQueue"><i class="plus icon"></i><translate>Add to queue</translate></button> <button class="item basic" ref="addNext" data-ref="addNext" :disabled="!playable" @click.stop.prevent="addNext()" :title="labels.playNext"><i class="step forward icon"></i><translate>Play next</translate></button> <button class="item basic" ref="playNow" data-ref="playNow" :disabled="!playable" @click.stop.prevent="addNext(true)" :title="labels.playNow"><i class="play icon"></i><translate>Play now</translate></button> + <button v-if="track" class="item basic" :disabled="!playable" @click.stop.prevent="$store.dispatch('radios/start', {type: 'similar', objectId: track.id})" :title="labels.startRadio"><i class="feed icon"></i><translate>Start radio</translate></button> </div> </div> </span> @@ -62,7 +63,8 @@ export default { return { playNow: this.$gettext('Play now'), addToQueue: this.$gettext('Add to current queue'), - playNext: this.$gettext('Play next') + playNext: this.$gettext('Play next'), + startRadio: this.$gettext('Play similar songs') } }, title () {