From 98a80c4a874c6679287071c245dec9ec8c131899 Mon Sep 17 00:00:00 2001 From: Petitminion Date: Tue, 3 Jun 2025 03:40:40 +0200 Subject: [PATCH 1/9] WIP:newfeature(backend):fetch musicbrainz metadata from search bar --- api/funkwhale_api/federation/api_views.py | 15 ++- api/funkwhale_api/federation/contexts.py | 17 +++ api/funkwhale_api/federation/models.py | 7 + api/funkwhale_api/federation/tasks.py | 134 +++++++++++++++++++ api/funkwhale_api/musicbrainz/serializers.py | 123 +++++++++++++++++ api/tests/federation/test_tasks.py | 84 ++++++++++++ 6 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 api/funkwhale_api/musicbrainz/serializers.py diff --git a/api/funkwhale_api/federation/api_views.py b/api/funkwhale_api/federation/api_views.py index a076f5a1d..5498c67d5 100644 --- a/api/funkwhale_api/federation/api_views.py +++ b/api/funkwhale_api/federation/api_views.py @@ -1,3 +1,5 @@ +from urllib.parse import urlparse + import requests.exceptions from django.conf import settings from django.db import transaction @@ -252,11 +254,18 @@ class FetchViewSet( if fetch.status == "finished": # a duplicate was returned, no need to fetch again return - if settings.FEDERATION_SYNCHRONOUS_FETCH: - tasks.fetch(fetch_id=fetch.pk) + + parsed_url = urlparse(fetch.url) + domain = parsed_url.netloc + if domain in fetch.supported_services: + tasks.third_party_fetch(fetch_id=fetch.pk) fetch.refresh_from_db() else: - common_utils.on_commit(tasks.fetch.delay, fetch_id=fetch.pk) + if settings.FEDERATION_SYNCHRONOUS_FETCH: + tasks.fetch(fetch_id=fetch.pk) + fetch.refresh_from_db() + else: + common_utils.on_commit(tasks.fetch.delay, fetch_id=fetch.pk) class DomainViewSet( diff --git a/api/funkwhale_api/federation/contexts.py b/api/funkwhale_api/federation/contexts.py index 5ea7ae4d4..6efa06ed9 100644 --- a/api/funkwhale_api/federation/contexts.py +++ b/api/funkwhale_api/federation/contexts.py @@ -359,6 +359,22 @@ CONTEXTS = [ } }, }, + { + "shortId": "MB", + "contextUrl": None, + "documentUrl": "http://musicbrainz.org/ns/mmd-1.0#", + "document": { + "@context": { + "mb": "http://musicbrainz.org/ns/mmd-1.0#", + "schema": "http://schema.org#", + "Recording": "schema:MusicRecording", + "name": "schema:name", + "duration": "schema:duration", + "@id": "@id", + "@type": "@type", + }, + }, + }, ] CONTEXTS_BY_ID = {c["shortId"]: c for c in CONTEXTS} @@ -392,3 +408,4 @@ SEC = NS(CONTEXTS_BY_ID["SEC"]) FW = NS(CONTEXTS_BY_ID["FW"]) SC = NS(CONTEXTS_BY_ID["SC"]) LITEPUB = NS(CONTEXTS_BY_ID["LITEPUB"]) +MB = NS(CONTEXTS_BY_ID["MB"]) diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index b01355493..982619c9e 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -18,6 +18,7 @@ from funkwhale_api.common import session from funkwhale_api.common import utils as common_utils from funkwhale_api.common import validators as common_validators from funkwhale_api.music import utils as music_utils +from funkwhale_api.musicbrainz import serializers as musicbrainz_serializers from . import utils as federation_utils @@ -411,8 +412,14 @@ class Fetch(models.Model): contexts.AS.Organization: [serializers.ActorSerializer], contexts.AS.Service: [serializers.ActorSerializer], contexts.AS.Application: [serializers.ActorSerializer], + # for mb the key must be the api namespace + "recordings": [musicbrainz_serializers.RecordingSerializer], } + @property + def supported_services(self): + return ["musicbrainz.org"] + class InboxItem(models.Model): """ diff --git a/api/funkwhale_api/federation/tasks.py b/api/funkwhale_api/federation/tasks.py index fbdf83a0c..e481530f5 100644 --- a/api/funkwhale_api/federation/tasks.py +++ b/api/funkwhale_api/federation/tasks.py @@ -2,6 +2,8 @@ import datetime import json import logging import os +import uuid +from urllib.parse import urlparse import requests from django.conf import settings @@ -13,6 +15,7 @@ from django.utils import timezone from dynamic_preferences.registries import global_preferences_registry from requests.exceptions import RequestException +from funkwhale_api import musicbrainz from funkwhale_api.audio import models as audio_models from funkwhale_api.common import models as common_models from funkwhale_api.common import preferences, session @@ -458,6 +461,137 @@ def fetch(fetch_obj): ) +def musicbrainz_type_handler(fetch): + url = fetch.url + path_parts = urlparse(url).path.strip("/").split("/") + type_ = path_parts[0] + "s" + mbid = path_parts[1] + try: + uuid.UUID(mbid) + except ValueError: + raise ValueError(f"could no get mbid from url {url}") + return type_, mbid + + +def musicbrainz_metadata_handler(type_, id): + def replace_hyphens_in_keys(obj): + if isinstance(obj, dict): + return { + k.replace("-", "_"): replace_hyphens_in_keys(v) for k, v in obj.items() + } + elif isinstance(obj, list): + return [replace_hyphens_in_keys(item) for item in obj] + else: + return obj + + result = replace_hyphens_in_keys( + getattr(musicbrainz.api, type_).get( + id=id, includes=["tags", "artists", "releases"] + ) + ) + + existing = ( + music_models.Track.objects.filter(mbid=id).first() + if music_models.Track.objects.filter(mbid=id).exists() + else None + ) + return result, existing + + +type_and_id_from_third_party = {"musicbrainz.org": musicbrainz_type_handler} +metadata_from_third_party_ = {"musicbrainz.org": musicbrainz_metadata_handler} + + +@celery.app.task(name="third_party_fetch") +@transaction.atomic +@celery.require_instance( + models.Fetch.objects.filter(status="pending").select_related("actor"), + "fetch_obj", + "fetch_id", +) +def third_party_fetch(fetch_obj): + def error(code, **kwargs): + fetch_obj.status = "errored" + fetch_obj.fetch_date = timezone.now() + fetch_obj.detail = {"error_code": code} + fetch_obj.detail.update(kwargs) + fetch_obj.save(update_fields=["fetch_date", "status", "detail"]) + + def check_url(url): + if not url.startswith("webfinger://"): + payload, updated = mrf.inbox.apply({"id": url}) + if not payload: + return error("blocked", message="Blocked by MRF") + + parsed_url = urlparse(url) + service = parsed_url.netloc + if service not in fetch_obj.supported_services: + return error("invalid_url", message=f"Unsupported domain {service}") + return service + + url = fetch_obj.url + actor = fetch_obj.actor + service = check_url(url) + + try: + type_, id = type_and_id_from_third_party[service](fetch_obj) + logger.debug("Parsed URL %s into type %s and id %s", url, type_, id) + except ValueError as e: + return error("url_parse_error", message=e.message) + + try: + result, existing = metadata_from_third_party_[service](type_, id) + logger.debug( + f"Remote answered with {result} and we found {existing} in database" + ) + except requests.exceptions.HTTPError as e: + return error( + "http", + status_code=e.response.status_code if e.response else None, + message=e.response.text, + ) + except requests.exceptions.Timeout: + return error("timeout") + except requests.exceptions.ConnectionError as e: + return error("connection", message=str(e)) + except requests.RequestException as e: + return error("request", message=str(e)) + except Exception as e: + return error("unhandled", message=str(e)) + + try: + serializer_classes = fetch_obj.serializers.get(type_) + except (KeyError, AttributeError): + fetch_obj.status = "skipped" + fetch_obj.fetch_date = timezone.now() + fetch_obj.detail = {"reason": "unhandled_type", "type": type_} + return fetch_obj.save(update_fields=["fetch_date", "status", "detail"]) + + serializer = None + for serializer_class in serializer_classes: + serializer = serializer_class( + existing, data=result, context={"fetch_actor": actor} + ) + if not serializer.is_valid(): + continue + else: + break + if serializer.errors: + return error("validation", validation_errors=serializer.errors) + try: + obj = serializer.save() + except Exception as e: + error("save", message=str(e)) + raise + + fetch_obj.object = obj + fetch_obj.status = "finished" + fetch_obj.fetch_date = timezone.now() + return fetch_obj.save( + update_fields=["fetch_date", "status", "object_id", "object_content_type"] + ) + + class PreserveSomeDataCollector(Collector): """ We need to delete everything related to an actor. Well… Almost everything. diff --git a/api/funkwhale_api/musicbrainz/serializers.py b/api/funkwhale_api/musicbrainz/serializers.py new file mode 100644 index 000000000..2c68ff6d3 --- /dev/null +++ b/api/funkwhale_api/musicbrainz/serializers.py @@ -0,0 +1,123 @@ +from rest_framework import serializers + +from funkwhale_api.tags import models as tags_models + + +class ArtistSerializer(serializers.Serializer): + """ + Serializer for Musicbrainz artist data. + """ + + id = serializers.CharField() + name = serializers.CharField() + + def create(self, validated_data): + from funkwhale_api.music.models import Artist + + data = { + "name": validated_data["name"], + "mbid": validated_data["id"], + } + artist = Artist.objects.create(**data) + return artist + + +class ArtistCreditSerializer(serializers.Serializer): + """ + Serializer for Musicbrainz artist data. + """ + + name = serializers.CharField() + joinphrase = serializers.CharField(allow_blank=True) + artist = ArtistSerializer() + + def create(self, validated_data): + from funkwhale_api.music.models import ArtistCredit + + data = { + "credit": validated_data["name"], + "joinphrase": validated_data.get("joinphrase", ""), + "artist": ArtistSerializer().create(validated_data["artist"]), + } + artist_credit = ArtistCredit.objects.create(**data) + return artist_credit + + +class ReleaseSerializer(serializers.Serializer): + """ + Serializer for Musicbrainz release data. + """ + + id = serializers.CharField() + title = serializers.CharField() + artist_credit = ArtistCreditSerializer(many=True) + tags = serializers.ListField(child=serializers.CharField(), allow_empty=True) + date = serializers.DateField(input_formats=["%Y", "%Y/%m/%d", "%Y-%m-%d"]) + + def create(self, validated_data): + from funkwhale_api.music.models import Album + + data = { + "title": validated_data["title"], + "mbid": validated_data["id"], + "release_date": validated_data.get("date"), + } + album = Album.objects.create(**data) + artist_credit = ArtistCreditSerializer(many=True).create( + validated_data["artist_credit"] + ) + album.artist_credit.set(artist_credit) + album.save() + tags_models.add_tags(album, *validated_data.get("tags", [])) + + return album + + def update(self, instance, validated_data): + instance.title = validated_data["title"] + instance.release_date = validated_data.get("date") + instance.save() + tags_models.add_tags(instance, *validated_data.get("tags", [])) + + return instance + + +class RecordingSerializer(serializers.Serializer): + """ + Serializer for Musicbrainz track data. + """ + + # class Meta: + # model = Track + + id = serializers.CharField() + title = serializers.CharField() + artist_credit = ArtistCreditSerializer(many=True) + releases = ReleaseSerializer(many=True) + tags = serializers.ListField(child=serializers.CharField(), allow_empty=True) + + def create(self, validated_data): + from funkwhale_api.music.models import Track + + data = { + "title": validated_data["title"], + "mbid": validated_data["id"], + # In mb a recording can have various releases, we take the fist one + "album": ReleaseSerializer(many=True).create(validated_data["releases"])[0], + } + track = Track.objects.create(**data) + artist_credit = ArtistCreditSerializer(many=True).create( + validated_data["artist_credit"] + ) + track.artist_credit.set(artist_credit) + track.save() + + tags_models.add_tags(track, *validated_data.get("tags", [])) + + return track + + def update(self, instance, validated_data): + instance.title = validated_data["title"] + instance.save() + tags_models.add_tags(instance, *validated_data.get("tags", [])) + + return instance diff --git a/api/tests/federation/test_tasks.py b/api/tests/federation/test_tasks.py index ef48374b4..f7c7aa6e7 100644 --- a/api/tests/federation/test_tasks.py +++ b/api/tests/federation/test_tasks.py @@ -736,3 +736,87 @@ def test_fetch_webfinger_create_actor(factories, r_mock, mocker): assert init.call_args[0][1] == actor assert init.call_args[1]["data"] == payload assert save.call_count == 1 + + +def test_third_party_fetch_success(factories, r_mock, mocker): + track = factories["music.Track"]() + url = f"https://musicbrainz.org/recording/{track.mbid}" + fetch = factories["federation.Fetch"](url=url) + payload = { + "releases": [ + { + "status": "Promotion", + "id": "220ffb88-49ed-4df4-a330-46f8e7353ff0", + "country": "DE", + "title": "With Oi! To Hope for Myanmar 2022", + "quality": "normal", + "release-events": [ + { + "area": { + "name": "Germany", + "id": "85752fda-13c4-31a3-bee5-0e5cb1f51dad", + "sort-name": "Germany", + "disambiguation": "", + "iso-3166-1-codes": ["DE"], + }, + "date": "2022", + } + ], + "disambiguation": "Version aus 2022", + "status-id": "518ffc83-5cde-34df-8627-81bff5093d92", + "packaging-id": "8f931351-d2e2-310f-afc6-37b89ddba246", + "artist-credit": [ + { + "artist": { + "sort-name": "Various Artists", + "name": "Various Artists", + "disambiguation": "add compilations to this artist", + "type": "Other", + "type-id": "ac897045-5043-3294-969b-187360e45d86", + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + }, + "joinphrase": "", + "name": "Various Artists", + } + ], + "barcode": "", + "date": "2022", + "packaging": "Digipak", + "text-representation": {"language": "mul", "script": "Latn"}, + } + ], + "disambiguation": "", + "video": False, + "first-release-date": "2022", + "artist-credit": [ + { + "artist": { + "name": "The Rebel Riot", + "disambiguation": "", + "sort-name": "Rebel Riot, The", + "type": "Group", + "id": "1ff2cd0c-2ac1-4296-b650-77ef57bb0d01", + "country": "MM", + "type-id": "e431f5f6-b5d2-343d-8b36-72607fffb74b", + }, + "name": "The Rebel Riot", + "joinphrase": "", + } + ], + "title": "A.C.A.B.", + "id": "455cd030-7394-4244-9a53-3b96a666b1c6", + "length": 193253, + } + init = mocker.spy(serializers.ArtistSerializer, "__init__") + save = mocker.spy(serializers.ArtistSerializer, "save") + + r_mock.get(url, json=payload) + + tasks.third_party_fetch(fetch_id=fetch.pk) + + fetch.refresh_from_db() + + assert fetch.status == "finished" + assert init.call_count == 1 + assert init.call_args[1]["data"] == payload + assert save.call_count == 1 From 07f37b458cd4c2802f59f165bbdc892f779885d2 Mon Sep 17 00:00:00 2001 From: Petitminion Date: Tue, 3 Jun 2025 14:09:12 +0200 Subject: [PATCH 2/9] enhancement(backend):support mb release has fetch object --- api/funkwhale_api/federation/models.py | 1 + api/funkwhale_api/federation/tasks.py | 12 +- api/funkwhale_api/musicbrainz/serializers.py | 133 +++++++++++++++++-- 3 files changed, 129 insertions(+), 17 deletions(-) diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 982619c9e..fbec8d601 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -414,6 +414,7 @@ class Fetch(models.Model): contexts.AS.Application: [serializers.ActorSerializer], # for mb the key must be the api namespace "recordings": [musicbrainz_serializers.RecordingSerializer], + "releases": [musicbrainz_serializers.ReleaseSerializer], } @property diff --git a/api/funkwhale_api/federation/tasks.py b/api/funkwhale_api/federation/tasks.py index e481530f5..d9d73607e 100644 --- a/api/funkwhale_api/federation/tasks.py +++ b/api/funkwhale_api/federation/tasks.py @@ -484,10 +484,13 @@ def musicbrainz_metadata_handler(type_, id): else: return obj + if type_ == "recordings": + includes = ["tags", "artists", "releases"] + elif type_ == "releases": + includes = ["tags", "artists", "recordings"] + result = replace_hyphens_in_keys( - getattr(musicbrainz.api, type_).get( - id=id, includes=["tags", "artists", "releases"] - ) + getattr(musicbrainz.api, type_).get(id=id, includes=includes) ) existing = ( @@ -537,7 +540,7 @@ def third_party_fetch(fetch_obj): type_, id = type_and_id_from_third_party[service](fetch_obj) logger.debug("Parsed URL %s into type %s and id %s", url, type_, id) except ValueError as e: - return error("url_parse_error", message=e.message) + return error("url_parse_error", message=str(e)) try: result, existing = metadata_from_third_party_[service](type_, id) @@ -587,6 +590,7 @@ def third_party_fetch(fetch_obj): fetch_obj.object = obj fetch_obj.status = "finished" fetch_obj.fetch_date = timezone.now() + # to do : trigger third party download ? return fetch_obj.save( update_fields=["fetch_date", "status", "object_id", "object_content_type"] ) diff --git a/api/funkwhale_api/musicbrainz/serializers.py b/api/funkwhale_api/musicbrainz/serializers.py index 2c68ff6d3..85e1851da 100644 --- a/api/funkwhale_api/musicbrainz/serializers.py +++ b/api/funkwhale_api/musicbrainz/serializers.py @@ -1,7 +1,12 @@ +import logging + from rest_framework import serializers +from funkwhale_api import musicbrainz from funkwhale_api.tags import models as tags_models +logger = logging.getLogger(__name__) + class ArtistSerializer(serializers.Serializer): """ @@ -18,7 +23,7 @@ class ArtistSerializer(serializers.Serializer): "name": validated_data["name"], "mbid": validated_data["id"], } - artist = Artist.objects.create(**data) + artist, created = Artist.objects.get_or_create(**data) return artist @@ -39,13 +44,13 @@ class ArtistCreditSerializer(serializers.Serializer): "joinphrase": validated_data.get("joinphrase", ""), "artist": ArtistSerializer().create(validated_data["artist"]), } - artist_credit = ArtistCredit.objects.create(**data) + artist_credit, created = ArtistCredit.objects.get_or_create(**data) return artist_credit -class ReleaseSerializer(serializers.Serializer): +class ReleaseForTrackSerializer(serializers.Serializer): """ - Serializer for Musicbrainz release data. + Serializer for Musicbrainz release data when returned in a recording object. """ id = serializers.CharField() @@ -60,9 +65,9 @@ class ReleaseSerializer(serializers.Serializer): data = { "title": validated_data["title"], "mbid": validated_data["id"], - "release_date": validated_data.get("date"), + "release_date": validated_data.get("date", None), } - album = Album.objects.create(**data) + album, created = Album.objects.get_or_create(**data) artist_credit = ArtistCreditSerializer(many=True).create( validated_data["artist_credit"] ) @@ -70,6 +75,9 @@ class ReleaseSerializer(serializers.Serializer): album.save() tags_models.add_tags(album, *validated_data.get("tags", [])) + if validated_data["media"]: + # an album can have various media/physical representation, we take the first one + validated_data["media"][0] return album def update(self, instance, validated_data): @@ -86,25 +94,29 @@ class RecordingSerializer(serializers.Serializer): Serializer for Musicbrainz track data. """ - # class Meta: - # model = Track - id = serializers.CharField() title = serializers.CharField() artist_credit = ArtistCreditSerializer(many=True) - releases = ReleaseSerializer(many=True) + releases = ReleaseForTrackSerializer(many=True, required=False) tags = serializers.ListField(child=serializers.CharField(), allow_empty=True) def create(self, validated_data): from funkwhale_api.music.models import Track - data = { + data = {"mbid": validated_data["id"]} + defaults = { "title": validated_data["title"], "mbid": validated_data["id"], # In mb a recording can have various releases, we take the fist one - "album": ReleaseSerializer(many=True).create(validated_data["releases"])[0], + "album": ( + ReleaseForTrackSerializer(many=True).create(validated_data["releases"])[ + 0 + ] + if validated_data.get("releases") + else None + ), } - track = Track.objects.create(**data) + track, created = Track.objects.get_or_create(**data, defaults=defaults) artist_credit = ArtistCreditSerializer(many=True).create( validated_data["artist_credit"] ) @@ -121,3 +133,98 @@ class RecordingSerializer(serializers.Serializer): tags_models.add_tags(instance, *validated_data.get("tags", [])) return instance + + +class RecordingForReleaseSerializer(serializers.Serializer): + id = serializers.CharField() + title = serializers.CharField() + + def create(self, validated_data): + def replace_hyphens_in_keys(obj): + if isinstance(obj, dict): + return { + k.replace("-", "_"): replace_hyphens_in_keys(v) + for k, v in obj.items() + } + elif isinstance(obj, list): + return [replace_hyphens_in_keys(item) for item in obj] + else: + return obj + + recordings_data = musicbrainz.api.recordings.get( + id=validated_data["id"], includes=["tags", "artists"] + ) + recordings_data = replace_hyphens_in_keys(recordings_data) + serializer = RecordingSerializer(data=recordings_data) + serializer.is_valid(raise_exception=True) + track = serializer.save() + track.album = validated_data["album"] + track.save() + return track + + def update(self, instance, validated_data): + instance.title = validated_data["title"] + tags_models.add_tags(instance, *validated_data.get("tags", [])) + instance.album = validated_data["album"] + instance.save() + return instance + + +class TrackSerializer(serializers.Serializer): + recording = RecordingForReleaseSerializer() + + +class MediaSerializer(serializers.Serializer): + tracks = TrackSerializer(many=True) + + +class ReleaseSerializer(serializers.Serializer): + """ + Serializer for Musicbrainz release data. + """ + + id = serializers.CharField() + title = serializers.CharField() + artist_credit = ArtistCreditSerializer(many=True) + tags = serializers.ListField(child=serializers.CharField(), allow_empty=True) + date = serializers.DateField(input_formats=["%Y", "%Y/%m/%d", "%Y-%m-%d"]) + media = serializers.ListField(child=MediaSerializer()) + + def create(self, validated_data): + from funkwhale_api.music.models import Album + + data = { + "title": validated_data["title"], + "mbid": validated_data["id"], + "release_date": validated_data.get("date"), + } + album, created = Album.objects.get_or_create(**data) + artist_credit = ArtistCreditSerializer(many=True).create( + validated_data["artist_credit"] + ) + album.artist_credit.set(artist_credit) + album.save() + tags_models.add_tags(album, *validated_data.get("tags", [])) + + # an album can have various media/physical representation, we take the first one + recordings = [t["recording"] for t in validated_data["media"][0]["tracks"]] + for r in recordings: + r["album"] = album + RecordingForReleaseSerializer().create(r) + + return album + + # this will never be used while FetchViewSet and third_party_fetch filter out finished fetch + # Would be nice to have a way to manually update releases from Musicbrainz + def update(self, instance, validated_data): + logger.info(f"Updating release {instance} with data: {validated_data}") + instance.title = validated_data["title"] + instance.release_date = validated_data.get("date") + instance.save() + tags_models.add_tags(instance, *validated_data.get("tags", [])) + + recordings = [t["recording"] for t in validated_data["media"][0]["tracks"]] + for r in recordings: + r["album"] = instance + RecordingForReleaseSerializer().update(r) + return instance From 3ae34ecfd4801ce5db475df96ae3349ef29b0b57 Mon Sep 17 00:00:00 2001 From: Petitminion Date: Tue, 3 Jun 2025 16:45:02 +0200 Subject: [PATCH 3/9] enhancement(backend):trigger plugins download on third_party metadata fetch --- api/funkwhale_api/federation/tasks.py | 32 +++++++++++++++++++++++++-- docs/specs/fetch-third-party/index.md | 17 ++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 docs/specs/fetch-third-party/index.md diff --git a/api/funkwhale_api/federation/tasks.py b/api/funkwhale_api/federation/tasks.py index d9d73607e..d8694fe5c 100644 --- a/api/funkwhale_api/federation/tasks.py +++ b/api/funkwhale_api/federation/tasks.py @@ -7,6 +7,7 @@ from urllib.parse import urlparse import requests from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.db import transaction from django.db.models import F, Q @@ -15,6 +16,7 @@ from django.utils import timezone from dynamic_preferences.registries import global_preferences_registry from requests.exceptions import RequestException +from config import plugins from funkwhale_api import musicbrainz from funkwhale_api.audio import models as audio_models from funkwhale_api.common import models as common_models @@ -461,6 +463,31 @@ def fetch(fetch_obj): ) +def trigger_third_party_upload_hook(fetch): + if fetch.status == "finished" and fetch.object: + if fetch.object_content_type == ContentType.objects.get_for_model( + music_models.Track + ): + if not music_models.Track.objects.filter(pk=fetch.object.pk).playable_by( + fetch.actor + ): + plugins.trigger_hook( + plugins.TRIGGER_THIRD_PARTY_UPLOAD, + track=fetch.object, + ) + if fetch.object_content_type == ContentType.objects.get_for_model( + music_models.Album + ): + for track in fetch.object.tracks.all(): + if not music_models.Track.objects.filter( + pk=fetch.object.pk + ).playable_by(fetch.actor): + plugins.trigger_hook( + plugins.TRIGGER_THIRD_PARTY_UPLOAD, + track=track, + ) + + def musicbrainz_type_handler(fetch): url = fetch.url path_parts = urlparse(url).path.strip("/").split("/") @@ -590,10 +617,11 @@ def third_party_fetch(fetch_obj): fetch_obj.object = obj fetch_obj.status = "finished" fetch_obj.fetch_date = timezone.now() - # to do : trigger third party download ? - return fetch_obj.save( + trigger_third_party_upload_hook(fetch_obj) + fetch_obj.save( update_fields=["fetch_date", "status", "object_id", "object_content_type"] ) + return fetch_obj class PreserveSomeDataCollector(Collector): diff --git a/docs/specs/fetch-third-party/index.md b/docs/specs/fetch-third-party/index.md new file mode 100644 index 000000000..d69d6a7dd --- /dev/null +++ b/docs/specs/fetch-third-party/index.md @@ -0,0 +1,17 @@ +# Collections + +## The issue + +Has a user I want to be able to get metadata from third party services (to add them to my favorites or to a playlist) + +## Solution + +paste the track or release url into the search bar. Funkwhale will get the mmetadata and create the objects in db. First implementation with Musicbrainz : + +- track : https://musicbrainz.org/release/{mbid} +- release : https://musicbrainz.org/recording/{mbid} + +## Implementation + +third_party_fetch in federation.tasks +musicbrainz.serializers From c072f67a4a417a8d4b82347bac417c7fb3202ee8 Mon Sep 17 00:00:00 2001 From: Petitminion Date: Tue, 3 Jun 2025 16:58:17 +0200 Subject: [PATCH 4/9] romove unused code --- api/funkwhale_api/federation/contexts.py | 17 ----- api/tests/federation/test_tasks.py | 84 ------------------------ 2 files changed, 101 deletions(-) diff --git a/api/funkwhale_api/federation/contexts.py b/api/funkwhale_api/federation/contexts.py index 6efa06ed9..5ea7ae4d4 100644 --- a/api/funkwhale_api/federation/contexts.py +++ b/api/funkwhale_api/federation/contexts.py @@ -359,22 +359,6 @@ CONTEXTS = [ } }, }, - { - "shortId": "MB", - "contextUrl": None, - "documentUrl": "http://musicbrainz.org/ns/mmd-1.0#", - "document": { - "@context": { - "mb": "http://musicbrainz.org/ns/mmd-1.0#", - "schema": "http://schema.org#", - "Recording": "schema:MusicRecording", - "name": "schema:name", - "duration": "schema:duration", - "@id": "@id", - "@type": "@type", - }, - }, - }, ] CONTEXTS_BY_ID = {c["shortId"]: c for c in CONTEXTS} @@ -408,4 +392,3 @@ SEC = NS(CONTEXTS_BY_ID["SEC"]) FW = NS(CONTEXTS_BY_ID["FW"]) SC = NS(CONTEXTS_BY_ID["SC"]) LITEPUB = NS(CONTEXTS_BY_ID["LITEPUB"]) -MB = NS(CONTEXTS_BY_ID["MB"]) diff --git a/api/tests/federation/test_tasks.py b/api/tests/federation/test_tasks.py index f7c7aa6e7..ef48374b4 100644 --- a/api/tests/federation/test_tasks.py +++ b/api/tests/federation/test_tasks.py @@ -736,87 +736,3 @@ def test_fetch_webfinger_create_actor(factories, r_mock, mocker): assert init.call_args[0][1] == actor assert init.call_args[1]["data"] == payload assert save.call_count == 1 - - -def test_third_party_fetch_success(factories, r_mock, mocker): - track = factories["music.Track"]() - url = f"https://musicbrainz.org/recording/{track.mbid}" - fetch = factories["federation.Fetch"](url=url) - payload = { - "releases": [ - { - "status": "Promotion", - "id": "220ffb88-49ed-4df4-a330-46f8e7353ff0", - "country": "DE", - "title": "With Oi! To Hope for Myanmar 2022", - "quality": "normal", - "release-events": [ - { - "area": { - "name": "Germany", - "id": "85752fda-13c4-31a3-bee5-0e5cb1f51dad", - "sort-name": "Germany", - "disambiguation": "", - "iso-3166-1-codes": ["DE"], - }, - "date": "2022", - } - ], - "disambiguation": "Version aus 2022", - "status-id": "518ffc83-5cde-34df-8627-81bff5093d92", - "packaging-id": "8f931351-d2e2-310f-afc6-37b89ddba246", - "artist-credit": [ - { - "artist": { - "sort-name": "Various Artists", - "name": "Various Artists", - "disambiguation": "add compilations to this artist", - "type": "Other", - "type-id": "ac897045-5043-3294-969b-187360e45d86", - "id": "89ad4ac3-39f7-470e-963a-56509c546377", - }, - "joinphrase": "", - "name": "Various Artists", - } - ], - "barcode": "", - "date": "2022", - "packaging": "Digipak", - "text-representation": {"language": "mul", "script": "Latn"}, - } - ], - "disambiguation": "", - "video": False, - "first-release-date": "2022", - "artist-credit": [ - { - "artist": { - "name": "The Rebel Riot", - "disambiguation": "", - "sort-name": "Rebel Riot, The", - "type": "Group", - "id": "1ff2cd0c-2ac1-4296-b650-77ef57bb0d01", - "country": "MM", - "type-id": "e431f5f6-b5d2-343d-8b36-72607fffb74b", - }, - "name": "The Rebel Riot", - "joinphrase": "", - } - ], - "title": "A.C.A.B.", - "id": "455cd030-7394-4244-9a53-3b96a666b1c6", - "length": 193253, - } - init = mocker.spy(serializers.ArtistSerializer, "__init__") - save = mocker.spy(serializers.ArtistSerializer, "save") - - r_mock.get(url, json=payload) - - tasks.third_party_fetch(fetch_id=fetch.pk) - - fetch.refresh_from_db() - - assert fetch.status == "finished" - assert init.call_count == 1 - assert init.call_args[1]["data"] == payload - assert save.call_count == 1 From 37b57214517d9b6adc5946fad250defc9ac887db Mon Sep 17 00:00:00 2001 From: Petitminion Date: Tue, 3 Jun 2025 17:30:00 +0200 Subject: [PATCH 5/9] doc --- docs/specs/fetch-third-party/index.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/specs/fetch-third-party/index.md b/docs/specs/fetch-third-party/index.md index d69d6a7dd..467704e1e 100644 --- a/docs/specs/fetch-third-party/index.md +++ b/docs/specs/fetch-third-party/index.md @@ -2,11 +2,12 @@ ## The issue -Has a user I want to be able to get metadata from third party services (to add them to my favorites or to a playlist) +Has a user I want to be able to get metadata from third party services (to add tracks to my favorites or to a playlist) +Has a user I want to clone a third party playslit into Funkwhale ## Solution -paste the track or release url into the search bar. Funkwhale will get the mmetadata and create the objects in db. First implementation with Musicbrainz : +paste the audio object (track, album, playlist) url into the search bar. Funkwhale will get the mmetadata and create the objects in db. First implementation with Musicbrainz : - track : https://musicbrainz.org/release/{mbid} - release : https://musicbrainz.org/recording/{mbid} @@ -15,3 +16,7 @@ paste the track or release url into the search bar. Funkwhale will get the mmeta third_party_fetch in federation.tasks musicbrainz.serializers + +## Call for developers + +This is time consuming. The main logic is implemented. Now everybody can add support for any third party service. From 1be4b5204ae9c33aaac7a4d9f8e7e4823c0067bc Mon Sep 17 00:00:00 2001 From: Petitminion Date: Tue, 3 Jun 2025 17:43:44 +0200 Subject: [PATCH 6/9] changelog --- changes/changelog.d/2452.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/changelog.d/2452.feature diff --git a/changes/changelog.d/2452.feature b/changes/changelog.d/2452.feature new file mode 100644 index 000000000..a0700aabb --- /dev/null +++ b/changes/changelog.d/2452.feature @@ -0,0 +1 @@ +Import tracks and albums from Musicbrainz into Funkwhale though the search bar (#2926) From 7f6d066acc7bac8374e411dbbcd3cb74677658c9 Mon Sep 17 00:00:00 2001 From: Petitminion Date: Wed, 4 Jun 2025 02:10:15 +0200 Subject: [PATCH 7/9] fix(front):search fetch federation object was expecting a music object but got a activitypub obj --- api/funkwhale_api/common/schema.yml | 12 +++++++++++ .../federation/api_serializers.py | 21 ++++++++++++++++++- api/funkwhale_api/federation/api_views.py | 17 +++++++++++++++ api/funkwhale_api/federation/serializers.py | 3 ++- api/funkwhale_api/federation/tasks.py | 1 - api/tests/federation/test_api_serializers.py | 7 ++++++- front/src/composables/audio/usePlayOptions.ts | 12 ++++++++--- front/src/generated/types.ts | 4 ++++ 8 files changed, 70 insertions(+), 7 deletions(-) diff --git a/api/funkwhale_api/common/schema.yml b/api/funkwhale_api/common/schema.yml index 8289537e5..b27a21c0b 100644 --- a/api/funkwhale_api/common/schema.yml +++ b/api/funkwhale_api/common/schema.yml @@ -2462,6 +2462,9 @@ paths: responses: '201': content: + application/activity+json: + schema: + $ref: '#/components/schemas/Fetch' application/json: schema: $ref: '#/components/schemas/Fetch' @@ -2484,6 +2487,9 @@ paths: responses: '200': content: + application/activity+json: + schema: + $ref: '#/components/schemas/Fetch' application/json: schema: $ref: '#/components/schemas/Fetch' @@ -11972,6 +11978,9 @@ paths: responses: '201': content: + application/activity+json: + schema: + $ref: '#/components/schemas/Fetch' application/json: schema: $ref: '#/components/schemas/Fetch' @@ -11994,6 +12003,9 @@ paths: responses: '200': content: + application/activity+json: + schema: + $ref: '#/components/schemas/Fetch' application/json: schema: $ref: '#/components/schemas/Fetch' diff --git a/api/funkwhale_api/federation/api_serializers.py b/api/funkwhale_api/federation/api_serializers.py index c3e103af7..1fd9afdc1 100644 --- a/api/funkwhale_api/federation/api_serializers.py +++ b/api/funkwhale_api/federation/api_serializers.py @@ -13,7 +13,9 @@ from funkwhale_api.audio import models as audio_models from funkwhale_api.audio import serializers as audio_serializers from funkwhale_api.common import serializers as common_serializers from funkwhale_api.music import models as music_models +from funkwhale_api.music import serializers as music_serializers from funkwhale_api.playlists import models as playlists_models +from funkwhale_api.playlists import serializers as playlist_serializers from funkwhale_api.users import serializers as users_serializers from . import filters, models @@ -197,10 +199,19 @@ OBJECT_SERIALIZER_MAPPING = { music_models.Artist: federation_serializers.ArtistSerializer, music_models.Album: federation_serializers.AlbumSerializer, music_models.Track: federation_serializers.TrackSerializer, + music_models.Library: federation_serializers.LibrarySerializer, models.Actor: federation_serializers.APIActorSerializer, audio_models.Channel: audio_serializers.ChannelSerializer, playlists_models.Playlist: federation_serializers.PlaylistSerializer, } +OBJECT_MUSIC_SERIALIZER_MAPPING = { + music_models.Artist: music_serializers.ArtistSerializer, + music_models.Album: music_serializers.AlbumSerializer, + music_models.Track: music_serializers.TrackSerializer, + models.Actor: federation_serializers.APIActorSerializer, + audio_models.Channel: audio_serializers.ChannelSerializer, + playlists_models.Playlist: playlist_serializers.PlaylistSerializer, +} def convert_url_to_webfinger(url): @@ -283,6 +294,9 @@ class FetchSerializer(serializers.ModelSerializer): return value return f"webfinger://{value}" + # to do : this is incomplete, schema conflict because + # federation serializers have the same name than musi serializer -> upgrade fed serializers to new names + # and add the new object here @extend_schema_field( { "oneOf": [ @@ -300,7 +314,12 @@ class FetchSerializer(serializers.ModelSerializer): if obj is None: return None - serializer_class = OBJECT_SERIALIZER_MAPPING.get(type(obj)) + media_type = self.context.get("media_type") + + if media_type == "application/activity+json": + serializer_class = OBJECT_SERIALIZER_MAPPING.get(type(obj)) + else: + serializer_class = OBJECT_MUSIC_SERIALIZER_MAPPING.get(type(obj)) if serializer_class: return serializer_class(obj).data return None diff --git a/api/funkwhale_api/federation/api_views.py b/api/funkwhale_api/federation/api_views.py index 5498c67d5..3386dd173 100644 --- a/api/funkwhale_api/federation/api_views.py +++ b/api/funkwhale_api/federation/api_views.py @@ -7,10 +7,13 @@ from django.db.models import Count, Q from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import decorators, mixins, permissions, response, viewsets from rest_framework.exceptions import NotFound as RestNotFound +from rest_framework.negotiation import DefaultContentNegotiation +from rest_framework.renderers import JSONRenderer from funkwhale_api.common import preferences from funkwhale_api.common import utils as common_utils from funkwhale_api.common.permissions import ConditionalAuthentication +from funkwhale_api.common.renderers import ActivityStreamRenderer from funkwhale_api.music import models as music_models from funkwhale_api.music import serializers as music_serializers from funkwhale_api.music import views as music_views @@ -245,10 +248,24 @@ class FetchViewSet( serializer_class = api_serializers.FetchSerializer permission_classes = [permissions.IsAuthenticated] throttling_scopes = {"create": {"authenticated": "fetch"}} + renderer_classes = [ActivityStreamRenderer, JSONRenderer] def get_queryset(self): return super().get_queryset().filter(actor=self.request.user.actor) + def get_serializer_context(self): + context = super().get_serializer_context() + + negotiator = DefaultContentNegotiation() + try: + renderer, media_type = negotiator.select_renderer( + self.request, self.get_renderers() + ) + context["media_type"] = media_type + except Exception: + context["media_type"] = None + return context + def perform_create(self, serializer): fetch = serializer.save(actor=self.request.user.actor) if fetch.status == "finished": diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index dcdaca87d..490a0b3b6 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -1066,6 +1066,7 @@ class LibrarySerializer(PaginatedCollectionSerializer): privacy = {"": "me", "./": "me", None: "me", contexts.AS.Public: "everyone"} library, created = music_models.Library.objects.update_or_create( fid=validated_data["id"], + uuid=validated_data["id"].rstrip("/").split("/")[-1], actor=actor, defaults={ "uploads_count": validated_data["totalItems"], @@ -1449,7 +1450,7 @@ class AlbumSerializer(MusicEntitySerializer): acs.append( utils.retrieve_ap_object( ac["id"], - actor=self.context.get("fetch_actor"), + actor=self.context.get("_actor"), queryset=music_models.ArtistCredit, serializer_class=ArtistCreditSerializer, ) diff --git a/api/funkwhale_api/federation/tasks.py b/api/funkwhale_api/federation/tasks.py index d8694fe5c..dbf3168f9 100644 --- a/api/funkwhale_api/federation/tasks.py +++ b/api/funkwhale_api/federation/tasks.py @@ -454,7 +454,6 @@ def fetch(fetch_obj): max_pages=settings.FEDERATION_COLLECTION_MAX_PAGES - 1, is_page=True, ) - fetch_obj.object = obj fetch_obj.status = "finished" fetch_obj.fetch_date = timezone.now() diff --git a/api/tests/federation/test_api_serializers.py b/api/tests/federation/test_api_serializers.py index 25339b345..730cdf6e2 100644 --- a/api/tests/federation/test_api_serializers.py +++ b/api/tests/federation/test_api_serializers.py @@ -173,7 +173,12 @@ def test_fetch_serializer_with_object( "actor": serializers.APIActorSerializer(fetch.actor).data, } - assert api_serializers.FetchSerializer(fetch).data == expected + assert ( + api_serializers.FetchSerializer( + fetch, context={"media_type": "application/activity+json"} + ).data + == expected + ) def test_fetch_serializer_unhandled_obj(factories, to_api_date): diff --git a/front/src/composables/audio/usePlayOptions.ts b/front/src/composables/audio/usePlayOptions.ts index 108571f33..41632913b 100644 --- a/front/src/composables/audio/usePlayOptions.ts +++ b/front/src/composables/audio/usePlayOptions.ts @@ -210,13 +210,19 @@ export default (props: PlayOptionsProps) => { if (!id) { throw new Error("Library id not found in response."); } - const fetchResponse = await axios.post('federation/fetches', - { object: id } + const fetchResponse = await axios.post( + 'federation/fetches', + { object_uri: id }, + { + headers: { + Accept: 'application/activity+json' + } + } ); const response = await axios.post( 'federation/follows/library', - { target: fetchResponse.data.object.uuid } + { target: fetchResponse.data.object.id.split('/').pop() } ); return response; diff --git a/front/src/generated/types.ts b/front/src/generated/types.ts index 03e6c53af..65b4a4d4b 100644 --- a/front/src/generated/types.ts +++ b/front/src/generated/types.ts @@ -10590,6 +10590,7 @@ export interface operations { [name: string]: unknown; }; content: { + "application/activity+json": components["schemas"]["Fetch"]; "application/json": components["schemas"]["Fetch"]; }; }; @@ -10612,6 +10613,7 @@ export interface operations { [name: string]: unknown; }; content: { + "application/activity+json": components["schemas"]["Fetch"]; "application/json": components["schemas"]["Fetch"]; }; }; @@ -17537,6 +17539,7 @@ export interface operations { [name: string]: unknown; }; content: { + "application/activity+json": components["schemas"]["Fetch"]; "application/json": components["schemas"]["Fetch"]; }; }; @@ -17559,6 +17562,7 @@ export interface operations { [name: string]: unknown; }; content: { + "application/activity+json": components["schemas"]["Fetch"]; "application/json": components["schemas"]["Fetch"]; }; }; From 422a0333acb8610a997445955573f5b04e4646ba Mon Sep 17 00:00:00 2001 From: Petitminion Date: Wed, 11 Jun 2025 15:45:03 +0200 Subject: [PATCH 8/9] fix:support positions --- api/funkwhale_api/musicbrainz/serializers.py | 41 +++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/api/funkwhale_api/musicbrainz/serializers.py b/api/funkwhale_api/musicbrainz/serializers.py index 85e1851da..787123bf4 100644 --- a/api/funkwhale_api/musicbrainz/serializers.py +++ b/api/funkwhale_api/musicbrainz/serializers.py @@ -5,6 +5,8 @@ from rest_framework import serializers from funkwhale_api import musicbrainz from funkwhale_api.tags import models as tags_models +from . import client + logger = logging.getLogger(__name__) @@ -48,7 +50,7 @@ class ArtistCreditSerializer(serializers.Serializer): return artist_credit -class ReleaseForTrackSerializer(serializers.Serializer): +class ReleaseForRecordingSerializer(serializers.Serializer): """ Serializer for Musicbrainz release data when returned in a recording object. """ @@ -74,10 +76,6 @@ class ReleaseForTrackSerializer(serializers.Serializer): album.artist_credit.set(artist_credit) album.save() tags_models.add_tags(album, *validated_data.get("tags", [])) - - if validated_data["media"]: - # an album can have various media/physical representation, we take the first one - validated_data["media"][0] return album def update(self, instance, validated_data): @@ -97,8 +95,9 @@ class RecordingSerializer(serializers.Serializer): id = serializers.CharField() title = serializers.CharField() artist_credit = ArtistCreditSerializer(many=True) - releases = ReleaseForTrackSerializer(many=True, required=False) + releases = ReleaseForRecordingSerializer(many=True, required=False) tags = serializers.ListField(child=serializers.CharField(), allow_empty=True) + position = serializers.IntegerField(required=False, allow_null=True) def create(self, validated_data): from funkwhale_api.music.models import Track @@ -109,13 +108,26 @@ class RecordingSerializer(serializers.Serializer): "mbid": validated_data["id"], # In mb a recording can have various releases, we take the fist one "album": ( - ReleaseForTrackSerializer(many=True).create(validated_data["releases"])[ - 0 - ] + ReleaseForRecordingSerializer(many=True).create( + validated_data["releases"] + )[0] if validated_data.get("releases") else None ), + # this will be none if the recording is not fetched from the release endpoint + "position": validated_data.get("position", None), } + + if defaults["album"] is not None and defaults["position"] is None: + result = client.api.releases.get( + id=validated_data["releases"][0]["id"], + includes=["tags", "artists", "recordings"], + ) + tracks = result["media"][0]["tracks"] + defaults["position"] = next( + (o for o in tracks if o["recording"]["id"] == data["mbid"]), {} + ).get("position", None) + track, created = Track.objects.get_or_create(**data, defaults=defaults) artist_credit = ArtistCreditSerializer(many=True).create( validated_data["artist_credit"] @@ -138,6 +150,8 @@ class RecordingSerializer(serializers.Serializer): class RecordingForReleaseSerializer(serializers.Serializer): id = serializers.CharField() title = serializers.CharField() + # not in Musicbrainz recording object, but used to store the position of the track in the album + position = serializers.IntegerField(required=False, allow_null=True) def create(self, validated_data): def replace_hyphens_in_keys(obj): @@ -155,6 +169,7 @@ class RecordingForReleaseSerializer(serializers.Serializer): id=validated_data["id"], includes=["tags", "artists"] ) recordings_data = replace_hyphens_in_keys(recordings_data) + recordings_data["position"] = validated_data.get("position", None) serializer = RecordingSerializer(data=recordings_data) serializer.is_valid(raise_exception=True) track = serializer.save() @@ -172,6 +187,7 @@ class RecordingForReleaseSerializer(serializers.Serializer): class TrackSerializer(serializers.Serializer): recording = RecordingForReleaseSerializer() + position = serializers.IntegerField() class MediaSerializer(serializers.Serializer): @@ -207,7 +223,12 @@ class ReleaseSerializer(serializers.Serializer): tags_models.add_tags(album, *validated_data.get("tags", [])) # an album can have various media/physical representation, we take the first one - recordings = [t["recording"] for t in validated_data["media"][0]["tracks"]] + tracks = [t for t in validated_data["media"][0]["tracks"]] + recordings = [] + for t in tracks: + t["recording"]["position"] = t["position"] + recordings.append(t["recording"]) + for r in recordings: r["album"] = album RecordingForReleaseSerializer().create(r) From 1e714dd28e740265d022fc21d1a167dbaaebb7ea Mon Sep 17 00:00:00 2001 From: Petitminion Date: Wed, 11 Jun 2025 15:49:15 +0200 Subject: [PATCH 9/9] fix(front):make alert more discrete --- front/src/components/federation/LibraryWidget.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/front/src/components/federation/LibraryWidget.vue b/front/src/components/federation/LibraryWidget.vue index 2918d9854..507aa1a9d 100644 --- a/front/src/components/federation/LibraryWidget.vue +++ b/front/src/components/federation/LibraryWidget.vue @@ -73,7 +73,6 @@ watch(() => props.url, () => { /> {{ t('components.federation.LibraryWidget.empty.noMatch') }}