diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 90b833a2b5aa257bd8d0ad4bbabbd234286feaf0..34ec02632cf44069c52b28a81ccf83e96feca148 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -4,7 +4,6 @@ import urllib from django.conf import settings from django.db import transaction from django.db.models import Count, Prefetch, Sum, F, Q -from django.db.models.functions import Length from django.utils import timezone 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 routes 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 . 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 file_path = get_file_path(f.audio_file) 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 response = Response(status=302) response["Location"] = f.audio_file.url @@ -482,6 +482,7 @@ class Search(views.APIView): "albums": serializers.AlbumSerializer( self.get_albums(query), many=True ).data, + "tags": TagSerializer(self.get_tags(query), many=True).data, } return Response(results, status=200) @@ -520,15 +521,8 @@ class Search(views.APIView): def get_tags(self, query): search_fields = ["name__unaccent"] query_obj = utils.get_query(query, search_fields) - - # We want the shortest tag first - qs = ( - Tag.objects.all() - .annotate(name_length=Length("name")) - .order_by("name_length") - ) - - return qs.filter(query_obj)[: self.max_results] + qs = Tag.objects.all().filter(query_obj) + return common_utils.order_for_search(qs, "name")[: self.max_results] class LicenseViewSet(viewsets.ReadOnlyModelViewSet): diff --git a/api/funkwhale_api/radios/filters.py b/api/funkwhale_api/radios/filters.py index 810673bd664f6b7ddf56aee7403a2dfac5445b83..a92dbae889dc7b08d679147b75f92f5074189b74 100644 --- a/api/funkwhale_api/radios/filters.py +++ b/api/funkwhale_api/radios/filters.py @@ -178,9 +178,9 @@ class TagFilter(RadioFilter): "autocomplete_fields": { "remoteValues": "results", "name": "name", - "value": "slug", + "value": "name", }, - "autocomplete_qs": "query={query}", + "autocomplete_qs": "q={query}&ordering=length", "label": "Tags", "placeholder": "Select tags", } @@ -189,4 +189,8 @@ class TagFilter(RadioFilter): label = "Tag" 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) + ) diff --git a/api/funkwhale_api/radios/radios.py b/api/funkwhale_api/radios/radios.py index 8940cdc1525e371a358b3bb5f2f6fcc827429962..86c84ad137b98b16d8b2e4ce50d5679e58131afd 100644 --- a/api/funkwhale_api/radios/radios.py +++ b/api/funkwhale_api/radios/radios.py @@ -2,6 +2,7 @@ import random from django.core.exceptions import ValidationError from django.db import connection +from django.db.models import Q from rest_framework import serializers from funkwhale_api.moderation import filters as moderation_filters @@ -14,6 +15,8 @@ from .registries import registry class SimpleRadio(object): + related_object_field = None + def clean(self, instance): return @@ -146,6 +149,8 @@ class CustomRadio(SessionRadio): class RelatedObjectRadio(SessionRadio): """Abstract radio related to an object (tag, artist, user...)""" + related_object_field = serializers.IntegerField(required=True) + def clean(self, instance): super().clean(instance) if not instance.related_object: @@ -162,10 +167,22 @@ class RelatedObjectRadio(SessionRadio): @registry.register(name="tag") class TagRadio(RelatedObjectRadio): 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): 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): diff --git a/api/funkwhale_api/radios/serializers.py b/api/funkwhale_api/radios/serializers.py index 397452ecc0e5c6646a82ea663fdc6bb099e2f803..65e48449a8e704a42e118646187da1ae24b117a1 100644 --- a/api/funkwhale_api/radios/serializers.py +++ b/api/funkwhale_api/radios/serializers.py @@ -54,6 +54,9 @@ class RadioSessionTrackSerializer(serializers.ModelSerializer): class RadioSessionSerializer(serializers.ModelSerializer): + + related_object_id = serializers.CharField(required=False, allow_null=True) + class Meta: model = models.RadioSession fields = ( @@ -66,7 +69,17 @@ class RadioSessionSerializer(serializers.ModelSerializer): ) 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 def create(self, validated_data): @@ -77,3 +90,11 @@ class RadioSessionSerializer(serializers.ModelSerializer): validated_data["related_object_id"] ) 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 diff --git a/api/tests/radios/test_radios.py b/api/tests/radios/test_radios.py index 36f76e1c1f551db62ab658608a298b35bf11eddb..040217aac39308d5f6d77f4236e33fa05381b8a1 100644 --- a/api/tests/radios/test_radios.py +++ b/api/tests/radios/test_radios.py @@ -197,16 +197,19 @@ def test_can_start_artist_radio(factories): def test_can_start_tag_radio(factories): user = factories["users.User"]() - factories["music.Upload"].create_batch(5) tag = factories["tags.Tag"]() - good_files = factories["music.Upload"].create_batch(5, track__set_tags=[tag]) - good_tracks = [f.track for f in good_files] + good_tracks = [ + 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() session = radio.start_session(user, related_object=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 diff --git a/api/tests/radios/test_serializers.py b/api/tests/radios/test_serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..54019d5e12559b26419f0564c1651bff259041a0 --- /dev/null +++ b/api/tests/radios/test_serializers.py @@ -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 diff --git a/front/src/components/audio/SearchBar.vue b/front/src/components/audio/SearchBar.vue index e0c343fede1fb99263b18b3427acc6bd3b7e8f27..d7445d13b09bf409125cb16956df137fe4f19f2d 100644 --- a/front/src/components/audio/SearchBar.vue +++ b/front/src/components/audio/SearchBar.vue @@ -32,6 +32,7 @@ export default { let artistLabel = this.$pgettext('*/*/*/Noun', 'Artist') let albumLabel = this.$pgettext('*/*/*', 'Album') let trackLabel = this.$pgettext('*/*/*/Noun', 'Track') + let tagLabel = this.$pgettext('*/*/*/Noun', 'Tag') let self = this var searchQuery; @@ -75,6 +76,9 @@ export default { }, getDescription (r) { return '' + }, + getId (t) { + return t.id } }, { @@ -86,6 +90,9 @@ export default { }, getDescription (r) { return '' + }, + getId (t) { + return t.id } }, { @@ -97,6 +104,23 @@ export default { }, getDescription (r) { 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: [] } initialResponse[category.code].forEach(result => { + let id = category.getId(result) results[category.code].results.push({ title: category.getTitle(result), - id: result.id, + id, routerUrl: { name: category.route, params: { - id: result.id + id } }, description: category.getDescription(result) diff --git a/front/src/components/library/TagDetail.vue b/front/src/components/library/TagDetail.vue index 86ad1562a342397fbb9e2fdadbc20647318ce79d..904bce7aecb8b21aa0a8f513c8cce20367e4cfce 100644 --- a/front/src/components/library/TagDetail.vue +++ b/front/src/components/library/TagDetail.vue @@ -6,6 +6,7 @@ {{ labels.title }} </span> </h2> + <radio-button type="tag" :object-id="id"></radio-button> <div class="ui hidden divider"></div> <div class="ui row"> <artist-widget :controls="false" :filters="{playable: true, ordering: '-creation_date', tag: id}"> @@ -39,10 +40,10 @@ <script> - import TrackWidget from "@/components/audio/track/Widget" import AlbumWidget from "@/components/audio/album/Widget" import ArtistWidget from "@/components/audio/artist/Widget" +import RadioButton from "@/components/radios/Button" export default { props: { @@ -52,6 +53,7 @@ export default { ArtistWidget, AlbumWidget, TrackWidget, + RadioButton, }, computed: { labels() { diff --git a/front/src/components/radios/Button.vue b/front/src/components/radios/Button.vue index e66f97ad5fafe1622e6a586c8e2d88155e87a766..f335a6596273838e844f1357f7c9c5503ace7ff7 100644 --- a/front/src/components/radios/Button.vue +++ b/front/src/components/radios/Button.vue @@ -12,7 +12,7 @@ export default { props: { customRadioId: {required: false}, type: {type: String, required: false}, - objectId: {type: Number, default: null} + objectId: {default: null} }, methods: { toggleRadio () {