Skip to content
Snippets Groups Projects
tasks.py 30.8 KiB
Newer Older
  • Learn to ignore specific revisions
  • Eliot Berriot's avatar
    Eliot Berriot committed
    import collections
    
    import datetime
    
    from django.utils import timezone
    from django.db import transaction
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    from django.db.models import F, Q
    
    from django.dispatch import receiver
    
    
    from musicbrainzngs import ResponseError
    
    from requests.exceptions import RequestException
    
    from funkwhale_api import musicbrainz
    
    from funkwhale_api.common import channels, preferences
    
    from funkwhale_api.common import utils as common_utils
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    from funkwhale_api.federation import routes
    
    from funkwhale_api.federation import library as lb
    
    from funkwhale_api.federation import utils as federation_utils
    
    from funkwhale_api.tags import models as tags_models
    
    from funkwhale_api.tags import tasks as tags_tasks
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    from funkwhale_api.taskapp import celery
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    from . import licenses
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    from . import models
    
    from . import metadata
    from . import signals
    
    def populate_album_cover(album, source=None, replace=False):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        if album.attachment_cover and not replace:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        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)
    
            return common_utils.attach_file(album, "attachment_cover", cover)
        if album.mbid:
            logger.info(
                "[Album %s] Fetching cover from musicbrainz release %s",
                album.pk,
                str(album.mbid),
            )
    
                image_data = musicbrainz.api.images.get_front(str(album.mbid))
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            except ResponseError as exc:
                logger.warning(
                    "[Album %s] cannot fetch cover from musicbrainz: %s", album.pk, str(exc)
                )
    
            else:
                return common_utils.attach_file(
                    album,
                    "attachment_cover",
                    {"content": image_data, "mimetype": "image/jpeg"},
                    fetch=True,
                )
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    
    
    IMAGE_TYPES = [("jpg", "image/jpeg"), ("jpeg", "image/jpeg"), ("png", "image/png")]
    FOLDER_IMAGE_NAMES = ["cover", "folder"]
    
    
    
    def get_cover_from_fs(dir_path):
        if os.path.exists(dir_path):
    
            for name in FOLDER_IMAGE_NAMES:
                for e, m in IMAGE_TYPES:
                    cover_path = os.path.join(dir_path, "{}.{}".format(name, e))
                    if not os.path.exists(cover_path):
                        logger.debug("Cover %s does not exists", cover_path)
                        continue
                    with open(cover_path, "rb") as c:
                        logger.info("Found cover at %s", cover_path)
                        return {"mimetype": m, "content": c.read()}
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    @celery.app.task(name="music.start_library_scan")
    
    @celery.require_instance(
        models.LibraryScan.objects.select_related().filter(status="pending"), "library_scan"
    )
    def start_library_scan(library_scan):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        try:
            data = lb.get_library_data(library_scan.library.fid, actor=library_scan.actor)
        except Exception:
            library_scan.status = "errored"
            library_scan.save(update_fields=["status", "modification_date"])
            raise
    
        library_scan.modification_date = timezone.now()
        library_scan.status = "scanning"
        library_scan.total_files = data["totalItems"]
        library_scan.save(update_fields=["status", "modification_date", "total_files"])
        scan_library_page.delay(library_scan_id=library_scan.pk, page_url=data["first"])
    
    
    @celery.app.task(
        name="music.scan_library_page",
        retry_backoff=60,
        max_retries=5,
        autoretry_for=[RequestException],
    )
    @celery.require_instance(
        models.LibraryScan.objects.select_related().filter(status="scanning"),
        "library_scan",
    )
    def scan_library_page(library_scan, page_url):
        data = lb.get_library_page(library_scan.library, page_url, library_scan.actor)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        uploads = []
    
    
        for item_serializer in data["items"]:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            upload = item_serializer.save(library=library_scan.library)
            uploads.append(upload)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        library_scan.processed_files = F("processed_files") + len(uploads)
    
        library_scan.modification_date = timezone.now()
        update_fields = ["modification_date", "processed_files"]
    
        next_page = data.get("next")
        fetch_next = next_page and next_page != page_url
    
        if not fetch_next:
            update_fields.append("status")
            library_scan.status = "finished"
        library_scan.save(update_fields=update_fields)
    
        if fetch_next:
            scan_library_page.delay(library_scan_id=library_scan.pk, page_url=next_page)
    
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    def getter(data, *keys, default=None):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            return default
    
        v = data
        for k in keys:
    
            try:
                v = v[k]
            except KeyError:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                return default
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    class UploadImportError(ValueError):
    
        def __init__(self, code):
            self.code = code
            super().__init__(code)
    
    
    
    def fail_import(upload, error_code, detail=None, **fields):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        old_status = upload.import_status
        upload.import_status = "errored"
    
        upload.import_details = {"error_code": error_code, "detail": detail}
        upload.import_details.update(fields)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        upload.import_date = timezone.now()
        upload.save(update_fields=["import_details", "import_status", "import_date"])
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    
        broadcast = getter(
            upload.import_metadata, "funkwhale", "config", "broadcast", default=True
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        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")
    
    @celery.require_instance(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        models.Upload.objects.filter(import_status="pending").select_related(
    
            "library__actor__user", "library__channel__artist",
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        "upload",
    
    def process_upload(upload, update_denormalization=True):
    
        """
        Main handler to process uploads submitted by user and create the corresponding
        metadata (tracks/artists/albums) in our DB.
        """
    
        channel = upload.library.get_channel()
        # When upload is linked to a channel instead of a library
        # we willingly ignore the metadata embedded in the file itself
        # and rely on user metadata only
        use_file_metadata = channel is None
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        import_metadata = upload.import_metadata or {}
    
        internal_config = {"funkwhale": import_metadata.get("funkwhale", {})}
        forced_values_serializer = serializers.ImportMetadataSerializer(
    
            data=import_metadata,
            context={"actor": upload.library.actor, "channel": channel},
    
        )
        if forced_values_serializer.is_valid():
            forced_values = forced_values_serializer.validated_data
        else:
            forced_values = {}
    
            if not use_file_metadata:
                detail = forced_values_serializer.errors
                metadata_dump = import_metadata
                return fail_import(
                    upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump
                )
    
            # ensure the upload is associated with the channel artist
            forced_values["artist"] = upload.library.channel.artist
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        old_status = upload.import_status
    
        additional_data = {"upload_source": upload.source}
    
        if use_file_metadata:
            audio_file = upload.get_audio_file()
    
            m = metadata.Metadata(audio_file)
    
                serializer = metadata.TrackMetadataSerializer(data=m)
                serializer.is_valid()
            except Exception:
                fail_import(upload, "unknown_error")
                raise
            if not serializer.is_valid():
                detail = serializer.errors
                try:
                    metadata_dump = m.all()
                except Exception as e:
                    logger.warn("Cannot dump metadata for file %s: %s", audio_file, str(e))
                return fail_import(
                    upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump
                )
    
            final_metadata = collections.ChainMap(
                additional_data, serializer.validated_data, internal_config
            )
        else:
            final_metadata = collections.ChainMap(
                additional_data, forced_values, internal_config,
            )
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            track = get_track_from_import_metadata(
    
                final_metadata, attributed_to=upload.library.actor, **forced_values
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            )
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        except UploadImportError as e:
            return fail_import(upload, e.code)
    
            fail_import(upload, "unknown_error")
            raise
    
        # under some situations, we want to skip the import (
        # for instance if the user already owns the files)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        owned_duplicates = get_owned_duplicates(upload, track)
        upload.track = track
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            upload.import_status = "skipped"
            upload.import_details = {
    
                "code": "already_imported_in_owned_libraries",
                "duplicates": list(owned_duplicates),
            }
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            upload.import_date = timezone.now()
            upload.save(
    
                update_fields=["import_details", "import_status", "import_date", "track"]
            )
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            signals.upload_import_status_updated.send(
    
                old_status=old_status,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                new_status=upload.import_status,
                upload=upload,
    
                sender=None,
            )
            return
    
        # all is good, let's finalize the import
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        audio_data = upload.get_audio_data()
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            upload.duration = audio_data["duration"]
            upload.size = audio_data["size"]
            upload.bitrate = audio_data["bitrate"]
        upload.import_status = "finished"
        upload.import_date = timezone.now()
        upload.save(
    
            update_fields=[
                "track",
                "import_status",
                "import_date",
                "size",
                "duration",
                "bitrate",
            ]
        )
    
        if channel:
            common_utils.update_modification_date(channel.artist)
    
        if update_denormalization:
            models.TrackActor.create_entries(
                library=upload.library,
                upload_and_track_ids=[(upload.pk, upload.track_id)],
                delete_existing=False,
            )
    
    
        if track.album and not track.album.attachment_cover:
    
            populate_album_cover(
                track.album, source=final_metadata.get("upload_source"),
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        broadcast = getter(
    
            internal_config, "funkwhale", "config", "broadcast", default=True
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        if broadcast:
            signals.upload_import_status_updated.send(
                old_status=old_status,
                new_status=upload.import_status,
                upload=upload,
                sender=None,
            )
        dispatch_outbox = getter(
    
            internal_config, "funkwhale", "config", "dispatch_outbox", default=True
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        )
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        if dispatch_outbox:
            routes.outbox.dispatch(
                {"type": "Create", "object": {"type": "Audio"}}, context={"upload": upload}
            )
    
    def get_cover(obj, field):
        cover = obj.get(field)
        if cover:
    
            try:
                url = cover["url"]
            except KeyError:
                url = cover["href"]
    
            return {"mimetype": cover["mediaType"], "url": url}
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    def federation_audio_track_to_metadata(payload, references):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        """
        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.
        """
        new_data = {
            "title": payload["name"],
    
            "disc_number": payload.get("disc"),
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            "license": payload.get("license"),
            "copyright": payload.get("copyright"),
    
            "description": payload.get("description"),
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            "attributed_to": references.get(payload.get("attributedTo")),
    
            "mbid": str(payload.get("musicbrainzId"))
            if payload.get("musicbrainzId")
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            else None,
    
            "cover_data": get_cover(payload, "image"),
    
            "album": {
                "title": payload["album"]["name"],
                "fdate": payload["album"]["published"],
                "fid": payload["album"]["id"],
    
                "description": payload["album"].get("description"),
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "attributed_to": references.get(payload["album"].get("attributedTo")),
    
                "mbid": str(payload["album"]["musicbrainzId"])
                if payload["album"].get("musicbrainzId")
                else None,
    
                "cover_data": get_cover(payload["album"], "cover"),
    
                "release_date": payload["album"].get("released"),
    
                "tags": [t["name"] for t in payload["album"].get("tags", []) or []],
    
                "artists": [
                    {
                        "fid": a["id"],
                        "name": a["name"],
                        "fdate": a["published"],
    
                        "cover_data": get_cover(a, "image"),
    
                        "description": a.get("description"),
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                        "attributed_to": references.get(a.get("attributedTo")),
    
                        "mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None,
    
                        "tags": [t["name"] for t in a.get("tags", []) or []],
    
                    }
                    for a in payload["album"]["artists"]
                ],
            },
            "artists": [
                {
                    "fid": a["id"],
                    "name": a["name"],
                    "fdate": a["published"],
    
                    "description": a.get("description"),
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    "attributed_to": references.get(a.get("attributedTo")),
    
                    "mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None,
    
                    "tags": [t["name"] for t in a.get("tags", []) or []],
    
                    "cover_data": get_cover(a, "image"),
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            # federation
            "fid": payload["id"],
            "fdate": payload["published"],
    
            "tags": [t["name"] for t in payload.get("tags", []) or []],
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        }
        return new_data
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    def get_owned_duplicates(upload, track):
    
        """
        Ensure we skip duplicate tracks to avoid wasting user/instance storage
        """
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        owned_libraries = upload.library.actor.libraries.all()
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            models.Upload.objects.filter(
    
                track__isnull=False, library__in=owned_libraries, track=track
            )
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            .exclude(pk=upload.pk)
    
            .values_list("uuid", flat=True)
        )
    
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    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 get_track_from_import_metadata(
        data, update_cover=False, attributed_to=None, **forced_values
    ):
        track = _get_track(data, attributed_to=attributed_to, **forced_values)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        if update_cover and track and not track.album.attachment_cover:
    
            populate_album_cover(track.album, source=data.get("upload_source"))
    
    def truncate(v, length):
        if v is None:
            return v
        return v[:length]
    
    
    
    def _get_track(data, attributed_to=None, **forced_values):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        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")
    
            return track
    
        from_activity_id = data.get("from_activity_id", None)
    
        track_mbid = (
            forced_values["mbid"] if "mbid" in forced_values else data.get("mbid", None)
        )
    
        try:
            album_mbid = getter(data, "album", "mbid")
        except TypeError:
            # album is forced
            album_mbid = None
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        track_fid = getter(data, "fid")
    
        query = None
    
    
        if album_mbid and track_mbid:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            query = Q(mbid=track_mbid, album__mbid=album_mbid)
    
        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
    
        if "artist" in forced_values:
            artist = forced_values["artist"]
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        else:
    
            artists = getter(data, "artists", default=[])
            artist_data = artists[0]
            artist_mbid = artist_data.get("mbid", None)
            artist_fid = artist_data.get("fid", None)
            artist_name = truncate(artist_data["name"], models.MAX_LENGTHS["ARTIST_NAME"])
    
            if artist_mbid:
                query = Q(mbid=artist_mbid)
            else:
                query = Q(name__iexact=artist_name)
            if artist_fid:
                query |= Q(fid=artist_fid)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            defaults = {
    
                "name": artist_name,
                "mbid": artist_mbid,
                "fid": artist_fid,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "from_activity_id": from_activity_id,
    
                "attributed_to": artist_data.get("attributed_to", attributed_to),
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            }
    
            if artist_data.get("fdate"):
                defaults["creation_date"] = artist_data.get("fdate")
    
            artist, created = get_best_candidate_or_create(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                models.Artist, query, defaults=defaults, sort_fields=["mbid", "fid"]
    
                tags_models.add_tags(artist, *artist_data.get("tags", []))
    
                common_utils.attach_content(
                    artist, "description", artist_data.get("description")
                )
    
                common_utils.attach_file(
                    artist, "attachment_cover", artist_data.get("cover_data")
                )
    
        if "album" in forced_values:
            album = forced_values["album"]
    
            if "artist" in forced_values:
                album_artist = forced_values["artist"]
    
                album_artists = getter(data, "album", "artists", default=artists) or artists
                album_artist_data = album_artists[0]
                album_artist_name = truncate(
                    album_artist_data.get("name"), models.MAX_LENGTHS["ARTIST_NAME"]
                )
                if album_artist_name == artist_name:
                    album_artist = artist
                else:
                    query = Q(name__iexact=album_artist_name)
                    album_artist_mbid = album_artist_data.get("mbid", None)
                    album_artist_fid = album_artist_data.get("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,
                        "attributed_to": album_artist_data.get(
                            "attributed_to", attributed_to
                        ),
                    }
                    if album_artist_data.get("fdate"):
                        defaults["creation_date"] = album_artist_data.get("fdate")
    
                    album_artist, created = get_best_candidate_or_create(
                        models.Artist, query, defaults=defaults, sort_fields=["mbid", "fid"]
                    )
                    if created:
                        tags_models.add_tags(
                            album_artist, *album_artist_data.get("tags", [])
                        )
                        common_utils.attach_content(
                            album_artist,
                            "description",
                            album_artist_data.get("description"),
                        )
                        common_utils.attach_file(
                            album_artist,
                            "attachment_cover",
                            album_artist_data.get("cover_data"),
                        )
    
            # get / create album
            if "album" in data:
                album_data = data["album"]
                album_title = truncate(
                    album_data["title"], models.MAX_LENGTHS["ALBUM_TITLE"]
                )
                album_fid = album_data.get("fid", None)
    
                if album_mbid:
                    query = Q(mbid=album_mbid)
                else:
                    query = Q(title__iexact=album_title, artist=album_artist)
    
                if album_fid:
                    query |= Q(fid=album_fid)
    
                    "title": album_title,
                    "artist": album_artist,
                    "mbid": album_mbid,
                    "release_date": album_data.get("release_date"),
                    "fid": album_fid,
    
                    "from_activity_id": from_activity_id,
    
                    "attributed_to": album_data.get("attributed_to", attributed_to),
    
                if album_data.get("fdate"):
                    defaults["creation_date"] = album_data.get("fdate")
    
                album, created = get_best_candidate_or_create(
                    models.Album, query, defaults=defaults, sort_fields=["mbid", "fid"]
    
                    tags_models.add_tags(album, *album_data.get("tags", []))
    
                    common_utils.attach_content(
    
                        album, "description", album_data.get("description")
    
                    common_utils.attach_file(
    
                        album, "attachment_cover", album_data.get("cover_data")
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        # get / create track
    
        track_title = (
            forced_values["title"]
            if "title" in forced_values
            else truncate(data["title"], models.MAX_LENGTHS["TRACK_TITLE"])
        )
        position = (
            forced_values["position"]
            if "position" in forced_values
            else data.get("position", 1)
        )
        disc_number = (
            forced_values["disc_number"]
            if "disc_number" in forced_values
            else data.get("disc_number")
        )
        license = (
            forced_values["license"]
            if "license" in forced_values
            else licenses.match(data.get("license"), data.get("copyright"))
        )
        copyright = (
            forced_values["copyright"]
            if "copyright" in forced_values
            else truncate(data.get("copyright"), models.MAX_LENGTHS["COPYRIGHT"])
        )
    
        description = (
            {"text": forced_values["description"], "content_type": "text/markdown"}
            if "description" in forced_values
            else data.get("description")
        )
        cover_data = (
            forced_values["cover"] if "cover" in forced_values else data.get("cover_data")
        )
    
        query = Q(
            title__iexact=track_title,
            artist=artist,
            album=album,
            position=position,
            disc_number=disc_number,
        )
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        if track_mbid:
    
            if album_mbid:
                query |= Q(mbid=track_mbid, album__mbid=album_mbid)
            else:
                query |= Q(mbid=track_mbid)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        if track_fid:
            query |= Q(fid=track_fid)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        defaults = {
            "title": track_title,
            "album": album,
            "mbid": track_mbid,
            "artist": artist,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            "fid": track_fid,
            "from_activity_id": from_activity_id,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            "attributed_to": data.get("attributed_to", attributed_to),
    
            "license": license,
            "copyright": copyright,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        }
        if data.get("fdate"):
            defaults["creation_date"] = data.get("fdate")
    
    
        track, created = get_best_candidate_or_create(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            models.Track, query, defaults=defaults, sort_fields=["mbid", "fid"]
    
            tags = (
                forced_values["tags"] if "tags" in forced_values else data.get("tags", [])
            )
            tags_models.add_tags(track, *tags)
    
            common_utils.attach_content(track, "description", description)
            common_utils.attach_file(track, "attachment_cover", cover_data)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    @receiver(signals.upload_import_status_updated)
    def broadcast_import_status_update_to_owner(old_status, new_status, upload, **kwargs):
        user = upload.library.actor.get_user()
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        from . import serializers
    
    
        group = "user.{}.imports".format(user.pk)
        channels.group_send(
            group,
            {
                "type": "event.send",
                "text": "",
                "data": {
                    "type": "import.status_updated",
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    "upload": serializers.UploadForOwnerSerializer(upload).data,
    
                    "old_status": old_status,
                    "new_status": new_status,
                },
            },
        )
    
    
    
    @celery.app.task(name="music.clean_transcoding_cache")
    def clean_transcoding_cache():
        delay = preferences.get("music__transcoding_cache_duration")
        if delay < 1:
            return  # cache clearing disabled
        limit = timezone.now() - datetime.timedelta(minutes=delay)
        candidates = (
            models.UploadVersion.objects.filter(
                (Q(accessed_date__lt=limit) | Q(accessed_date=None))
            )
            .only("audio_file", "id")
            .order_by("id")
        )
        return candidates.delete()
    
    @celery.app.task(name="music.albums_set_tags_from_tracks")
    @transaction.atomic
    def albums_set_tags_from_tracks(ids=None, dry_run=False):
        qs = models.Album.objects.filter(tagged_items__isnull=True).order_by("id")
        qs = federation_utils.local_qs(qs)
        qs = qs.values_list("id", flat=True)
        if ids is not None:
            qs = qs.filter(pk__in=ids)
        data = tags_tasks.get_tags_from_foreign_key(
            ids=qs, foreign_key_model=models.Track, foreign_key_attr="album",
        )
        logger.info("Found automatic tags for %s albums…", len(data))
        if dry_run:
            logger.info("Running in dry-run mode, not commiting")
            return
    
        tags_tasks.add_tags_batch(
            data, model=models.Album,
        )
        return data
    
    
    @celery.app.task(name="music.artists_set_tags_from_tracks")
    @transaction.atomic
    def artists_set_tags_from_tracks(ids=None, dry_run=False):
        qs = models.Artist.objects.filter(tagged_items__isnull=True).order_by("id")
        qs = federation_utils.local_qs(qs)
        qs = qs.values_list("id", flat=True)
        if ids is not None:
            qs = qs.filter(pk__in=ids)
        data = tags_tasks.get_tags_from_foreign_key(
            ids=qs, foreign_key_model=models.Track, foreign_key_attr="artist",
        )
        logger.info("Found automatic tags for %s artists…", len(data))
        if dry_run:
            logger.info("Running in dry-run mode, not commiting")
            return
    
        tags_tasks.add_tags_batch(
            data, model=models.Artist,
        )
        return data
    
    
    
    def get_prunable_tracks(
        exclude_favorites=True, exclude_playlists=True, exclude_listenings=True
    ):
        """
        Returns a list of tracks with no associated uploads,
        excluding the one that were listened/favorited/included in playlists.
        """
    
        purgeable_tracks_with_upload = (
            models.Upload.objects.exclude(track=None)
            .filter(import_status="skipped")
            .values("track")
        )
    
        queryset = queryset.filter(
            Q(uploads__isnull=True) | Q(pk__in=purgeable_tracks_with_upload)
        )
    
        if exclude_favorites:
            queryset = queryset.filter(track_favorites__isnull=True)
        if exclude_playlists:
            queryset = queryset.filter(playlist_tracks__isnull=True)
        if exclude_listenings:
            queryset = queryset.filter(listenings__isnull=True)
    
        return queryset
    
    
    def get_prunable_albums():
        return models.Album.objects.filter(tracks__isnull=True)
    
    
    def get_prunable_artists():
        return models.Artist.objects.filter(tracks__isnull=True, albums__isnull=True)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    
    
    def update_library_entity(obj, data):
        """
        Given an obj and some updated fields, will persist the changes on the obj
        and also check if the entity need to be aliased with existing objs (i.e
        if a mbid was added on the obj, and match another entity with the same mbid)
        """
        for key, value in data.items():
            setattr(obj, key, value)
    
        # Todo: handle integrity error on unique fields (such as MBID)
        obj.save(update_fields=list(data.keys()))
    
        return obj
    
    
    
    UPDATE_CONFIG = {
        "track": {
            "position": {},
            "title": {},
            "mbid": {},
            "disc_number": {},
            "copyright": {},
            "license": {
                "getter": lambda data, field: licenses.match(
                    data.get("license"), data.get("copyright")
                )
            },
        },
        "album": {"title": {}, "mbid": {}, "release_date": {}},
        "artist": {"name": {}, "mbid": {}},
        "album_artist": {"name": {}, "mbid": {}},
    }
    
    
    @transaction.atomic
    def update_track_metadata(audio_metadata, track):
        # XXX: implement this to support updating metadata when an imported files
        # is updated by an outside tool (e.g beets).
        serializer = metadata.TrackMetadataSerializer(data=audio_metadata)
        serializer.is_valid(raise_exception=True)
        new_data = serializer.validated_data
    
        to_update = [
            ("track", track, lambda data: data),
            ("album", track.album, lambda data: data["album"]),
            ("artist", track.artist, lambda data: data["artists"][0]),
            (
                "album_artist",
                track.album.artist if track.album else None,
                lambda data: data["album"]["artists"][0],
            ),
        ]
        for id, obj, data_getter in to_update:
            if not obj:
                continue
            obj_updated_fields = []
            try:
                obj_data = data_getter(new_data)
            except IndexError:
                continue
            for field, config in UPDATE_CONFIG[id].items():
                getter = config.get(
                    "getter", lambda data, field: data[config.get("field", field)]
                )
                try:
                    new_value = getter(obj_data, field)
                except KeyError:
                    continue
                old_value = getattr(obj, field)
                if new_value == old_value:
                    continue
                obj_updated_fields.append(field)
                setattr(obj, field, new_value)
    
            if obj_updated_fields:
                obj.save(update_fields=obj_updated_fields)
    
        if track.album and "album" in new_data and new_data["album"].get("cover_data"):
            common_utils.attach_file(
                track.album, "attachment_cover", new_data["album"].get("cover_data")
            )