Merge branch '170-subsonic-podcasts' into 'develop'

See #170: subsonic API for podcasts

See merge request funkwhale/funkwhale!1057
This commit is contained in:
Eliot Berriot 2020-03-18 15:52:23 +01:00
commit 831b6e1a44
8 changed files with 452 additions and 9 deletions

View File

@ -135,7 +135,7 @@ test_api:
only:
- branches
before_script:
- apk add make git gcc python3-dev
- apk add make git gcc python3-dev musl-dev
- cd api
- pip3 install -r requirements/base.txt
- pip3 install -r requirements/local.txt

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)

View File

@ -33,3 +33,5 @@ env =
PROXY_MEDIA=true
MUSIC_USE_DENORMALIZATION=true
EXTERNAL_MEDIA_PROXY_ENABLED=true
DISABLE_PASSWORD_VALIDATORS=false
DISABLE_PASSWORD_VALIDATORS=false

View File

@ -302,3 +302,55 @@ def test_scrobble_serializer(factories):
assert listening.user == user
assert listening.track == track
def test_channel_serializer(factories):
description = factories["common.Content"]()
channel = factories["audio.Channel"](external=True, artist__description=description)
upload = factories["music.Upload"](
playable=True, library=channel.library, duration=42
)
expected = {
"id": str(channel.uuid),
"url": channel.rss_url,
"title": channel.artist.name,
"description": description.as_plain_text,
"coverArt": "at-{}".format(channel.artist.attachment_cover.uuid),
"originalImageUrl": channel.artist.attachment_cover.url,
"status": "completed",
"episode": [serializers.get_channel_episode_data(upload, channel.uuid)],
}
data = serializers.get_channel_data(channel, [upload])
assert data == expected
def test_channel_episode_serializer(factories):
description = factories["common.Content"]()
channel = factories["audio.Channel"]()
track = factories["music.Track"](description=description, artist=channel.artist)
upload = factories["music.Upload"](
playable=True, track=track, bitrate=128000, duration=42
)
expected = {
"id": str(upload.uuid),
"channelId": str(channel.uuid),
"streamId": upload.track.id,
"title": track.title,
"description": description.as_plain_text,
"coverArt": "at-{}".format(track.attachment_cover.uuid),
"isDir": "false",
"year": track.creation_date.year,
"created": track.creation_date.isoformat(),
"publishDate": track.creation_date.isoformat(),
"genre": "Podcast",
"size": upload.size,
"duration": upload.duration,
"bitrate": upload.bitrate / 1000,
"contentType": upload.mimetype,
"suffix": upload.extension,
"status": "completed",
}
data = serializers.get_channel_episode_data(upload, channel.uuid)
assert data == expected

View File

