See #170: subsonic API for podcasts

This commit is contained in:
Eliot Berriot 2020-03-18 15:52:23 +01:00
commit 23d3893f01
8 changed files with 452 additions and 9 deletions

View file

@ -31,6 +31,15 @@ class ChannelQuerySet(models.QuerySet):
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)

View file

@ -295,6 +295,8 @@ def clean_html(html, permissive=False):
def render_html(text, content_type, permissive=False):
if not text:
return ""
rendered = render_markdown(text)
if content_type == "text/html":
rendered = text
@ -307,6 +309,8 @@ def render_html(text, content_type, permissive=False):
def render_plain_text(html):
if not html:
return ""
return bleach.clean(html, tags=[], strip=True)

View file

@ -102,7 +102,7 @@ def get_track_data(album, track, upload):
"id": track.pk,
"isDir": "false",
"title": track.title,
"album": album.title,
"album": album.title if album else "",
"artist": album.artist.name,
"track": track.position or 1,
"discNumber": track.disc_number or 1,
@ -118,18 +118,20 @@ def get_track_data(album, track, upload):
"path": get_track_path(track, upload.extension or "mp3"),
"duration": upload.duration or 0,
"created": to_subsonic_date(track.creation_date),
"albumId": album.pk,
"artistId": album.artist.pk,
"albumId": album.pk if album else "",
"artistId": album.artist.pk if album else track.artist.pk,
"type": "music",
}
if track.album.attachment_cover_id:
data["coverArt"] = "al-{}".format(track.album.id)
if album and album.attachment_cover_id:
data["coverArt"] = "al-{}".format(album.id)
if upload.bitrate:
data["bitrate"] = int(upload.bitrate / 1000)
if upload.size:
data["size"] = upload.size
if album.release_date:
data["year"] = album.release_date.year
else:
data["year"] = track.creation_date.year
return data
@ -287,7 +289,7 @@ def get_user_detail_data(user):
"adminRole": "false",
"settingsRole": "false",
"commentRole": "false",
"podcastRole": "false",
"podcastRole": "true",
"coverArtRole": "false",
"shareRole": "false",
"uploadRole": "true",
@ -319,3 +321,53 @@ def get_genre_data(tag):
"albumCount": getattr(tag, "_albums_count", 0),
"value": tag.name,
}
def get_channel_data(channel, uploads):
data = {
"id": str(channel.uuid),
"url": channel.get_rss_url(),
"title": channel.artist.name,
"description": channel.artist.description.as_plain_text
if channel.artist.description
else "",
"coverArt": "at-{}".format(channel.artist.attachment_cover.uuid)
if channel.artist.attachment_cover
else "",
"originalImageUrl": channel.artist.attachment_cover.url
if channel.artist.attachment_cover
else "",
"status": "completed",
}
if uploads:
data["episode"] = [
get_channel_episode_data(upload, channel.uuid) for upload in uploads
]
return data
def get_channel_episode_data(upload, channel_id):
return {
"id": str(upload.uuid),
"channelId": str(channel_id),
"streamId": upload.track.id,
"title": upload.track.title,
"description": upload.track.description.as_plain_text
if upload.track.description
else "",
"coverArt": "at-{}".format(upload.track.attachment_cover.uuid)
if upload.track.attachment_cover
else "",
"isDir": "false",
"year": upload.track.creation_date.year,
"publishDate": upload.track.creation_date.isoformat(),
"created": upload.track.creation_date.isoformat(),
"genre": "Podcast",
"size": upload.size if upload.size else "",
"duration": upload.duration if upload.duration else "",
"bitrate": upload.bitrate / 1000 if upload.bitrate else "",
"contentType": upload.mimetype or "audio/mpeg",
"suffix": upload.extension or "mp3",
"status": "completed",
}

View file

