発散解像度 -divergence resolution-
Signed-off-by: Shin'ya Minazuki <shinyoukai@laidback.moe>
This commit is contained in:
parent
1ff613dee6
commit
01bb65f8da
457 changed files with 929 additions and 602 deletions
0
api/funkwhale_api/audio/__init__.py
Normal file
0
api/funkwhale_api/audio/__init__.py
Normal file
15
api/funkwhale_api/audio/admin.py
Normal file
15
api/funkwhale_api/audio/admin.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from funkwhale_api.common import admin
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
@admin.register(models.Channel)
|
||||
class ChannelAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"uuid",
|
||||
"artist",
|
||||
"attributed_to",
|
||||
"actor",
|
||||
"library",
|
||||
"creation_date",
|
||||
]
|
||||
113
api/funkwhale_api/audio/categories.py
Normal file
113
api/funkwhale_api/audio/categories.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# from https://help.apple.com/itc/podcasts_connect/#/itc9267a2f12
|
||||
ITUNES_CATEGORIES = {
|
||||
"Arts": [
|
||||
"Books",
|
||||
"Design",
|
||||
"Fashion & Beauty",
|
||||
"Food",
|
||||
"Performing Arts",
|
||||
"Visual Arts",
|
||||
],
|
||||
"Business": [
|
||||
"Careers",
|
||||
"Entrepreneurship",
|
||||
"Investing",
|
||||
"Management",
|
||||
"Marketing",
|
||||
"Non-Profit",
|
||||
],
|
||||
"Comedy": ["Comedy Interviews", "Improv", "Stand-Up"],
|
||||
"Education": ["Courses", "How To", "Language Learning", "Self-Improvement"],
|
||||
"Fiction": ["Comedy Fiction", "Drama", "Science Fiction"],
|
||||
"Government": [],
|
||||
"History": [],
|
||||
"Health & Fitness": [
|
||||
"Alternative Health",
|
||||
"Fitness",
|
||||
"Medicine",
|
||||
"Mental Health",
|
||||
"Nutrition",
|
||||
"Sexuality",
|
||||
],
|
||||
"Kids & Family": [
|
||||
"Education for Kids",
|
||||
"Parenting",
|
||||
"Pets & Animals",
|
||||
"Stories for Kids",
|
||||
],
|
||||
"Leisure": [
|
||||
"Animation & Manga",
|
||||
"Automotive",
|
||||
"Aviation",
|
||||
"Crafts",
|
||||
"Games",
|
||||
"Hobbies",
|
||||
"Home & Garden",
|
||||
"Video Games",
|
||||
],
|
||||
"Music": ["Music Commentary", "Music History", "Music Interviews"],
|
||||
"News": [
|
||||
"Business News",
|
||||
"Daily News",
|
||||
"Entertainment News",
|
||||
"News Commentary",
|
||||
"Politics",
|
||||
"Sports News",
|
||||
"Tech News",
|
||||
],
|
||||
"Religion & Spirituality": [
|
||||
"Buddhism",
|
||||
"Christianity",
|
||||
"Hinduism",
|
||||
"Islam",
|
||||
"Judaism",
|
||||
"Religion",
|
||||
"Spirituality",
|
||||
],
|
||||
"Science": [
|
||||
"Astronomy",
|
||||
"Chemistry",
|
||||
"Earth Sciences",
|
||||
"Life Sciences",
|
||||
"Mathematics",
|
||||
"Natural Sciences",
|
||||
"Nature",
|
||||
"Physics",
|
||||
"Social Sciences",
|
||||
],
|
||||
"Society & Culture": [
|
||||
"Documentary",
|
||||
"Personal Journals",
|
||||
"Philosophy",
|
||||
"Places & Travel",
|
||||
"Relationships",
|
||||
],
|
||||
"Sports": [
|
||||
"Baseball",
|
||||
"Basketball",
|
||||
"Cricket",
|
||||
"Fantasy Sports",
|
||||
"Football",
|
||||
"Golf",
|
||||
"Hockey",
|
||||
"Rugby",
|
||||
"Running",
|
||||
"Soccer",
|
||||
"Swimming",
|
||||
"Tennis",
|
||||
"Volleyball",
|
||||
"Wilderness",
|
||||
"Wrestling",
|
||||
],
|
||||
"Technology": [],
|
||||
"True Crime": [],
|
||||
"TV & Film": [
|
||||
"After Shows",
|
||||
"Film History",
|
||||
"Film Interviews",
|
||||
"Film Reviews",
|
||||
"TV Reviews",
|
||||
],
|
||||
}
|
||||
|
||||
ITUNES_SUBCATEGORIES = [s for p in ITUNES_CATEGORIES.values() for s in p]
|
||||
25
api/funkwhale_api/audio/dynamic_preferences_registry.py
Normal file
25
api/funkwhale_api/audio/dynamic_preferences_registry.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from dynamic_preferences import types
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
audio = types.Section("audio")
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
class ChannelsEnabled(types.BooleanPreference):
|
||||
section = audio
|
||||
name = "channels_enabled"
|
||||
default = True
|
||||
verbose_name = "Enable channels"
|
||||
help_text = (
|
||||
"If disabled, the channels feature will be completely switched off, "
|
||||
"and users won't be able to create channels or subscribe to them."
|
||||
)
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
class MaxChannels(types.IntegerPreference):
|
||||
show_in_api = True
|
||||
section = audio
|
||||
default = 20
|
||||
name = "max_channels"
|
||||
verbose_name = "Max channels allowed per user"
|
||||
68
api/funkwhale_api/audio/factories.py
Normal file
68
api/funkwhale_api/audio/factories.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import uuid
|
||||
|
||||
import factory
|
||||
|
||||
from funkwhale_api.factories import NoUpdateOnCreate, registry
|
||||
from funkwhale_api.federation import actors
|
||||
from funkwhale_api.federation import factories as federation_factories
|
||||
from funkwhale_api.music import factories as music_factories
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
def set_actor(o):
|
||||
return models.generate_actor(str(o.uuid))
|
||||
|
||||
|
||||
def get_rss_channel_name():
|
||||
return f"rssfeed-{uuid.uuid4()}"
|
||||
|
||||
|
||||
@registry.register
|
||||
class ChannelFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
uuid = factory.Faker("uuid4")
|
||||
attributed_to = factory.SubFactory(federation_factories.ActorFactory)
|
||||
library = factory.SubFactory(
|
||||
federation_factories.MusicLibraryFactory,
|
||||
actor=factory.SelfAttribute("..attributed_to"),
|
||||
privacy_level="everyone",
|
||||
)
|
||||
actor = factory.LazyAttribute(set_actor)
|
||||
artist = factory.SubFactory(
|
||||
music_factories.ArtistFactory,
|
||||
attributed_to=factory.SelfAttribute("..attributed_to"),
|
||||
)
|
||||
rss_url = factory.Faker("url")
|
||||
metadata = factory.LazyAttribute(lambda o: {})
|
||||
|
||||
class Meta:
|
||||
model = "audio.Channel"
|
||||
|
||||
class Params:
|
||||
external = factory.Trait(
|
||||
attributed_to=factory.LazyFunction(actors.get_service_actor),
|
||||
library__privacy_level="me",
|
||||
actor=factory.SubFactory(
|
||||
federation_factories.ActorFactory,
|
||||
local=True,
|
||||
preferred_username=factory.LazyFunction(get_rss_channel_name),
|
||||
),
|
||||
)
|
||||
local = factory.Trait(
|
||||
attributed_to=factory.SubFactory(
|
||||
federation_factories.ActorFactory, local=True
|
||||
),
|
||||
library__privacy_level="everyone",
|
||||
artist__local=True,
|
||||
)
|
||||
|
||||
|
||||
@registry.register(name="audio.Subscription")
|
||||
class SubscriptionFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
uuid = factory.Faker("uuid4")
|
||||
approved = True
|
||||
target = factory.LazyAttribute(lambda o: ChannelFactory().actor)
|
||||
actor = factory.SubFactory(federation_factories.ActorFactory)
|
||||
|
||||
class Meta:
|
||||
model = "federation.Follow"
|
||||
106
api/funkwhale_api/audio/filters.py
Normal file
106
api/funkwhale_api/audio/filters.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import django_filters
|
||||
from django.db.models import Q
|
||||
|
||||
from funkwhale_api.common import fields
|
||||
from funkwhale_api.common import filters as common_filters
|
||||
from funkwhale_api.federation import actors
|
||||
from funkwhale_api.moderation import filters as moderation_filters
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
def filter_tags(queryset, name, value):
|
||||
non_empty_tags = [v.lower() for v in value if v]
|
||||
for tag in non_empty_tags:
|
||||
queryset = queryset.filter(artist__tagged_items__tag__name=tag).distinct()
|
||||
return queryset
|
||||
|
||||
|
||||
TAG_FILTER = common_filters.MultipleQueryFilter(method=filter_tags)
|
||||
|
||||
|
||||
class ChannelFilter(moderation_filters.HiddenContentFilterSet):
|
||||
q = fields.SearchFilter(
|
||||
search_fields=["artist__name", "actor__summary", "actor__preferred_username"]
|
||||
)
|
||||
tag = TAG_FILTER
|
||||
scope = common_filters.ActorScopeFilter(actor_field="attributed_to", distinct=True)
|
||||
subscribed = django_filters.BooleanFilter(
|
||||
field_name="_", method="filter_subscribed"
|
||||
)
|
||||
external = django_filters.BooleanFilter(field_name="_", method="filter_external")
|
||||
ordering = django_filters.OrderingFilter(
|
||||
# tuple-mapping retains order
|
||||
fields=(
|
||||
("creation_date", "creation_date"),
|
||||
("artist__modification_date", "modification_date"),
|
||||
("?", "random"),
|
||||
)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Channel
|
||||
fields = []
|
||||
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["CHANNEL"]
|
||||
|
||||
def filter_subscribed(self, queryset, name, value):
|
||||
if not self.request.user.is_authenticated:
|
||||
return queryset.none()
|
||||
|
||||
emitted_follows = self.request.user.actor.emitted_follows.exclude(
|
||||
target__channel__isnull=True
|
||||
)
|
||||
|
||||
query = Q(actor__in=emitted_follows.values_list("target", flat=True))
|
||||
|
||||
if value:
|
||||
return queryset.filter(query)
|
||||
else:
|
||||
return queryset.exclude(query)
|
||||
|
||||
def filter_external(self, queryset, name, value):
|
||||
query = Q(
|
||||
attributed_to=actors.get_service_actor(),
|
||||
actor__preferred_username__startswith="rssfeed-",
|
||||
)
|
||||
if value:
|
||||
queryset = queryset.filter(query)
|
||||
else:
|
||||
queryset = queryset.exclude(query)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class IncludeChannelsFilterSet(django_filters.FilterSet):
|
||||
"""
|
||||
|
||||
A filterset that include a "include_channels" param. Meant for compatibility
|
||||
with clients that don't support channels yet:
|
||||
|
||||
- include_channels=false : exclude objects associated with a channel
|
||||
- include_channels=true : don't exclude objects associated with a channel
|
||||
- not specified: include_channels=false
|
||||
|
||||
Usage:
|
||||
|
||||
class MyFilterSet(IncludeChannelsFilterSet):
|
||||
class Meta:
|
||||
include_channels_field = "album__artist__channel"
|
||||
|
||||
"""
|
||||
|
||||
include_channels = django_filters.BooleanFilter(
|
||||
field_name="_", method="filter_include_channels"
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.data = self.data.copy()
|
||||
self.data.setdefault("include_channels", False)
|
||||
|
||||
def filter_include_channels(self, queryset, name, value):
|
||||
if value is True:
|
||||
return queryset
|
||||
else:
|
||||
params = {self.__class__.Meta.include_channels_field: None}
|
||||
return queryset.filter(**params)
|
||||
31
api/funkwhale_api/audio/migrations/0001_initial.py
Normal file
31
api/funkwhale_api/audio/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Generated by Django 2.2.6 on 2019-10-29 12:57
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('federation', '0021_auto_20191029_1257'),
|
||||
('music', '0041_auto_20191021_1705'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Channel',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True)),
|
||||
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('actor', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='channel', to='federation.Actor')),
|
||||
('artist', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='channel', to='music.Artist')),
|
||||
('attributed_to', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_channels', to='federation.Actor')),
|
||||
('library', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='channel', to='music.Library')),
|
||||
],
|
||||
),
|
||||
]
|
||||
21
api/funkwhale_api/audio/migrations/0002_channel_metadata.py
Normal file
21
api/funkwhale_api/audio/migrations/0002_channel_metadata.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Generated by Django 2.2.9 on 2020-01-31 06:24
|
||||
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
import django.core.serializers.json
|
||||
from django.db import migrations
|
||||
import funkwhale_api.audio.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('audio', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='channel',
|
||||
name='metadata',
|
||||
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=funkwhale_api.audio.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000),
|
||||
),
|
||||
]
|
||||
18
api/funkwhale_api/audio/migrations/0003_channel_rss_url.py
Normal file
18
api/funkwhale_api/audio/migrations/0003_channel_rss_url.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 2.2.10 on 2020-02-06 15:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('audio', '0002_channel_metadata'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='channel',
|
||||
name='rss_url',
|
||||
field=models.URLField(blank=True, max_length=500, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 3.2.13 on 2022-06-27 19:15
|
||||
|
||||
import django.core.serializers.json
|
||||
from django.db import migrations, models
|
||||
import funkwhale_api.audio.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('audio', '0003_channel_rss_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='channel',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, default=funkwhale_api.audio.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000),
|
||||
),
|
||||
]
|
||||
0
api/funkwhale_api/audio/migrations/__init__.py
Normal file
0
api/funkwhale_api/audio/migrations/__init__.py
Normal file
122
api/funkwhale_api/audio/models.py
Normal file
122
api/funkwhale_api/audio/models.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import uuid
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import models
|
||||
from django.db.models import JSONField
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from funkwhale_api.federation import keys
|
||||
from funkwhale_api.federation import models as federation_models
|
||||
from funkwhale_api.federation import utils as federation_utils
|
||||
from funkwhale_api.users import models as user_models
|
||||
|
||||
|
||||
def empty_dict():
|
||||
return {}
|
||||
|
||||
|
||||
class ChannelQuerySet(models.QuerySet):
|
||||
def external_rss(self, include=True):
|
||||
from funkwhale_api.federation import actors
|
||||
|
||||
query = models.Q(
|
||||
attributed_to=actors.get_service_actor(),
|
||||
actor__preferred_username__startswith="rssfeed-",
|
||||
)
|
||||
if include:
|
||||
return self.filter(query)
|
||||
return self.exclude(query)
|
||||
|
||||
def subscribed(self, actor):
|
||||
if not actor:
|
||||
return self.none()
|
||||
|
||||
subscriptions = actor.emitted_follows.filter(
|
||||
approved=True, target__channel__isnull=False
|
||||
)
|
||||
return self.filter(actor__in=subscriptions.values_list("target", flat=True))
|
||||
|
||||
|
||||
class Channel(models.Model):
|
||||
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
|
||||
artist = models.OneToOneField(
|
||||
"music.Artist", on_delete=models.CASCADE, related_name="channel"
|
||||
)
|
||||
# the owner of the channel
|
||||
attributed_to = models.ForeignKey(
|
||||
"federation.Actor", on_delete=models.CASCADE, related_name="owned_channels"
|
||||
)
|
||||
# the federation actor created for the channel
|
||||
# (the one people can follow to receive updates)
|
||||
actor = models.OneToOneField(
|
||||
"federation.Actor", on_delete=models.CASCADE, related_name="channel"
|
||||
)
|
||||
|
||||
library = models.OneToOneField(
|
||||
"music.Library", on_delete=models.CASCADE, related_name="channel"
|
||||
)
|
||||
creation_date = models.DateTimeField(default=timezone.now)
|
||||
rss_url = models.URLField(max_length=500, null=True, blank=True)
|
||||
|
||||
# metadata to enhance rss feed
|
||||
metadata = JSONField(
|
||||
default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder, blank=True
|
||||
)
|
||||
|
||||
fetches = GenericRelation(
|
||||
"federation.Fetch",
|
||||
content_type_field="object_content_type",
|
||||
object_id_field="object_id",
|
||||
)
|
||||
objects = ChannelQuerySet.as_manager()
|
||||
|
||||
@property
|
||||
def fid(self):
|
||||
if not self.is_external_rss:
|
||||
return self.actor.fid
|
||||
|
||||
@property
|
||||
def is_local(self) -> bool:
|
||||
return self.actor.is_local
|
||||
|
||||
@property
|
||||
def is_external_rss(self):
|
||||
return self.actor.preferred_username.startswith("rssfeed-")
|
||||
|
||||
def get_absolute_url(self):
|
||||
suffix = self.uuid
|
||||
if self.actor.is_local:
|
||||
suffix = self.actor.preferred_username
|
||||
else:
|
||||
suffix = self.actor.full_username
|
||||
return federation_utils.full_url(f"/channels/{suffix}")
|
||||
|
||||
def get_rss_url(self):
|
||||
if not self.artist.is_local or self.is_external_rss:
|
||||
return self.rss_url
|
||||
|
||||
return federation_utils.full_url(
|
||||
reverse(
|
||||
"api:v1:channels-rss",
|
||||
kwargs={"composite": self.actor.preferred_username},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def generate_actor(username, **kwargs):
|
||||
actor_data = user_models.get_actor_data(username, **kwargs)
|
||||
private, public = keys.get_key_pair()
|
||||
actor_data["private_key"] = private.decode("utf-8")
|
||||
actor_data["public_key"] = public.decode("utf-8")
|
||||
|
||||
return federation_models.Actor.objects.create(**actor_data)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Channel)
|
||||
def delete_channel_related_objs(instance, **kwargs):
|
||||
instance.library.delete()
|
||||
instance.artist.delete()
|
||||
35
api/funkwhale_api/audio/renderers.py
Normal file
35
api/funkwhale_api/audio/renderers.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import xml.etree.ElementTree as ET
|
||||
|
||||
from rest_framework import negotiation, renderers
|
||||
|
||||
from funkwhale_api.subsonic.renderers import dict_to_xml_tree
|
||||
|
||||
|
||||
class PodcastRSSRenderer(renderers.JSONRenderer):
|
||||
media_type = "application/rss+xml"
|
||||
|
||||
def render(self, data, accepted_media_type=None, renderer_context=None):
|
||||
if not data:
|
||||
# when stream view is called, we don't have any data
|
||||
return super().render(data, accepted_media_type, renderer_context)
|
||||
final = {
|
||||
"version": "2.0",
|
||||
"xmlns:atom": "http://www.w3.org/2005/Atom",
|
||||
"xmlns:itunes": "http://www.itunes.com/dtds/podcast-1.0.dtd",
|
||||
"xmlns:content": "http://purl.org/rss/1.0/modules/content/",
|
||||
"xmlns:media": "http://search.yahoo.com/mrss/",
|
||||
}
|
||||
final.update(data)
|
||||
tree = dict_to_xml_tree("rss", final)
|
||||
return render_xml(tree)
|
||||
|
||||
|
||||
class PodcastRSSContentNegociation(negotiation.DefaultContentNegotiation):
|
||||
def select_renderer(self, request, renderers, format_suffix=None):
|
||||
return (PodcastRSSRenderer(), PodcastRSSRenderer.media_type)
|
||||
|
||||
|
||||
def render_xml(tree):
|
||||
return b'<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(
|
||||
tree, encoding="utf-8"
|
||||
)
|
||||
998
api/funkwhale_api/audio/serializers.py
Normal file
998
api/funkwhale_api/audio/serializers.py
Normal file
|
|
@ -0,0 +1,998 @@
|
|||
import datetime
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import feedparser
|
||||
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
|
||||
|
||||
if sys.version_info < (3, 9):
|
||||
from backports.zoneinfo import ZoneInfo
|
||||
else:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
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=ZoneInfo("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]}],
|
||||
}
|
||||
104
api/funkwhale_api/audio/spa_views.py
Normal file
104
api/funkwhale_api/audio/spa_views.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import urllib.parse
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse
|
||||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.common import middleware, preferences, utils
|
||||
from funkwhale_api.federation import utils as federation_utils
|
||||
from funkwhale_api.music import spa_views
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
def channel_detail(query, redirect_to_ap):
|
||||
queryset = models.Channel.objects.filter(query).select_related(
|
||||
"artist__attachment_cover", "actor", "library"
|
||||
)
|
||||
try:
|
||||
obj = queryset.get()
|
||||
except models.Channel.DoesNotExist:
|
||||
return []
|
||||
|
||||
if redirect_to_ap:
|
||||
raise middleware.ApiRedirect(obj.actor.fid)
|
||||
|
||||
obj_url = utils.join_url(
|
||||
settings.FUNQUAIL_URL,
|
||||
utils.spa_reverse(
|
||||
"channel_detail", kwargs={"username": obj.actor.full_username}
|
||||
),
|
||||
)
|
||||
metas = [
|
||||
{"tag": "meta", "property": "og:url", "content": obj_url},
|
||||
{"tag": "meta", "property": "og:title", "content": obj.artist.name},
|
||||
{"tag": "meta", "property": "og:type", "content": "profile"},
|
||||
]
|
||||
|
||||
if obj.artist.attachment_cover:
|
||||
metas.append(
|
||||
{
|
||||
"tag": "meta",
|
||||
"property": "og:image",
|
||||
"content": obj.artist.attachment_cover.download_url_medium_square_crop,
|
||||
}
|
||||
)
|
||||
|
||||
if preferences.get("federation__enabled"):
|
||||
metas.append(
|
||||
{
|
||||
"tag": "link",
|
||||
"rel": "alternate",
|
||||
"type": "application/activity+json",
|
||||
"href": obj.actor.fid,
|
||||
}
|
||||
)
|
||||
|
||||
metas.append(
|
||||
{
|
||||
"tag": "link",
|
||||
"rel": "alternate",
|
||||
"type": "application/rss+xml",
|
||||
"href": obj.get_rss_url(),
|
||||
"title": f"{obj.artist.name} - RSS Podcast Feed",
|
||||
},
|
||||
)
|
||||
|
||||
if obj.library.uploads.all().playable_by(None).exists():
|
||||
metas.append(
|
||||
{
|
||||
"tag": "link",
|
||||
"rel": "alternate",
|
||||
"type": "application/json+oembed",
|
||||
"href": (
|
||||
utils.join_url(settings.FUNQUAIL_URL, reverse("api:v1:oembed"))
|
||||
+ f"?format=json&url={urllib.parse.quote_plus(obj_url)}"
|
||||
),
|
||||
}
|
||||
)
|
||||
# twitter player is also supported in various software
|
||||
metas += spa_views.get_twitter_card_metas(type="channel", id=obj.uuid)
|
||||
return metas
|
||||
|
||||
|
||||
def channel_detail_uuid(request, uuid, redirect_to_ap):
|
||||
validator = serializers.UUIDField().to_internal_value
|
||||
try:
|
||||
uuid = validator(uuid)
|
||||
except serializers.ValidationError:
|
||||
return []
|
||||
return channel_detail(Q(uuid=uuid), redirect_to_ap)
|
||||
|
||||
|
||||
def channel_detail_username(request, username, redirect_to_ap):
|
||||
validator = federation_utils.get_actor_data_from_username
|
||||
try:
|
||||
username_data = validator(username)
|
||||
except serializers.ValidationError:
|
||||
return []
|
||||
query = Q(
|
||||
actor__domain=username_data["domain"],
|
||||
actor__preferred_username__iexact=username_data["username"],
|
||||
)
|
||||
return channel_detail(query, redirect_to_ap)
|
||||
50
api/funkwhale_api/audio/tasks.py
Normal file
50
api/funkwhale_api/audio/tasks.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import datetime
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from funkwhale_api.taskapp import celery
|
||||
|
||||
from . import models, serializers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery.app.task(name="audio.fetch_rss_feeds")
|
||||
def fetch_rss_feeds():
|
||||
limit = timezone.now() - datetime.timedelta(
|
||||
seconds=settings.PODCASTS_RSS_FEED_REFRESH_DELAY
|
||||
)
|
||||
candidates = (
|
||||
models.Channel.objects.external_rss()
|
||||
.filter(actor__last_fetch_date__lte=limit)
|
||||
.values_list("rss_url", flat=True)
|
||||
)
|
||||
|
||||
total = len(candidates)
|
||||
logger.info("Refreshing %s rss feeds…", total)
|
||||
for url in candidates:
|
||||
fetch_rss_feed.delay(rss_url=url)
|
||||
|
||||
|
||||
@celery.app.task(name="audio.fetch_rss_feed")
|
||||
@transaction.atomic
|
||||
def fetch_rss_feed(rss_url):
|
||||
channel = (
|
||||
models.Channel.objects.external_rss()
|
||||
.filter(rss_url=rss_url)
|
||||
.order_by("id")
|
||||
.first()
|
||||
)
|
||||
if not channel:
|
||||
logger.warn("Cannot refresh non external feed")
|
||||
return
|
||||
|
||||
try:
|
||||
serializers.get_channel_from_rss_url(rss_url)
|
||||
except serializers.BlockedFeedException:
|
||||
# channel was blocked since last fetch, let's delete it
|
||||
logger.info("Deleting blocked channel linked to %s", rss_url)
|
||||
channel.delete()
|
||||
344
api/funkwhale_api/audio/views.py
Normal file
344
api/funkwhale_api/audio/views.py
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
from django import http
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Prefetch, Q, Sum
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
from rest_framework import decorators, exceptions, mixins
|
||||
from rest_framework import permissions as rest_permissions
|
||||
from rest_framework import response, viewsets
|
||||
|
||||
from funkwhale_api.common import locales, permissions, preferences
|
||||
from funkwhale_api.common import utils as common_utils
|
||||
from funkwhale_api.common.mixins import MultipleLookupDetailMixin
|
||||
from funkwhale_api.federation import actors
|
||||
from funkwhale_api.federation import models as federation_models
|
||||
from funkwhale_api.federation import routes
|
||||
from funkwhale_api.federation import tasks as federation_tasks
|
||||
from funkwhale_api.federation import utils as federation_utils
|
||||
from funkwhale_api.music import models as music_models
|
||||
from funkwhale_api.music import views as music_views
|
||||
from funkwhale_api.users.oauth import permissions as oauth_permissions
|
||||
|
||||
from . import categories, filters, models, renderers, serializers
|
||||
|
||||
ARTIST_PREFETCH_QS = (
|
||||
music_models.Artist.objects.select_related(
|
||||
"description",
|
||||
"attachment_cover",
|
||||
)
|
||||
.prefetch_related(music_views.TAG_PREFETCH)
|
||||
.annotate(_tracks_count=Count("tracks"))
|
||||
)
|
||||
|
||||
|
||||
class ChannelsMixin:
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not preferences.get("audio__channels_enabled"):
|
||||
return http.HttpResponse(status=405)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
metedata_choices=extend_schema(operation_id="get_channel_metadata_choices"),
|
||||
subscribe=extend_schema(operation_id="subscribe_channel"),
|
||||
unsubscribe=extend_schema(operation_id="unsubscribe_channel"),
|
||||
rss_subscribe=extend_schema(operation_id="subscribe_channel_rss"),
|
||||
)
|
||||
class ChannelViewSet(
|
||||
ChannelsMixin,
|
||||
MultipleLookupDetailMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
url_lookups = [
|
||||
{
|
||||
"lookup_field": "uuid",
|
||||
"validator": serializers.serializers.UUIDField().to_internal_value,
|
||||
},
|
||||
{
|
||||
"lookup_field": "username",
|
||||
"validator": federation_utils.get_actor_data_from_username,
|
||||
"get_query": lambda v: Q(
|
||||
actor__domain=v["domain"],
|
||||
actor__preferred_username__iexact=v["username"],
|
||||
),
|
||||
},
|
||||
]
|
||||
filterset_class = filters.ChannelFilter
|
||||
serializer_class = serializers.ChannelSerializer
|
||||
queryset = (
|
||||
models.Channel.objects.all()
|
||||
.prefetch_related(
|
||||
"library",
|
||||
"attributed_to",
|
||||
"actor",
|
||||
Prefetch("artist", queryset=ARTIST_PREFETCH_QS),
|
||||
)
|
||||
.order_by("-creation_date")
|
||||
)
|
||||
permission_classes = [
|
||||
oauth_permissions.ScopePermission,
|
||||
permissions.OwnerPermission,
|
||||
]
|
||||
required_scope = "libraries"
|
||||
anonymous_policy = "setting"
|
||||
owner_checks = ["write"]
|
||||
owner_field = "attributed_to.user"
|
||||
owner_exception = exceptions.PermissionDenied
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.request.method.lower() in ["head", "get", "options"]:
|
||||
return serializers.ChannelSerializer
|
||||
elif self.action in ["update", "partial_update"]:
|
||||
return serializers.ChannelUpdateSerializer
|
||||
elif self.action == "create":
|
||||
return serializers.ChannelCreateSerializer
|
||||
return serializers.ChannelSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
if self.action == "retrieve":
|
||||
queryset = queryset.annotate(
|
||||
_downloads_count=Sum("artist__tracks__downloads_count")
|
||||
)
|
||||
return queryset
|
||||
|
||||
def perform_create(self, serializer):
|
||||
return serializer.save(attributed_to=self.request.user.actor)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
if self.request.GET.get("output") == "opml":
|
||||
queryset = self.filter_queryset(self.get_queryset())[:500]
|
||||
opml = serializers.get_opml(
|
||||
channels=queryset,
|
||||
date=timezone.now(),
|
||||
title="FunQuail channels OPML export",
|
||||
)
|
||||
xml_body = renderers.render_xml(renderers.dict_to_xml_tree("opml", opml))
|
||||
return http.HttpResponse(xml_body, content_type="application/xml")
|
||||
|
||||
else:
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
def get_object(self):
|
||||
obj = super().get_object()
|
||||
if (
|
||||
self.action == "retrieve"
|
||||
and self.request.GET.get("refresh", "").lower() == "true"
|
||||
):
|
||||
obj = music_views.refetch_obj(obj, self.get_queryset())
|
||||
return obj
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
permission_classes=[rest_permissions.IsAuthenticated],
|
||||
serializer_class=serializers.SubscriptionSerializer,
|
||||
)
|
||||
def subscribe(self, request, *args, **kwargs):
|
||||
object = self.get_object()
|
||||
subscription = federation_models.Follow(actor=request.user.actor)
|
||||
subscription.fid = subscription.get_federation_id()
|
||||
subscription, created = SubscriptionsViewSet.queryset.get_or_create(
|
||||
target=object.actor,
|
||||
actor=request.user.actor,
|
||||
defaults={
|
||||
"approved": True,
|
||||
"fid": subscription.fid,
|
||||
"uuid": subscription.uuid,
|
||||
},
|
||||
)
|
||||
# prefetch stuff
|
||||
subscription = SubscriptionsViewSet.queryset.get(pk=subscription.pk)
|
||||
if not object.actor.is_local:
|
||||
routes.outbox.dispatch({"type": "Follow"}, context={"follow": subscription})
|
||||
|
||||
data = serializers.SubscriptionSerializer(subscription).data
|
||||
return response.Response(data, status=201)
|
||||
|
||||
@extend_schema(responses={204: None})
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post", "delete"],
|
||||
permission_classes=[rest_permissions.IsAuthenticated],
|
||||
)
|
||||
def unsubscribe(self, request, *args, **kwargs):
|
||||
object = self.get_object()
|
||||
follow_qs = request.user.actor.emitted_follows.filter(target=object.actor)
|
||||
follow = follow_qs.first()
|
||||
if follow:
|
||||
if not object.actor.is_local:
|
||||
routes.outbox.dispatch(
|
||||
{"type": "Undo", "object": {"type": "Follow"}},
|
||||
context={"follow": follow},
|
||||
)
|
||||
follow_qs.delete()
|
||||
return response.Response(status=204)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
content_negotiation_class=renderers.PodcastRSSContentNegociation,
|
||||
)
|
||||
def rss(self, request, *args, **kwargs):
|
||||
object = self.get_object()
|
||||
if not object.attributed_to.is_local:
|
||||
return response.Response({"detail": "Not found"}, status=404)
|
||||
|
||||
if object.attributed_to == actors.get_service_actor():
|
||||
# external feed, we redirect to the canonical one
|
||||
return http.HttpResponseRedirect(object.rss_url)
|
||||
|
||||
uploads = (
|
||||
object.library.uploads.playable_by(None)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"track",
|
||||
queryset=music_models.Track.objects.select_related(
|
||||
"attachment_cover", "description"
|
||||
).prefetch_related(
|
||||
music_views.TAG_PREFETCH,
|
||||
),
|
||||
),
|
||||
)
|
||||
.select_related("track__attachment_cover", "track__description")
|
||||
.order_by("-creation_date")
|
||||
)[:50]
|
||||
data = serializers.rss_serialize_channel_full(channel=object, uploads=uploads)
|
||||
return response.Response(data, status=200)
|
||||
|
||||
@decorators.action(
|
||||
methods=["get"],
|
||||
detail=False,
|
||||
url_path="metadata-choices",
|
||||
url_name="metadata_choices",
|
||||
permission_classes=[],
|
||||
)
|
||||
def metedata_choices(self, request, *args, **kwargs):
|
||||
data = {
|
||||
"language": [
|
||||
{"value": code, "label": name} for code, name in locales.ISO_639_CHOICES
|
||||
],
|
||||
"itunes_category": [
|
||||
{"value": code, "label": code, "children": children}
|
||||
for code, children in categories.ITUNES_CATEGORIES.items()
|
||||
],
|
||||
}
|
||||
return response.Response(data)
|
||||
|
||||
@decorators.action(
|
||||
methods=["post"],
|
||||
detail=False,
|
||||
url_path="rss-subscribe",
|
||||
url_name="rss_subscribe",
|
||||
)
|
||||
@transaction.atomic
|
||||
def rss_subscribe(self, request, *args, **kwargs):
|
||||
serializer = serializers.RssSubscribeSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return response.Response(serializer.errors, status=400)
|
||||
channel = (
|
||||
models.Channel.objects.filter(
|
||||
rss_url=serializer.validated_data["url"],
|
||||
)
|
||||
.order_by("id")
|
||||
.first()
|
||||
)
|
||||
if not channel:
|
||||
# try to retrieve the channel via its URL and create it
|
||||
try:
|
||||
channel, uploads = serializers.get_channel_from_rss_url(
|
||||
serializer.validated_data["url"]
|
||||
)
|
||||
except serializers.FeedFetchException as e:
|
||||
return response.Response(
|
||||
{"detail": str(e)},
|
||||
status=400,
|
||||
)
|
||||
|
||||
subscription = federation_models.Follow(actor=request.user.actor)
|
||||
subscription.fid = subscription.get_federation_id()
|
||||
subscription, created = SubscriptionsViewSet.queryset.get_or_create(
|
||||
target=channel.actor,
|
||||
actor=request.user.actor,
|
||||
defaults={
|
||||
"approved": True,
|
||||
"fid": subscription.fid,
|
||||
"uuid": subscription.uuid,
|
||||
},
|
||||
)
|
||||
# prefetch stuff
|
||||
subscription = SubscriptionsViewSet.queryset.get(pk=subscription.pk)
|
||||
|
||||
return response.Response(
|
||||
serializers.SubscriptionSerializer(subscription).data, status=201
|
||||
)
|
||||
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
context["subscriptions_count"] = self.action in [
|
||||
"retrieve",
|
||||
"create",
|
||||
"update",
|
||||
"partial_update",
|
||||
]
|
||||
if self.request.user.is_authenticated:
|
||||
context["actor"] = self.request.user.actor
|
||||
return context
|
||||
|
||||
@transaction.atomic
|
||||
def perform_destroy(self, instance):
|
||||
instance.__class__.objects.filter(pk=instance.pk).delete()
|
||||
common_utils.on_commit(
|
||||
federation_tasks.remove_actor.delay, actor_id=instance.actor.pk
|
||||
)
|
||||
|
||||
|
||||
class SubscriptionsViewSet(
|
||||
ChannelsMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
lookup_field = "uuid"
|
||||
serializer_class = serializers.SubscriptionSerializer
|
||||
queryset = (
|
||||
federation_models.Follow.objects.exclude(target__channel__isnull=True)
|
||||
.prefetch_related(
|
||||
"target__channel__library",
|
||||
"target__channel__attributed_to",
|
||||
"actor",
|
||||
Prefetch("target__channel__artist", queryset=ARTIST_PREFETCH_QS),
|
||||
)
|
||||
.order_by("-creation_date")
|
||||
)
|
||||
permission_classes = [
|
||||
oauth_permissions.ScopePermission,
|
||||
rest_permissions.IsAuthenticated,
|
||||
]
|
||||
required_scope = "libraries"
|
||||
anonymous_policy = False
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
return qs.filter(actor=self.request.user.actor)
|
||||
|
||||
@extend_schema(
|
||||
responses=serializers.AllSubscriptionsSerializer(),
|
||||
operation_id="get_all_subscriptions",
|
||||
)
|
||||
@decorators.action(methods=["get"], detail=False)
|
||||
def all(self, request, *args, **kwargs):
|
||||
"""
|
||||
Return all the subscriptions of the current user, with only limited data
|
||||
to have a performant endpoint and avoid lots of queries just to display
|
||||
subscription status in the UI
|
||||
"""
|
||||
subscriptions = self.get_queryset().values("uuid", "target__channel__uuid")
|
||||
|
||||
payload = serializers.AllSubscriptionsSerializer(subscriptions).data
|
||||
return response.Response(payload, status=200)
|
||||
Loading…
Add table
Add a link
Reference in a new issue