kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
994 wiersze
36 KiB
Python
994 wiersze
36 KiB
Python
import datetime
|
|
import logging
|
|
import time
|
|
import uuid
|
|
|
|
import feedparser
|
|
import pytz
|
|
import requests
|
|
from django.conf import settings
|
|
from django.db import transaction
|
|
from django.db.models import Q
|
|
from django.templatetags.static import static
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
from drf_spectacular.types import OpenApiTypes
|
|
from drf_spectacular.utils import extend_schema_field
|
|
from rest_framework import serializers
|
|
|
|
from funkwhale_api.common import locales, preferences
|
|
from funkwhale_api.common import serializers as common_serializers
|
|
from funkwhale_api.common import session
|
|
from funkwhale_api.common import utils as common_utils
|
|
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.serializers import COVER_WRITE_FIELD, CoverField
|
|
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, models
|
|
|
|
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(
|
|
f'"{child}" is not a valid subcategory for "{parent}"'
|
|
)
|
|
|
|
return child
|
|
|
|
|
|
class ChannelCreateSerializer(serializers.Serializer):
|
|
name = serializers.CharField(max_length=federation_models.MAX_LENGTHS["ACTOR_NAME"])
|
|
username = serializers.CharField(
|
|
max_length=federation_models.MAX_LENGTHS["ACTOR_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 = 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"],
|
|
)
|
|
channel.actor = models.generate_actor(
|
|
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
|
|
|
|
|
|
NOOP = object()
|
|
|
|
|
|
class ChannelUpdateSerializer(serializers.Serializer):
|
|
name = serializers.CharField(max_length=federation_models.MAX_LENGTHS["ACTOR_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 = 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:
|
|
common_utils.attach_content(
|
|
obj.artist, "description", validated_data["description"]
|
|
)
|
|
|
|
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 obj
|
|
|
|
def to_representation(self, obj):
|
|
return ChannelSerializer(obj, context=self.context).data
|
|
|
|
|
|
class SimpleChannelArtistSerializer(serializers.Serializer):
|
|
id = serializers.IntegerField()
|
|
fid = serializers.URLField()
|
|
mbid = serializers.CharField()
|
|
name = serializers.CharField()
|
|
creation_date = serializers.DateTimeField()
|
|
modification_date = serializers.DateTimeField()
|
|
is_local = serializers.BooleanField()
|
|
content_category = serializers.CharField()
|
|
description = common_serializers.ContentSerializer(allow_null=True, required=False)
|
|
cover = CoverField(allow_null=True, required=False)
|
|
channel = serializers.UUIDField(allow_null=True, required=False)
|
|
tracks_count = serializers.IntegerField(source="_tracks_count", required=False)
|
|
tags = serializers.ListField(
|
|
child=serializers.CharField(), source="_prefetched_tagged_items", required=False
|
|
)
|
|
|
|
|
|
class ChannelSerializer(serializers.ModelSerializer):
|
|
artist = SimpleChannelArtistSerializer()
|
|
actor = serializers.SerializerMethodField()
|
|
downloads_count = serializers.SerializerMethodField()
|
|
attributed_to = federation_serializers.APIActorSerializer()
|
|
rss_url = serializers.CharField(source="get_rss_url")
|
|
url = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = models.Channel
|
|
fields = [
|
|
"uuid",
|
|
"artist",
|
|
"attributed_to",
|
|
"actor",
|
|
"creation_date",
|
|
"metadata",
|
|
"rss_url",
|
|
"url",
|
|
"downloads_count",
|
|
]
|
|
|
|
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) -> int:
|
|
return obj.actor.received_follows.exclude(approved=False).count()
|
|
|
|
def get_downloads_count(self, obj) -> int:
|
|
return getattr(obj, "_downloads_count", None) or 0
|
|
|
|
@extend_schema_field(federation_serializers.APIActorSerializer)
|
|
def get_actor(self, obj):
|
|
if obj.attributed_to == actors.get_service_actor():
|
|
return None
|
|
return federation_serializers.APIActorSerializer(obj.actor).data
|
|
|
|
@extend_schema_field(OpenApiTypes.URI)
|
|
def get_url(self, obj):
|
|
return obj.actor.url
|
|
|
|
|
|
class InlineSubscriptionSerializer(serializers.Serializer):
|
|
uuid = serializers.UUIDField()
|
|
channel = serializers.UUIDField(source="target__channel__uuid")
|
|
|
|
|
|
class AllSubscriptionsSerializer(serializers.Serializer):
|
|
results = InlineSubscriptionSerializer(source="*", many=True)
|
|
count = serializers.SerializerMethodField()
|
|
|
|
def get_count(self, o) -> int:
|
|
return len(o)
|
|
|
|
|
|
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(
|
|
f"Error while fetching feed: HTTP {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(f"Error while fetching feed: {e}")
|
|
except Exception as e:
|
|
raise FeedFetchException(f"Error while fetching feed: {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(f"Invalid xml content: {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
|
|
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,
|
|
)
|
|
if uploads:
|
|
latest_track_date = max([upload.track.creation_date for upload in uploads])
|
|
common_utils.update_modification_date(channel.artist, date=latest_track_date)
|
|
return channel, uploads
|
|
|
|
|
|
# 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 = f"rssfeed-{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"],
|
|
"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(f"Invalid duration {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(f"Invalid duration {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)
|
|
content = serializers.ListField(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_content(self, v):
|
|
# TODO: Are there RSS feeds that use more than one content item?
|
|
content = v[0].get("value")
|
|
if not content:
|
|
return
|
|
return {
|
|
"content_type": v[0].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"],
|
|
"artist": channel.artist,
|
|
}
|
|
)
|
|
if "rights" in validated_data:
|
|
track_defaults["copyright"] = validated_data["rights"]
|
|
|
|
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, f"{field}_id")
|
|
new_id_value = getattr(track, f"{field}_id")
|
|
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)
|
|
|
|
# "content" refers to the <content:encoded> node in the RSS feed,
|
|
# whereas "summary_detail" refers to the <description> node.
|
|
# <description> is intended to be used as a short summary and is often
|
|
# encoded merely as plain text, whereas <content:encoded> contains
|
|
# the full episode description and is generally encoded as HTML.
|
|
#
|
|
# For details, see https://www.rssboard.org/rss-profile#element-channel-item-description
|
|
summary = validated_data.get("content")
|
|
if not summary:
|
|
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(
|
|
reverse(
|
|
"api:v1:stream-detail", kwargs={"uuid": str(upload.track.uuid)}
|
|
)
|
|
+ ".mp3"
|
|
),
|
|
"length": upload.size or 0,
|
|
"type": "audio/mpeg",
|
|
}
|
|
],
|
|
}
|
|
if upload.track.description:
|
|
data["itunes:subtitle"] = [{"value": upload.track.description.truncate(254)}]
|
|
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(254)}]
|
|
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]}],
|
|
}
|