@ -6,7 +6,8 @@ import functools
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Q
from django.db import transaction
from django.db.models import Count, Prefetch, Q
from django.utils import timezone
from rest_framework import exceptions
from rest_framework import permissions as rest_permissions
@ -16,12 +17,17 @@ from rest_framework.serializers import ValidationError
import funkwhale_api
from funkwhale_api.activity import record
from funkwhale_api.audio import models as audio_models
from funkwhale_api.audio import serializers as audio_serializers
from funkwhale_api.audio import views as audio_views
from funkwhale_api.common import (
fields,
preferences,
models as common_models,
utils as common_utils,
tasks as common_tasks,
)
from funkwhale_api.federation import models as federation_models
from funkwhale_api.favorites.models import TrackFavorite
from funkwhale_api.moderation import filters as moderation_filters
from funkwhale_api.music import models as music_models
@ -101,6 +107,22 @@ def get_playlist_qs(request):
return qs.order_by("-creation_date")
def requires_channels(f):
@functools.wraps(f)
def inner(*args, **kwargs):
if not preferences.get("audio__channels_enabled"):
payload = {
"error": {
"code": 0,
"message": "Channels / podcasts are disabled on this pod",
}
}
return response.Response(payload, status=405)
return f(*args, **kwargs)
return inner
class SubsonicViewSet(viewsets.GenericViewSet):
content_negotiation_class = negotiation.SubsonicContentNegociation
authentication_classes = [authentication.SubsonicAuthentication]
@ -752,6 +774,14 @@ class SubsonicViewSet(viewsets.GenericViewSet):
{"error": {"code": 70, "message": "cover art not found."}}
)
attachment = album.attachment_cover
elif id.startswith("at-"):
try:
attachment_id = id.replace("at-", "")
attachment = common_models.Attachment.objects.get(uuid=attachment_id)
except (TypeError, ValueError, music_models.Album.DoesNotExist):
return response.Response(
{"error": {"code": 70, "message": "cover art not found."}}
)
else:
return response.Response(
{"error": {"code": 70, "message": "cover art not found."}}
@ -810,3 +840,149 @@ class SubsonicViewSet(viewsets.GenericViewSet):
"genres": {"genre": [serializers.get_genre_data(tag) for tag in queryset]}
}
return response.Response(data)
# podcast related views
@action(
detail=False,
methods=["get", "post"],
url_name="create_podcast_channel",
url_path="createPodcastChannel",
)
@requires_channels
@transaction.atomic
def create_podcast_channel(self, request, *args, **kwargs):
data = request.GET or request.POST
serializer = audio_serializers.RssSubscribeSerializer(data=data)
if not serializer.is_valid():
return response.Response({"error": {"code": 0, "message": "invalid url"}})
channel = (
audio_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 = audio_serializers.get_channel_from_rss_url(
serializer.validated_data["url"]
)
except audio_serializers.FeedFetchException as e:
return response.Response(
{
"error": {
"code": 0,
"message": "Error while fetching url: {}".format(e),
}
}
)
subscription = federation_models.Follow(actor=request.user.actor)
subscription.fid = subscription.get_federation_id()
audio_views.SubscriptionsViewSet.queryset.get_or_create(
target=channel.actor,
actor=request.user.actor,
defaults={
"approved": True,
"fid": subscription.fid,
"uuid": subscription.uuid,
},
)
return response.Response({"status": "ok"})
@action(
detail=False,
methods=["get", "post"],
url_name="delete_podcast_channel",
url_path="deletePodcastChannel",
)
@requires_channels
@find_object(
audio_models.Channel.objects.all().select_related("actor"),
model_field="uuid",
field="id",
cast=str,
)
def delete_podcast_channel(self, request, *args, **kwargs):
channel = kwargs.pop("obj")
actor = request.user.actor
actor.emitted_follows.filter(target=channel.actor).delete()
return response.Response({"status": "ok"})
@action(
detail=False,
methods=["get", "post"],
url_name="get_podcasts",
url_path="getPodcasts",
)
@requires_channels
def get_podcasts(self, request, *args, **kwargs):
data = request.GET or request.POST
id = data.get("id")
channels = audio_models.Channel.objects.subscribed(request.user.actor)
if id:
channels = channels.filter(uuid=id)
channels = channels.select_related(
"artist__attachment_cover", "artist__description", "library", "actor"
)
uploads_qs = (
music_models.Upload.objects.playable_by(request.user.actor)
.select_related("track__attachment_cover", "track__description",)
.order_by("-track__creation_date")
)
if data.get("includeEpisodes", "true") == "true":
channels = channels.prefetch_related(
Prefetch(
"library__uploads",
queryset=uploads_qs,
to_attr="_prefetched_uploads",
)
)
data = {
"podcasts": {
"channel": [
serializers.get_channel_data(
channel, getattr(channel.library, "_prefetched_uploads", [])
)
for channel in channels
]
},
}
return response.Response(data)
@action(
detail=False,
methods=["get", "post"],
url_name="get_newest_podcasts",
url_path="getNewestPodcasts",
)
@requires_channels
def get_newest_podcasts(self, request, *args, **kwargs):
data = request.GET or request.POST
try:
count = int(data["count"])
except (TypeError, KeyError, ValueError):
count = 20
channels = audio_models.Channel.objects.subscribed(request.user.actor)
uploads = (
music_models.Upload.objects.playable_by(request.user.actor)
.filter(library__channel__in=channels)
.select_related(
"track__attachment_cover", "track__description", "library__channel"
)
.order_by("-track__creation_date")
)
data = {
"newestPodcasts": {
"episode": [
serializers.get_channel_episode_data(
upload, upload.library.channel.uuid
)
for upload in uploads[:count]
]
}
}
return response.Response(data)