Skip to content
Snippets Groups Projects
serializers.py 33.6 KiB
Newer Older
  • Learn to ignore specific revisions
  • import datetime
    import logging
    import time
    import uuid
    
    
    from django.db.models import Q
    from django.utils import timezone
    
    import feedparser
    import requests
    import pytz
    
    from django.templatetags.static import static
    
    from django.urls import reverse
    
    from funkwhale_api.common import serializers as common_serializers
    from funkwhale_api.common import utils as common_utils
    
    from funkwhale_api.common import locales
    
    from funkwhale_api.common import preferences
    
    from funkwhale_api.common import session
    from funkwhale_api.federation import actors
    
    from funkwhale_api.federation import models as federation_models
    
    from funkwhale_api.federation import serializers as federation_serializers
    
    from funkwhale_api.federation import utils as federation_utils
    
    from funkwhale_api.moderation import mrf
    
    from funkwhale_api.music import models as music_models
    from funkwhale_api.music import serializers as music_serializers
    from funkwhale_api.tags import models as tags_models
    from funkwhale_api.tags import serializers as tags_serializers
    
    from funkwhale_api.users import serializers as users_serializers
    
    from . import categories
    
    logger = logging.getLogger(__name__)
    
    
    
    class ChannelMetadataSerializer(serializers.Serializer):
        itunes_category = serializers.ChoiceField(
            choices=categories.ITUNES_CATEGORIES, required=True
        )
    
        itunes_subcategory = serializers.CharField(required=False, allow_null=True)
    
        language = serializers.ChoiceField(required=True, choices=locales.ISO_639_CHOICES)
        copyright = serializers.CharField(required=False, allow_null=True, max_length=255)
        owner_name = serializers.CharField(required=False, allow_null=True, max_length=255)
        owner_email = serializers.EmailField(required=False, allow_null=True)
        explicit = serializers.BooleanField(required=False)
    
        def validate(self, validated_data):
            validated_data = super().validate(validated_data)
            subcategory = self._validate_itunes_subcategory(
                validated_data["itunes_category"], validated_data.get("itunes_subcategory")
            )
            if subcategory:
                validated_data["itunes_subcategory"] = subcategory
            return validated_data
    
        def _validate_itunes_subcategory(self, parent, child):
            if not child:
                return
    
            if child not in categories.ITUNES_CATEGORIES[parent]:
                raise serializers.ValidationError(
                    '"{}" is not a valid subcategory for "{}"'.format(child, parent)
                )
    
            return child
    
    
    
    class ChannelCreateSerializer(serializers.Serializer):
        name = serializers.CharField(max_length=music_models.MAX_LENGTHS["ARTIST_NAME"])
    
        username = serializers.CharField(
            max_length=music_models.MAX_LENGTHS["ARTIST_NAME"],
            validators=[users_serializers.ASCIIUsernameValidator()],
        )
    
        description = common_serializers.ContentSerializer(allow_null=True)
    
        tags = tags_serializers.TagsListField()
    
        content_category = serializers.ChoiceField(
            choices=music_models.ARTIST_CONTENT_CATEGORY_CHOICES
        )
    
        metadata = serializers.DictField(required=False)
    
        cover = music_serializers.COVER_WRITE_FIELD
    
    
        def validate(self, validated_data):
    
            existing_channels = self.context["actor"].owned_channels.count()
            if existing_channels >= preferences.get("audio__max_channels"):
                raise serializers.ValidationError(
                    "You have reached the maximum amount of allowed channels"
                )
    
            validated_data = super().validate(validated_data)
            metadata = validated_data.pop("metadata", {})
            if validated_data["content_category"] == "podcast":
                metadata_serializer = ChannelMetadataSerializer(data=metadata)
                metadata_serializer.is_valid(raise_exception=True)
                metadata = metadata_serializer.validated_data
            validated_data["metadata"] = metadata
            return validated_data
    
        def validate_username(self, value):
    
            if value.lower() in [n.lower() for n in settings.ACCOUNT_USERNAME_BLACKLIST]:
                raise serializers.ValidationError("This username is already taken")
    
    
            matching = federation_models.Actor.objects.local().filter(
                preferred_username__iexact=value
            )
            if matching.exists():
                raise serializers.ValidationError("This username is already taken")
            return value
    
    
        @transaction.atomic
        def create(self, validated_data):
    
            from . import views
    
    
            cover = validated_data.pop("cover", None)
    
            description = validated_data.get("description")
    
            artist = music_models.Artist.objects.create(
    
                attributed_to=validated_data["attributed_to"],
                name=validated_data["name"],
                content_category=validated_data["content_category"],
    
                attachment_cover=cover,
    
            common_utils.attach_content(artist, "description", description)
    
            if validated_data.get("tags", []):
                tags_models.set_tags(artist, *validated_data["tags"])
    
            channel = models.Channel(
    
                artist=artist,
                attributed_to=validated_data["attributed_to"],
                metadata=validated_data["metadata"],
    
                validated_data["username"], name=validated_data["name"],
    
            )
    
            channel.library = music_models.Library.objects.create(
                name=channel.actor.preferred_username,
    
                privacy_level="everyone",
    
                actor=validated_data["attributed_to"],
            )
            channel.save()
    
            channel = views.ChannelViewSet.queryset.get(pk=channel.pk)
    
            return channel
    
        def to_representation(self, obj):
    
            return ChannelSerializer(obj, context=self.context).data
    
    class ChannelUpdateSerializer(serializers.Serializer):
        name = serializers.CharField(max_length=music_models.MAX_LENGTHS["ARTIST_NAME"])
    
        description = common_serializers.ContentSerializer(allow_null=True)
    
        tags = tags_serializers.TagsListField()
    
        content_category = serializers.ChoiceField(
            choices=music_models.ARTIST_CONTENT_CATEGORY_CHOICES
        )
    
        metadata = serializers.DictField(required=False)
    
        cover = music_serializers.COVER_WRITE_FIELD
    
    
        def validate(self, validated_data):
            validated_data = super().validate(validated_data)
            require_metadata_validation = False
            new_content_category = validated_data.get("content_category")
            metadata = validated_data.pop("metadata", NOOP)
            if (
                new_content_category == "podcast"
                and self.instance.artist.content_category != "postcast"
            ):
                # updating channel, setting as podcast
                require_metadata_validation = True
            elif self.instance.artist.content_category == "postcast" and metadata != NOOP:
                # channel is podcast, and metadata was updated
                require_metadata_validation = True
            else:
                metadata = self.instance.metadata
    
            if require_metadata_validation:
                metadata_serializer = ChannelMetadataSerializer(data=metadata)
                metadata_serializer.is_valid(raise_exception=True)
                metadata = metadata_serializer.validated_data
    
            validated_data["metadata"] = metadata
            return validated_data
    
    
        @transaction.atomic
        def update(self, obj, validated_data):
            if validated_data.get("tags") is not None:
                tags_models.set_tags(obj.artist, *validated_data["tags"])
            actor_update_fields = []
    
            artist_update_fields = []
    
            obj.metadata = validated_data["metadata"]
            obj.save(update_fields=["metadata"])
    
    
            if "description" in validated_data:
                description_obj = common_utils.attach_content(
                    obj.artist, "description", validated_data["description"]
                )
                if description_obj:
                    actor_update_fields.append(("summary", description_obj.rendered))
    
    
            if "name" in validated_data:
                actor_update_fields.append(("name", validated_data["name"]))
    
                artist_update_fields.append(("name", validated_data["name"]))
    
            if "content_category" in validated_data:
                artist_update_fields.append(
                    ("content_category", validated_data["content_category"])
                )
    
            if "cover" in validated_data:
                artist_update_fields.append(("attachment_cover", validated_data["cover"]))
    
    
            if actor_update_fields:
                for field, value in actor_update_fields:
                    setattr(obj.actor, field, value)
                obj.actor.save(update_fields=[f for f, _ in actor_update_fields])
    
    
            if artist_update_fields:
                for field, value in artist_update_fields:
                    setattr(obj.artist, field, value)
                obj.artist.save(update_fields=[f for f, _ in artist_update_fields])
    
    
            return ChannelSerializer(obj, context=self.context).data
    
    
    
    class ChannelSerializer(serializers.ModelSerializer):
        artist = serializers.SerializerMethodField()
    
        actor = serializers.SerializerMethodField()
    
        attributed_to = federation_serializers.APIActorSerializer()
    
        rss_url = serializers.CharField(source="get_rss_url")
    
        url = serializers.SerializerMethodField()
    
            fields = [
                "uuid",
                "artist",
                "attributed_to",
                "actor",
                "creation_date",
                "metadata",
    
    
        def get_artist(self, obj):
            return music_serializers.serialize_artist_simple(obj.artist)
    
        def to_representation(self, obj):
            data = super().to_representation(obj)
            if self.context.get("subscriptions_count"):
                data["subscriptions_count"] = self.get_subscriptions_count(obj)
            return data
    
        def get_subscriptions_count(self, obj):
            return obj.actor.received_follows.exclude(approved=False).count()
    
    
        def get_actor(self, obj):
            if obj.attributed_to == actors.get_service_actor():
                return None
            return federation_serializers.APIActorSerializer(obj.actor).data
    
    
        def get_url(self, obj):
            return obj.actor.url
    
    
    
    class SubscriptionSerializer(serializers.Serializer):
        approved = serializers.BooleanField(read_only=True)
        fid = serializers.URLField(read_only=True)
        uuid = serializers.UUIDField(read_only=True)
        creation_date = serializers.DateTimeField(read_only=True)
    
        def to_representation(self, obj):
            data = super().to_representation(obj)
            data["channel"] = ChannelSerializer(obj.target.channel).data
            return data
    
    class RssSubscribeSerializer(serializers.Serializer):
        url = serializers.URLField()
    
    
    class FeedFetchException(Exception):
        pass
    
    
    class BlockedFeedException(FeedFetchException):
        pass
    
    
    def retrieve_feed(url):
        try:
            logger.info("Fetching RSS feed at %s", url)
            response = session.get_session().get(url)
            response.raise_for_status()
        except requests.exceptions.HTTPError as e:
            if e.response:
                raise FeedFetchException(
                    "Error while fetching feed: HTTP {}".format(e.response.status_code)
                )
            raise FeedFetchException("Error while fetching feed: unknown error")
        except requests.exceptions.Timeout:
            raise FeedFetchException("Error while fetching feed: timeout")
        except requests.exceptions.ConnectionError:
            raise FeedFetchException("Error while fetching feed: connection error")
        except requests.RequestException as e:
            raise FeedFetchException("Error while fetching feed: {}".format(e))
        except Exception as e:
            raise FeedFetchException("Error while fetching feed: {}".format(e))
    
        return response
    
    
    @transaction.atomic
    
    def get_channel_from_rss_url(url, raise_exception=False):
    
        # first, check if the url is blocked
        is_valid, _ = mrf.inbox.apply({"id": url})
        if not is_valid:
            logger.warn("Feed fetch for url %s dropped by MRF", url)
            raise BlockedFeedException("This feed or domain is blocked")
    
        # retrieve the XML payload at the given URL
        response = retrieve_feed(url)
    
        parsed_feed = feedparser.parse(response.text)
        serializer = RssFeedSerializer(data=parsed_feed["feed"])
    
        if not serializer.is_valid(raise_exception=raise_exception):
    
            raise FeedFetchException("Invalid xml content: {}".format(serializer.errors))
    
        # second mrf check with validated data
        urls_to_check = set()
        atom_link = serializer.validated_data.get("atom_link")
    
        if atom_link and atom_link != url:
            urls_to_check.add(atom_link)
    
        if serializer.validated_data["link"] != url:
            urls_to_check.add(serializer.validated_data["link"])
    
        for u in urls_to_check:
            is_valid, _ = mrf.inbox.apply({"id": u})
            if not is_valid:
                logger.warn("Feed fetch for url %s dropped by MRF", u)
                raise BlockedFeedException("This feed or domain is blocked")
    
        # now, we're clear, we can save the data
        channel = serializer.save(rss_url=url)
    
        entries = parsed_feed.entries or []
        uploads = []
        track_defaults = {}
        existing_uploads = list(
            channel.library.uploads.all().select_related(
                "track__description", "track__attachment_cover"
            )
        )
    
        if parsed_feed.feed.get("rights"):
    
            track_defaults["copyright"] = parsed_feed.feed.rights[
                : music_models.MAX_LENGTHS["COPYRIGHT"]
            ]
        for entry in entries[: settings.PODCASTS_RSS_FEED_MAX_ITEMS]:
    
            logger.debug("Importing feed item %s", entry.id)
            s = RssFeedItemSerializer(data=entry)
    
            if not s.is_valid(raise_exception=raise_exception):
    
                logger.debug("Skipping invalid RSS feed item %s, ", entry, str(s.errors))
    
                continue
            uploads.append(
                s.save(channel, existing_uploads=existing_uploads, **track_defaults)
            )
    
        common_utils.on_commit(
            music_models.TrackActor.create_entries,
            library=channel.library,
            delete_existing=True,
        )
    
        latest_upload_date = max([upload.creation_date for upload in uploads])
        if (
            not channel.artist.modification_date
            or channel.artist.modification_date < latest_upload_date
        ):
            common_utils.update_modification_date(channel.artist)
    
    # RSS related stuff
    # https://github.com/simplepie/simplepie-ng/wiki/Spec:-iTunes-Podcast-RSS
    # is extremely useful
    
    
    
    class RssFeedSerializer(serializers.Serializer):
        title = serializers.CharField()
    
        link = serializers.URLField(required=False, allow_blank=True)
    
        language = serializers.CharField(required=False, allow_blank=True)
        rights = serializers.CharField(required=False, allow_blank=True)
        itunes_explicit = serializers.BooleanField(required=False, allow_null=True)
        tags = serializers.ListField(required=False)
        atom_link = serializers.DictField(required=False)
    
        links = serializers.ListField(required=False)
    
        summary_detail = serializers.DictField(required=False)
        author_detail = serializers.DictField(required=False)
        image = serializers.DictField(required=False)
    
        def validate_atom_link(self, v):
            if (
                v.get("rel", "self") == "self"
                and v.get("type", "application/rss+xml") == "application/rss+xml"
            ):
                return v["href"]
    
    
        def validate_links(self, v):
            for link in v:
                if link.get("rel") == "self":
                    return link.get("href")
    
    
        def validate_summary_detail(self, v):
            content = v.get("value")
            if not content:
                return
            return {
                "content_type": v.get("type", "text/plain"),
                "text": content,
            }
    
        def validate_image(self, v):
            url = v.get("href")
            if url:
                return {
                    "url": url,
                    "mimetype": common_utils.get_mimetype_from_ext(url) or "image/jpeg",
                }
    
        def validate_tags(self, v):
            data = {}
            for row in v:
                if row.get("scheme") != "http://www.itunes.com/":
                    continue
                term = row["term"]
                if "parent" not in data and term in categories.ITUNES_CATEGORIES:
                    data["parent"] = term
                elif "child" not in data and term in categories.ITUNES_SUBCATEGORIES:
                    data["child"] = term
                elif (
                    term not in categories.ITUNES_SUBCATEGORIES
                    and term not in categories.ITUNES_CATEGORIES
                ):
                    raw_tags = term.split(" ")
                    data["tags"] = []
                    tag_serializer = tags_serializers.TagNameField()
                    for tag in raw_tags:
                        try:
                            data["tags"].append(tag_serializer.to_internal_value(tag))
                        except Exception:
                            pass
    
            return data
    
    
        def validate(self, data):
            validated_data = super().validate(data)
            if not validated_data.get("link"):
                validated_data["link"] = validated_data.get("links")
            if not validated_data.get("link"):
                raise serializers.ValidationError("Missing link")
            return validated_data
    
    
        @transaction.atomic
        def save(self, rss_url):
            validated_data = self.validated_data
            # because there may be redirections from the original feed URL
            real_rss_url = validated_data.get("atom_link", rss_url) or rss_url
            service_actor = actors.get_service_actor()
            author = validated_data.get("author_detail", {})
            categories = validated_data.get("tags", {})
            metadata = {
                "explicit": validated_data.get("itunes_explicit", False),
                "copyright": validated_data.get("rights"),
                "owner_name": author.get("name"),
                "owner_email": author.get("email"),
                "itunes_category": categories.get("parent"),
                "itunes_subcategory": categories.get("child"),
                "language": validated_data.get("language"),
            }
            public_url = validated_data["link"]
            existing = (
                models.Channel.objects.external_rss()
                .filter(
                    Q(rss_url=real_rss_url) | Q(rss_url=rss_url) | Q(actor__url=public_url)
                )
                .first()
            )
            channel_defaults = {
                "rss_url": real_rss_url,
                "metadata": metadata,
            }
            if existing:
                artist_kwargs = {"channel": existing}
                actor_kwargs = {"channel": existing}
                actor_defaults = {"url": public_url}
            else:
                artist_kwargs = {"pk": None}
                actor_kwargs = {"pk": None}
                preferred_username = "rssfeed-{}".format(uuid.uuid4())
                actor_defaults = {
                    "preferred_username": preferred_username,
                    "type": "Application",
                    "domain": service_actor.domain,
                    "url": public_url,
                    "fid": federation_utils.full_url(
                        reverse(
                            "federation:actors-detail",
                            kwargs={"preferred_username": preferred_username},
                        )
                    ),
                }
                channel_defaults["attributed_to"] = service_actor
    
            actor_defaults["last_fetch_date"] = timezone.now()
    
            # create/update the artist profile
            artist, created = music_models.Artist.objects.update_or_create(
                **artist_kwargs,
                defaults={
                    "attributed_to": service_actor,
    
                    "name": validated_data["title"][
                        : music_models.MAX_LENGTHS["ARTIST_NAME"]
                    ],
    
                    "content_category": "podcast",
                },
            )
    
            cover = validated_data.get("image")
    
            if cover:
                common_utils.attach_file(artist, "attachment_cover", cover)
            tags = categories.get("tags", [])
    
            if tags:
                tags_models.set_tags(artist, *tags)
    
            summary = validated_data.get("summary_detail")
            if summary:
                common_utils.attach_content(artist, "description", summary)
    
            if created:
                channel_defaults["artist"] = artist
    
            # create/update the actor
            actor, created = federation_models.Actor.objects.update_or_create(
                **actor_kwargs, defaults=actor_defaults
            )
            if created:
                channel_defaults["actor"] = actor
    
            # create the library
            if not existing:
                channel_defaults["library"] = music_models.Library.objects.create(
                    actor=service_actor,
                    privacy_level=settings.PODCASTS_THIRD_PARTY_VISIBILITY,
                    name=actor_defaults["preferred_username"],
                )
    
            # create/update the channel
            channel, created = models.Channel.objects.update_or_create(
                pk=existing.pk if existing else None, defaults=channel_defaults,
            )
            return channel
    
    
    class ItunesDurationField(serializers.CharField):
        def to_internal_value(self, v):
            try:
                return int(v)
            except (ValueError, TypeError):
                pass
            parts = v.split(":")
            int_parts = []
            for part in parts:
                try:
                    int_parts.append(int(part))
                except (ValueError, TypeError):
                    raise serializers.ValidationError("Invalid duration {}".format(v))
    
            if len(int_parts) == 2:
                hours = 0
                minutes, seconds = int_parts
            elif len(int_parts) == 3:
                hours, minutes, seconds = int_parts
            else:
                raise serializers.ValidationError("Invalid duration {}".format(v))
    
            return (hours * 3600) + (minutes * 60) + seconds
    
    
    class DummyField(serializers.Field):
        def to_internal_value(self, v):
            return v
    
    
    def get_cached_upload(uploads, expected_track_uuid):
        for upload in uploads:
            if upload.track.uuid == expected_track_uuid:
                return upload
    
    
    
    class PermissiveIntegerField(serializers.IntegerField):
        def to_internal_value(self, v):
            try:
                return super().to_internal_value(v)
            except serializers.ValidationError:
                return self.default
    
    
    
    class RssFeedItemSerializer(serializers.Serializer):
        id = serializers.CharField()
        title = serializers.CharField()
        rights = serializers.CharField(required=False, allow_blank=True)
    
        itunes_season = serializers.IntegerField(
            required=False, allow_null=True, default=None
        )
        itunes_episode = PermissiveIntegerField(
            required=False, allow_null=True, default=None
        )
        itunes_duration = ItunesDurationField(
            required=False, allow_null=True, default=None, allow_blank=True
        )
    
        links = serializers.ListField()
        tags = serializers.ListField(required=False)
        summary_detail = serializers.DictField(required=False)
        published_parsed = DummyField(required=False)
        image = serializers.DictField(required=False)
    
        def validate_summary_detail(self, v):
            content = v.get("value")
            if not content:
                return
            return {
                "content_type": v.get("type", "text/plain"),
                "text": content,
            }
    
        def validate_image(self, v):
            url = v.get("href")
            if url:
                return {
                    "url": url,
                    "mimetype": common_utils.get_mimetype_from_ext(url) or "image/jpeg",
                }
    
        def validate_links(self, v):
            data = {}
            for row in v:
                if not row.get("type", "").startswith("audio/"):
                    continue
                if row.get("rel") != "enclosure":
                    continue
                try:
    
                    size = int(row.get("length", 0) or 0) or None
    
                except (TypeError, ValueError):
                    raise serializers.ValidationError("Invalid size")
    
                data["audio"] = {
    
                    "mimetype": common_utils.get_audio_mimetype(row["type"]),
    
                    "size": size,
                    "source": row["href"],
                }
    
            if not data:
                raise serializers.ValidationError("No valid audio enclosure found")
    
            return data
    
        def validate_tags(self, v):
            data = {}
            for row in v:
                if row.get("scheme") != "http://www.itunes.com/":
                    continue
                term = row["term"]
                raw_tags = term.split(" ")
                data["tags"] = []
                tag_serializer = tags_serializers.TagNameField()
                for tag in raw_tags:
                    try:
                        data["tags"].append(tag_serializer.to_internal_value(tag))
                    except Exception:
                        pass
    
            return data
    
        @transaction.atomic
        def save(self, channel, existing_uploads=[], **track_defaults):
            validated_data = self.validated_data
            categories = validated_data.get("tags", {})
            expected_uuid = uuid.uuid3(
                uuid.NAMESPACE_URL, "rss://{}-{}".format(channel.pk, validated_data["id"])
            )
            existing_upload = get_cached_upload(existing_uploads, expected_uuid)
            if existing_upload:
                existing_track = existing_upload.track
            else:
                existing_track = (
                    music_models.Track.objects.filter(
                        uuid=expected_uuid, artist__channel=channel
                    )
                    .select_related("description", "attachment_cover")
                    .first()
                )
                if existing_track:
                    existing_upload = existing_track.uploads.filter(
                        library=channel.library
                    ).first()
    
            track_defaults = track_defaults
            track_defaults.update(
                {
    
                    "disc_number": validated_data.get("itunes_season", 1) or 1,
                    "position": validated_data.get("itunes_episode", 1) or 1,
    
                    "title": validated_data["title"][
                        : music_models.MAX_LENGTHS["TRACK_TITLE"]
                    ],
    
                    "artist": channel.artist,
                }
            )
            if "rights" in validated_data:
    
                track_defaults["copyright"] = validated_data["rights"][
    
                    : music_models.MAX_LENGTHS["COPYRIGHT"]
                ]
    
    
            if "published_parsed" in validated_data:
                track_defaults["creation_date"] = datetime.datetime.fromtimestamp(
                    time.mktime(validated_data["published_parsed"])
                ).replace(tzinfo=pytz.utc)
    
            upload_defaults = {
                "source": validated_data["links"]["audio"]["source"],
                "size": validated_data["links"]["audio"]["size"],
                "mimetype": validated_data["links"]["audio"]["mimetype"],
    
                "duration": validated_data.get("itunes_duration") or None,
    
                "import_status": "finished",
                "library": channel.library,
            }
            if existing_track:
                track_kwargs = {"pk": existing_track.pk}
                upload_kwargs = {"track": existing_track}
            else:
                track_kwargs = {"pk": None}
                track_defaults["uuid"] = expected_uuid
                upload_kwargs = {"pk": None}
    
            if existing_upload and existing_upload.source != upload_defaults["source"]:
                # delete existing upload, the url to the audio file has changed
                existing_upload.delete()
    
            # create/update the track
            track, created = music_models.Track.objects.update_or_create(
                **track_kwargs, defaults=track_defaults,
            )
            # optimisation for reducing SQL queries, because we cannot use select_related with
            # update or create, so we restore the cache by hand
            if existing_track:
                for field in ["attachment_cover", "description"]:
                    cached_id_value = getattr(existing_track, "{}_id".format(field))
                    new_id_value = getattr(track, "{}_id".format(field))
                    if new_id_value and cached_id_value == new_id_value:
                        setattr(track, field, getattr(existing_track, field))
    
            cover = validated_data.get("image")
    
            if cover:
                common_utils.attach_file(track, "attachment_cover", cover)
            tags = categories.get("tags", [])
    
            if tags:
                tags_models.set_tags(track, *tags)
    
            summary = validated_data.get("summary_detail")
            if summary:
                common_utils.attach_content(track, "description", summary)
    
            if created:
                upload_defaults["track"] = track
    
            # create/update the upload
            upload, created = music_models.Upload.objects.update_or_create(
                **upload_kwargs, defaults=upload_defaults
            )
    
            return upload
    
    
    
    def rfc822_date(dt):
    
        return dt.strftime("%a, %d %b %Y %H:%M:%S %z")
    
    
    def rss_duration(seconds):
        if not seconds:
            return "00:00:00"
        full_hours = seconds // 3600
        full_minutes = (seconds - (full_hours * 3600)) // 60
        remaining_seconds = seconds - (full_hours * 3600) - (full_minutes * 60)
        return "{}:{}:{}".format(
            str(full_hours).zfill(2),
            str(full_minutes).zfill(2),
            str(remaining_seconds).zfill(2),
        )
    
    
    def rss_serialize_item(upload):
        data = {
            "title": [{"value": upload.track.title}],
            "itunes:title": [{"value": upload.track.title}],
            "guid": [{"cdata_value": str(upload.uuid), "isPermalink": "false"}],
    
            "pubDate": [{"value": rfc822_date(upload.creation_date)}],
    
            "itunes:duration": [{"value": rss_duration(upload.duration)}],
            "itunes:explicit": [{"value": "no"}],
            "itunes:episodeType": [{"value": "full"}],
            "itunes:season": [{"value": upload.track.disc_number or 1}],
            "itunes:episode": [{"value": upload.track.position or 1}],
            "link": [{"value": federation_utils.full_url(upload.track.get_absolute_url())}],
            "enclosure": [
                {
    
                    # we enforce MP3, since it's the only format supported everywhere
                    "url": federation_utils.full_url(upload.get_listen_url(to="mp3")),
    
                    "length": upload.size or 0,
    
                    "type": "audio/mpeg",
    
                }
            ],
        }
        if upload.track.description:
            data["itunes:subtitle"] = [{"value": upload.track.description.truncate(255)}]
            data["itunes:summary"] = [{"cdata_value": upload.track.description.rendered}]
            data["description"] = [{"value": upload.track.description.as_plain_text}]
    
        if upload.track.attachment_cover:
            data["itunes:image"] = [
                {"href": upload.track.attachment_cover.download_url_original}
            ]
    
        tagged_items = getattr(upload.track, "_prefetched_tagged_items", [])
        if tagged_items:
            data["itunes:keywords"] = [
                {"value": " ".join([ti.tag.name for ti in tagged_items])}
            ]
    
        return data
    
    
    def rss_serialize_channel(channel):
        metadata = channel.metadata or {}
        explicit = metadata.get("explicit", False)
        copyright = metadata.get("copyright", "All rights reserved")
        owner_name = metadata.get("owner_name", channel.attributed_to.display_name)
        owner_email = metadata.get("owner_email")
        itunes_category = metadata.get("itunes_category")
        itunes_subcategory = metadata.get("itunes_subcategory")
        language = metadata.get("language")
    
        data = {
            "title": [{"value": channel.artist.name}],
            "copyright": [{"value": copyright}],
            "itunes:explicit": [{"value": "no" if not explicit else "yes"}],
            "itunes:author": [{"value": owner_name}],
            "itunes:owner": [{"itunes:name": [{"value": owner_name}]}],
            "itunes:type": [{"value": "episodic"}],
            "link": [{"value": channel.get_absolute_url()}],
            "atom:link": [
                {
                    "href": channel.get_rss_url(),
                    "rel": "self",
                    "type": "application/rss+xml",
    
                },
                {
                    "href": channel.actor.fid,
                    "rel": "alternate",
                    "type": "application/activity+json",
                },
    
            ],
        }
        if language:
            data["language"] = [{"value": language}]
    
        if owner_email:
            data["itunes:owner"][0]["itunes:email"] = [{"value": owner_email}]
    
        if itunes_category:
            node = {"text": itunes_category}
            if itunes_subcategory:
                node["itunes:category"] = [{"text": itunes_subcategory}]
            data["itunes:category"] = [node]
    
        if channel.artist.description:
            data["itunes:subtitle"] = [{"value": channel.artist.description.truncate(255)}]
            data["itunes:summary"] = [{"cdata_value": channel.artist.description.rendered}]
            data["description"] = [{"value": channel.artist.description.as_plain_text}]
    
        if channel.artist.attachment_cover:
            data["itunes:image"] = [
                {"href": channel.artist.attachment_cover.download_url_original}
            ]
    
        else:
            placeholder_url = federation_utils.full_url(
                static("images/podcasts-cover-placeholder.png")
            )
            data["itunes:image"] = [{"href": placeholder_url}]
    
    
        tagged_items = getattr(channel.artist, "_prefetched_tagged_items", [])
    
        if tagged_items:
            data["itunes:keywords"] = [
                {"value": " ".join([ti.tag.name for ti in tagged_items])}
            ]
    
        return data
    
    
    def rss_serialize_channel_full(channel, uploads):
        channel_data = rss_serialize_channel(channel)
        channel_data["item"] = [rss_serialize_item(upload) for upload in uploads]
        return {"channel": channel_data}
    
    
    
    # OPML stuff
    def get_opml_outline(channel):
        return {
            "title": channel.artist.name,
            "text": channel.artist.name,
            "type": "rss",
            "xmlUrl": channel.get_rss_url(),
            "htmlUrl": channel.actor.url,
        }
    
    
    def get_opml(channels, date, title):
        return {
            "version": "2.0",
            "head": [{"date": [{"value": rfc822_date(date)}], "title": [{"value": title}]}],
            "body": [{"outline": [get_opml_outline(channel) for channel in channels]}],
        }