Skip to content
Snippets Groups Projects
tasks.py 14.8 KiB
Newer Older
  • Learn to ignore specific revisions
  • from django.utils import timezone
    from django.db import transaction
    from django.db.models import F
    from django.dispatch import receiver
    
    
    from musicbrainzngs import ResponseError
    
    from requests.exceptions import RequestException
    
    from funkwhale_api.common import channels
    
    from funkwhale_api.common import preferences
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    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
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    from funkwhale_api.taskapp import celery
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    from . import models
    
    from . import metadata
    from . import signals
    from . import serializers
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    @celery.app.task(name="acoustid.set_on_upload")
    @celery.require_instance(models.Upload, "upload")
    def set_acoustid_on_upload(upload):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        result = client.get_best_match(upload.audio_file.path)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            upload.acoustid_track_id = id
            upload.save(update_fields=["acoustid_track_id"])
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            return update(result["id"])
    
    def import_track_from_remote(metadata):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            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]
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            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
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            )[0]
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            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
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            )
    
            return models.Track.get_or_create_from_title(
    
                metadata["title"], artist=artist, album=album
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            )[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
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        )
    
        return models.Track.get_or_create_from_title(
    
            metadata["title"], artist=artist, album=album
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        )[0]
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    def update_album_cover(album, upload, replace=False):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        if upload:
    
            # maybe the file has a cover embedded?
            try:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                metadata = upload.get_metadata()
    
            except FileNotFoundError:
                metadata = None
            if metadata:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                cover = metadata.get_picture("cover_front")
    
                if cover:
                    # best case scenario, cover is embedded in the track
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    logger.info("[Album %s] Using cover embedded in file", album.pk)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            if upload.source and upload.source.startswith("file://"):
    
                # let's look for a cover in the same directory
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                path = os.path.dirname(upload.source.replace("file://", "", 1))
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                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(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "[Album %s] Fetching cover from musicbrainz release %s",
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                str(album.mbid),
            )
    
            return album.get_image()
        except ResponseError as exc:
            logger.warning(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "[Album %s] cannot fetch cover from musicbrainz: %s", album.pk, str(exc)
            )
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    IMAGE_TYPES = [("jpg", "image/jpeg"), ("png", "image/png")]
    
    
    
    def get_cover_from_fs(dir_path):
        if os.path.exists(dir_path):
            for e, m in IMAGE_TYPES:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                cover_path = os.path.join(dir_path, "cover.{}".format(e))
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    logger.debug("Cover %s does not exists", cover_path)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                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="Lyrics.fetch_content")
    @celery.require_instance(models.Lyrics, "lyrics")
    
    def fetch_content(lyrics):
        html = lyrics_utils._get_html(lyrics.url)
        content = lyrics_utils.extract_content(html)
        cleaned_content = lyrics_utils.clean_content(content)
        lyrics.content = cleaned_content
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        lyrics.save(update_fields=["content"])
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    @celery.app.task(name="music.import_batch_notify_followers")
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        models.ImportBatch.objects.filter(status="finished"), "import_batch"
    )
    
    def import_batch_notify_followers(import_batch):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        if not preferences.get("federation__enabled"):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        if import_batch.source == "federation":
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
    
        followers = library_actor.get_approved_followers()
        jobs = import_batch.jobs.filter(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            status="finished", library_track__isnull=True, upload__isnull=False
        ).select_related("upload__track__artist", "upload__track__album__artist")
        uploads = [job.upload for job in jobs]
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        collection = federation_serializers.CollectionSerializer(
            {
                "actor": library_actor,
    
                "id": import_batch.get_federation_id(),
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "items": uploads,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "item_serializer": federation_serializers.AudioSerializer,
            }
        ).data
    
        for f in followers:
            create = federation_serializers.ActivitySerializer(
                {
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    "type": "Create",
                    "id": collection["id"],
                    "object": collection,
    
                    "actor": library_actor.fid,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    "to": [f.url],
    
                }
            ).data
    
            activity.deliver(create, on_behalf_of=library_actor, to=[f.url])
    
    
    
    @celery.app.task(
        name="music.start_library_scan",
        retry_backoff=60,
        max_retries=5,
        autoretry_for=[RequestException],
    )
    @celery.require_instance(
        models.LibraryScan.objects.select_related().filter(status="pending"), "library_scan"
    )
    def start_library_scan(library_scan):
        data = lb.get_library_data(library_scan.library.fid, actor=library_scan.actor)
        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)
            if upload.import_status == "pending" and not upload.track:
    
                # this track is not matched to any musicbrainz or other musical
                # metadata
    
                process_upload.delay(upload_id=upload.pk)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            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)
    
    
    def getter(data, *keys):
        if not data:
            return
        v = data
        for k in keys:
    
            try:
                v = v[k]
            except KeyError:
                return
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    class UploadImportError(ValueError):
    
        def __init__(self, code):
            self.code = code
            super().__init__(code)
    
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    def fail_import(upload, error_code):
        old_status = upload.import_status
        upload.import_status = "errored"
        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,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            new_status=upload.import_status,
            upload=upload,
    
    @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"
        ),
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        "upload",
    
    def process_upload(upload):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        data = upload.import_metadata or {}
        old_status = upload.import_status
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            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
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                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
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                track = import_track_from_remote(upload.metadata)
        except UploadImportError as e:
            return fail_import(upload, e.code)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            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",
            ]
        )
    
    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,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        routes.outbox.dispatch(
            {"type": "Create", "object": {"type": "Audio"}}, context={"upload": upload}
        )
    
        if not track.album.cover:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            update_album_cover(track.album, upload)
    
    
    
    def get_track_from_import_metadata(data):
        track_mbid = getter(data, "track", "mbid")
        track_uuid = getter(data, "track", "uuid")
    
        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:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                raise UploadImportError(code="track_uuid_not_found")
    
    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)
        )
    
    
    @transaction.atomic
    def import_track_data_from_file(file, hints={}):
        data = metadata.Metadata(file)
        album = None
        track_mbid = data.get("musicbrainz_recordingid", None)
        album_mbid = data.get("musicbrainz_albumid", 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]
    
        artist = album.artist if album else None
        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]
    
        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},
        )[0]
        return track
    
    
    
    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()
    
        if not user:
            return
        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,
                },
            },
        )