Merge branch '432-tags-radio' into 'develop'
See #432: tags radio and search See merge request funkwhale/funkwhale!834
This commit is contained in:
commit
9376f808e9
|
@ -4,7 +4,6 @@ import urllib
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Count, Prefetch, Sum, F, Q
|
from django.db.models import Count, Prefetch, Sum, F, Q
|
||||||
from django.db.models.functions import Length
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from rest_framework import mixins
|
from rest_framework import mixins
|
||||||
|
@ -24,6 +23,7 @@ from funkwhale_api.federation import api_serializers as federation_api_serialize
|
||||||
from funkwhale_api.federation import decorators as federation_decorators
|
from funkwhale_api.federation import decorators as federation_decorators
|
||||||
from funkwhale_api.federation import routes
|
from funkwhale_api.federation import routes
|
||||||
from funkwhale_api.tags.models import Tag, TaggedItem
|
from funkwhale_api.tags.models import Tag, TaggedItem
|
||||||
|
from funkwhale_api.tags.serializers import TagSerializer
|
||||||
from funkwhale_api.users.oauth import permissions as oauth_permissions
|
from funkwhale_api.users.oauth import permissions as oauth_permissions
|
||||||
|
|
||||||
from . import filters, licenses, models, serializers, tasks, utils
|
from . import filters, licenses, models, serializers, tasks, utils
|
||||||
|
@ -339,7 +339,7 @@ def handle_serve(upload, user, format=None, max_bitrate=None, proxy_media=True):
|
||||||
f = transcoded_version
|
f = transcoded_version
|
||||||
file_path = get_file_path(f.audio_file)
|
file_path = get_file_path(f.audio_file)
|
||||||
mt = f.mimetype
|
mt = f.mimetype
|
||||||
if not proxy_media:
|
if not proxy_media and f.audio_file:
|
||||||
# we simply issue a 302 redirect to the real URL
|
# we simply issue a 302 redirect to the real URL
|
||||||
response = Response(status=302)
|
response = Response(status=302)
|
||||||
response["Location"] = f.audio_file.url
|
response["Location"] = f.audio_file.url
|
||||||
|
@ -482,6 +482,7 @@ class Search(views.APIView):
|
||||||
"albums": serializers.AlbumSerializer(
|
"albums": serializers.AlbumSerializer(
|
||||||
self.get_albums(query), many=True
|
self.get_albums(query), many=True
|
||||||
).data,
|
).data,
|
||||||
|
"tags": TagSerializer(self.get_tags(query), many=True).data,
|
||||||
}
|
}
|
||||||
return Response(results, status=200)
|
return Response(results, status=200)
|
||||||
|
|
||||||
|
@ -520,15 +521,8 @@ class Search(views.APIView):
|
||||||
def get_tags(self, query):
|
def get_tags(self, query):
|
||||||
search_fields = ["name__unaccent"]
|
search_fields = ["name__unaccent"]
|
||||||
query_obj = utils.get_query(query, search_fields)
|
query_obj = utils.get_query(query, search_fields)
|
||||||
|
qs = Tag.objects.all().filter(query_obj)
|
||||||
# We want the shortest tag first
|
return common_utils.order_for_search(qs, "name")[: self.max_results]
|
||||||
qs = (
|
|
||||||
Tag.objects.all()
|
|
||||||
.annotate(name_length=Length("name"))
|
|
||||||
.order_by("name_length")
|
|
||||||
)
|
|
||||||
|
|
||||||
return qs.filter(query_obj)[: self.max_results]
|
|
||||||
|
|
||||||
|
|
||||||
class LicenseViewSet(viewsets.ReadOnlyModelViewSet):
|
class LicenseViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
|
|
@ -178,9 +178,9 @@ class TagFilter(RadioFilter):
|
||||||
"autocomplete_fields": {
|
"autocomplete_fields": {
|
||||||
"remoteValues": "results",
|
"remoteValues": "results",
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"value": "slug",
|
"value": "name",
|
||||||
},
|
},
|
||||||
"autocomplete_qs": "query={query}",
|
"autocomplete_qs": "q={query}&ordering=length",
|
||||||
"label": "Tags",
|
"label": "Tags",
|
||||||
"placeholder": "Select tags",
|
"placeholder": "Select tags",
|
||||||
}
|
}
|
||||||
|
@ -189,4 +189,8 @@ class TagFilter(RadioFilter):
|
||||||
label = "Tag"
|
label = "Tag"
|
||||||
|
|
||||||
def get_query(self, candidates, names, **kwargs):
|
def get_query(self, candidates, names, **kwargs):
|
||||||
return Q(tags__slug__in=names)
|
return (
|
||||||
|
Q(tagged_items__tag__name__in=names)
|
||||||
|
| Q(artist__tagged_items__tag__name__in=names)
|
||||||
|
| Q(album__tagged_items__tag__name__in=names)
|
||||||
|
)
|
||||||
|
|
|
@ -2,6 +2,7 @@ import random
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
|
from django.db.models import Q
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from funkwhale_api.moderation import filters as moderation_filters
|
from funkwhale_api.moderation import filters as moderation_filters
|
||||||
|
@ -14,6 +15,8 @@ from .registries import registry
|
||||||
|
|
||||||
|
|
||||||
class SimpleRadio(object):
|
class SimpleRadio(object):
|
||||||
|
related_object_field = None
|
||||||
|
|
||||||
def clean(self, instance):
|
def clean(self, instance):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -146,6 +149,8 @@ class CustomRadio(SessionRadio):
|
||||||
class RelatedObjectRadio(SessionRadio):
|
class RelatedObjectRadio(SessionRadio):
|
||||||
"""Abstract radio related to an object (tag, artist, user...)"""
|
"""Abstract radio related to an object (tag, artist, user...)"""
|
||||||
|
|
||||||
|
related_object_field = serializers.IntegerField(required=True)
|
||||||
|
|
||||||
def clean(self, instance):
|
def clean(self, instance):
|
||||||
super().clean(instance)
|
super().clean(instance)
|
||||||
if not instance.related_object:
|
if not instance.related_object:
|
||||||
|
@ -162,10 +167,22 @@ class RelatedObjectRadio(SessionRadio):
|
||||||
@registry.register(name="tag")
|
@registry.register(name="tag")
|
||||||
class TagRadio(RelatedObjectRadio):
|
class TagRadio(RelatedObjectRadio):
|
||||||
model = Tag
|
model = Tag
|
||||||
|
related_object_field = serializers.CharField(required=True)
|
||||||
|
|
||||||
|
def get_related_object(self, name):
|
||||||
|
return self.model.objects.get(name=name)
|
||||||
|
|
||||||
def get_queryset(self, **kwargs):
|
def get_queryset(self, **kwargs):
|
||||||
qs = super().get_queryset(**kwargs)
|
qs = super().get_queryset(**kwargs)
|
||||||
return qs.filter(tagged_items__tag=self.session.related_object)
|
query = (
|
||||||
|
Q(tagged_items__tag=self.session.related_object)
|
||||||
|
| Q(artist__tagged_items__tag=self.session.related_object)
|
||||||
|
| Q(album__tagged_items__tag=self.session.related_object)
|
||||||
|
)
|
||||||
|
return qs.filter(query)
|
||||||
|
|
||||||
|
def get_related_object_id_repr(self, obj):
|
||||||
|
return obj.name
|
||||||
|
|
||||||
|
|
||||||
def weighted_choice(choices):
|
def weighted_choice(choices):
|
||||||
|
|
|
@ -54,6 +54,9 @@ class RadioSessionTrackSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
|
|
||||||
class RadioSessionSerializer(serializers.ModelSerializer):
|
class RadioSessionSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
|
related_object_id = serializers.CharField(required=False, allow_null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.RadioSession
|
model = models.RadioSession
|
||||||
fields = (
|
fields = (
|
||||||
|
@ -66,7 +69,17 @@ class RadioSessionSerializer(serializers.ModelSerializer):
|
||||||
)
|
)
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
registry[data["radio_type"]]().validate_session(data, **self.context)
|
radio_conf = registry[data["radio_type"]]()
|
||||||
|
if radio_conf.related_object_field:
|
||||||
|
try:
|
||||||
|
data[
|
||||||
|
"related_object_id"
|
||||||
|
] = radio_conf.related_object_field.to_internal_value(
|
||||||
|
data["related_object_id"]
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
raise serializers.ValidationError("Radio requires a related object")
|
||||||
|
radio_conf.validate_session(data, **self.context)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
|
@ -77,3 +90,11 @@ class RadioSessionSerializer(serializers.ModelSerializer):
|
||||||
validated_data["related_object_id"]
|
validated_data["related_object_id"]
|
||||||
)
|
)
|
||||||
return super().create(validated_data)
|
return super().create(validated_data)
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
repr = super().to_representation(instance)
|
||||||
|
radio_conf = registry[repr["radio_type"]]()
|
||||||
|
handler = getattr(radio_conf, "get_related_object_id_repr", None)
|
||||||
|
if handler and instance.related_object:
|
||||||
|
repr["related_object_id"] = handler(instance.related_object)
|
||||||
|
return repr
|
||||||
|
|
|
@ -197,16 +197,19 @@ def test_can_start_artist_radio(factories):
|
||||||
|
|
||||||
def test_can_start_tag_radio(factories):
|
def test_can_start_tag_radio(factories):
|
||||||
user = factories["users.User"]()
|
user = factories["users.User"]()
|
||||||
factories["music.Upload"].create_batch(5)
|
|
||||||
tag = factories["tags.Tag"]()
|
tag = factories["tags.Tag"]()
|
||||||
good_files = factories["music.Upload"].create_batch(5, track__set_tags=[tag])
|
good_tracks = [
|
||||||
good_tracks = [f.track for f in good_files]
|
factories["music.Track"](set_tags=[tag.name]),
|
||||||
|
factories["music.Track"](album__set_tags=[tag.name]),
|
||||||
|
factories["music.Track"](album__artist__set_tags=[tag.name]),
|
||||||
|
]
|
||||||
|
factories["music.Track"].create_batch(3, set_tags=["notrock"])
|
||||||
|
|
||||||
radio = radios.TagRadio()
|
radio = radios.TagRadio()
|
||||||
session = radio.start_session(user, related_object=tag)
|
session = radio.start_session(user, related_object=tag)
|
||||||
assert session.radio_type == "tag"
|
assert session.radio_type == "tag"
|
||||||
|
|
||||||
for i in range(5):
|
for i in range(3):
|
||||||
assert radio.pick(filter_playable=False) in good_tracks
|
assert radio.pick(filter_playable=False) in good_tracks
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
from funkwhale_api.radios import serializers
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_tag_radio(factories):
|
||||||
|
tag = factories["tags.Tag"]()
|
||||||
|
|
||||||
|
data = {"radio_type": "tag", "related_object_id": tag.name}
|
||||||
|
|
||||||
|
serializer = serializers.RadioSessionSerializer(data=data)
|
||||||
|
assert serializer.is_valid(raise_exception=True) is True
|
||||||
|
|
||||||
|
session = serializer.save()
|
||||||
|
|
||||||
|
assert session.related_object_id == tag.pk
|
||||||
|
assert session.related_object == tag
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_artist_radio(factories):
|
||||||
|
artist = factories["music.Artist"]()
|
||||||
|
|
||||||
|
data = {"radio_type": "artist", "related_object_id": artist.pk}
|
||||||
|
|
||||||
|
serializer = serializers.RadioSessionSerializer(data=data)
|
||||||
|
assert serializer.is_valid(raise_exception=True) is True
|
||||||
|
|
||||||
|
session = serializer.save()
|
||||||
|
|
||||||
|
assert session.related_object_id == artist.pk
|
||||||
|
assert session.related_object == artist
|
||||||
|
|
||||||
|
|
||||||
|
def test_tag_radio_repr(factories):
|
||||||
|
tag = factories["tags.Tag"]()
|
||||||
|
session = factories["radios.RadioSession"](related_object=tag, radio_type="tag")
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
"id": session.pk,
|
||||||
|
"radio_type": "tag",
|
||||||
|
"custom_radio": None,
|
||||||
|
"user": session.user.pk,
|
||||||
|
"related_object_id": tag.name,
|
||||||
|
"creation_date": session.creation_date.isoformat().split("+")[0] + "Z",
|
||||||
|
}
|
||||||
|
assert serializers.RadioSessionSerializer(session).data == expected
|
|
@ -32,6 +32,7 @@ export default {
|
||||||
let artistLabel = this.$pgettext('*/*/*/Noun', 'Artist')
|
let artistLabel = this.$pgettext('*/*/*/Noun', 'Artist')
|
||||||
let albumLabel = this.$pgettext('*/*/*', 'Album')
|
let albumLabel = this.$pgettext('*/*/*', 'Album')
|
||||||
let trackLabel = this.$pgettext('*/*/*/Noun', 'Track')
|
let trackLabel = this.$pgettext('*/*/*/Noun', 'Track')
|
||||||
|
let tagLabel = this.$pgettext('*/*/*/Noun', 'Tag')
|
||||||
let self = this
|
let self = this
|
||||||
var searchQuery;
|
var searchQuery;
|
||||||
|
|
||||||
|
@ -75,6 +76,9 @@ export default {
|
||||||
},
|
},
|
||||||
getDescription (r) {
|
getDescription (r) {
|
||||||
return ''
|
return ''
|
||||||
|
},
|
||||||
|
getId (t) {
|
||||||
|
return t.id
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -86,6 +90,9 @@ export default {
|
||||||
},
|
},
|
||||||
getDescription (r) {
|
getDescription (r) {
|
||||||
return ''
|
return ''
|
||||||
|
},
|
||||||
|
getId (t) {
|
||||||
|
return t.id
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -97,6 +104,23 @@ export default {
|
||||||
},
|
},
|
||||||
getDescription (r) {
|
getDescription (r) {
|
||||||
return ''
|
return ''
|
||||||
|
},
|
||||||
|
getId (t) {
|
||||||
|
return t.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'tags',
|
||||||
|
route: 'library.tags.detail',
|
||||||
|
name: tagLabel,
|
||||||
|
getTitle (r) {
|
||||||
|
return r.name
|
||||||
|
},
|
||||||
|
getDescription (r) {
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
getId (t) {
|
||||||
|
return t.name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -106,13 +130,14 @@ export default {
|
||||||
results: []
|
results: []
|
||||||
}
|
}
|
||||||
initialResponse[category.code].forEach(result => {
|
initialResponse[category.code].forEach(result => {
|
||||||
|
let id = category.getId(result)
|
||||||
results[category.code].results.push({
|
results[category.code].results.push({
|
||||||
title: category.getTitle(result),
|
title: category.getTitle(result),
|
||||||
id: result.id,
|
id,
|
||||||
routerUrl: {
|
routerUrl: {
|
||||||
name: category.route,
|
name: category.route,
|
||||||
params: {
|
params: {
|
||||||
id: result.id
|
id
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
description: category.getDescription(result)
|
description: category.getDescription(result)
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
{{ labels.title }}
|
{{ labels.title }}
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
<radio-button type="tag" :object-id="id"></radio-button>
|
||||||
<div class="ui hidden divider"></div>
|
<div class="ui hidden divider"></div>
|
||||||
<div class="ui row">
|
<div class="ui row">
|
||||||
<artist-widget :controls="false" :filters="{playable: true, ordering: '-creation_date', tag: id}">
|
<artist-widget :controls="false" :filters="{playable: true, ordering: '-creation_date', tag: id}">
|
||||||
|
@ -39,10 +40,10 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
|
|
||||||
import TrackWidget from "@/components/audio/track/Widget"
|
import TrackWidget from "@/components/audio/track/Widget"
|
||||||
import AlbumWidget from "@/components/audio/album/Widget"
|
import AlbumWidget from "@/components/audio/album/Widget"
|
||||||
import ArtistWidget from "@/components/audio/artist/Widget"
|
import ArtistWidget from "@/components/audio/artist/Widget"
|
||||||
|
import RadioButton from "@/components/radios/Button"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
|
@ -52,6 +53,7 @@ export default {
|
||||||
ArtistWidget,
|
ArtistWidget,
|
||||||
AlbumWidget,
|
AlbumWidget,
|
||||||
TrackWidget,
|
TrackWidget,
|
||||||
|
RadioButton,
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
labels() {
|
labels() {
|
||||||
|
|
|
@ -12,7 +12,7 @@ export default {
|
||||||
props: {
|
props: {
|
||||||
customRadioId: {required: false},
|
customRadioId: {required: false},
|
||||||
type: {type: String, required: false},
|
type: {type: String, required: false},
|
||||||
objectId: {type: Number, default: null}
|
objectId: {default: null}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
toggleRadio () {
|
toggleRadio () {
|
||||||
|
|
Loading…
Reference in New Issue