From 5ce4cc8379a6d5501d6ac75e07e8c93d0a85c770 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Wed, 30 Jan 2019 16:54:06 +0100 Subject: [PATCH] [Experimental] Added a new "Similar" radio based on users history (suggested by @gordon) --- api/funkwhale_api/radios/radios.py | 77 ++++++++++++++++++- api/tests/radios/test_radios.py | 17 ++++ changes/changelog.d/similar-radio.enhancement | 1 + front/src/components/audio/PlayButton.vue | 4 +- 4 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 changes/changelog.d/similar-radio.enhancement diff --git a/api/funkwhale_api/radios/radios.py b/api/funkwhale_api/radios/radios.py index 8d9eb816a..9ccb94e4f 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 7e8f260d0..cedb6bd7f 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 000000000..d4c0a58de --- /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 d438a14a0..07cb1f585 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -15,6 +15,7 @@ + @@ -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 () {