Skip to content
Snippets Groups Projects
Commit e6a5f69f authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Merge branch 'markov-radio' into 'develop'

[Experimental] Added a new "Similar" radio based on users history (suggested by @gordon)

See merge request funkwhale/funkwhale!578
parents 602a4c3b 5ce4cc83
No related branches found
No related tags found
No related merge requests found
import random import random
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Count from django.db import connection
from rest_framework import serializers from rest_framework import serializers
from taggit.models import Tag from taggit.models import Tag
...@@ -43,8 +43,7 @@ class SessionRadio(SimpleRadio): ...@@ -43,8 +43,7 @@ class SessionRadio(SimpleRadio):
return self.session return self.session
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
qs = Track.objects.annotate(uploads_count=Count("uploads")) return Track.objects.all()
return qs.filter(uploads_count__gt=0)
def get_queryset_kwargs(self): def get_queryset_kwargs(self):
return {} return {}
...@@ -56,6 +55,10 @@ class SessionRadio(SimpleRadio): ...@@ -56,6 +55,10 @@ class SessionRadio(SimpleRadio):
queryset = self.filter_from_session(queryset) queryset = self.filter_from_session(queryset)
if kwargs.pop("filter_playable", True): if kwargs.pop("filter_playable", True):
queryset = queryset.playable_by(self.session.user.actor) queryset = queryset.playable_by(self.session.user.actor)
queryset = self.filter_queryset(queryset)
return queryset
def filter_queryset(self, queryset):
return queryset return queryset
def filter_from_session(self, queryset): def filter_from_session(self, queryset):
...@@ -153,6 +156,74 @@ class TagRadio(RelatedObjectRadio): ...@@ -153,6 +156,74 @@ class TagRadio(RelatedObjectRadio):
return qs.filter(tags__in=[self.session.related_object]) 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") @registry.register(name="artist")
class ArtistRadio(RelatedObjectRadio): class ArtistRadio(RelatedObjectRadio):
model = Artist model = Artist
......
...@@ -237,3 +237,20 @@ def test_can_start_less_listened_radio(factories): ...@@ -237,3 +237,20 @@ def test_can_start_less_listened_radio(factories):
for i in range(5): for i in range(5):
assert radio.pick(filter_playable=False) in good_tracks 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
[Experimental] Added a new "Similar" radio based on users history (suggested by @gordon)
...@@ -15,6 +15,7 @@ ...@@ -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="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="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 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>
</div> </div>
</span> </span>
...@@ -62,7 +63,8 @@ export default { ...@@ -62,7 +63,8 @@ export default {
return { return {
playNow: this.$gettext('Play now'), playNow: this.$gettext('Play now'),
addToQueue: this.$gettext('Add to current queue'), addToQueue: this.$gettext('Add to current queue'),
playNext: this.$gettext('Play next') playNext: this.$gettext('Play next'),
startRadio: this.$gettext('Play similar songs')
} }
}, },
title () { title () {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment