kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
				
				
				
			Merge branch '170-subsonic-podcasts' into 'develop'
See #170: subsonic API for podcasts See merge request funkwhale/funkwhale!1057environments/review-front-list-6rg6z1/deployments/4496
						commit
						831b6e1a44
					
				| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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]
 | 
			
		||||
            ],
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Ładowanie…
	
		Reference in New Issue