From 1bee3a4675a63156eee4f56e8a63be4acd308d53 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sun, 23 Sep 2018 12:38:42 +0000 Subject: [PATCH] Import trust source --- api/funkwhale_api/federation/serializers.py | 90 ++-- api/funkwhale_api/music/metadata.py | 57 ++- api/funkwhale_api/music/models.py | 33 +- api/funkwhale_api/music/tasks.py | 455 +++++++++++------- .../management/commands/import_files.py | 47 +- api/requirements/local.txt | 1 + api/tests/conftest.py | 9 +- api/tests/federation/test_serializers.py | 128 +---- api/tests/music/test.mp3 | Bin 297745 -> 297745 bytes api/tests/music/test_metadata.py | 86 +++- api/tests/music/test_tasks.py | 355 +++++++++++--- api/tests/test_import_audio_file.py | 35 +- dev.yml | 1 + .../views/content/libraries/FilesTable.vue | 4 + 14 files changed, 872 insertions(+), 429 deletions(-) diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 99ed708f1..71cd7a831 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -4,7 +4,6 @@ import urllib.parse from django.core.exceptions import ObjectDoesNotExist from django.core.paginator import Paginator -from django.db.models import F, Q from rest_framework import serializers from funkwhale_api.common import utils as funkwhale_utils @@ -21,6 +20,31 @@ AP_CONTEXT = [ logger = logging.getLogger(__name__) +class LinkSerializer(serializers.Serializer): + type = serializers.ChoiceField(choices=["Link"]) + href = serializers.URLField(max_length=500) + mediaType = serializers.CharField() + + def __init__(self, *args, **kwargs): + self.allowed_mimetypes = kwargs.pop("allowed_mimetypes", []) + super().__init__(*args, **kwargs) + + def validate_mediaType(self, v): + if not self.allowed_mimetypes: + # no restrictions + return v + for mt in self.allowed_mimetypes: + if mt.endswith("/*"): + if v.startswith(mt.replace("*", "")): + return v + else: + if v == mt: + return v + raise serializers.ValidationError( + "Invalid mimetype {}. Allowed: {}".format(v, self.allowed_mimetypes) + ) + + class ActorSerializer(serializers.Serializer): id = serializers.URLField(max_length=500) outbox = serializers.URLField(max_length=500) @@ -626,32 +650,8 @@ class MusicEntitySerializer(serializers.Serializer): musicbrainzId = serializers.UUIDField(allow_null=True, required=False) name = serializers.CharField(max_length=1000) - def create(self, validated_data): - mbid = validated_data.get("musicbrainzId") - candidates = self.model.objects.filter( - Q(mbid=mbid) | Q(fid=validated_data["id"]) - ).order_by(F("fid").desc(nulls_last=True)) - - existing = candidates.first() - if existing: - return existing - - # nothing matching in our database, let's create a new object - return self.model.objects.create(**self.get_create_data(validated_data)) - - def get_create_data(self, validated_data): - return { - "mbid": validated_data.get("musicbrainzId"), - "fid": validated_data["id"], - "name": validated_data["name"], - "creation_date": validated_data["published"], - "from_activity": self.context.get("activity"), - } - class ArtistSerializer(MusicEntitySerializer): - model = music_models.Artist - def to_representation(self, instance): d = { "type": "Artist", @@ -667,9 +667,11 @@ class ArtistSerializer(MusicEntitySerializer): class AlbumSerializer(MusicEntitySerializer): - model = music_models.Album released = serializers.DateField(allow_null=True, required=False) artists = serializers.ListField(child=ArtistSerializer(), min_length=1) + cover = LinkSerializer( + allowed_mimetypes=["image/*"], allow_null=True, required=False + ) def to_representation(self, instance): d = { @@ -688,7 +690,12 @@ class AlbumSerializer(MusicEntitySerializer): ], } if instance.cover: - d["cover"] = {"type": "Image", "url": utils.full_url(instance.cover.url)} + d["cover"] = { + "type": "Link", + "href": utils.full_url(instance.cover.url), + "mediaType": mimetypes.guess_type(instance.cover.path)[0] + or "image/jpeg", + } if self.context.get("include_ap_context", self.parent is None): d["@context"] = AP_CONTEXT return d @@ -711,7 +718,6 @@ class AlbumSerializer(MusicEntitySerializer): class TrackSerializer(MusicEntitySerializer): - model = music_models.Track position = serializers.IntegerField(min_value=0, allow_null=True, required=False) artists = serializers.ListField(child=ArtistSerializer(), min_length=1) album = AlbumSerializer() @@ -738,32 +744,22 @@ class TrackSerializer(MusicEntitySerializer): d["@context"] = AP_CONTEXT return d - def get_create_data(self, validated_data): - artist_data = validated_data["artists"][0] - artist = ArtistSerializer( - context={"activity": self.context.get("activity")} - ).create(artist_data) - album = AlbumSerializer( - context={"activity": self.context.get("activity")} - ).create(validated_data["album"]) + def create(self, validated_data): + from funkwhale_api.music import tasks as music_tasks - return { - "mbid": validated_data.get("musicbrainzId"), - "fid": validated_data["id"], - "title": validated_data["name"], - "position": validated_data.get("position"), - "creation_date": validated_data["published"], - "artist": artist, - "album": album, - "from_activity": self.context.get("activity"), - } + metadata = music_tasks.federation_audio_track_to_metadata(validated_data) + from_activity = self.context.get("activity") + if from_activity: + metadata["from_activity_id"] = from_activity.pk + track = music_tasks.get_track_from_import_metadata(metadata) + return track class UploadSerializer(serializers.Serializer): type = serializers.ChoiceField(choices=["Audio"]) id = serializers.URLField(max_length=500) library = serializers.URLField(max_length=500) - url = serializers.JSONField() + url = LinkSerializer(allowed_mimetypes=["audio/*"]) published = serializers.DateTimeField() updated = serializers.DateTimeField(required=False, allow_null=True) bitrate = serializers.IntegerField(min_value=0) diff --git a/api/funkwhale_api/music/metadata.py b/api/funkwhale_api/music/metadata.py index 4c754ae05..21daf2747 100644 --- a/api/funkwhale_api/music/metadata.py +++ b/api/funkwhale_api/music/metadata.py @@ -93,9 +93,9 @@ def convert_track_number(v): class FirstUUIDField(forms.UUIDField): def to_python(self, value): try: - # sometimes, Picard leaves to uuids in the field, separated - # by a slash - value = value.split("/")[0] + # sometimes, Picard leaves two uuids in the field, separated + # by a slash or a ; + value = value.split(";")[0].split("/")[0].strip() except (AttributeError, IndexError, TypeError): pass @@ -107,10 +107,18 @@ def get_date(value): return datetime.date(parsed.year, parsed.month, parsed.day) +def split_and_return_first(separator): + def inner(v): + return v.split(separator)[0].strip() + + return inner + + VALIDATION = { "musicbrainz_artistid": FirstUUIDField(), "musicbrainz_albumid": FirstUUIDField(), "musicbrainz_recordingid": FirstUUIDField(), + "musicbrainz_albumartistid": FirstUUIDField(), } CONF = { @@ -123,10 +131,15 @@ CONF = { }, "title": {}, "artist": {}, + "album_artist": { + "field": "albumartist", + "to_application": split_and_return_first(";"), + }, "album": {}, "date": {"field": "date", "to_application": get_date}, "musicbrainz_albumid": {}, "musicbrainz_artistid": {}, + "musicbrainz_albumartistid": {}, "musicbrainz_recordingid": {"field": "musicbrainz_trackid"}, }, }, @@ -139,10 +152,15 @@ CONF = { }, "title": {}, "artist": {}, + "album_artist": { + "field": "albumartist", + "to_application": split_and_return_first(";"), + }, "album": {}, "date": {"field": "date", "to_application": get_date}, "musicbrainz_albumid": {}, "musicbrainz_artistid": {}, + "musicbrainz_albumartistid": {}, "musicbrainz_recordingid": {"field": "musicbrainz_trackid"}, }, }, @@ -155,10 +173,12 @@ CONF = { }, "title": {}, "artist": {}, + "album_artist": {"field": "albumartist"}, "album": {}, "date": {"field": "date", "to_application": get_date}, "musicbrainz_albumid": {"field": "MusicBrainz Album Id"}, "musicbrainz_artistid": {"field": "MusicBrainz Artist Id"}, + "musicbrainz_albumartistid": {"field": "MusicBrainz Album Artist Id"}, "musicbrainz_recordingid": {"field": "MusicBrainz Track Id"}, }, }, @@ -169,10 +189,12 @@ CONF = { "track_number": {"field": "TRCK", "to_application": convert_track_number}, "title": {"field": "TIT2"}, "artist": {"field": "TPE1"}, + "album_artist": {"field": "TPE2"}, "album": {"field": "TALB"}, "date": {"field": "TDRC", "to_application": get_date}, "musicbrainz_albumid": {"field": "MusicBrainz Album Id"}, "musicbrainz_artistid": {"field": "MusicBrainz Artist Id"}, + "musicbrainz_albumartistid": {"field": "MusicBrainz Album Artist Id"}, "musicbrainz_recordingid": { "field": "UFID", "getter": get_mp3_recording_id, @@ -190,10 +212,12 @@ CONF = { }, "title": {}, "artist": {}, + "album_artist": {"field": "albumartist"}, "album": {}, "date": {"field": "date", "to_application": get_date}, "musicbrainz_albumid": {}, "musicbrainz_artistid": {}, + "musicbrainz_albumartistid": {}, "musicbrainz_recordingid": {"field": "musicbrainz_trackid"}, "test": {}, "pictures": {}, @@ -201,6 +225,19 @@ CONF = { }, } +ALL_FIELDS = [ + "track_number", + "title", + "artist", + "album_artist", + "album", + "date", + "musicbrainz_albumid", + "musicbrainz_artistid", + "musicbrainz_albumartistid", + "musicbrainz_recordingid", +] + class Metadata(object): def __init__(self, path): @@ -238,6 +275,20 @@ class Metadata(object): v = field.to_python(v) return v + def all(self): + """ + Return a dict containing all metadata of the file + """ + + data = {} + for field in ALL_FIELDS: + try: + data[field] = self.get(field, None) + except (TagNotFound, forms.ValidationError): + data[field] = None + + return data + def get_picture(self, picture_type="cover_front"): ptype = getattr(mutagen.id3.PictureType, picture_type.upper()) try: diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 51f1d4286..55f1c77b8 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -1,4 +1,5 @@ import datetime +import logging import os import tempfile import uuid @@ -21,11 +22,14 @@ from versatileimagefield.image_warmer import VersatileImageFieldWarmer from funkwhale_api import musicbrainz from funkwhale_api.common import fields +from funkwhale_api.common import session from funkwhale_api.common import utils as common_utils from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import utils as federation_utils from . import importers, metadata, utils +logger = logging.getLogger(__file__) + def empty_dict(): return {} @@ -240,14 +244,35 @@ class Album(APIModelMixin): def get_image(self, data=None): if data: - f = ContentFile(data["content"]) extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"} extension = extensions.get(data["mimetype"], "jpg") - self.cover.save("{}.{}".format(self.uuid, extension), f) - else: + if data.get("content"): + # we have to cover itself + f = ContentFile(data["content"]) + elif data.get("url"): + # we can fetch from a url + try: + response = session.get_session().get( + data.get("url"), + timeout=3, + verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, + ) + response.raise_for_status() + except Exception as e: + logger.warn( + "Cannot download cover at url %s: %s", data.get("url"), e + ) + return + else: + f = ContentFile(response.content) + self.cover.save("{}.{}".format(self.uuid, extension), f, save=False) + self.save(update_fields=["cover"]) + return self.cover.file + if self.mbid: image_data = musicbrainz.api.images.get_front(str(self.mbid)) f = ContentFile(image_data) - self.cover.save("{0}.jpg".format(self.mbid), f) + self.cover.save("{0}.jpg".format(self.mbid), f, save=False) + self.save(update_fields=["cover"]) return self.cover.file def __str__(self): diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py index 61ee15585..0a4c04225 100644 --- a/api/funkwhale_api/music/tasks.py +++ b/api/funkwhale_api/music/tasks.py @@ -1,9 +1,10 @@ +import collections import logging import os from django.utils import timezone from django.db import transaction -from django.db.models import F +from django.db.models import F, Q from django.dispatch import receiver from musicbrainzngs import ResponseError @@ -14,7 +15,6 @@ from funkwhale_api.common import preferences from funkwhale_api.federation import activity, actors, routes from funkwhale_api.federation import library as lb from funkwhale_api.federation import library as federation_serializers -from funkwhale_api.providers.acoustid import get_acoustid_client from funkwhale_api.taskapp import celery from . import lyrics as lyrics_utils @@ -26,102 +26,32 @@ from . import serializers logger = logging.getLogger(__name__) -@celery.app.task(name="acoustid.set_on_upload") -@celery.require_instance(models.Upload, "upload") -def set_acoustid_on_upload(upload): - client = get_acoustid_client() - result = client.get_best_match(upload.audio_file.path) - - def update(id): - upload.acoustid_track_id = id - upload.save(update_fields=["acoustid_track_id"]) - return id - - if result: - return update(result["id"]) - - -def import_track_from_remote(metadata): - try: - track_mbid = metadata["recording"]["musicbrainz_id"] - assert track_mbid # for null/empty values - except (KeyError, AssertionError): - pass - else: - return models.Track.get_or_create_from_api(mbid=track_mbid)[0] - - try: - album_mbid = metadata["release"]["musicbrainz_id"] - assert album_mbid # for null/empty values - except (KeyError, AssertionError): - pass - else: - album, _ = models.Album.get_or_create_from_api(mbid=album_mbid) - return models.Track.get_or_create_from_title( - metadata["title"], artist=album.artist, album=album - )[0] - - try: - artist_mbid = metadata["artist"]["musicbrainz_id"] - assert artist_mbid # for null/empty values - except (KeyError, AssertionError): - pass - else: - artist, _ = models.Artist.get_or_create_from_api(mbid=artist_mbid) - album, _ = models.Album.get_or_create_from_title( - metadata["album_title"], artist=artist - ) - return models.Track.get_or_create_from_title( - metadata["title"], artist=artist, album=album - )[0] - - # worst case scenario, we have absolutely no way to link to a - # musicbrainz resource, we rely on the name/titles - artist, _ = models.Artist.get_or_create_from_name(metadata["artist_name"]) - album, _ = models.Album.get_or_create_from_title( - metadata["album_title"], artist=artist - ) - return models.Track.get_or_create_from_title( - metadata["title"], artist=artist, album=album - )[0] - - -def update_album_cover(album, upload, replace=False): +def update_album_cover(album, source=None, cover_data=None, replace=False): if album.cover and not replace: return - if upload: - # maybe the file has a cover embedded? + if cover_data: + return album.get_image(data=cover_data) + + if source and source.startswith("file://"): + # let's look for a cover in the same directory + path = os.path.dirname(source.replace("file://", "", 1)) + logger.info("[Album %s] scanning covers from %s", album.pk, path) + cover = get_cover_from_fs(path) + if cover: + return album.get_image(data=cover) + if album.mbid: try: - metadata = upload.get_metadata() - except FileNotFoundError: - metadata = None - if metadata: - cover = metadata.get_picture("cover_front") - if cover: - # best case scenario, cover is embedded in the track - logger.info("[Album %s] Using cover embedded in file", album.pk) - return album.get_image(data=cover) - if upload.source and upload.source.startswith("file://"): - # let's look for a cover in the same directory - path = os.path.dirname(upload.source.replace("file://", "", 1)) - logger.info("[Album %s] scanning covers from %s", album.pk, path) - cover = get_cover_from_fs(path) - if cover: - return album.get_image(data=cover) - if not album.mbid: - return - try: - logger.info( - "[Album %s] Fetching cover from musicbrainz release %s", - album.pk, - str(album.mbid), - ) - return album.get_image() - except ResponseError as exc: - logger.warning( - "[Album %s] cannot fetch cover from musicbrainz: %s", album.pk, str(exc) - ) + logger.info( + "[Album %s] Fetching cover from musicbrainz release %s", + album.pk, + str(album.mbid), + ) + return album.get_image() + except ResponseError as exc: + logger.warning( + "[Album %s] cannot fetch cover from musicbrainz: %s", album.pk, str(exc) + ) IMAGE_TYPES = [("jpg", "image/jpeg"), ("png", "image/png")] @@ -244,15 +174,15 @@ def scan_library_page(library_scan, page_url): scan_library_page.delay(library_scan_id=library_scan.pk, page_url=next_page) -def getter(data, *keys): +def getter(data, *keys, default=None): if not data: - return + return default v = data for k in keys: try: v = v[k] except KeyError: - return + return default return v @@ -269,12 +199,17 @@ def fail_import(upload, error_code): upload.import_details = {"error_code": error_code} upload.import_date = timezone.now() upload.save(update_fields=["import_details", "import_status", "import_date"]) - signals.upload_import_status_updated.send( - old_status=old_status, - new_status=upload.import_status, - upload=upload, - sender=None, + + broadcast = getter( + upload.import_metadata, "funkwhale", "config", "broadcast", default=True ) + if broadcast: + signals.upload_import_status_updated.send( + old_status=old_status, + new_status=upload.import_status, + upload=upload, + sender=None, + ) @celery.app.task(name="music.process_upload") @@ -285,22 +220,29 @@ def fail_import(upload, error_code): "upload", ) def process_upload(upload): - data = upload.import_metadata or {} + import_metadata = upload.import_metadata or {} old_status = upload.import_status + audio_file = upload.get_audio_file() try: - track = get_track_from_import_metadata(upload.import_metadata or {}) - if not track and upload.audio_file: - # easy ways did not work. Now we have to be smart and use - # metadata from the file itself if any - track = import_track_data_from_file(upload.audio_file.file, hints=data) - if not track and upload.metadata: - # we can try to import using federation metadata - track = import_track_from_remote(upload.metadata) + additional_data = {} + if not audio_file: + # we can only rely on user proveded data + final_metadata = import_metadata + else: + # we use user provided data and data from the file itself + m = metadata.Metadata(audio_file) + file_metadata = m.all() + final_metadata = collections.ChainMap( + additional_data, import_metadata, file_metadata + ) + additional_data["cover_data"] = m.get_picture("cover_front") + additional_data["upload_source"] = upload.source + track = get_track_from_import_metadata(final_metadata) except UploadImportError as e: return fail_import(upload, e.code) except Exception: - fail_import(upload, "unknown_error") - raise + return fail_import(upload, "unknown_error") + # under some situations, we want to skip the import ( # for instance if the user already owns the files) owned_duplicates = get_owned_duplicates(upload, track) @@ -342,33 +284,69 @@ def process_upload(upload): "bitrate", ] ) - signals.upload_import_status_updated.send( - old_status=old_status, - new_status=upload.import_status, - upload=upload, - sender=None, + broadcast = getter( + import_metadata, "funkwhale", "config", "broadcast", default=True ) - routes.outbox.dispatch( - {"type": "Create", "object": {"type": "Audio"}}, context={"upload": upload} + if broadcast: + signals.upload_import_status_updated.send( + old_status=old_status, + new_status=upload.import_status, + upload=upload, + sender=None, + ) + dispatch_outbox = getter( + import_metadata, "funkwhale", "config", "dispatch_outbox", default=True ) - if not track.album.cover: - update_album_cover(track.album, upload) + if dispatch_outbox: + routes.outbox.dispatch( + {"type": "Create", "object": {"type": "Audio"}}, context={"upload": upload} + ) -def get_track_from_import_metadata(data): - track_mbid = getter(data, "track", "mbid") - track_uuid = getter(data, "track", "uuid") +def federation_audio_track_to_metadata(payload): + """ + Given a valid payload as returned by federation.serializers.TrackSerializer.validated_data, + returns a correct metadata payload for use with get_track_from_import_metadata. + """ + musicbrainz_recordingid = payload.get("musicbrainzId") + musicbrainz_artistid = payload["artists"][0].get("musicbrainzId") + musicbrainz_albumartistid = payload["album"]["artists"][0].get("musicbrainzId") + musicbrainz_albumid = payload["album"].get("musicbrainzId") - if track_mbid: - # easiest case: there is a MBID provided in the import_metadata - return models.Track.get_or_create_from_api(mbid=track_mbid)[0] - if track_uuid: - # another easy case, we have a reference to a uuid of a track that - # already exists in our database - try: - return models.Track.objects.get(uuid=track_uuid) - except models.Track.DoesNotExist: - raise UploadImportError(code="track_uuid_not_found") + new_data = { + "title": payload["name"], + "album": payload["album"]["name"], + "track_number": payload["position"], + "artist": payload["artists"][0]["name"], + "album_artist": payload["album"]["artists"][0]["name"], + "date": payload["album"].get("released"), + # musicbrainz + "musicbrainz_recordingid": str(musicbrainz_recordingid) + if musicbrainz_recordingid + else None, + "musicbrainz_artistid": str(musicbrainz_artistid) + if musicbrainz_artistid + else None, + "musicbrainz_albumartistid": str(musicbrainz_albumartistid) + if musicbrainz_albumartistid + else None, + "musicbrainz_albumid": str(musicbrainz_albumid) + if musicbrainz_albumid + else None, + # federation + "fid": payload["id"], + "artist_fid": payload["artists"][0]["id"], + "album_artist_fid": payload["album"]["artists"][0]["id"], + "album_fid": payload["album"]["id"], + "fdate": payload["published"], + "album_fdate": payload["album"]["published"], + "album_artist_fdate": payload["album"]["artists"][0]["published"], + "artist_fdate": payload["artists"][0]["published"], + } + cover = payload["album"].get("cover") + if cover: + new_data["cover_data"] = {"mimetype": cover["mediaType"], "url": cover["href"]} + return new_data def get_owned_duplicates(upload, track): @@ -385,45 +363,191 @@ def get_owned_duplicates(upload, track): ) +def get_best_candidate_or_create(model, query, defaults, sort_fields): + """ + Like queryset.get_or_create() but does not crash if multiple objects + are returned on the get() call + """ + candidates = model.objects.filter(query) + if candidates: + + return sort_candidates(candidates, sort_fields)[0], False + + return model.objects.create(**defaults), True + + +def sort_candidates(candidates, important_fields): + """ + Given a list of objects and a list of fields, + will return a sorted list of those objects by score. + + Score is higher for objects that have a non-empty attribute + that is also present in important fields:: + + artist1 = Artist(mbid=None, fid=None) + artist2 = Artist(mbid="something", fid=None) + + # artist2 has a mbid, so is sorted first + assert sort_candidates([artist1, artist2], ['mbid'])[0] == artist2 + + Only supports string fields. + """ + + # map each fields to its score, giving a higher score to first fields + fields_scores = {f: i + 1 for i, f in enumerate(sorted(important_fields))} + candidates_with_scores = [] + for candidate in candidates: + current_score = 0 + for field, score in fields_scores.items(): + v = getattr(candidate, field, "") + if v: + current_score += score + + candidates_with_scores.append((candidate, current_score)) + + return [c for c, s in reversed(sorted(candidates_with_scores, key=lambda v: v[1]))] + + @transaction.atomic -def import_track_data_from_file(file, hints={}): - data = metadata.Metadata(file) - album = None +def get_track_from_import_metadata(data): + track_uuid = getter(data, "funkwhale", "track", "uuid") + + if track_uuid: + # easy case, we have a reference to a uuid of a track that + # already exists in our database + try: + track = models.Track.objects.get(uuid=track_uuid) + except models.Track.DoesNotExist: + raise UploadImportError(code="track_uuid_not_found") + + if not track.album.cover: + update_album_cover( + track.album, + source=data.get("upload_source"), + cover_data=data.get("cover_data"), + ) + return track + + from_activity_id = data.get("from_activity_id", None) track_mbid = data.get("musicbrainz_recordingid", None) album_mbid = data.get("musicbrainz_albumid", None) + track_fid = getter(data, "fid") + + query = None if album_mbid and track_mbid: - # to gain performance and avoid additional mb lookups, - # we import from the release data, which is already cached - return models.Track.get_or_create_from_release(album_mbid, track_mbid)[0] - elif track_mbid: - return models.Track.get_or_create_from_api(track_mbid)[0] - elif album_mbid: - album = models.Album.get_or_create_from_api(album_mbid)[0] + query = Q(mbid=track_mbid, album__mbid=album_mbid) - artist = album.artist if album else None + if track_fid: + query = query | Q(fid=track_fid) if query else Q(fid=track_fid) + + if query: + # second easy case: we have a (track_mbid, album_mbid) pair or + # a federation uuid we can check on + try: + return sort_candidates(models.Track.objects.filter(query), ["mbid", "fid"])[ + 0 + ] + except IndexError: + pass + + # get / create artist and album artist artist_mbid = data.get("musicbrainz_artistid", None) - if not artist: - if artist_mbid: - artist = models.Artist.get_or_create_from_api(artist_mbid)[0] - else: - artist = models.Artist.objects.get_or_create( - name__iexact=data.get("artist"), defaults={"name": data.get("artist")} - )[0] + artist_fid = data.get("artist_fid", None) + artist_name = data["artist"] + query = Q(name__iexact=artist_name) + if artist_mbid: + query |= Q(mbid=artist_mbid) + if artist_fid: + query |= Q(fid=artist_fid) + defaults = { + "name": artist_name, + "mbid": artist_mbid, + "fid": artist_fid, + "from_activity_id": from_activity_id, + } + if data.get("artist_fdate"): + defaults["creation_date"] = data.get("artist_fdate") - release_date = data.get("date", default=None) - if not album: - album = models.Album.objects.get_or_create( - title__iexact=data.get("album"), - artist=artist, - defaults={"title": data.get("album"), "release_date": release_date}, - )[0] - position = data.get("track_number", default=None) - track = models.Track.objects.get_or_create( - title__iexact=data.get("title"), - album=album, - defaults={"title": data.get("title"), "position": position}, + artist = get_best_candidate_or_create( + models.Artist, query, defaults=defaults, sort_fields=["mbid", "fid"] )[0] + + album_artist_name = data.get("album_artist", artist_name) + if album_artist_name == artist_name: + album_artist = artist + else: + query = Q(name__iexact=album_artist_name) + album_artist_mbid = data.get("musicbrainz_albumartistid", None) + album_artist_fid = data.get("album_artist_fid", None) + if album_artist_mbid: + query |= Q(mbid=album_artist_mbid) + if album_artist_fid: + query |= Q(fid=album_artist_fid) + defaults = { + "name": album_artist_name, + "mbid": album_artist_mbid, + "fid": album_artist_fid, + "from_activity_id": from_activity_id, + } + if data.get("album_artist_fdate"): + defaults["creation_date"] = data.get("album_artist_fdate") + + album_artist = get_best_candidate_or_create( + models.Artist, query, defaults=defaults, sort_fields=["mbid", "fid"] + )[0] + + # get / create album + album_title = data["album"] + album_fid = data.get("album_fid", None) + query = Q(title__iexact=album_title, artist=album_artist) + if album_mbid: + query |= Q(mbid=album_mbid) + if album_fid: + query |= Q(fid=album_fid) + defaults = { + "title": album_title, + "artist": album_artist, + "mbid": album_mbid, + "release_date": data.get("date"), + "fid": album_fid, + "from_activity_id": from_activity_id, + } + if data.get("album_fdate"): + defaults["creation_date"] = data.get("album_fdate") + + album = get_best_candidate_or_create( + models.Album, query, defaults=defaults, sort_fields=["mbid", "fid"] + )[0] + if not album.cover: + update_album_cover( + album, source=data.get("upload_source"), cover_data=data.get("cover_data") + ) + + # get / create track + track_title = data["title"] + track_number = data.get("track_number", 1) + query = Q(title__iexact=track_title, artist=artist, album=album) + if track_mbid: + query |= Q(mbid=track_mbid) + if track_fid: + query |= Q(fid=track_fid) + defaults = { + "title": track_title, + "album": album, + "mbid": track_mbid, + "artist": artist, + "position": track_number, + "fid": track_fid, + "from_activity_id": from_activity_id, + } + if data.get("fdate"): + defaults["creation_date"] = data.get("fdate") + + track = get_best_candidate_or_create( + models.Track, query, defaults=defaults, sort_fields=["mbid", "fid"] + )[0] + return track @@ -432,6 +556,7 @@ def broadcast_import_status_update_to_owner(old_status, new_status, upload, **kw user = upload.library.actor.get_user() if not user: return + group = "user.{}.imports".format(user.pk) channels.group_send( group, diff --git a/api/funkwhale_api/providers/audiofile/management/commands/import_files.py b/api/funkwhale_api/providers/audiofile/management/commands/import_files.py index bc1c9af0a..d4917be5e 100644 --- a/api/funkwhale_api/providers/audiofile/management/commands/import_files.py +++ b/api/funkwhale_api/providers/audiofile/management/commands/import_files.py @@ -77,6 +77,29 @@ class Command(BaseCommand): "with their newest version." ), ) + parser.add_argument( + "--outbox", + action="store_true", + dest="outbox", + default=False, + help=( + "Use this flag to notify library followers of newly imported files. " + "You'll likely want to keep this disabled for CLI imports, especially if" + "you plan to import hundreds or thousands of files, as it will cause a lot " + "of overhead on your server and on servers you are federating with." + ), + ) + + parser.add_argument( + "--broadcast", + action="store_true", + dest="broadcast", + default=False, + help=( + "Use this flag to enable realtime updates about the import in the UI. " + "This causes some overhead, so it's disabled by default." + ), + ) parser.add_argument( "--reference", @@ -261,6 +284,8 @@ class Command(BaseCommand): async_, options["replace"], options["in_place"], + options["outbox"], + options["broadcast"], ) except Exception as e: if options["exit_on_failure"]: @@ -272,11 +297,29 @@ class Command(BaseCommand): errors.append((path, "{} {}".format(e.__class__.__name__, e))) return errors - def create_upload(self, path, reference, library, async_, replace, in_place): + def create_upload( + self, + path, + reference, + library, + async_, + replace, + in_place, + dispatch_outbox, + broadcast, + ): import_handler = tasks.process_upload.delay if async_ else tasks.process_upload upload = models.Upload(library=library, import_reference=reference) upload.source = "file://" + path - upload.import_metadata = {"replace": replace} + upload.import_metadata = { + "funkwhale": { + "config": { + "replace": replace, + "dispatch_outbox": dispatch_outbox, + "broadcast": broadcast, + } + } + } if not in_place: name = os.path.basename(path) with open(path, "rb") as f: diff --git a/api/requirements/local.txt b/api/requirements/local.txt index f11f976b8..c12f1ecb8 100644 --- a/api/requirements/local.txt +++ b/api/requirements/local.txt @@ -10,3 +10,4 @@ django-debug-toolbar>=1.9,<1.10 # improved REPL ipdb==0.8.1 black +profiling diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 1694e5623..a1688127c 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -11,7 +11,7 @@ import uuid from faker.providers import internet as internet_provider import factory import pytest -import requests_mock + from django.contrib.auth.models import AnonymousUser from django.core.cache import cache as django_cache from django.core.files import uploadedfile @@ -271,14 +271,13 @@ def media_root(settings): shutil.rmtree(tmp_dir) -@pytest.fixture -def r_mock(): +@pytest.fixture(autouse=True) +def r_mock(requests_mock): """ Returns a requests_mock.mock() object you can use to mock HTTP calls made using python-requests """ - with requests_mock.mock() as m: - yield m + yield requests_mock @pytest.fixture diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index 00bb011f2..54e044c31 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -1,3 +1,4 @@ +import io import pytest import uuid @@ -588,42 +589,6 @@ def test_music_library_serializer_from_private(factories, mocker): ) -@pytest.mark.parametrize( - "model,serializer_class", - [ - ("music.Artist", serializers.ArtistSerializer), - ("music.Album", serializers.AlbumSerializer), - ("music.Track", serializers.TrackSerializer), - ], -) -def test_music_entity_serializer_create_existing_mbid( - model, serializer_class, factories -): - entity = factories[model]() - data = {"musicbrainzId": str(entity.mbid), "id": "https://noop"} - serializer = serializer_class() - - assert serializer.create(data) == entity - - -@pytest.mark.parametrize( - "model,serializer_class", - [ - ("music.Artist", serializers.ArtistSerializer), - ("music.Album", serializers.AlbumSerializer), - ("music.Track", serializers.TrackSerializer), - ], -) -def test_music_entity_serializer_create_existing_fid( - model, serializer_class, factories -): - entity = factories[model](fid="https://entity.url") - data = {"musicbrainzId": None, "id": "https://entity.url"} - serializer = serializer_class() - - assert serializer.create(data) == entity - - def test_activity_pub_artist_serializer_to_ap(factories): artist = factories["music.Artist"]() expected = { @@ -639,30 +604,6 @@ def test_activity_pub_artist_serializer_to_ap(factories): assert serializer.data == expected -def test_activity_pub_artist_serializer_from_ap(factories): - activity = factories["federation.Activity"]() - - published = timezone.now() - data = { - "type": "Artist", - "id": "http://hello.artist", - "name": "John Smith", - "musicbrainzId": str(uuid.uuid4()), - "published": published.isoformat(), - } - serializer = serializers.ArtistSerializer(data=data, context={"activity": activity}) - - assert serializer.is_valid(raise_exception=True) - - artist = serializer.save() - - assert artist.from_activity == activity - assert artist.name == data["name"] - assert artist.fid == data["id"] - assert str(artist.mbid) == data["musicbrainzId"] - assert artist.creation_date == published - - def test_activity_pub_album_serializer_to_ap(factories): album = factories["music.Album"]() @@ -671,7 +612,11 @@ def test_activity_pub_album_serializer_to_ap(factories): "type": "Album", "id": album.fid, "name": album.title, - "cover": {"type": "Image", "url": utils.full_url(album.cover.url)}, + "cover": { + "type": "Link", + "mediaType": "image/jpeg", + "href": utils.full_url(album.cover.url), + }, "musicbrainzId": album.mbid, "published": album.creation_date.isoformat(), "released": album.release_date.isoformat(), @@ -686,49 +631,6 @@ def test_activity_pub_album_serializer_to_ap(factories): assert serializer.data == expected -def test_activity_pub_album_serializer_from_ap(factories): - activity = factories["federation.Activity"]() - - published = timezone.now() - released = timezone.now().date() - data = { - "type": "Album", - "id": "http://hello.album", - "name": "Purple album", - "musicbrainzId": str(uuid.uuid4()), - "published": published.isoformat(), - "released": released.isoformat(), - "artists": [ - { - "type": "Artist", - "id": "http://hello.artist", - "name": "John Smith", - "musicbrainzId": str(uuid.uuid4()), - "published": published.isoformat(), - } - ], - } - serializer = serializers.AlbumSerializer(data=data, context={"activity": activity}) - - assert serializer.is_valid(raise_exception=True) - - album = serializer.save() - artist = album.artist - - assert album.from_activity == activity - assert album.title == data["name"] - assert album.fid == data["id"] - assert str(album.mbid) == data["musicbrainzId"] - assert album.creation_date == published - assert album.release_date == released - - assert artist.from_activity == activity - assert artist.name == data["artists"][0]["name"] - assert artist.fid == data["artists"][0]["id"] - assert str(artist.mbid) == data["artists"][0]["musicbrainzId"] - assert artist.creation_date == published - - def test_activity_pub_track_serializer_to_ap(factories): track = factories["music.Track"]() expected = { @@ -753,7 +655,7 @@ def test_activity_pub_track_serializer_to_ap(factories): assert serializer.data == expected -def test_activity_pub_track_serializer_from_ap(factories): +def test_activity_pub_track_serializer_from_ap(factories, r_mock): activity = factories["federation.Activity"]() published = timezone.now() released = timezone.now().date() @@ -771,6 +673,11 @@ def test_activity_pub_track_serializer_from_ap(factories): "musicbrainzId": str(uuid.uuid4()), "published": published.isoformat(), "released": released.isoformat(), + "cover": { + "type": "Link", + "href": "https://cover.image/test.png", + "mediaType": "image/png", + }, "artists": [ { "type": "Artist", @@ -791,12 +698,14 @@ def test_activity_pub_track_serializer_from_ap(factories): } ], } + r_mock.get(data["album"]["cover"]["href"], body=io.BytesIO(b"coucou")) serializer = serializers.TrackSerializer(data=data, context={"activity": activity}) assert serializer.is_valid(raise_exception=True) track = serializer.save() album = track.album artist = track.artist + album_artist = track.album.artist assert track.from_activity == activity assert track.fid == data["id"] @@ -806,7 +715,8 @@ def test_activity_pub_track_serializer_from_ap(factories): assert str(track.mbid) == data["musicbrainzId"] assert album.from_activity == activity - + assert album.cover.read() == b"coucou" + assert album.cover.path.endswith(".png") assert album.title == data["album"]["name"] assert album.fid == data["album"]["id"] assert str(album.mbid) == data["album"]["musicbrainzId"] @@ -819,6 +729,12 @@ def test_activity_pub_track_serializer_from_ap(factories): assert str(artist.mbid) == data["artists"][0]["musicbrainzId"] assert artist.creation_date == published + assert album_artist.from_activity == activity + assert album_artist.name == data["album"]["artists"][0]["name"] + assert album_artist.fid == data["album"]["artists"][0]["id"] + assert str(album_artist.mbid) == data["album"]["artists"][0]["musicbrainzId"] + assert album_artist.creation_date == published + def test_activity_pub_upload_serializer_from_ap(factories, mocker): activity = factories["federation.Activity"]() diff --git a/api/tests/music/test.mp3 b/api/tests/music/test.mp3 index 8502de71b8284e9f30a397f58401d96fc42dbb17..6c1f52a35f665306af4959bb5ed024d2abea544a 100644 GIT binary patch delta 24122 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 Q$uF7wus~$`g)SCH0E8@z=Kufz delta 24097 zcmbO@O=#jYp$R7ZDGWsn1q_)CISly>B@7G`gMwI?85kHhu6xDEZp6TlQBqQ{c>&W* z7RF_pt=RjRlKLK1yv=<&FN{;fBlgA@E;i3Ut7MIXw_MxuI{UYD$GbJ}k|Pu6DNij? zR%j0M;NehK_{z4Y{-MmL3ijjs)PLkwctyGRZGHIYuH30GyBx3TpFvNOHap;x}8Di|nFa4>yd&uvq=Dx*$i=a1aSk={SL zj@sPS56R!T=4L%l!+(bJp~qyao}@24YaN>rJ2~8a-9-=0OaYNVR|e51@>}ZjCe|*0 z@k_t|OUP%_`D}NkzjcaC-?=ey+bhQY5XSahTwi~sY;-RaKB|+g6Qt?hdn|NHkAQoD ztI3;pCU4$_Tr{0+FPL#nYTbOJI}!1YvS;_4D3UV|IOYDpHEuoU57uh+!}}$FtbfS+ zdS|m|@v0w{Pc1*2oSZXJ>OaGEbyeF&QSNtFOw$u?J1pwbl^n=%0E(( zYHM8|&8vLeO1jc&-HhWqEQFS?GLJt})%DfuWBe`dhkh$p*Ump)$DW+}_^b%0_{qn= zy;x2fc-VI|MCa9doVO|6&$>@PbH3!BdU3YRDQ(w&89&LAs+nJRt?;ns*Px=1@3IAs zOtW@9b1u6p`&A?`z$4IAgz=I6mSxlJTie_B>0}0OepSc2<9@YBvf68(DIM>U?RJN5 z@AqBuZGFn+QtjMizpeW>Pw~$z6*|kas-h%ug8PHa@81lv%EHCU19iS#7u*tbvx|(fMM_LyP)9UnpHK_rZ4E>fcUrw|*Qy^3B*Ka~^9~pk3p`)k_)Qo6TAJ z_R;sR9{tR1mKKM%2QDqXUSs@7x?VJ6+O4%q!UdyZnLhkorYyZHo$=c|hr|=-EtIxq zYG%SBlcnD4=k7<(+f3yf_F2_;#|dn${NlZw`_WpyyKXlF`*<()9TDPGUijog%=Js5 zvS+u1Py2MxCfmelrKfV?)KX;@fvP60$(yB&{}?{XH9uy>+P8U0%-VbND)EH|cb;5!$NKb##YaAGnf9IUrj+Z=SM|OBPBO1|+VO6=eZb;1 z9qW45^)PE|o>2Ira8WhiA?xspMO_nFE9~EjvixV@|KtDAzPn80m@Sw8o4=*WK|Zrm z&VDxh&#*49{GZz1M=M^>(w*OFlKoaYuq7rU?!dRtN9sEz1l*rwE>8<8OI@L-w7W_3 z!9=yD5FvM+bgfrz&dhh}18SR zP?h`BKPFl9-4@gI#HfS5Wg$Ul4mwDx_A$t9+4{NsZRf|9ANl?l|H-}7J0A1VXI{uN zwZqQo^XD&DF5^(&q|?4m^O((9S6;Qd6WxzYzx40Ye+Ds=`+RQ4{M&!!%+IUe(HZ@8 zf0vn-%H+SDs;tVg*DRxVJ%0H$pi9&tG_YYo0^a!bTuTyU>MQSjUmv+@#nqq5o3zUIPIp+;rEP2gN^>Z9@jTUel1e$P zrCMHd1o#)ejSGIDli#_0mj98z;Sc9`tIFTHctrn;uIInNRgp4J$5iin#`8ZuAJ}Doq@OEGPVU;<*0Zfosy!v{3)k~>)VEIg#J^%a?+@9> zvH1_xyYD{~Kf--<#)7*K_og1ZvZvW1qCcUk!CD~v_s(Bld)_~8KX{wJXCL2W-%0)( zUW*-nE0wlfUMT9ECA)3eyp}p!2c^rub(Z`xzVI^pn0NZqb4KJ$>3e3*Rj*(5{f1iKGl^%N*F2U4ym(b4>2X5&mA3ZLdlH*Iu5UB- zWe;>zowhjd+ovYuhQsTx$klUws;|Epf6P2wPU7kw)^*1HLO+z=ZrR`D1Q}2BLNlHs9P7?{xQXwWpy96#+xYzJ4d#7a zYhR|9Her*W`(=%8DJ`WISBX$&2EIdcT}96&O}=0&QZM>o|JM4pJhqt+{iR=eZ;$z~ zZ&^Q2bMM7dryIa)ZnRK*>_5Qn;ktPKX=j2?NimZ&t#c+ zQu5TUi4z>Z9MkIW{9~E==q=lKk1NSWk#~QTV3tp!sc_W%@(;&U#Lh>CK0K zO`E^ABI~Hn#LVqs?{yFHOiS%;W)iNySeInUJ%M3uNV3KIw5;j&vHsqBeVqT^5d9(tV=v^4Zup1sg+VWa59uw2u{##!giXe{b-vPpjY{?K0k@a%ZmAEsxw z_m!}GP7L|aF#W}Jl>>)=^qI4=uk-pir*HLx?MLq#KQ?cAaBWScii%*&e&R&su#l)CpSn_Kv(8yF&8`h32TKGZo`) z|0JjVaQt}qkiD?zOcUMY^tP{mYOnCsUtFI5J-Vs3oc3=FjS&IK%TCm1$%H_>hmb~5hXuoi2#n<(>oZT{2C*1g9?7id49VVN- zuO+75_|I@))k{{jzNC4kQ~7`EFv>4n*R|T?^of^iSM}`aT(PL@xn)zA#sVfdo~##RT)${%cy^^r zf+Ux~f-mPRR%X0*WYl`GL1jvnLeX;bX5|P|DEvpW%W3TmA(#DVxj! z=D+#RAaLvW%r7sErv)A?QD4En;?>?C^$PR4w#_b8-u@)lwDkO)uctIMv*#SqvV73@ zN>Xu_=`=}^#m63NopR$)zR)$Pi{WSAz3l#*){pFOJPlSqv|cDytSP|%k5^vwq~%vF zzFAj3S$X%%nV=;XmrUGsQo7;Piy{fR7P&_oYw{oL)qcd^TFTF#ZT6ob@@6O!^E!a?J()aE0TqgO#x9`+$_V_5aF+06}wSyZAn^^ye(&MX@ndiTb z_stT^uK%s`L+H^f)kNNT{U#lCIh-to=KrjXi{HnUKm6`5{O_pQ@ut4xvxOe>A<4>%(l|1;D~UH5Fq zvz;jy&#R_g2y_)?X%z`{U`58Nx~dE{-oIn+I_ww!GUJ$v(xx-`lI=Q_scE3ahlh{s^h07P1&w8!7iluU{1}_zM>_MU!UrG#PM#yB+12*NfW)S z3{E7tahR^P>YKBE*Q>6co&I8f=k3o}9pANW{i1BIS@FF(#=LhY_s+}K_&GD=i^Y@m z3s0O_pS!+oeS@WkqCm*0c8ww7Pd z-<GmJXv-2Om7g~AgPrQ%-%PZyRvwLL9g!qc~ew=uR_jO>P*HYW0Q&V)`yC=Vx zrLkzonL|6|d*wMw&iBj{nC!L3K7D_~&tJ8_p4e`A{BDEt9N~k$^{-(Y1h1J~2JxkB7*pP40gNR_)0VKXU7r)mZ=7{V?pe*#3ua6K zyE!rf4$}KpMCVkTKeXj{{hQ!NyCohm#Tw?F;IP_KopxgV?ENYIo%J6Y{$1K<_hH&P zuZ%kVU3)ISRnJq_m##mdeEjbh#%~GL;aB%>_FZEhwqN)UtKz3ip6ggV6)m;So33g3 zYqO*O?-tRYlOJb$ztunf{g=$7^U14yCdR$|v^lrJR73pKOwPA^A5N{?7is%C>T}7J zrMbfXPk#OBRlAc{x3lhSIoCYNYdPHSD)g0hu-m+ybWhKJ`FWYGufKlse^bx=c;}z& z2e+eFpOlOK-76=$b6W6cPZM$D#lq*8Z2VXHb*pIU?Cf<*PKTF>v^B?T{k!p>VOvoR z+a!6eA5qr6&d0U~NIt3FW}kj}v3&l}(6np2t{;2fs`8)lF{aN)b{~3h!Shu(TDPIyBJ9$gA zulYpfdYQ+JDcqC4tPedXTVeX=&kwr?FXY+(l&{#Om>U&sE5vkI_uuBXMn->G1e^ae z#In{3e_;Q%`bXmjBYV$hbgiC^UT$kWDd*V^UZ?qqC|X4VkS&3Suh-d=v@Sr_*2Yxdo^W6#bBuDSWr z-a5S(L)|>>8`jS|^z!>H_Rf8Jud96gS4AGx|E7Ir|DN8B+w6~*Pg_vjV<_tzlYC~j zNl)A-8Dim3&!zFqM6Y@B>qD#PAw{aQQ81G@iYek3Uv zi2H1p|H+&E=&cpoB^$YJC6huM^J^bYtevP=vsTMI{?|+8m<|6Ks`h=@u*ui#(5-X! zU&}74XnzUivY%QK9@Z6qJvnOIChuP_{9Q%D70-Z1XApSuJzJxCRo$c?yN?+C2zQ(x z-0ivdT92xEROYF*hCv66wI-S8vTQS+#4d9+pY4z1g_`P&w2SYrl{@~voypi&(ZBfE zjl`e9FAHzpdhQ-$w)dl0t@R_n&WGN%rt4gmRyI zI~=fm@3tbf%~#gUOfm3cKlQ3wu2LpNh(GGp;?PTbQa|kd&v5?A4)2MtH$Jsz-e;S| zzi$2E^BqfbYod9wu1Ou|&R*x_7UVG_(OdB4b@m2F(J!Jkv)BL7_rI3={6l%q<=>iX zRX@KiErJ-u})s z;}?U!qr!vqeU%4)AI*yC_uX%Lmn&e~>g4;cr(au=&zYsWMqSG04ck(Qm)r%c2fi>Q z*l*yy@qDGpb=QfKvd+p(TpLa(d}RsON>NX4`tkn5rsI#}57pKm3SY5F_lx3#q`EgJ zoR1y&&#pEA&cqXsAA>k7j?W!5cGO@9>pfN&# z&F$L3)9GJ=KF7B|tlhKW{^4r7wpA%xW?j4cJj3DX!h{7A7=ADL*k@h*sD3N|p(j5s zf4Fx3Xq1k4;OdV2^A5h+c_o*axl3wJA5YMpF345CdfHn3%|F(y{`!-5 zrODb2=lOqZ_PO+*A;VYtsFcpd^AG(cZ_mtK_wb43%w*52Cx5S%`EXmN{_?@~6Y3u< zn9s5Pi+`upy6wxN4QA;E`8ruD2Spq$XnU;k+gJ94{NJj_U+zo3j_3U9YwfyY_oUB< zURHkV&Rm!?+fnJQzWm0}aE8))Lk*i1=WqUJ5G%c%-(}CU#>Tr~qTcFc7OSw`T=N8W zFPRp;qWaKvPD%EEm3J&&@m86n`wO#b?3LR5mpAFLb^N;asr3hK_o=-MihU_AZ2zt5 zUY7Z;@F_PqpPv;8UUB}n-=xQ{SnGPz_RE^~x0U~6Y(Hvc-mX^0z2aq^?uRcM_SIkd zxN{=Y`rlp;j{p1gXur!J!QVbF;zjl`#8fQyzw+ksN6riH^wKV~oKo|A>=v$QJSR`` z8`~nUQ--lZ{~46k)}|E13)eqbBG30nhP$K2bU zTFAR`^Q@OLo^_v2iXObh(-=O@*6qy%t+)LhlP$b1cbTe9gmVjOcdl6(vfMw?>-6T^zkcuK?3o<3x#MHX{hRZ7ZR+KhXwSK0dTg~x zN;1zIW#|74H7@p6uS$92ZXcFYzuh*X#_{9YXG?2! zr+Al~Tz)vmO(ozr`r=j+#&M0#ocw%))0pX@BvrSEe8o!GyHZ~1TAKgpNA z?ogY$#d>?RZ`?)y)26%SJmpsTJ*eIL`g@h7Sj5#UPb2c!uSor?Sv2ds$K~gnE^poa zF<&8Vl5sl2ox+~{&z{FcfA9T}9$zn7BmY5dv)X&}fD1W#G5dqg=Sv&fcbZ=JTE1of z^!l$i3b#9NT-3F`zCM5HjXQC%UW(hc_b#30W}~&@r2T}or)*|FzTUAe|F-)FvndC^ z>K2s+T<~kIwqfaAdE;)VA{MYLUtBj!N||XZaSwP%r%Vvu?pZ!Rre@PCqIyQR8=I z!heQCrVFBLx0=gv3aJ!kN}MlRR@uMjVtCN^*!|N#oA=GvKl6Oahcn{)W&a3&_;ttA zu6IdnRMZ`joc4adE!oS{kNbWPa+3bL>bJJ#vwfj|r@DltwX2=& zo*w^|`ef0&r*K(aKb$dggt7zwh$&RTG_2G(-`dfdgcRZQ) z&cc4v*0!J1LpJ=qdZ55SV$*|32JA79`=(yM=xZ)s5|n2x=c?XSQ~2TF?SDen_CLH# zSYNKXp&@+y<+Jsde{>vQ&Gqh&>f^WE$HlS>7vEocEL@klX3}z5u6gx>6RL%@onx&I z-B!*xnXG$gU!3-%SLVblIITtD$$%(YbOl%&VOuqT2qvQL!Cw|uNn4$t*I z>}@u4>8abxr2Le3ZhAj8ezy(t9zD*ul|4xt-u%=5G5?^HbC%T-vwvnE&ZMwi+hg~y zzM`{w`mxBuEA2An#c}H&{AcLc#om|LyQSAn^J&GALrjM&{onJy*UvpY@x9ljw$%w+ z7VTbqcAv_R`lyfQ?P5AGcC%w1FCy2SaJL6}6vJKde>z3n{8cifD(cYWO7JWt`m zKdT=qf6RY0EAH8MdB(c=E4QRs&(;)qbK^+Q$|tP#>WqzCl}Bsi%NNJIFg{to@YtG3 z#@?mNCb!O+c-pN>W#%7gn+Kocl0W7@*f;ZI8podChcDM{@fSWQxqZjHga6JK7K*bj znBHW*l40G0g4%xRLp3KI_gdKS&A*{e<@J3w8_)C){}~>YKHRx^hng<`(O{vV zhwgXV(rl#{@Z70?&e*Tmf8oIObJM=FKT>Z^{t(~$)A~Y^P=G1fSgBvyn-~ zGts{6neEQp`4uT|Uun6P!y{x%ib$oWOZb+hVu1ZIjTp{^W=AZ_oagH~Vn6e5Z|llIn-G zcMtw9UB$cDf8wvlm(+9Q>>fA1GOT}G&$1`KaOIDn{|pbmEna0dcdcu->b$A-MdrFs z8-IT*ZvNh6z#{lrk#WMK{Q)(GOK-iFWA&8}nf5r^t#z&b^)Ri^9!HW~CucC*e3Lco z`m^ot>V2H@&G$4tOKvaQ_hH`Rvg01tbnlo<+qBZMb%y-cjejrC$&dNKuRKrl;j3y7 z-Fms#U%!fNtU2*@wSN1yI<+5#)tPG!)L1|2K3(y!*6mWh!I6(1appa3hgR0#6WDPw z$=~$BJpD_53?Hsozvalh+WMo@z8AfkzVT*s>*d8dqN+U)(rj-}nIj-Fp}=DOO8b=g zoIki9PjBzt-f22tAm+!ugN5bG_bpRwb3LHsaZS}tyS|C9yoj%EzUIG+_1x?JanzK3 zjBiZav2(ur_4-HYi(+FuB2ODAhR*r>ruU=y${YSlyEcZdJU91e-rDS+wx_1N+j{%s zzx~3Q`_sGkckdIpQo5eeJNJ?I+LyY=+E@5)`liO+-e2bS_&Ceg2TjFsf{&%O-?z79 z&YCuNW#%WR=jH7+_1e|!6pzQ9X?AN7x}H^Cc1 zG1+K0b#>>dt$TvS9A-Q6t-V_LSkm-*M2+W_8s~?+nS0z>r)^bT!ZmHy+A9q4$Bkta zemgyU!oX%RVS~hD(MwtLn0`2Zl-cr+`D5(;=2A`1#03>6R<`;XZ__?;jN?4>nSiR4 z>$88{cerlDu<~JU6n8}b%x_<(T`Q@dnQ1S6}pPT!sP z?U?uXkACHn(}icQ*9lw+dUPqMYQE<7Tz1P_{!in=mu8E3rlv2sC*4u9ZKt^Tvpf7o zK?~|znQaOt;TD-T$TAQeS7&c##3UuZuivyk;r?yIJ)#fK${(<^lA3z9 z<@=%Io>%X=R=k;q8~-4tuB0IeXu?;Q^efo%qp9SRwtfp;9}Z5 zagv9`@v2|x-zxsjU;Z)u*nF;x;wZJdVn4iB-Er3Yn%24J=4`u$mKOe)uJhbyxw>ZG zi+#H&w#0k2_lgCx(_be_SlDJC-Jfw^;9i#4tn$W^fJ<3bS31AV-K*TN?P~D9-{1Z{ zDVlfnP5AcL1q6{U`N${3iR`XDOQmp3G9- zlU;UO|0W~X#pQoZcTU{1)NYgE+gXq8bYgG*vH4M&`B3w!XQOcVN1m|7PwQvi++Ub7 z)pK%#LU9p?|yr$kEbBY!FrA5r-Lt2jO`P9 z&bQVH?ftR-afPXDPIq^#*pF9LM@|{@-do@JG}^^KV(s*m?+&sDT)i}@Y0s`Y{eO&` zJo7Jq{?E|(H?O+=;CZf?ANCLLP0e1hNO{@TlH~zc%^v@=dv2Gq`gp4DhH8a%*SfS@ zckkXjbN4IIMBU_%0j~A3943JdGVeBj_|M>YUH?DB7PZUQ?uW>lN~@WDSMLA$qDA<} zvpvPf_iuW(xVHB1+6Vs`MC+4Zt@@Wgy*iI~&-uU41>gSs+<%Yt?)6!}#JYBz3%og9 z@|525Gga|N^}F}8&+@QS_!0g)+r8uC>P(^89bW0{XZF5uc&o*D@aNGg{>FMcg9`P> zBA;H>ab90r|4_M4QD&RC@6!{HS1sAtRL)S`9`jh8<+p%)gdvPyEF5HGbQt9}A0W@A{)| zw=~2?Y|-* z`a5;!r&&)08r0hyKQms|s(Wc`;Am&L_+Cx=-zhrvn@zQiAItMpp4XXmr|dsNrqPM# z?|l^%FC=G3|2XINIONOuTVj_F|5L9nyPS4$OS*mDwkA$y|K+ik*7qdh6DNtCi!#;W zi{LY??>pgoC2Zeq`wMs17rQR6e_j9jx!&xL+K<@Z7}{=cuPIKsr}HCO^<=yEb%iNE zw-s)dojA$OXz?mD+t7{^@?kZ0KTbZB@BH@a7vFl3%RBCV^YrlC_)7do`>sj0uZ0iC znSZc<%lqK=S-BsPS(*iH(r=|+h}3_-X3qF+-l`ic)q?T$Vl`fWr+<~ddHr}{+>LCh zx!Vt~zUkl_CUN%AOFzG2lVsNLzdKHCFFY@LX3>&6sf#Ud#jZIY`eX5Lhl=apQhprU zK2Po9`_lQ0cPERUTQ=?0CQtE@3Axshk59(PE?LjM&Eh}9t?GjxYwA9ft$h5xRqE;d z&Kmo2ChygmW?Vl1vgS{!FK=#&E&jeiruciCYnATtBm5E-+HTE$Kf0Gh{&@fJ-9oOn z*PbwaI{H|-={nbv1qp6Eum3aTTC8XN=zgd>pXJAE&yV^C^hM_L)@NC}%k`GmtW@24 zIAu-Y=1H3ls%x$BUiL`5{EXI>*mrre^8Pb?IDW>SZ%^*J8ncgPGgoAOx4&6`Px-Q% zP?Yt)E0dp{c6k1;a_2eYi=AIhzm>mr+vWdu;cva@8n+K?_;Vk}vz~l@_XeMOYsGID zm(l|Y9?So>$@ zu>bHs#-7I~-skc!tA8+izWl8F$Al}qemTp{d^F*@SyuGZ?N=80-#=#g)1}MifIZ(; z`?voYew)9VC;UehOF4*&86*|Xx7{F?M)+h(Kld7_=33d+xtC2StgDZaGz%lTX4N9NhL{XT3j zVw3-~Z)9)t$ZvTfR;9(w#r&&zdzM&2Os0@;Yl5SFC@;v%PA|hx#90r|QijqWxR24p-A3j^BtHz5Z>*-G2WWcE^0~Tk(qHo%a2G zl6A-KOZ+p_DWCQJ@Lqn$Ep>d0*G6rdZruN#BT*yzRY3Z+ZM}CY7p;H!pMhm-*}CaJ z>)K~VReU~PC)U9)SmEth|D$p7bG7Qz9Pg$cc&Dd$`1`A0+_;1|x$VpEB8PcvL8Qtsb zO7!5_eRHbk?X7)A)w7OWeOssS?`)jHWt;GaGv1s0wR?KqPkj2XZR=@|1%`L6#sB5D{Q-GJxjRqcXPh3(;H_L?XgbiYnPN*S0fj1W9yeSqtmynZ2SG} z%@%HfKbAdxo%&OyV*auETU!4a98|SLlP3LElI}^o*(c4k zqo3tomOR&_T=U~mss9<;Ra+&^?zkQB+HvefibI%xZp*CKc0&KoOVz%JwBdc&>woB* zkoUJ~AN{-TMXrmT@#j+1ty6`2o_EHI-!83n>a}n+Ro=x_uUTwszkc^e{cTIv{fYeO z+t&K9)+nC;csAp<%gVd@*$;o?*{!m7M}UWT(K?sF(tx7lld@m=v-~jk`ceNd*Z%M? z-TgPWyw*5UxY9DsHuA>zG?hvILB8$}`k6E3`L{SO%M>tjJC`=^QM!?Tsd4Sg)Z)`S zZO?CzU|`%I6FlE|(@ky(zwnDWx*~y*lC6^sO&uox3*f02?R_j;`$zksZ*)Z8?YFs! zIp5{n-tIosGjZOXv^}3a|1SMwT)+6%J{C4}-hTN$MVU?OfWhk%HrF;TwOp66 zdeNlZSmo=Q+Q-uVuHD0$cV^SFy^`#6UJA{B>firu`;WYX-1lEi5HILbxuj>Q{-Ry9 zK78$;{&y*H?~ZTW)W%XTz@f|{cyPV+kN)3!KZ1`lRlZr+?Xq{en!~o&rfZJ>-NDEH zXsK_e{#)}8KimA4*X)l~d-0z^lJAGhE9pP_G`u&@36Qq@@wg-c_jckbG3Er%! zVX;ddZu~Cuq-O2JpjNNK=ToAtN3Z*BT3>&9+v?oXo-5njH~iuHv3FgK$Vc1fZGXI% zoz8!7*Y)OtOKP4%xjJ*6Dtk8QOR7%|`geR?t@crQhJB(nS>A0Q;&!vQeqA}^%(ZV5 zo4HTUc{l0osXvE*pWM=Xu44V1xAs}_Qh)3&C2#t;ZEE_*^aDl>i{1xptz_2!UUM?I zp!E6EDZ=#=587tWxo+e7;oS4EO&{%>Y7$q!m?u5!aoNI$?bFSpDiaSoYZ)Ne+h>-Z75n{_!~K!auc&{lR-L=ia*S*dd+m_+-_+r~~zDfA4%6@+kOb$H}9$ zjKAIf#9cGlt+7w)#eVVWM|XuSOy~VD>t$pE-;uS8cjsl?Y2fC)UEnl9`)RyL^wEc> zkL=$f9w+@##Q0IJ&SH)JrkWFT3O6fDd%iQAIH%HL*1P;yA9C$q|Jrrw_YA-A@5vY5 ze#u^~v3|>4<74I9?PRYm;P_Rq-up)6v_#-{mW7p*?3%0Qp1jc96L!AupZtwi`aJ)P zs`Gi{V*9_?OJ;nWy;eTdZqk~XBR>x6?D@6ebzH3e5tWZx5ifVW-LUQA!7-*kL!U10q|dmF>WHNJnBl)TNme{lYXKKr)Z zr&sc(Nj}LA)^rw$POh1n`!1&V*!ir#vFq+w%dXV8x4i!F`}*t7uItvaiUh_wb7?GU z;Qx4*ee#@(lh4`u*9W>Xhy(>UO)6kizty09Khk-{&6ubd^_eUmw*)D^ICY@OUMXu{ z^TX5fyf4jul}>SqcHE&b<$Yen#*=nWu2h}xF0toZY_@vM%U>CB{Je{ak7`usIs^-JAK zVlDbgemnnX$PxdeeL$~1XLW6m$Kwyv5BH1xm~!fNP;lM^m)cLu+>ZZYo3&rSJ|kX2 zd9M1ycatAh+}tQLYwfjX%vYNY?0-CsSNZiK=$Y+*hTrTy{}~qK&j0(v|HF#B8vR9; zX^~%g?Gv8t*>>?kol1D)_Cxmlbz186zv~~(o4fG#iMk9E>+~|mH@}nZpRbMD^0%i% z`}UK)AMLwJ|1*fh>KR=x?x~aBF8K3e>NUspVP(JW_x=;iX!yJBKf|GSr#v)S3S$hE zR~*T@bA7@euOCN01Rs+M&bVflU%Glaf2!TYW$SP0K9Aq=YR5^2uR#WG**8r5Z?1p1 ztz~2Vn-5#p{&xQ!9jsg0fAD1diSUAOmk)Z~g(b=xFO;l~I5XK(CNp0o!+H(_!>XW` z*vDmuUhn*Pz3HENmdCG_sQC@Lk3TAOvYzW-b$e=mP>=p;8?V(Hlt7 zosYe?)z>kn#?bbR{Y$fr`oDhKoa2m{>dM|T?aO-6O))&FzJK}8 zy6-P{o|_$Y=g90soBV#Qj+m&sZ^nvAr{{F4q;a<%kJs=gst=by}r!q?@6DrX;-^hfOJ@(X($GX0-TZtaom zPMNCqxC7Uh{X1^Qcx8W%es6os$FiBr)9+;7lb>em9du>ERsqw)@9aW&1Xz=fdaCq1 zlwO;)Tx)wz`1xPIzrPCFeBpQXH>Mxj_J{5_UHf%zZPvAYN*~?MI#r~km2GcW|4!+& z(BY@5AN*NAUJ1Wk(!k2Vz#z-8blsCFp%WclMS>?Ul(3mRpIfqiK|yWCq6UlmQa{p< zS^d5K$D<}^pXinYryQ3!7wh}gRnBbxXC0TgvhIwX`bXJwrSE(8XbH2Eyw^z=~%U%rft7qByU8OOO(Do5b-+U!dL6JKtt*^qp>@}GQ6!Zz#hSv5W%)_0iJx7F{{ z`WWB4T=Sc)(W7ixp^IG{Pd=SHR>t1EV?DF_)!lq6f-d?k>t0^|Vt;IR+_&~X{@y=< zAC*IA9X{0i%~vpVQf^jtu{8U6my=)K_8#7M>%)4Pit<2bU^8Q=#x^sPO**o<~p+#lQUsb-V?TUHqYVv5$ z)OCNO|Lt=vE}F+K-}5K&w{eZm6+PX5f*Drgty{dq=3Y7;#=x+6^>dvkb9g3su0N&t zXuZ&n*T?47D}0>#pP@tL;?t}8sk`>C&gFxy#@3f4Cj> zBmIGXo1DbXK2Ov6g2&Bvu5@YESAB9$%%Vf~lF?$hbyt7Q{4M>5q5WIs5A~%lHr?*{ z`ul_a*Gl`&b&~EUY9|)#_-42DlldFZhkNV4E&s7y{m6Rh=~qnVb7l$qFRxtuJzg!d zM&gm<-y0v~Z?vqxbbfu*i$li>mh4@BB6XFI)JMXwo5p1_@0KY_`&@So|A zxl4c8sdPqUNlm#SaPz=To#=N@dLG+0u*^Gf^^nnupGoTuyxi&=cHVWvtZ)CGS$jvc zJ)ahKsPUiO)yLxZ#Z2?N;?(N7D|FAzVBLS~`Mx zN*}QL&(Np;pJD5tD_+@zk9oX37m>@>rPyd`QgUPZ4)D-Tnr{0+QEG1*ZiPL({jl_ z8S9cC)%Pm=DE{#H$Un^;(vL5hynN4;E`3vAiqC(BEw|n4AIJCy%J<7}KYrx*{LcGZ z<95xeS#Kj{y{tQF#ZAV^ZMR?e^xW?)wldvfwrlO*r*lr)xBZii{P`><&-<}K{`+iuUjE{i zZ`b#@{+kySkvq-%_D|!_7V_3leob!ulYQxnK3le&&b-Uc7am2HM(^&JaF{2NIm_B`x|F%YhI8gr+V#=_wwVbZ$`fiRtXE9KP|gm!G&Mpfx*f2Z*2R% z{by)O`lFVc?ONYyb%s~jy}0-r`%L%udP@$SwfN6a%69ZWL+#(ynJZ6uX=Yx({Oj_w ztZg2@`5(w1ylecx{7^0X!K#F?XpZifI}FS*F_w3AdS6c}uocmG8`tV8dP96ipsNUI zCzBY@j7i#)82>Z8{(XV@#pHvc_LDD&a@03{yQu%>`L+ogJQru&Sb314ry%?J{g!!r z7yb$SXg*%2d~NGuwYf{r&B|T+Y~j|RJGr0L1T`e2XCC@EO_AYc+}6kIJFe~H7mU0e zb$acOwioi7GQL$9d;hy!U&vl&6F&0?`?s8Fxmo!Fe;lveKO$Zndvb;I!uR1c?{if%>`p4?W4+@v+{1g5lGb6aRV0XSL%lWfU z*VVaND64$2{oME}j{ieg?*m)elCwf5b>24KdFmDycKlx1tK+YuGrFa;7I3XH5aD64 z)x0XS;>pgi?v1mnB00TXMJFo8tJNR${*$fVUZ1XMVx6c{Kiy9GisI&sgx{Z>+Rskl z|NS)kwbh@^{~4OL)woT1{iweAk7{ngsa?K}`{%gb&{jFyDj(ayf3ekPeR}SZZ+0qH zxm(X|UVcMwc4^{<={v=BXUbMhVq;^wc;Kt4{t@5o-_D_Zr!>uP-_+w^W_hajBxulZ& zT(>3G&RBnHyHKhCf59T>_|>AjZfsxH=qeH_H2H$9X#MqquE-sslJ;Jut_4dUObnl< zQH5XD32F~06{4(>0Ro?|X&Yc%o!t>zq@|aIn{ikLm z?0a#eM~KaVVe*fV#+4#nn{wUm^j@fv|IZNqX*JX4H46=`cyD+8eII!6_0Ma)CQKJy zMGk-#NP_X?RlJ7vv)*p>PT0m)U^RjkW zi&0m--Af<+e0)DOzqu`f{m=Hv7n-*So-{S@_$QlfFS@>Fy;z**(jSh}_8 z{LkP&S@Ws=%Skfz74z=(FVDO-%e8fHZ|fSfu&@(KZMSY2$`mR{aMiD}VBj;*+T!|B zeC4?c@rUcCKk#@qO>BDLHNAb=XXAdn-?=kCEbs6|ccxvtqBPBGBXxILZvgEF1^IyC z~E%|88S2+}Qi7hm$pQ~zGVUBew; zzOK=_c}ut6!#|ewLl5d&xqyBZk zhxB$O_w4&3Sz#X z)TOb&(N!dX5sr-_ZMNw4G+wFyRehyC<6TID-M6ZBRe=TSixx#5HtH$Wu~+UAOY$rG z_kR9tlNioStsVRPrz@RvyFKbflw?(fl=T{dBl%p^an_& z^=WM3&+cyN`F^ZkYEooR&H}w6PMgOJ!8?R52K-&hvyi>;`u$6@Rj-|tb!y(iRQYV~ zfsL-==2@F1ZX~ly@BLtZY?}2%?Od+ngLa1{7d`Nw+!*0r&pzSbb*YON>eJ45h-;-p z%KKEO7_57KUsG@$d+~Ic@JG{nS4U>t7pSpaksUQ}ad~cLS--U}Gv^`)cP)ue?lINj zuhsvEOpl)I^LN&)Lkc~=&TUUm+~&DwY68pT3Gp`dD@1=LKYbncE27U<|7hcc!f)A| zbr>ek=}~S{l!iC1-yvFR$dLTuR%a=oQMd)FP!W-4)5d z-T%nCLH^CG-~RtNujenR@%gcG$?IvqWPauR z{xgJKO~3LrIB)u_FLu5+`&hU)Uao%2c=N}d46o0gU#@@o@+xk1Y5waAzm9cjtUH9* z69m~G#I}n)X!+E{<}b^(WSx`ftDo!X%HXzP)xHOOb^GUO@^E;*ZoZPTBHpGq6U_GyP%x;D-8^`2m}AOP|lWc6ZxO&TNg(#?1}8 zKUEyJx&F*<*Y!BgOMlGk7iayNIyviGe?@}uR6~t<9@D4VJU^|zw66C*!@;Jj3=l<>eQoOkC zj6J_{X4IqTUhB7)Vk{Jw8GC$spYzF+&+7Wa__pU_*Taq8-3YrCBlBI$%|7a1<37%p zhdqDjfB1Js!;!tcHw6`lubQnr2)RTvgUhycOswx^c2$M#AI^LM-*{jdd60 zjDH+{*s*@Sjr5YtvVW47A4RU)8ol!3^ODDPCq8-Hp7UhAL-s8j&0^E{Z6>oj-z#28 zpLWf>`gG}SnIkJyuavQ`jo-bs?bogB>W*pEl@k|FQkM)5GH{e@N!t_sVBSjg!}}$( z?K!?m#$NYl`_FLr^XY4~ww*^R-(8%u@>hM@)=PmGvwHRBbdozk59N5-#W;A=3G0=lO;J!j}>}klJ*Dhf3UKOC+ho~*NfL|-}bBM z;G^iudYj*KTK+TiM=#v2Ad@mNp1H$`c?XGX4VCr%~@iaD-LbkR$p`~Lil9^U-mD- z52?GOD$EamuwZ<2Id^e_Rf@}k{|s7xygv3`$uC{>w<|*L`L5}QrR@6Ur`=TY-1n=$ zcGcmJ>+fW195E|!V`Fk##lC=Pm!{`Q>qXO!dJQYia%<{v*UN|hW_{GbKGus)P8E2Q+~_6>YG*eq7yx97EW#B+HgYo(n`DV!sY43 z+K1}YHh-Mnw&iZVf9|#N4>})z_4GM@XIK8Pa^2R`)wcQmm*p(a9{uF?(aOcgOPOK9 z*^7Ss%=`G4R@^^y^Vtv2kI@l7>a(jNGNLBrCLa#k_|r+K=5<1Cz~_GF`H$B7f4p<6 zcW!I%w^^X8n?n+HSUF3Woe4ENw*bLK1rIkq+wB) z2J_SYADVV^^y|~Ociy_Fzj)I%opo`XGK-9!K7P96z?r7~x;2W+vwoRv&QD%(o;kDr z{i4@F5r^}OCM$_=nq98t$EjHLEBy8Pw|5uXckEAj-w}54?~gT)cRfh&Smw#czE0%& zQ}zS%TgC4ge~h22Y~NO6p1w48?X$gW`qi%8UE1VvQ16C>zEThKtj`~PoovqDjQqE| z_HW0eOON$+Qm)%sRd8r@iE1y>k71s#>Spm=<$pbc)!#dgTOGbU+OHV>p!{3{8yei1cY!FIceFx0oG#e}3Wd*SF@Zuv@h4@JYW}*-6u`i*;#F z`O*7P-{nX0!nw}jb7soC-L|PAf5)!+cgN;NeEIYDXz7yMo3x9wk610dSfc&=Yj0Y0 zhFoap?2Iyo2|Lrj+l1D0RBZP9c)VR_>t40O!sK#^lA4760QV$5H|^#R``c?AKZbdYj{q?>DXg6MnB`|FZ7-!m|~AJ^Meb z*k_VeC$*!HzvWM+%=TG{IUH}_AOH2jJwZbDK-Hryai6v)UH-#g()#Db#Lsh2@!GA- z*j&5Vqki*bg=wut?(4MPrup3h?Ii4zf85vbPveK_<61QXp6yk(SF-rmPdO6)SiRNc zy_EL{=SKlomt3*mIPKiINBY~WjSR&(*bewFPFLPq_rjAY*6qV7W_P72rOFB`A2k06 ze-Pfer8oKTeDOWm58m5VoY=kVa$56QMK7J5Tt$VATjc9=!qt>!uULFKSL$y3svDhd z-!9zy!Mx&?;E(+eznGhEa?iPScZ=A?ePuds4@`E*+9egfs^|K9^Vr443nl9h+ZqR4 z-m+Ku;IFrH8gonfC8KYdtf}a;xR}CIYo6vj@3;Tby0zK!MZfOvy0!a9`oroK+x<-C zMNhQ9`L?<*O!!Y;@v(Zly=>X5rc}PqotRl-Z7*LF-hO!c#}`qu>;I_8Y0sAno6TLn zihcEo`c^l`=ko1Q>mRIdX*wK}FH$k@!|Qz-Q&LkV&&WCcJu&Xazi;dhqwV?aBs1mt za%6R`zBkowiu)Y8+xlJJoUc01=Tz}Ezn)a}>*h(-)BO8)hHvZeiqcrq2~7to^`H4~ zX+Jdoqx+wsUFhYtGfLOC{O{#-xvhvI#? z`;;o!eXc%mZ3($MQPwG8yOOEHcxo;G`8Gioztf})4@b`xc-;Z@&UZH<{{~jTq zgNj?O3e+5!e5f=0+4CRUtHTvP8vo;p{80R`{@a3EW%H}6AN~HCyQZY0{pN)J_dZXU z|1&V~`jz$VWmTKD{OXtR0~h`?9E>oTz51_9bdAl9-Iv!W{0_Mu@anN$+}AjfLzbsk z9IKc5($Ddq;h@((;n%BE|8AMJytVEcqvyJ7+8yGy%zO0Cv|le`u+8Sr<~3EHDIanD zas1nz4-P-5zkloW@lUr7b?Yq&`=<7}z&qylVq?}<49ObjmF=efTK-tRuTJnof7@Eq z(kl+x@q?BeYB&HRmv zcC+e#Ssi{!Z?U?n%JzwiZq*5Nt&Tjj-m371*e$t-t|DO#(4&W>>x=8`rXTrsPj24h zLz&5TtNG7uyZ+*68uO|q$%1<`E!GQNw$I2HzkknePVOIDw)O0%3lnSfbn}$$KAN9l z->>k^^h<9={aecq=e>U%o5%CP_vLSuTl-dgGFzOtN^e2ay?c?;r<06~o(Z`7NPYMm zRl2ujb>I=Jn3daphD`R34Zi-aKI7P$RE^)8p4L}>E`KZd+qx$I@5<7MB|ATEZ+~M_ zoi3F5i-lLE!0oR2MuqA1-})~wXg`hbxxaDyG5cmKm0RmO_wXh^;^h)Ovuj`9yrct* ze=IS0n=147d64bvm-15e2hHs?DqNrLzF_01-uLC=zjgOcN6z-#*Hg{@Sy}z%ukil* zABPXd%kGn`sQP5Ve{`?aht*EEY9H~s9m+Uyc;!_2WtZxo&OBTBvt(m%PkOYV)XJ|< zpVl{De%9_ZwN(Dzt+=f3fAseKmSbOjaKVjCdBK#6d7T@lAO5B-2HkNQXMZF>ZTgxWoGc@|haFt#Z)$oVyS z*0)!O3d7shpWS+U2A%)$ALNt zVz(S`&zVy=x#!PM(2ijBANwCpnPj~-|6rYd&;02r{KB~(%FM!aqF#yfwtK4NFz)1fp1GcHnX*aQQh$C)*ZLRl&3~=_$FB1# zw5(UFsx;5*ev`e8ctn>yb5zFm0{{FXg)2Ibyl2RAAKs_=F!30l{2BIn7VEFvzrh^e z9QdR9k+cNojqTfhElHUFaQBw3umfN2@2M@0pXMw3_u2M!>vo%LzqRX@Ynj>(i;Nhr zZllHvyt^C!Gn9nit^eR#FS?~l=hchtl6{e1G<8l*GDzLDE2n?m>Bb+r)h2V7vVAZA zeQ*E5i_*7)zsPlItPfZ)c>;Ix0^8oAYR?3E95YfU5m zgf45YFV=Q{a{IU-Pn$8HnByz%#9d$3z039&hRKIrzWc8E<3=OmcVXZ`V zkgwerYagEHt1Ob8l=acA_S^21`o~+he3psV3S4ph%+s~=lU}WK>$wxU<$Cn*?SK9= zFrHnRq5a_6^*4Vs7f0TAR-YzhEwR`xVS=J$==rGKA6|#_AF&tR+8cJ0Vf%lEma1dM zt+zM!T%Oxf)ni&%b*_G8;*#6hQMpGCMMWK7)4lRZ5kr>(%ey=O8Fsio(%$of@xkr+ zd=<%o*GxrIuG+ZQ>Mi?VaQx=l8}m0G{LjGH&+?yP%iH+)Lnhhb$Gip2?j-Hn_q%TY zW$R5^H}-^9hO9Q%IA&H}pZ!nb&zZmL_i&0Io`36l$c8?JX;p8GO}xZ=x7TZ&*>OJp zo-u#!*44k&{&+SYh~L8ADjiUm%eJzV_&-;2;O-Ita%Wrj60#CJ1GAiQwY;pc!{IV-vrc=V#FI)dl`qJ~;+a_*GwkGkls$T<`TjHC&NVMz$*xIC{m&4v_JY@e zrb-t5-6=jx7OXpRx8L!D`CIP?{%!L`w)VGOyS3rU9@&Hw8(w*9OtO_LPu_K9ThRs0 zWAYQ_-nKj_{8}Q_yKZr=$hXh#h4n1mJ5_p{wfO_w+4j%V<@vEcgxR5`cT)>r`JDweEuluYZKf(`|0+) z{p}^Y=G8C!V;$cuXWV?W{8~^%g_pfvb+vV?w%KZ<2|NNOr~O;M`?JmYZxern%$M57Rk8e#EAySW zsOekT|5Y!%FaNCm6#L`&wn-Iv{~2OFo<90uK7ZD#3qC7(7X%j?dCXwh$lddf<#)R1 zU+yPO92Uwu)}PsbEBvs$&QhnO9o`PsKi- z$HQi+u3ltaCZAV2%99o6 zDf2+9agzTw4dz4jIr_Kk?tiQLQIN7_N0Ys1w)NYRg?3&}H-0WJVEh@Zyv-c?ekSDjqp zz1WevKK;dqSAtU-Zm#?pTQBoKvncZ9iE|IHi$0Fpc+E!t5%^}T68l<8 z?xjLXQU~{r16q;|=PY%vT<;I6k63wHe9`auFMs~&`KMN;w?2DKh4{nge^hUOx$%Ba z!1*PaK`+k!p6xBM`P+PL`GVaw%lDu8w`}j^f1DQ|R_@7symQSv-Ow!-?-pKDW2-j^ z*gDH-Is3BI9sTpa@m9niJl);jwU6;?jpEwB+icYjca}erj`ceys2ufY<~$+(Ks82Y z8*{s@-~ImxUAlHZ^n-ox9?#soYuEVe`6edjE8e;2%#!AwRK0ocv4~&mw~DOz9Fmot zUVrTMm!EfTPmT2W-PYw5^^Fs>bW3QT_kV`^roIaC*u6gt{#krj`sG*M)`#mepM3o(G5!o$&dc-Kjwe)_2FGjPxVc%T7{nR`8`ugPgs3l^3AhFiLXzsSeOc@@=Pf1H%^W?jYfb`yV*+mY=vvV~rB70X=vR`|Ls z;N$(34E#A)eyuThRsYNNc-z8{{~6l8#F~ff?25i9*Qwj=6>M#DXycO=I)3+NGCXdK zxjMgni#*?s-CM8i$$fBoPaf-*{f3O+rdb9|e7|km*Y%P=Zt*k4S1k7Yv42HH)3IaU z?)y!|0g)AGNHF(T+>r zh4hEbV-0Ql&U@|x({qNVlgFce3;*M+uYdH{{n&ga)8B8Se$_e$$|>&<_AfX(XEKit zn|~Z{)s47x^}1X8-mm}qtn9pd-hYPniQhy;0^>R-1iFeafKK=P5dTNi_*=#F<8opb z>y$FeeC;+b+4S=INv@~Mww3+8E&pA%hWUT72c( zo{t6F^`r0oXHc|Wn^X7r()G!8r{~M){Ab9pZ@R@V-L0T#%GUp8*VKKLZSQP2*)Q%W z{g!y&^tI^)&I1GrQat7Rm(a zBn3@e)M@tQO~~)wS$~&(EPZ*oMs-~r`=_&eTaPUK+`BtDdCou4AF<1C-h5rK|J(FN z-K}Mrh2P$)hl^d__&s>9r)}}}y_FR|cQ{7xpZ@ClgUg01FaP=*c=JcFNBxi44>?tK zZ2Xb@h`U)u$W^9ny0~y;>Vy@!^DT1HE$;R-#<@RC%guM)@WI4e>y^~>j>JhRw~sMN zO#jH{X>avq|BtW<(Z^W(o9q;CJ)bC*HG$_47Y zQ#;FE&WgLM?dwoqE+Y0yIZ)z-(W#inyTfnK|KPn}#C=i4;^T4L;r|(&uj=UR@lNV- zKjrc1IYZUc#ealv?YAn8zjy12`z}YD+a0$2Gd$HlDD7MEcWzB6`@!7c2gm#6tS`zf zxSUzPtL0_xKKadC)-*=N9!_HxJ9c@(UN`M^b6p(|+1}T`GykW^yj}CxJ(@PFXWhan zO-oV)?*y;DX#y{4ev1F&;{C0C_~YqAQQ@6i9_5zaW&L*NtW5Q-XtrnmQ;Lome{QhO zlbibWs_E2NuTAYi99nLUa#LpQ+Hk(%KSP>j{U7$<8Wr0Q$H`u7Zh1G22LgiS_#no2vTP)|uBIbokGZdU}1se+ChikLm5X^Yq!W zC;we^^Pz;8d3@!=6EBRoHNRNA+WP(Z8|B}=M}Is2QETJ%dK!H!ic59fN@@9dsmGLe z$SrI*Xgfn*K5$-)snwLs6`}L3->2+Xwpi)zxqH92ymz3dcWf$bn}Bq^jQw%VIR2)8 zR+G#%UN4GEY!CcBP4vh5ho4i41W4q=n=<~yuV2kIqjaL?JuaQUo>B+ zLiorm4@vV|sb@DxoYxoDc>F5+gW3JK56h<3eS9UyHRsh$NBQO^3uwP zAEDE=mrMWI`ttfbvp+ssD_@@eTc=U~m0Ok1!vYC-LFy*1cb{uJ0-_h&j7^^3$KoJh}2h^W3aT*D2)-SC|KED^C2dDpYI9 z&*re56L@$g6|hVE3YlmAVOeuBe{^6oymtJvz+d6~Zc9)>duK3UB% z53Y}yxNLUdYS~}b`=(d>eo>$HpF#ZfzcTe76BnOc_ha4ns1LnMzSqfIdu|@qc1tap zXHvr6-HqQPV>TRoW#Hy0TK(vI=bPMhdyF5f?$kRs_o1)KZEvP`J*K~(K2eeX*0ruy zY;%q7x-900d0QXe6?#^g|LEF--P@y?4tY<#@v3aa&yM*Qx#3{-1%f zcK+eq_@e^8D(1$K`=(nv>$I8P>2LjdUgV))UfI+9N#EYxox1hiO`3th_ zL_e^%m*gh>XZYu+~vBdgd5&uuP2E>cUoi?Mj`kU7K zrP+e(&oj?k@t@&dZ--?3kz&_%wpVAqkmvfR@uU8+d}GOe(I07F^-E6wXns)pBVOJl zi2q@!jo{mf6C7=G>#F{pl;7-$d_?Y3;38gR3 zJHN7bTET4!TdNdNADXm=<3V5cC;M-Ge`ne${}6u6-x=+6!6tHbMbxi<*|#~rSTeRM zP3>t?Sm<}Cwd=vx+w;Euy6<4IX4R$Ej8A7{ql-`_%V0qL0)|h`Vk5F?H!{IjIl(TEBHlX+Dnml)5`p((9Gm8VvlaIf8=T|i+pvYfsW1G|*sm!eXC)Zz{$l)e6XZqp#Ryn~R?u%TPR=6Mj z7A3nTd&|}>4&Lg!Pkx{C*SB=(jjt+yeMQ0}7w5iHSh}c-1=_@Sn0(h3gys3`|KAJ= za`t9mU|?ckU@+SJu|Se}QlVrsS4lfp31d4~2~#^)3G;TY5|-6s>?uVBnK}6-lRp-S KOux{@;s^i&YNEyf diff --git a/api/tests/music/test_metadata.py b/api/tests/music/test_metadata.py index f105b6b7e..82c991c0b 100644 --- a/api/tests/music/test_metadata.py +++ b/api/tests/music/test_metadata.py @@ -9,24 +9,24 @@ from funkwhale_api.music import metadata DATA_DIR = os.path.dirname(os.path.abspath(__file__)) -@pytest.mark.parametrize( - "field,value", - [ - ("title", "Peer Gynt Suite no. 1, op. 46: I. Morning"), - ("artist", "Edvard Grieg"), - ("album", "Peer Gynt Suite no. 1, op. 46"), - ("date", datetime.date(2012, 8, 15)), - ("track_number", 1), - ("musicbrainz_albumid", uuid.UUID("a766da8b-8336-47aa-a3ee-371cc41ccc75")), - ("musicbrainz_recordingid", uuid.UUID("bd21ac48-46d8-4e78-925f-d9cc2a294656")), - ("musicbrainz_artistid", uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823")), - ], -) -def test_can_get_metadata_from_opus_file(field, value): - path = os.path.join(DATA_DIR, "test.opus") +def test_get_all_metadata_at_once(): + path = os.path.join(DATA_DIR, "test.ogg") data = metadata.Metadata(path) - assert data.get(field) == value + expected = { + "title": "Peer Gynt Suite no. 1, op. 46: I. Morning", + "artist": "Edvard Grieg", + "album_artist": "Edvard Grieg", + "album": "Peer Gynt Suite no. 1, op. 46", + "date": datetime.date(2012, 8, 15), + "track_number": 1, + "musicbrainz_albumid": uuid.UUID("a766da8b-8336-47aa-a3ee-371cc41ccc75"), + "musicbrainz_recordingid": uuid.UUID("bd21ac48-46d8-4e78-925f-d9cc2a294656"), + "musicbrainz_artistid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"), + "musicbrainz_albumartistid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"), + } + + assert data.all() == expected @pytest.mark.parametrize( @@ -34,12 +34,17 @@ def test_can_get_metadata_from_opus_file(field, value): [ ("title", "Peer Gynt Suite no. 1, op. 46: I. Morning"), ("artist", "Edvard Grieg"), + ("album_artist", "Edvard Grieg"), ("album", "Peer Gynt Suite no. 1, op. 46"), ("date", datetime.date(2012, 8, 15)), ("track_number", 1), ("musicbrainz_albumid", uuid.UUID("a766da8b-8336-47aa-a3ee-371cc41ccc75")), ("musicbrainz_recordingid", uuid.UUID("bd21ac48-46d8-4e78-925f-d9cc2a294656")), ("musicbrainz_artistid", uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823")), + ( + "musicbrainz_albumartistid", + uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"), + ), ], ) def test_can_get_metadata_from_ogg_file(field, value): @@ -49,17 +54,47 @@ def test_can_get_metadata_from_ogg_file(field, value): assert data.get(field) == value +@pytest.mark.parametrize( + "field,value", + [ + ("title", "Peer Gynt Suite no. 1, op. 46: I. Morning"), + ("artist", "Edvard Grieg"), + ("album_artist", "Edvard Grieg"), + ("album", "Peer Gynt Suite no. 1, op. 46"), + ("date", datetime.date(2012, 8, 15)), + ("track_number", 1), + ("musicbrainz_albumid", uuid.UUID("a766da8b-8336-47aa-a3ee-371cc41ccc75")), + ("musicbrainz_recordingid", uuid.UUID("bd21ac48-46d8-4e78-925f-d9cc2a294656")), + ("musicbrainz_artistid", uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823")), + ( + "musicbrainz_albumartistid", + uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"), + ), + ], +) +def test_can_get_metadata_from_opus_file(field, value): + path = os.path.join(DATA_DIR, "test.opus") + data = metadata.Metadata(path) + + assert data.get(field) == value + + @pytest.mark.parametrize( "field,value", [ ("title", "Drei Kreuze (dass wir hier sind)"), ("artist", "Die Toten Hosen"), + ("album_artist", "Die Toten Hosen"), ("album", "Ballast der Republik"), ("date", datetime.date(2012, 5, 4)), ("track_number", 1), ("musicbrainz_albumid", uuid.UUID("1f0441ad-e609-446d-b355-809c445773cf")), ("musicbrainz_recordingid", uuid.UUID("124d0150-8627-46bc-bc14-789a3bc960c8")), ("musicbrainz_artistid", uuid.UUID("c3bc80a6-1f4a-4e17-8cf0-6b1efe8302f1")), + ( + "musicbrainz_albumartistid", + uuid.UUID("c3bc80a6-1f4a-4e17-8cf0-6b1efe8302f1"), + ), ], ) def test_can_get_metadata_from_ogg_theora_file(field, value): @@ -73,13 +108,18 @@ def test_can_get_metadata_from_ogg_theora_file(field, value): "field,value", [ ("title", "Bend"), - ("artist", "Bindrpilot"), + ("artist", "Binärpilot"), + ("album_artist", "Binärpilot"), ("album", "You Can't Stop Da Funk"), ("date", datetime.date(2006, 2, 7)), ("track_number", 2), ("musicbrainz_albumid", uuid.UUID("ce40cdb1-a562-4fd8-a269-9269f98d4124")), ("musicbrainz_recordingid", uuid.UUID("f269d497-1cc0-4ae4-a0c4-157ec7d73fcb")), ("musicbrainz_artistid", uuid.UUID("9c6bddde-6228-4d9f-ad0d-03f6fcb19e13")), + ( + "musicbrainz_albumartistid", + uuid.UUID("9c6bddde-6228-4d9f-ad0d-03f6fcb19e13"), + ), ], ) def test_can_get_metadata_from_id3_mp3_file(field, value): @@ -108,12 +148,17 @@ def test_can_get_pictures(name): [ ("title", "999,999"), ("artist", "Nine Inch Nails"), + ("album_artist", "Nine Inch Nails"), ("album", "The Slip"), ("date", datetime.date(2008, 5, 5)), ("track_number", 1), ("musicbrainz_albumid", uuid.UUID("12b57d46-a192-499e-a91f-7da66790a1c1")), ("musicbrainz_recordingid", uuid.UUID("30f3f33e-8d0c-4e69-8539-cbd701d18f28")), ("musicbrainz_artistid", uuid.UUID("b7ffd2af-418f-4be2-bdd1-22f8b48613da")), + ( + "musicbrainz_albumartistid", + uuid.UUID("b7ffd2af-418f-4be2-bdd1-22f8b48613da"), + ), ], ) def test_can_get_metadata_from_flac_file(field, value): @@ -133,7 +178,12 @@ def test_can_get_metadata_from_flac_file_not_crash_if_empty(): @pytest.mark.parametrize( "field_name", - ["musicbrainz_artistid", "musicbrainz_albumid", "musicbrainz_recordingid"], + [ + "musicbrainz_artistid", + "musicbrainz_albumid", + "musicbrainz_recordingid", + "musicbrainz_albumartistid", + ], ) def test_mbid_clean_keeps_only_first(field_name): u1 = str(uuid.uuid4()) diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py index c58bce7db..de5e0310f 100644 --- a/api/tests/music/test_tasks.py +++ b/api/tests/music/test_tasks.py @@ -1,12 +1,14 @@ import datetime +import io import os import pytest import uuid from django.core.paginator import Paginator +from django.utils import timezone from funkwhale_api.federation import serializers as federation_serializers -from funkwhale_api.music import signals, tasks +from funkwhale_api.music import metadata, signals, tasks DATA_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -16,84 +18,163 @@ DATA_DIR = os.path.dirname(os.path.abspath(__file__)) def test_can_create_track_from_file_metadata_no_mbid(db, mocker): metadata = { - "artist": ["Test artist"], - "album": ["Test album"], - "title": ["Test track"], - "TRACKNUMBER": ["4"], - "date": ["2012-08-15"], + "title": "Test track", + "artist": "Test artist", + "album": "Test album", + "date": datetime.date(2012, 8, 15), + "track_number": 4, } - mocker.patch("mutagen.File", return_value=metadata) - mocker.patch( - "funkwhale_api.music.metadata.Metadata.get_file_type", return_value="OggVorbis" - ) - track = tasks.import_track_data_from_file(os.path.join(DATA_DIR, "dummy_file.ogg")) + mocker.patch("funkwhale_api.music.metadata.Metadata.all", return_value=metadata) - assert track.title == metadata["title"][0] + track = tasks.get_track_from_import_metadata(metadata) + + assert track.title == metadata["title"] assert track.mbid is None assert track.position == 4 - assert track.album.title == metadata["album"][0] + assert track.album.title == metadata["album"] assert track.album.mbid is None assert track.album.release_date == datetime.date(2012, 8, 15) - assert track.artist.name == metadata["artist"][0] + assert track.artist.name == metadata["artist"] assert track.artist.mbid is None def test_can_create_track_from_file_metadata_mbid(factories, mocker): - album = factories["music.Album"]() - artist = factories["music.Artist"]() - mocker.patch( - "funkwhale_api.music.models.Album.get_or_create_from_api", - return_value=(album, True), - ) - - album_data = { - "release": { - "id": album.mbid, - "medium-list": [ - { - "track-list": [ - { - "id": "03baca8b-855a-3c05-8f3d-d3235287d84d", - "position": "4", - "number": "4", - "recording": { - "id": "2109e376-132b-40ad-b993-2bb6812e19d4", - "title": "Teen Age Riot", - "artist-credit": [ - {"artist": {"id": artist.mbid, "name": artist.name}} - ], - }, - } - ], - "track-count": 1, - } - ], - } - } - mocker.patch("funkwhale_api.musicbrainz.api.releases.get", return_value=album_data) - track_data = album_data["release"]["medium-list"][0]["track-list"][0] metadata = { - "musicbrainz_albumid": [album.mbid], - "musicbrainz_trackid": [track_data["recording"]["id"]], + "title": "Test track", + "artist": "Test artist", + "album_artist": "Test album artist", + "album": "Test album", + "date": datetime.date(2012, 8, 15), + "track_number": 4, + "musicbrainz_albumid": "ce40cdb1-a562-4fd8-a269-9269f98d4124", + "musicbrainz_recordingid": "f269d497-1cc0-4ae4-a0c4-157ec7d73fcb", + "musicbrainz_artistid": "9c6bddde-6228-4d9f-ad0d-03f6fcb19e13", + "musicbrainz_albumartistid": "9c6bddde-6478-4d9f-ad0d-03f6fcb19e13", } - mocker.patch("mutagen.File", return_value=metadata) - mocker.patch( - "funkwhale_api.music.metadata.Metadata.get_file_type", return_value="OggVorbis" - ) - track = tasks.import_track_data_from_file(os.path.join(DATA_DIR, "dummy_file.ogg")) - assert track.title == track_data["recording"]["title"] - assert track.mbid == track_data["recording"]["id"] + mocker.patch("funkwhale_api.music.metadata.Metadata.all", return_value=metadata) + + track = tasks.get_track_from_import_metadata(metadata) + + assert track.title == metadata["title"] + assert track.mbid == metadata["musicbrainz_recordingid"] + assert track.position == 4 + assert track.album.title == metadata["album"] + assert track.album.mbid == metadata["musicbrainz_albumid"] + assert track.album.artist.mbid == metadata["musicbrainz_albumartistid"] + assert track.album.artist.name == metadata["album_artist"] + assert track.album.release_date == datetime.date(2012, 8, 15) + assert track.artist.name == metadata["artist"] + assert track.artist.mbid == metadata["musicbrainz_artistid"] + + +def test_can_create_track_from_file_metadata_mbid_existing_album_artist( + factories, mocker +): + artist = factories["music.Artist"]() + album = factories["music.Album"]() + metadata = { + "artist": "", + "album": "", + "title": "Hello", + "track_number": 4, + "musicbrainz_albumid": album.mbid, + "musicbrainz_recordingid": "f269d497-1cc0-4ae4-a0c4-157ec7d73fcb", + "musicbrainz_artistid": artist.mbid, + "musicbrainz_albumartistid": album.artist.mbid, + } + + mocker.patch("funkwhale_api.music.metadata.Metadata.all", return_value=metadata) + + track = tasks.get_track_from_import_metadata(metadata) + + assert track.title == metadata["title"] + assert track.mbid == metadata["musicbrainz_recordingid"] assert track.position == 4 assert track.album == album assert track.artist == artist -def test_upload_import_mbid(now, factories, temp_signal, mocker): +def test_can_create_track_from_file_metadata_fid_existing_album_artist( + factories, mocker +): + artist = factories["music.Artist"]() + album = factories["music.Album"]() + metadata = { + "artist": "", + "album": "", + "title": "Hello", + "track_number": 4, + "fid": "https://hello", + "album_fid": album.fid, + "artist_fid": artist.fid, + "album_artist_fid": album.artist.fid, + } + + mocker.patch("funkwhale_api.music.metadata.Metadata.all", return_value=metadata) + + track = tasks.get_track_from_import_metadata(metadata) + + assert track.title == metadata["title"] + assert track.fid == metadata["fid"] + assert track.position == 4 + assert track.album == album + assert track.artist == artist + + +def test_can_create_track_from_file_metadata_federation(factories, mocker, r_mock): + metadata = { + "artist": "Artist", + "album": "Album", + "album_artist": "Album artist", + "title": "Hello", + "track_number": 4, + "fid": "https://hello", + "album_fid": "https://album.fid", + "artist_fid": "https://artist.fid", + "album_artist_fid": "https://album.artist.fid", + "fdate": timezone.now(), + "album_fdate": timezone.now(), + "album_artist_fdate": timezone.now(), + "artist_fdate": timezone.now(), + "cover_data": {"url": "https://cover/hello.png", "mimetype": "image/png"}, + } + r_mock.get(metadata["cover_data"]["url"], body=io.BytesIO(b"coucou")) + mocker.patch("funkwhale_api.music.metadata.Metadata.all", return_value=metadata) + + track = tasks.get_track_from_import_metadata(metadata) + + assert track.title == metadata["title"] + assert track.fid == metadata["fid"] + assert track.creation_date == metadata["fdate"] + assert track.position == 4 + assert track.album.cover.read() == b"coucou" + assert track.album.cover.path.endswith(".png") + assert track.album.fid == metadata["album_fid"] + assert track.album.title == metadata["album"] + assert track.album.creation_date == metadata["album_fdate"] + assert track.album.artist.fid == metadata["album_artist_fid"] + assert track.album.artist.name == metadata["album_artist"] + assert track.album.artist.creation_date == metadata["album_artist_fdate"] + assert track.artist.fid == metadata["artist_fid"] + assert track.artist.name == metadata["artist"] + assert track.artist.creation_date == metadata["artist_fdate"] + + +def test_sort_candidates(factories): + artist1 = factories["music.Artist"].build(fid=None, mbid=None) + artist2 = factories["music.Artist"].build(fid=None) + artist3 = factories["music.Artist"].build(mbid=None) + result = tasks.sort_candidates([artist1, artist2, artist3], ["mbid", "fid"]) + + assert result == [artist2, artist3, artist1] + + +def test_upload_import(now, factories, temp_signal, mocker): outbox = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") track = factories["music.Track"]() upload = factories["music.Upload"]( - track=None, import_metadata={"track": {"mbid": track.mbid}} + track=None, import_metadata={"funkwhale": {"track": {"uuid": str(track.uuid)}}} ) with temp_signal(signals.upload_import_status_updated) as handler: @@ -123,7 +204,29 @@ def test_upload_import_get_audio_data(factories, mocker): ) track = factories["music.Track"]() upload = factories["music.Upload"]( - track=None, import_metadata={"track": {"mbid": track.mbid}} + track=None, import_metadata={"funkwhale": {"track": {"uuid": track.uuid}}} + ) + + tasks.process_upload(upload_id=upload.pk) + + upload.refresh_from_db() + assert upload.size == 23 + assert upload.duration == 42 + assert upload.bitrate == 66 + + +def test_upload_import_in_place(factories, mocker): + mocker.patch( + "funkwhale_api.music.models.Upload.get_audio_data", + return_value={"size": 23, "duration": 42, "bitrate": 66}, + ) + track = factories["music.Track"]() + path = os.path.join(DATA_DIR, "test.ogg") + upload = factories["music.Upload"]( + track=None, + audio_file=None, + source="file://{}".format(path), + import_metadata={"funkwhale": {"track": {"uuid": track.uuid}}}, ) tasks.process_upload(upload_id=upload.pk) @@ -141,13 +244,13 @@ def test_upload_import_skip_existing_track_in_own_library(factories, temp_signal track=track, import_status="finished", library=library, - import_metadata={"track": {"mbid": track.mbid}}, + import_metadata={"funkwhale": {"track": {"uuid": track.mbid}}}, ) duplicate = factories["music.Upload"]( track=track, import_status="pending", library=library, - import_metadata={"track": {"mbid": track.mbid}}, + import_metadata={"funkwhale": {"track": {"uuid": track.uuid}}}, ) with temp_signal(signals.upload_import_status_updated) as handler: tasks.process_upload(upload_id=duplicate.pk) @@ -172,7 +275,7 @@ def test_upload_import_skip_existing_track_in_own_library(factories, temp_signal def test_upload_import_track_uuid(now, factories): track = factories["music.Track"]() upload = factories["music.Upload"]( - track=None, import_metadata={"track": {"uuid": track.uuid}} + track=None, import_metadata={"funkwhale": {"track": {"uuid": track.uuid}}} ) tasks.process_upload(upload_id=upload.pk) @@ -184,9 +287,43 @@ def test_upload_import_track_uuid(now, factories): assert upload.import_date == now +def test_upload_import_skip_federation(now, factories, mocker): + outbox = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") + track = factories["music.Track"]() + upload = factories["music.Upload"]( + track=None, + import_metadata={ + "funkwhale": { + "track": {"uuid": track.uuid}, + "config": {"dispatch_outbox": False}, + } + }, + ) + + tasks.process_upload(upload_id=upload.pk) + + outbox.assert_not_called() + + +def test_upload_import_skip_broadcast(now, factories, mocker): + group_send = mocker.patch("funkwhale_api.common.channels.group_send") + track = factories["music.Track"]() + upload = factories["music.Upload"]( + library__actor__local=True, + track=None, + import_metadata={ + "funkwhale": {"track": {"uuid": track.uuid}, "config": {"broadcast": False}} + }, + ) + + tasks.process_upload(upload_id=upload.pk) + + group_send.assert_not_called() + + def test_upload_import_error(factories, now, temp_signal): upload = factories["music.Upload"]( - import_metadata={"track": {"uuid": uuid.uuid4()}} + import_metadata={"funkwhale": {"track": {"uuid": uuid.uuid4()}}} ) with temp_signal(signals.upload_import_status_updated) as handler: tasks.process_upload(upload_id=upload.pk) @@ -209,32 +346,26 @@ def test_upload_import_updates_cover_if_no_cover(factories, mocker, now): album = factories["music.Album"](cover="") track = factories["music.Track"](album=album) upload = factories["music.Upload"]( - track=None, import_metadata={"track": {"uuid": track.uuid}} + track=None, import_metadata={"funkwhale": {"track": {"uuid": track.uuid}}} ) tasks.process_upload(upload_id=upload.pk) - mocked_update.assert_called_once_with(album, upload) + mocked_update.assert_called_once_with(album, source=None, cover_data=None) def test_update_album_cover_mbid(factories, mocker): album = factories["music.Album"](cover="") mocked_get = mocker.patch("funkwhale_api.music.models.Album.get_image") - tasks.update_album_cover(album=album, upload=None) + tasks.update_album_cover(album=album) mocked_get.assert_called_once_with() def test_update_album_cover_file_data(factories, mocker): album = factories["music.Album"](cover="", mbid=None) - upload = factories["music.Upload"](track__album=album) mocked_get = mocker.patch("funkwhale_api.music.models.Album.get_image") - mocker.patch( - "funkwhale_api.music.metadata.Metadata.get_picture", - return_value={"hello": "world"}, - ) - tasks.update_album_cover(album=album, upload=upload) - upload.get_metadata() + tasks.update_album_cover(album=album, cover_data={"hello": "world"}) mocked_get.assert_called_once_with(data={"hello": "world"}) @@ -245,19 +376,87 @@ def test_update_album_cover_file_cover_separate_file(ext, mimetype, factories, m with open(image_path, "rb") as f: image_content = f.read() album = factories["music.Album"](cover="", mbid=None) - upload = factories["music.Upload"]( - track__album=album, source="file://" + image_path - ) mocked_get = mocker.patch("funkwhale_api.music.models.Album.get_image") mocker.patch("funkwhale_api.music.metadata.Metadata.get_picture", return_value=None) - tasks.update_album_cover(album=album, upload=upload) - upload.get_metadata() + tasks.update_album_cover(album=album, source="file://" + image_path) mocked_get.assert_called_once_with( data={"mimetype": mimetype, "content": image_content} ) +def test_federation_audio_track_to_metadata(now): + published = now + released = now.date() + payload = { + "type": "Track", + "id": "http://hello.track", + "musicbrainzId": str(uuid.uuid4()), + "name": "Black in back", + "position": 5, + "published": published.isoformat(), + "album": { + "published": published.isoformat(), + "type": "Album", + "id": "http://hello.album", + "name": "Purple album", + "musicbrainzId": str(uuid.uuid4()), + "released": released.isoformat(), + "artists": [ + { + "type": "Artist", + "published": published.isoformat(), + "id": "http://hello.artist", + "name": "John Smith", + "musicbrainzId": str(uuid.uuid4()), + } + ], + }, + "artists": [ + { + "published": published.isoformat(), + "type": "Artist", + "id": "http://hello.trackartist", + "name": "Bob Smith", + "musicbrainzId": str(uuid.uuid4()), + } + ], + } + serializer = federation_serializers.TrackSerializer(data=payload) + serializer.is_valid(raise_exception=True) + expected = { + "artist": payload["artists"][0]["name"], + "album": payload["album"]["name"], + "album_artist": payload["album"]["artists"][0]["name"], + "title": payload["name"], + "date": released, + "track_number": payload["position"], + # musicbrainz + "musicbrainz_albumid": payload["album"]["musicbrainzId"], + "musicbrainz_recordingid": payload["musicbrainzId"], + "musicbrainz_artistid": payload["artists"][0]["musicbrainzId"], + "musicbrainz_albumartistid": payload["album"]["artists"][0]["musicbrainzId"], + # federation + "fid": payload["id"], + "album_fid": payload["album"]["id"], + "artist_fid": payload["artists"][0]["id"], + "album_artist_fid": payload["album"]["artists"][0]["id"], + "fdate": serializer.validated_data["published"], + "artist_fdate": serializer.validated_data["artists"][0]["published"], + "album_artist_fdate": serializer.validated_data["album"]["artists"][0][ + "published" + ], + "album_fdate": serializer.validated_data["album"]["published"], + } + + result = tasks.federation_audio_track_to_metadata(serializer.validated_data) + assert result == expected + + # ensure we never forget to test a mandatory field + for k in metadata.ALL_FIELDS: + assert k in result + + def test_scan_library_fetches_page_and_calls_scan_page(now, mocker, factories, r_mock): scan = factories["music.LibraryScan"]() collection_conf = { diff --git a/api/tests/test_import_audio_file.py b/api/tests/test_import_audio_file.py index a7b2380ed..ad4b4be0e 100644 --- a/api/tests/test_import_audio_file.py +++ b/api/tests/test_import_audio_file.py @@ -54,6 +54,39 @@ def test_import_files_stores_proper_data(factories, mocker, now, path): assert upload.import_reference == "cli-{}".format(now.isoformat()) assert upload.import_status == "pending" assert upload.source == "file://{}".format(path) + assert upload.import_metadata == { + "funkwhale": { + "config": {"replace": False, "dispatch_outbox": False, "broadcast": False} + } + } + + mocked_process.assert_called_once_with(upload_id=upload.pk) + + +def test_import_with_outbox_flag(factories, mocker): + library = factories["music.Library"](actor__local=True) + path = os.path.join(DATA_DIR, "dummy_file.ogg") + mocked_process = mocker.patch("funkwhale_api.music.tasks.process_upload") + call_command( + "import_files", str(library.uuid), path, outbox=True, interactive=False + ) + upload = library.uploads.last() + + assert upload.import_metadata["funkwhale"]["config"]["dispatch_outbox"] is True + + mocked_process.assert_called_once_with(upload_id=upload.pk) + + +def test_import_with_broadcast_flag(factories, mocker): + library = factories["music.Library"](actor__local=True) + path = os.path.join(DATA_DIR, "dummy_file.ogg") + mocked_process = mocker.patch("funkwhale_api.music.tasks.process_upload") + call_command( + "import_files", str(library.uuid), path, broadcast=True, interactive=False + ) + upload = library.uploads.last() + + assert upload.import_metadata["funkwhale"]["config"]["broadcast"] is True mocked_process.assert_called_once_with(upload_id=upload.pk) @@ -67,7 +100,7 @@ def test_import_with_replace_flag(factories, mocker): ) upload = library.uploads.last() - assert upload.import_metadata["replace"] is True + assert upload.import_metadata["funkwhale"]["config"]["replace"] is True mocked_process.assert_called_once_with(upload_id=upload.pk) diff --git a/dev.yml b/dev.yml index a67085e44..5ac74424c 100644 --- a/dev.yml +++ b/dev.yml @@ -30,6 +30,7 @@ services: - .env.dev - .env image: postgres + command: postgres -c log_min_duration_statement=0 volumes: - "./data/${COMPOSE_PROJECT_NAME-node1}/postgres:/var/lib/postgresql/data" networks: diff --git a/front/src/views/content/libraries/FilesTable.vue b/front/src/views/content/libraries/FilesTable.vue index c657cc7f9..ef34b3983 100644 --- a/front/src/views/content/libraries/FilesTable.vue +++ b/front/src/views/content/libraries/FilesTable.vue @@ -282,6 +282,7 @@ export default { 'search.tokens': { handler (newValue) { this.search.query = compileTokens(newValue) + this.page = 1 this.fetchData() }, deep: true @@ -290,6 +291,9 @@ export default { this.page = 1 this.fetchData() }, + page: function () { + this.fetchData() + }, ordering: function () { this.page = 1 this.fetchData()