New radios: play your own content, or a given library
This commit is contained in:
parent
a89eb8db6e
commit
2090806398
|
@ -204,6 +204,7 @@ class APIActorSerializer(serializers.ModelSerializer):
|
||||||
"type",
|
"type",
|
||||||
"manually_approves_followers",
|
"manually_approves_followers",
|
||||||
"full_username",
|
"full_username",
|
||||||
|
"is_local",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -5,10 +5,11 @@ from django.db import connection
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from rest_framework import serializers
|
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.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 funkwhale_api.tags.models import Tag
|
||||||
|
|
||||||
from . import filters, models
|
from . import filters, models
|
||||||
from .registries import registry
|
from .registries import registry
|
||||||
|
|
||||||
|
@ -271,3 +272,47 @@ class LessListenedRadio(SessionRadio):
|
||||||
qs = super().get_queryset(**kwargs)
|
qs = super().get_queryset(**kwargs)
|
||||||
listened = self.session.user.listenings.all().values_list("track", flat=True)
|
listened = self.session.user.listenings.all().values_list("track", flat=True)
|
||||||
return qs.exclude(pk__in=listened).order_by("?")
|
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
|
||||||
|
|
|
@ -47,6 +47,28 @@ def test_can_pick_by_weight():
|
||||||
assert picks[2] > picks[1]
|
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):
|
def test_can_get_choices_for_favorites_radio(factories):
|
||||||
files = factories["music.Upload"].create_batch(10)
|
files = factories["music.Upload"].create_batch(10)
|
||||||
tracks = [f.track for f in files]
|
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
|
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):
|
def test_can_start_artist_radio_from_api(logged_in_api_client, preferences, factories):
|
||||||
artist = factories["music.Artist"]()
|
artist = factories["music.Artist"]()
|
||||||
url = reverse("api:v1:radios:sessions-list")
|
url = reverse("api:v1:radios:sessions-list")
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
Added two new radios to play your own content or a given library tracks
|
|
@ -16,7 +16,7 @@
|
||||||
</div>
|
</div>
|
||||||
<library-card
|
<library-card
|
||||||
:display-scan="false"
|
: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"
|
:library="library"
|
||||||
:display-copy-fid="true"
|
:display-copy-fid="true"
|
||||||
v-for="library in libraries"
|
v-for="library in libraries"
|
||||||
|
@ -48,16 +48,16 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created () {
|
created () {
|
||||||
this.fetchData()
|
this.fetchData(this.url)
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
fetchData () {
|
fetchData (url) {
|
||||||
this.isLoading = true
|
this.isLoading = true
|
||||||
let self = this
|
let self = this
|
||||||
let params = _.clone({})
|
let params = _.clone({})
|
||||||
params.page_size = this.limit
|
params.page_size = this.limit
|
||||||
params.offset = this.offset
|
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.previousPage = response.data.previous
|
||||||
self.nextPage = response.data.next
|
self.nextPage = response.data.next
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
<translate translate-context="Content/Radio/Title">Instance radios</translate>
|
<translate translate-context="Content/Radio/Title">Instance radios</translate>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="ui cards">
|
<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 v-if="isAuthenticated && hasFavorites" :type="'favorites'"></radio-card>
|
||||||
<radio-card :type="'random'"></radio-card>
|
<radio-card :type="'random'"></radio-card>
|
||||||
<radio-card v-if="$store.state.auth.authenticated" :type="'less-listened'"></radio-card>
|
<radio-card v-if="$store.state.auth.authenticated" :type="'less-listened'"></radio-card>
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
<div class="extra content">
|
<div class="extra content">
|
||||||
<user-link v-if="radio.user" :user="radio.user" class="left floated" />
|
<user-link v-if="radio.user" :user="radio.user" class="left floated" />
|
||||||
<div class="ui hidden divider"></div>
|
<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
|
<router-link
|
||||||
class="ui basic yellow button right floated"
|
class="ui basic yellow button right floated"
|
||||||
v-if="$store.state.auth.authenticated && type === 'custom' && radio.user.id === $store.state.auth.profile.id"
|
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 {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
type: {type: String, required: true},
|
type: {type: String, required: true},
|
||||||
customRadio: {required: false}
|
customRadio: {required: false},
|
||||||
|
objectId: {required: false},
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
RadioButton
|
RadioButton
|
||||||
|
|
|
@ -10,6 +10,10 @@ export default {
|
||||||
getters: {
|
getters: {
|
||||||
types: state => {
|
types: state => {
|
||||||
return {
|
return {
|
||||||
|
actor_content: {
|
||||||
|
name: 'Your content',
|
||||||
|
description: "Picks from your own libraries"
|
||||||
|
},
|
||||||
random: {
|
random: {
|
||||||
name: 'Random',
|
name: 'Random',
|
||||||
description: "Totally random picks, maybe you'll discover new things?"
|
description: "Totally random picks, maybe you'll discover new things?"
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<h3 class="ui header"><translate translate-context="Content/Library/Title">Current library</translate></h3>
|
<h3 class="ui header"><translate translate-context="Content/Library/Title">Current library</translate></h3>
|
||||||
<library-card :library="library" />
|
<library-card :library="library" />
|
||||||
|
<radio-button :type="'library'" :object-id="library.uuid"></radio-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui hidden divider"></div>
|
<div class="ui hidden divider"></div>
|
||||||
|
@ -12,12 +13,14 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import RadioButton from '@/components/radios/Button'
|
||||||
import LibraryCard from './Card'
|
import LibraryCard from './Card'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: ['library'],
|
props: ['library'],
|
||||||
components: {
|
components: {
|
||||||
LibraryCard
|
LibraryCard,
|
||||||
|
RadioButton,
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
links () {
|
links () {
|
||||||
|
|
|
@ -93,7 +93,9 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="displayFollow" :class="['ui', 'bottom', {two: library.follow}, 'attached', 'buttons']">
|
<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
|
<button
|
||||||
v-if="!library.follow"
|
v-if="!library.follow"
|
||||||
@click="follow()"
|
@click="follow()"
|
||||||
|
@ -112,10 +114,6 @@
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="library.follow.approved">
|
<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
|
<dangerous-button
|
||||||
color=""
|
color=""
|
||||||
:class="['ui', 'button']"
|
:class="['ui', 'button']"
|
||||||
|
@ -128,12 +126,14 @@
|
||||||
<div slot="modal-confirm"><translate translate-context="*/Library/Button.Label/Verb">Unfollow</translate></div>
|
<div slot="modal-confirm"><translate translate-context="*/Library/Button.Label/Verb">Unfollow</translate></div>
|
||||||
</dangerous-button>
|
</dangerous-button>
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import ReportMixin from '@/components/mixins/Report'
|
import ReportMixin from '@/components/mixins/Report'
|
||||||
|
import RadioButton from '@/components/radios/Button'
|
||||||
import jQuery from 'jquery'
|
import jQuery from 'jquery'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -144,6 +144,9 @@ export default {
|
||||||
displayScan: {type: Boolean, default: true},
|
displayScan: {type: Boolean, default: true},
|
||||||
displayCopyFid: {type: Boolean, default: true},
|
displayCopyFid: {type: Boolean, default: true},
|
||||||
},
|
},
|
||||||
|
components: {
|
||||||
|
RadioButton
|
||||||
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
isLoadingFollow: false,
|
isLoadingFollow: false,
|
||||||
|
@ -195,7 +198,13 @@ export default {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
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: {
|
methods: {
|
||||||
launchScan () {
|
launchScan () {
|
||||||
|
|
Loading…
Reference in New Issue