Skip to content
Snippets Groups Projects
Select Git revision
  • develop default protected
  • add-favorite-albums
  • 896-add-electron-support
  • dark-theme
  • webdav
  • live-streaming
  • 0.19.0
  • 0.19.0-rc2
  • 0.19.0-rc1
  • 0.18.3
  • 0.18.2
  • 0.18.1
  • 0.18
  • 0.17
  • 0.16.3
  • 0.16.2
  • 0.16.1
  • 0.16
  • 0.15
  • 0.14.2
  • 0.14.1
  • 0.14
  • 0.13
  • 0.12
  • 0.11
  • 0.10
26 results

serializers.py

Blame
  • Forked from funkwhale / funkwhale
    6544 commits behind the upstream repository.
    serializers.py 27.69 KiB
    import logging
    import mimetypes
    import urllib.parse
    
    from django.core.exceptions import ObjectDoesNotExist
    from django.core.paginator import Paginator
    from django.db import transaction
    from rest_framework import serializers
    
    from funkwhale_api.common import serializers as common_serializers
    from funkwhale_api.common import utils as funkwhale_utils
    from funkwhale_api.music import models as music_models
    from funkwhale_api.music import tasks as music_tasks
    
    from . import activity, filters, models, utils
    
    AP_CONTEXT = [
        "https://www.w3.org/ns/activitystreams",
        "https://w3id.org/security/v1",
        {},
    ]
    
    logger = logging.getLogger(__name__)
    
    
    class ActorSerializer(serializers.Serializer):
        id = serializers.URLField(max_length=500)
        outbox = serializers.URLField(max_length=500)
        inbox = serializers.URLField(max_length=500)
        type = serializers.ChoiceField(choices=models.TYPE_CHOICES)
        preferredUsername = serializers.CharField()
        manuallyApprovesFollowers = serializers.NullBooleanField(required=False)
        name = serializers.CharField(required=False, max_length=200)
        summary = serializers.CharField(max_length=None, required=False)
        followers = serializers.URLField(max_length=500, required=False, allow_null=True)
        following = serializers.URLField(max_length=500, required=False, allow_null=True)
        publicKey = serializers.JSONField(required=False)
    
        def to_representation(self, instance):
            ret = {
                "id": instance.url,
                "outbox": instance.outbox_url,
                "inbox": instance.inbox_url,
                "preferredUsername": instance.preferred_username,
                "type": instance.type,
            }
            if instance.name:
                ret["name"] = instance.name
            if instance.followers_url:
                ret["followers"] = instance.followers_url
            if instance.following_url:
                ret["following"] = instance.following_url
            if instance.summary:
                ret["summary"] = instance.summary
            if instance.manually_approves_followers is not None:
                ret["manuallyApprovesFollowers"] = instance.manually_approves_followers
    
            ret["@context"] = AP_CONTEXT
            if instance.public_key:
                ret["publicKey"] = {
                    "owner": instance.url,
                    "publicKeyPem": instance.public_key,
                    "id": "{}#main-key".format(instance.url),
                }
            ret["endpoints"] = {}
            if instance.shared_inbox_url:
                ret["endpoints"]["sharedInbox"] = instance.shared_inbox_url
            try:
                if instance.user.avatar:
                    ret["icon"] = {
                        "type": "Image",
                        "mediaType": mimetypes.guess_type(instance.user.avatar.path)[0],
                        "url": utils.full_url(instance.user.avatar.crop["400x400"].url),
                    }
            except ObjectDoesNotExist:
                pass
            return ret
    
        def prepare_missing_fields(self):
            kwargs = {
                "url": self.validated_data["id"],
                "outbox_url": self.validated_data["outbox"],
                "inbox_url": self.validated_data["inbox"],
                "following_url": self.validated_data.get("following"),
                "followers_url": self.validated_data.get("followers"),
                "summary": self.validated_data.get("summary"),
                "type": self.validated_data["type"],
                "name": self.validated_data.get("name"),
                "preferred_username": self.validated_data["preferredUsername"],
            }
            maf = self.validated_data.get("manuallyApprovesFollowers")
            if maf is not None:
                kwargs["manually_approves_followers"] = maf
            domain = urllib.parse.urlparse(kwargs["url"]).netloc
            kwargs["domain"] = domain
            for endpoint, url in self.initial_data.get("endpoints", {}).items():
                if endpoint == "sharedInbox":
                    kwargs["shared_inbox_url"] = url
                    break
            try:
                kwargs["public_key"] = self.initial_data["publicKey"]["publicKeyPem"]
            except KeyError:
                pass
            return kwargs
    
        def build(self):
            d = self.prepare_missing_fields()
            return models.Actor(**d)
    
        def save(self, **kwargs):
            d = self.prepare_missing_fields()
            d.update(kwargs)
            return models.Actor.objects.update_or_create(url=d["url"], defaults=d)[0]
    
        def validate_summary(self, value):
            if value:
                return value[:500]
    
    
    class APIActorSerializer(serializers.ModelSerializer):
        class Meta:
            model = models.Actor
            fields = [
                "id",
                "url",
                "creation_date",
                "summary",
                "preferred_username",
                "name",
                "last_fetch_date",
                "domain",
                "type",
                "manually_approves_followers",
            ]
    
    
    class LibraryActorSerializer(ActorSerializer):
        url = serializers.ListField(child=serializers.JSONField())
    
        def validate(self, validated_data):
            try:
                urls = validated_data["url"]
            except KeyError:
                raise serializers.ValidationError("Missing URL field")
    
            for u in urls:
                try:
                    if u["name"] != "library":
                        continue
                    validated_data["library_url"] = u["href"]
                    break
                except KeyError:
                    continue
    
            return validated_data
    
    
    class APIFollowSerializer(serializers.ModelSerializer):
        class Meta:
            model = models.Follow
            fields = [
                "uuid",
                "actor",
                "target",
                "approved",
                "creation_date",
                "modification_date",
            ]
    
    
    class APILibrarySerializer(serializers.ModelSerializer):
        actor = APIActorSerializer()
        follow = APIFollowSerializer()
    
        class Meta:
            model = models.Library
    
            read_only_fields = [
                "actor",
                "uuid",
                "url",
                "tracks_count",
                "follow",
                "fetched_date",
                "modification_date",
                "creation_date",
            ]
            fields = [
                "autoimport",
                "federation_enabled",
                "download_files",
            ] + read_only_fields
    
    
    class APILibraryScanSerializer(serializers.Serializer):
        until = serializers.DateTimeField(required=False)
    
    
    class APILibraryFollowUpdateSerializer(serializers.Serializer):
        follow = serializers.IntegerField()
        approved = serializers.BooleanField()
    
        def validate_follow(self, value):
            from . import actors
    
            library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
            qs = models.Follow.objects.filter(pk=value, target=library_actor)
            try:
                return qs.get()
            except models.Follow.DoesNotExist:
                raise serializers.ValidationError("Invalid follow")
    
        def save(self):
            new_status = self.validated_data["approved"]
            follow = self.validated_data["follow"]
            if new_status == follow.approved:
                return follow
    
            follow.approved = new_status
            follow.save(update_fields=["approved", "modification_date"])
            if new_status:
                activity.accept_follow(follow)
            return follow
    
    
    class APILibraryCreateSerializer(serializers.ModelSerializer):
        actor = serializers.URLField(max_length=500)
        federation_enabled = serializers.BooleanField()
        uuid = serializers.UUIDField(read_only=True)
    
        class Meta:
            model = models.Library
            fields = ["uuid", "actor", "autoimport", "federation_enabled", "download_files"]
    
        def validate(self, validated_data):
            from . import actors
            from . import library
    
            actor_url = validated_data["actor"]
            actor_data = actors.get_actor_data(actor_url)
            acs = LibraryActorSerializer(data=actor_data)
            acs.is_valid(raise_exception=True)
            try:
                actor = models.Actor.objects.get(url=actor_url)
            except models.Actor.DoesNotExist:
                actor = acs.save()
            library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
            validated_data["follow"] = models.Follow.objects.get_or_create(
                actor=library_actor, target=actor
            )[0]
            if validated_data["follow"].approved is None:
                funkwhale_utils.on_commit(
                    activity.deliver,
                    FollowSerializer(validated_data["follow"]).data,
                    on_behalf_of=validated_data["follow"].actor,
                    to=[validated_data["follow"].target.url],
                )
    
            library_data = library.get_library_data(acs.validated_data["library_url"])
            if "errors" in library_data:
                # we pass silently because it may means we require permission
                # before scanning
                pass
            validated_data["library"] = library_data
            validated_data["library"].setdefault("id", acs.validated_data["library_url"])
            validated_data["actor"] = actor
            return validated_data
    
        def create(self, validated_data):
            library = models.Library.objects.update_or_create(
                url=validated_data["library"]["id"],
                defaults={
                    "actor": validated_data["actor"],
                    "follow": validated_data["follow"],
                    "tracks_count": validated_data["library"].get("totalItems"),
                    "federation_enabled": validated_data["federation_enabled"],
                    "autoimport": validated_data["autoimport"],
                    "download_files": validated_data["download_files"],
                },
            )[0]
            return library
    
    
    class APILibraryTrackSerializer(serializers.ModelSerializer):
        library = APILibrarySerializer()
        status = serializers.SerializerMethodField()
    
        class Meta:
            model = models.LibraryTrack
            fields = [
                "id",
                "url",
                "audio_url",
                "audio_mimetype",
                "creation_date",
                "modification_date",
                "fetched_date",
                "published_date",
                "metadata",
                "artist_name",
                "album_title",
                "title",
                "library",
                "local_track_file",
                "status",
            ]
    
        def get_status(self, o):
            try:
                if o.local_track_file is not None:
                    return "imported"
            except music_models.TrackFile.DoesNotExist:
                pass
            for job in o.import_jobs.all():
                if job.status == "pending":
                    return "import_pending"
            return "not_imported"
    
    
    class FollowSerializer(serializers.Serializer):
        id = serializers.URLField(max_length=500)
        object = serializers.URLField(max_length=500)
        actor = serializers.URLField(max_length=500)
        type = serializers.ChoiceField(choices=["Follow"])
    
        def validate_object(self, v):
            expected = self.context.get("follow_target")
            if expected and expected.url != v:
                raise serializers.ValidationError("Invalid target")
            try:
                return models.Actor.objects.get(url=v)
            except models.Actor.DoesNotExist:
                raise serializers.ValidationError("Target not found")
    
        def validate_actor(self, v):
            expected = self.context.get("follow_actor")
            if expected and expected.url != v:
                raise serializers.ValidationError("Invalid actor")
            try:
                return models.Actor.objects.get(url=v)
            except models.Actor.DoesNotExist:
                raise serializers.ValidationError("Actor not found")
    
        def save(self, **kwargs):
            return models.Follow.objects.get_or_create(
                actor=self.validated_data["actor"],
                target=self.validated_data["object"],
                **kwargs,  # noqa
            )[0]
    
        def to_representation(self, instance):
            return {
                "@context": AP_CONTEXT,
                "actor": instance.actor.url,
                "id": instance.get_federation_url(),
                "object": instance.target.url,
                "type": "Follow",
            }
    
    
    class APIFollowSerializer(serializers.ModelSerializer):
        actor = APIActorSerializer()
        target = APIActorSerializer()
    
        class Meta:
            model = models.Follow
            fields = [
                "uuid",
                "id",
                "approved",
                "creation_date",
                "modification_date",
                "actor",
                "target",
            ]
    
    
    class AcceptFollowSerializer(serializers.Serializer):
        id = serializers.URLField(max_length=500)
        actor = serializers.URLField(max_length=500)
        object = FollowSerializer()
        type = serializers.ChoiceField(choices=["Accept"])
    
        def validate_actor(self, v):
            expected = self.context.get("follow_target")
            if expected and expected.url != v:
                raise serializers.ValidationError("Invalid actor")
            try:
                return models.Actor.objects.get(url=v)
            except models.Actor.DoesNotExist:
                raise serializers.ValidationError("Actor not found")
    
        def validate(self, validated_data):
            # we ensure the accept actor actually match the follow target
            if validated_data["actor"] != validated_data["object"]["object"]:
                raise serializers.ValidationError("Actor mismatch")
            try:
                validated_data["follow"] = (
                    models.Follow.objects.filter(
                        target=validated_data["actor"],
                        actor=validated_data["object"]["actor"],
                    )
                    .exclude(approved=True)
                    .get()
                )
            except models.Follow.DoesNotExist:
                raise serializers.ValidationError("No follow to accept")
            return validated_data
    
        def to_representation(self, instance):
            return {
                "@context": AP_CONTEXT,
                "id": instance.get_federation_url() + "/accept",
                "type": "Accept",
                "actor": instance.target.url,
                "object": FollowSerializer(instance).data,
            }
    
        def save(self):
            self.validated_data["follow"].approved = True
            self.validated_data["follow"].save()
            return self.validated_data["follow"]
    
    
    class UndoFollowSerializer(serializers.Serializer):
        id = serializers.URLField(max_length=500)
        actor = serializers.URLField(max_length=500)
        object = FollowSerializer()
        type = serializers.ChoiceField(choices=["Undo"])
    
        def validate_actor(self, v):
            expected = self.context.get("follow_target")
            if expected and expected.url != v:
                raise serializers.ValidationError("Invalid actor")
            try:
                return models.Actor.objects.get(url=v)
            except models.Actor.DoesNotExist:
                raise serializers.ValidationError("Actor not found")
    
        def validate(self, validated_data):
            # we ensure the accept actor actually match the follow actor
            if validated_data["actor"] != validated_data["object"]["actor"]:
                raise serializers.ValidationError("Actor mismatch")
            try:
                validated_data["follow"] = models.Follow.objects.filter(
                    actor=validated_data["actor"], target=validated_data["object"]["object"]
                ).get()
            except models.Follow.DoesNotExist:
                raise serializers.ValidationError("No follow to remove")
            return validated_data
    
        def to_representation(self, instance):
            return {
                "@context": AP_CONTEXT,
                "id": instance.get_federation_url() + "/undo",
                "type": "Undo",
                "actor": instance.actor.url,
                "object": FollowSerializer(instance).data,
            }
    
        def save(self):
            return self.validated_data["follow"].delete()
    
    
    class ActorWebfingerSerializer(serializers.Serializer):
        subject = serializers.CharField()
        aliases = serializers.ListField(child=serializers.URLField(max_length=500))
        links = serializers.ListField()
        actor_url = serializers.URLField(max_length=500, required=False)
    
        def validate(self, validated_data):
            validated_data["actor_url"] = None
            for l in validated_data["links"]:
                try:
                    if not l["rel"] == "self":
                        continue
                    if not l["type"] == "application/activity+json":
                        continue
                    validated_data["actor_url"] = l["href"]
                    break
                except KeyError:
                    pass
            if validated_data["actor_url"] is None:
                raise serializers.ValidationError("No valid actor url found")
            return validated_data
    
        def to_representation(self, instance):
            data = {}
            data["subject"] = "acct:{}".format(instance.webfinger_subject)
            data["links"] = [
                {"rel": "self", "href": instance.url, "type": "application/activity+json"}
            ]
            data["aliases"] = [instance.url]
            return data
    
    
    class ActivitySerializer(serializers.Serializer):
        actor = serializers.URLField(max_length=500)
        id = serializers.URLField(max_length=500, required=False)
        type = serializers.ChoiceField(choices=[(c, c) for c in activity.ACTIVITY_TYPES])
        object = serializers.JSONField()
    
        def validate_object(self, value):
            try:
                type = value["type"]
            except KeyError:
                raise serializers.ValidationError("Missing object type")
            except TypeError:
                # probably a URL
                return value
            try:
                object_serializer = OBJECT_SERIALIZERS[type]
            except KeyError:
                raise serializers.ValidationError("Unsupported type {}".format(type))
    
            serializer = object_serializer(data=value)
            serializer.is_valid(raise_exception=True)
            return serializer.data
    
        def validate_actor(self, value):
            request_actor = self.context.get("actor")
            if request_actor and request_actor.url != value:
                raise serializers.ValidationError(
                    "The actor making the request do not match" " the activity actor"
                )
            return value
    
        def to_representation(self, conf):
            d = {}
            d.update(conf)
    
            if self.context.get("include_ap_context", True):
                d["@context"] = AP_CONTEXT
            return d
    
    
    class ObjectSerializer(serializers.Serializer):
        id = serializers.URLField(max_length=500)
        url = serializers.URLField(max_length=500, required=False, allow_null=True)
        type = serializers.ChoiceField(choices=[(c, c) for c in activity.OBJECT_TYPES])
        content = serializers.CharField(required=False, allow_null=True)
        summary = serializers.CharField(required=False, allow_null=True)
        name = serializers.CharField(required=False, allow_null=True)
        published = serializers.DateTimeField(required=False, allow_null=True)
        updated = serializers.DateTimeField(required=False, allow_null=True)
        to = serializers.ListField(
            child=serializers.URLField(max_length=500), required=False, allow_null=True
        )
        cc = serializers.ListField(
            child=serializers.URLField(max_length=500), required=False, allow_null=True
        )
        bto = serializers.ListField(
            child=serializers.URLField(max_length=500), required=False, allow_null=True
        )
        bcc = serializers.ListField(
            child=serializers.URLField(max_length=500), required=False, allow_null=True
        )
    
    
    OBJECT_SERIALIZERS = {t: ObjectSerializer for t in activity.OBJECT_TYPES}
    
    
    class PaginatedCollectionSerializer(serializers.Serializer):
        type = serializers.ChoiceField(choices=["Collection"])
        totalItems = serializers.IntegerField(min_value=0)
        actor = serializers.URLField(max_length=500)
        id = serializers.URLField(max_length=500)
        first = serializers.URLField(max_length=500)
        last = serializers.URLField(max_length=500)
    
        def to_representation(self, conf):
            paginator = Paginator(conf["items"], conf.get("page_size", 20))
            first = funkwhale_utils.set_query_parameter(conf["id"], page=1)
            current = first
            last = funkwhale_utils.set_query_parameter(conf["id"], page=paginator.num_pages)
            d = {
                "id": conf["id"],
                "actor": conf["actor"].url,
                "totalItems": paginator.count,
                "type": "Collection",
                "current": current,
                "first": first,
                "last": last,
            }
            if self.context.get("include_ap_context", True):
                d["@context"] = AP_CONTEXT
            return d
    
    
    class CollectionPageSerializer(serializers.Serializer):
        type = serializers.ChoiceField(choices=["CollectionPage"])
        totalItems = serializers.IntegerField(min_value=0)
        items = serializers.ListField()
        actor = serializers.URLField(max_length=500)
        id = serializers.URLField(max_length=500)
        first = serializers.URLField(max_length=500)
        last = serializers.URLField(max_length=500)
        next = serializers.URLField(max_length=500, required=False)
        prev = serializers.URLField(max_length=500, required=False)
        partOf = serializers.URLField(max_length=500)
    
        def validate_items(self, v):
            item_serializer = self.context.get("item_serializer")
            if not item_serializer:
                return v
            raw_items = [item_serializer(data=i, context=self.context) for i in v]
            valid_items = []
            for i in raw_items:
                if i.is_valid():
                    valid_items.append(i)
                else:
                    logger.debug("Invalid item %s: %s", i.data, i.errors)
    
            return valid_items
    
        def to_representation(self, conf):
            page = conf["page"]
            first = funkwhale_utils.set_query_parameter(conf["id"], page=1)
            last = funkwhale_utils.set_query_parameter(
                conf["id"], page=page.paginator.num_pages
            )
            id = funkwhale_utils.set_query_parameter(conf["id"], page=page.number)
            d = {
                "id": id,
                "partOf": conf["id"],
                "actor": conf["actor"].url,
                "totalItems": page.paginator.count,
                "type": "CollectionPage",
                "first": first,
                "last": last,
                "items": [
                    conf["item_serializer"](
                        i, context={"actor": conf["actor"], "include_ap_context": False}
                    ).data
                    for i in page.object_list
                ],
            }
    
            if page.has_previous():
                d["prev"] = funkwhale_utils.set_query_parameter(
                    conf["id"], page=page.previous_page_number()
                )
    
            if page.has_next():
                d["next"] = funkwhale_utils.set_query_parameter(
                    conf["id"], page=page.next_page_number()
                )
    
            if self.context.get("include_ap_context", True):
                d["@context"] = AP_CONTEXT
            return d
    
    
    class ArtistMetadataSerializer(serializers.Serializer):
        musicbrainz_id = serializers.UUIDField(required=False, allow_null=True)
        name = serializers.CharField()
    
    
    class ReleaseMetadataSerializer(serializers.Serializer):
        musicbrainz_id = serializers.UUIDField(required=False, allow_null=True)
        title = serializers.CharField()
    
    
    class RecordingMetadataSerializer(serializers.Serializer):
        musicbrainz_id = serializers.UUIDField(required=False, allow_null=True)
        title = serializers.CharField()
    
    
    class AudioMetadataSerializer(serializers.Serializer):
        artist = ArtistMetadataSerializer()
        release = ReleaseMetadataSerializer()
        recording = RecordingMetadataSerializer()
        bitrate = serializers.IntegerField(required=False, allow_null=True, min_value=0)
        size = serializers.IntegerField(required=False, allow_null=True, min_value=0)
        length = serializers.IntegerField(required=False, allow_null=True, min_value=0)
    
    
    class AudioSerializer(serializers.Serializer):
        type = serializers.CharField()
        id = serializers.URLField(max_length=500)
        url = serializers.JSONField()
        published = serializers.DateTimeField()
        updated = serializers.DateTimeField(required=False)
        metadata = AudioMetadataSerializer()
    
        def validate_type(self, v):
            if v != "Audio":
                raise serializers.ValidationError("Invalid type for audio")
            return v
    
        def validate_url(self, v):
            try:
                v["href"]
            except (KeyError, TypeError):
                raise serializers.ValidationError("Missing href")
    
            try:
                media_type = v["mediaType"]
            except (KeyError, TypeError):
                raise serializers.ValidationError("Missing mediaType")
    
            if not media_type or not media_type.startswith("audio/"):
                raise serializers.ValidationError("Invalid mediaType")
    
            return v
    
        def create(self, validated_data):
            defaults = {
                "audio_mimetype": validated_data["url"]["mediaType"],
                "audio_url": validated_data["url"]["href"],
                "metadata": validated_data["metadata"],
                "artist_name": validated_data["metadata"]["artist"]["name"],
                "album_title": validated_data["metadata"]["release"]["title"],
                "title": validated_data["metadata"]["recording"]["title"],
                "published_date": validated_data["published"],
                "modification_date": validated_data.get("updated"),
            }
            return models.LibraryTrack.objects.get_or_create(
                library=self.context["library"], url=validated_data["id"], defaults=defaults
            )[0]
    
        def to_representation(self, instance):
            track = instance.track
            album = instance.track.album
            artist = instance.track.artist
    
            d = {
                "type": "Audio",
                "id": instance.get_federation_url(),
                "name": instance.track.full_name,
                "published": instance.creation_date.isoformat(),
                "updated": instance.modification_date.isoformat(),
                "metadata": {
                    "artist": {
                        "musicbrainz_id": str(artist.mbid) if artist.mbid else None,
                        "name": artist.name,
                    },
                    "release": {
                        "musicbrainz_id": str(album.mbid) if album.mbid else None,
                        "title": album.title,
                    },
                    "recording": {
                        "musicbrainz_id": str(track.mbid) if track.mbid else None,
                        "title": track.title,
                    },
                    "bitrate": instance.bitrate,
                    "size": instance.size,
                    "length": instance.duration,
                },
                "url": {
                    "href": utils.full_url(instance.path),
                    "type": "Link",
                    "mediaType": instance.mimetype,
                },
                "attributedTo": [self.context["actor"].url],
            }
            if self.context.get("include_ap_context", True):
                d["@context"] = AP_CONTEXT
            return d
    
    
    class CollectionSerializer(serializers.Serializer):
        def to_representation(self, conf):
            d = {
                "id": conf["id"],
                "actor": conf["actor"].url,
                "totalItems": len(conf["items"]),
                "type": "Collection",
                "items": [
                    conf["item_serializer"](
                        i, context={"actor": conf["actor"], "include_ap_context": False}
                    ).data
                    for i in conf["items"]
                ],
            }
    
            if self.context.get("include_ap_context", True):
                d["@context"] = AP_CONTEXT
            return d
    
    
    class LibraryTrackActionSerializer(common_serializers.ActionSerializer):
        actions = [common_serializers.Action("import", allow_all=True)]
        filterset_class = filters.LibraryTrackFilter
    
        @transaction.atomic
        def handle_import(self, objects):
            batch = music_models.ImportBatch.objects.create(
                source="federation", submitted_by=self.context["submitted_by"]
            )
            jobs = []
            for lt in objects:
                job = music_models.ImportJob(
                    batch=batch, library_track=lt, mbid=lt.mbid, source=lt.url
                )
                jobs.append(job)
    
            music_models.ImportJob.objects.bulk_create(jobs)
            funkwhale_utils.on_commit(
                music_tasks.import_batch_run.delay, import_batch_id=batch.pk
            )
    
            return {"batch": {"id": batch.pk}}