@ -731,6 +731,19 @@ def test_get_cover_art_album(factories, logged_in_api_client):
).decode("utf-8")
def test_get_cover_art_attachment(factories, logged_in_api_client):
attachment = factories["common.Attachment"]()
url = reverse("api:subsonic-get_cover_art")
assert url.endswith("getCoverArt") is True
response = logged_in_api_client.get(url, {"id": "at-{}".format(attachment.uuid)})
assert response.status_code == 200
assert response["Content-Type"] == ""
assert response["X-Accel-Redirect"] == music_views.get_file_path(
attachment.file
).decode("utf-8")
def test_get_avatar(factories, logged_in_api_client):
user = factories["users.User"]()
url = reverse("api:subsonic-get_avatar")
@ -776,7 +789,7 @@ def test_get_user(f, db, logged_in_api_client, factories):
"settingsRole": "false",
"playlistRole": "true",
"commentRole": "false",
"podcastRole": "false",
"podcastRole": "true",
"streamRole": "true",
"jukeboxRole": "true",
"coverArtRole": "false",
@ -787,3 +800,138 @@ def test_get_user(f, db, logged_in_api_client, factories):
],
}
}
def test_create_podcast_channel(logged_in_api_client, factories, mocker):
channel = factories["audio.Channel"](external=True)
rss_url = "https://rss.url/"
get_channel_from_rss_url = mocker.patch(
"funkwhale_api.audio.serializers.get_channel_from_rss_url",
return_value=(channel, []),
)
actor = logged_in_api_client.user.create_actor()
url = reverse("api:subsonic-create_podcast_channel")
assert url.endswith("createPodcastChannel") is True
response = logged_in_api_client.get(url, {"f": "json", "url": rss_url})
assert response.status_code == 200
assert response.data == {"status": "ok"}
subscription = actor.emitted_follows.get(target=channel.actor)
assert subscription.approved is True
get_channel_from_rss_url.assert_called_once_with(rss_url)
def test_delete_podcast_channel(logged_in_api_client, factories, mocker):
actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"](external=True)
subscription = factories["federation.Follow"](actor=actor, target=channel.actor)
other_subscription = factories["federation.Follow"](target=channel.actor)
url = reverse("api:subsonic-delete_podcast_channel")
assert url.endswith("deletePodcastChannel") is True
response = logged_in_api_client.get(url, {"f": "json", "id": channel.uuid})
assert response.status_code == 200
assert response.data == {"status": "ok"}
other_subscription.refresh_from_db()
with pytest.raises(subscription.DoesNotExist):
subscription.refresh_from_db()
def test_get_podcasts(logged_in_api_client, factories, mocker):
actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"](
external=True, library__privacy_level="everyone"
)
upload1 = factories["music.Upload"](
playable=True,
track__artist=channel.artist,
library=channel.library,
bitrate=128000,
duration=42,
)
upload2 = factories["music.Upload"](
playable=True,
track__artist=channel.artist,
library=channel.library,
bitrate=256000,
duration=43,
)
factories["federation.Follow"](actor=actor, target=channel.actor, approved=True)
factories["music.Upload"](import_status="pending", track__artist=channel.artist)
factories["audio.Channel"](external=True)
factories["federation.Follow"]()
url = reverse("api:subsonic-get_podcasts")
assert url.endswith("getPodcasts") is True
response = logged_in_api_client.get(url, {"f": "json"})
assert response.status_code == 200
assert response.data == {
"podcasts": {
"channel": [serializers.get_channel_data(channel, [upload2, upload1])],
}
}
def test_get_podcasts_by_id(logged_in_api_client, factories, mocker):
actor = logged_in_api_client.user.create_actor()
channel1 = factories["audio.Channel"](
external=True, library__privacy_level="everyone"
)
channel2 = factories["audio.Channel"](
external=True, library__privacy_level="everyone"
)
upload1 = factories["music.Upload"](
playable=True,
track__artist=channel1.artist,
library=channel1.library,
bitrate=128000,
duration=42,
)
factories["music.Upload"](
playable=True,
track__artist=channel2.artist,
library=channel2.library,
bitrate=256000,
duration=43,
)
factories["federation.Follow"](actor=actor, target=channel1.actor, approved=True)
factories["federation.Follow"](actor=actor, target=channel2.actor, approved=True)
url = reverse("api:subsonic-get_podcasts")
assert url.endswith("getPodcasts") is True
response = logged_in_api_client.get(url, {"f": "json", "id": channel1.uuid})
assert response.status_code == 200
assert response.data == {
"podcasts": {"channel": [serializers.get_channel_data(channel1, [upload1])]}
}
def test_get_newest_podcasts(logged_in_api_client, factories, mocker):
actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"](
external=True, library__privacy_level="everyone"
)
upload1 = factories["music.Upload"](
playable=True,
track__artist=channel.artist,
library=channel.library,
bitrate=128000,
duration=42,
)
upload2 = factories["music.Upload"](
playable=True,
track__artist=channel.artist,
library=channel.library,
bitrate=256000,
duration=43,
)
factories["federation.Follow"](actor=actor, target=channel.actor, approved=True)
url = reverse("api:subsonic-get_newest_podcasts")
assert url.endswith("getNewestPodcasts") is True
response = logged_in_api_client.get(url, {"f": "json"})
assert response.status_code == 200
assert response.data == {
"newestPodcasts": {
"episode": [
serializers.get_channel_episode_data(upload, channel.uuid)
for upload in [upload2, upload1]
],
}
}