Skip to content
Snippets Groups Projects
tasks.py 11.7 KiB
Newer Older
  • Learn to ignore specific revisions
  • Eliot Berriot's avatar
    Eliot Berriot committed
    from django.conf import settings
    
    from django.core.files.base import ContentFile
    
    from musicbrainzngs import ResponseError
    
    
    from funkwhale_api.common import preferences
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    from funkwhale_api.federation import activity, actors
    
    from funkwhale_api.federation import serializers as federation_serializers
    
    from funkwhale_api.providers.acoustid import get_acoustid_client
    
    from funkwhale_api.providers.audiofile import tasks as audiofile_tasks
    
    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 utils as music_utils
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    @celery.app.task(name="acoustid.set_on_track_file")
    @celery.require_instance(models.TrackFile, "track_file")
    
    def set_acoustid_on_track_file(track_file):
        client = get_acoustid_client()
        result = client.get_best_match(track_file.audio_file.path)
    
        def update(id):
            track_file.acoustid_track_id = id
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            track_file.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(library_track):
        metadata = library_track.metadata
        try:
    
    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(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                library_track.title, artist=album.artist, album=album
            )[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(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                library_track.album_title, artist=artist
            )
    
            return models.Track.get_or_create_from_title(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                library_track.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
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        artist, _ = models.Artist.get_or_create_from_name(library_track.artist_name)
    
        album, _ = models.Album.get_or_create_from_title(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            library_track.album_title, artist=artist
        )
    
        return models.Track.get_or_create_from_title(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            library_track.title, artist=artist, album=album
        )[0]
    
    def _do_import(import_job, use_acoustid=False):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        logger.info("[Import Job %s] starting job", import_job.pk)
    
        from_file = bool(import_job.audio_file)
        mbid = import_job.mbid
    
        replace = import_job.replace_if_duplicate
    
        acoustid_track_id = None
        duration = None
        track = None
    
        # use_acoustid = use_acoustid and preferences.get('providers_acoustid__api_key')
        # Acoustid is not reliable, we disable it for now.
        use_acoustid = False
    
        if not mbid and use_acoustid and from_file:
    
            # we try to deduce mbid from acoustid
            client = get_acoustid_client()
            match = client.get_best_match(import_job.audio_file.path)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                duration = match["recordings"][0]["duration"]
                mbid = match["recordings"][0]["id"]
                acoustid_track_id = match["id"]
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "[Import Job %s] importing track from musicbrainz recording %s",
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                str(mbid),
            )
    
            track, _ = models.Track.get_or_create_from_api(mbid=mbid)
    
        elif import_job.audio_file:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "[Import Job %s] importing track from uploaded track data at %s",
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                import_job.audio_file.path,
            )
            track = audiofile_tasks.import_track_data_from_path(import_job.audio_file.path)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "[Import Job %s] importing track from federated library track %s",
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                import_job.library_track.pk,
            )
    
            track = import_track_from_remote(import_job.library_track)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        elif import_job.source.startswith("file://"):
            tf_path = import_job.source.replace("file://", "", 1)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "[Import Job %s] importing track from local track data at %s",
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                tf_path,
            )
            track = audiofile_tasks.import_track_data_from_path(tf_path)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "Not enough data to process import, "
                "add a mbid, an audio file or a library track"
            )
    
            logger.info("[Import Job %s] deleting existing audio file", import_job.pk)
            track.files.all().delete()
    
        elif track.files.count() > 0:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "[Import Job %s] skipping, we already have a file for this track",
                import_job.pk,
            )
    
            if import_job.audio_file:
                import_job.audio_file.delete()
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            import_job.status = "skipped"
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        track_file = track_file or models.TrackFile(track=track, source=import_job.source)
    
        track_file.acoustid_track_id = acoustid_track_id
        if from_file:
            track_file.audio_file = ContentFile(import_job.audio_file.read())
            track_file.audio_file.name = import_job.audio_file.name
            track_file.duration = duration
    
            track_file.library_track = import_job.library_track
    
            track_file.mimetype = import_job.library_track.audio_mimetype
            if import_job.library_track.library.download_files:
    
                raise NotImplementedError()
            else:
                # no downloading, we hotlink
                pass
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        elif not import_job.audio_file and not import_job.source.startswith("file://"):
    
            # not an inplace import, and we have a source, so let's download it
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            logger.info("[Import Job %s] downloading audio file from remote", import_job.pk)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        elif not import_job.audio_file and import_job.source.startswith("file://"):
    
            # in place import, we set mimetype from extension
            path, ext = os.path.splitext(import_job.source)
            track_file.mimetype = music_utils.get_type_from_ext(ext)
    
        # if no cover is set on track album, we try to update it as well:
        if not track.album.cover:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            logger.info("[Import Job %s] retrieving album cover", import_job.pk)
    
            update_album_cover(track.album, track_file)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        import_job.status = "finished"
    
        import_job.track_file = track_file
        if import_job.audio_file:
            # it's imported on the track, we don't need it anymore
            import_job.audio_file.delete()
        import_job.save()
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        logger.info("[Import Job %s] job finished", import_job.pk)
    
    def update_album_cover(album, track_file, replace=False):
        if album.cover and not replace:
            return
    
        if track_file:
            # maybe the file has a cover embedded?
            try:
                metadata = track_file.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 track_file.source and track_file.source.startswith("file://"):
    
                # let's look for a cover in the same directory
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                path = os.path.dirname(track_file.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(
    
    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="ImportJob.run", bind=True)
    
    @celery.require_instance(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        models.ImportJob.objects.filter(status__in=["pending", "errored"]), "import_job"
    )
    
    def import_job_run(self, import_job, use_acoustid=False):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            logger.error("[Import Job %s] Error during import: %s", import_job.pk, str(exc))
            import_job.status = "errored"
            import_job.save(update_fields=["status"])
    
            tf = _do_import(import_job, use_acoustid=use_acoustid)
    
            return tf.pk if tf else None
    
        except Exception as exc:
            if not settings.DEBUG:
    
                try:
                    self.retry(exc=exc, countdown=30, max_retries=3)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    @celery.app.task(name="ImportBatch.run")
    @celery.require_instance(models.ImportBatch, "import_batch")
    
    def import_batch_run(import_batch):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        for job_id in import_batch.jobs.order_by("id").values_list("id", flat=True):
    
            import_job_run.delay(import_job_id=job_id)
    
    
    
    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, track_file__isnull=False
        ).select_related("track_file__track__artist", "track_file__track__album__artist")
    
        track_files = [job.track_file for job in jobs]
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        collection = federation_serializers.CollectionSerializer(
            {
                "actor": library_actor,
                "id": import_batch.get_federation_url(),
                "items": track_files,
                "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.url,
                    "to": [f.url],
    
                }
            ).data
    
            activity.deliver(create, on_behalf_of=library_actor, to=[f.url])