1
0
mirror of https://github.com/spl0k/supysonic.git synced 2024-12-22 08:56:17 +00:00

Implement podcast: create+delete+get channel(s); delete episode

This commit is contained in:
Carl Hall 2020-07-05 11:16:54 -07:00
parent b438bb0121
commit dd4614d735
12 changed files with 503 additions and 29 deletions

View File

@ -78,13 +78,13 @@ or with version 1.8.0.
| [`createShare`](#createshare) | | ❌ |
| [`updateShare`](#updateshare) | | ❌ |
| [`deleteShare`](#deleteshare) | | ❌ |
| [`getPodcasts`](#getpodcasts) | | |
| [`getPodcasts`](#getpodcasts) | | ✔️ |
| [`getNewestPodcasts`](#getnewestpodcasts) | 1.14.0 | ❔ |
| [`refreshPodcasts`](#refreshpodcasts) | 1.9.0 | |
| [`createPodcastChannel`](#createpodcastchannel) | 1.9.0 | |
| [`deletePodcastChannel`](#deletepodcastchannel) | 1.9.0 | |
| [`deletePodcastEpisode`](#deletepodcastepisode) | 1.9.0 | |
| [`downloadPodcastEpisode`](#downloadpodcastepisode) | 1.9.0 | |
| [`refreshPodcasts`](#refreshpodcasts) | 1.9.0 | 📅 |
| [`createPodcastChannel`](#createpodcastchannel) | 1.9.0 | ✔️ |
| [`deletePodcastChannel`](#deletepodcastchannel) | 1.9.0 | ✔️ |
| [`deletePodcastEpisode`](#deletepodcastepisode) | 1.9.0 | ✔️ |
| [`downloadPodcastEpisode`](#downloadpodcastepisode) | 1.9.0 | 📅 |
| [`jukeboxControl`](#jukeboxcontrol) | | ✔️ |
| [`getInternetRadioStations`](#getinternetradiostations) | 1.9.0 | ✔️ |
| [`createInternetRadioStation`](#createinternetradiostation) | 1.16.0 | ✔️ |
@ -555,12 +555,12 @@ No parameter
### Podcast
#### `getPodcasts`
✔️
| Parameter | Vers. | |
|-------------------|-------|---|
| `includeEpisodes` | 1.9.0 | |
| `id` | 1.9.0 | |
| `includeEpisodes` | 1.9.0 | ✔️ |
| `id` | 1.9.0 | ✔️ |
#### `getNewestPodcasts`
❔ 1.14.0
@ -575,25 +575,25 @@ No parameter
No parameter
#### `createPodcastChannel`
1.9.0
✔️ 1.9.0
| Parameter | Vers. | |
|-----------|-------|---|
| `url` | 1.9.0 | |
| `url` | 1.9.0 | ✔️ |
#### `deletePodcastChannel`
1.9.0
✔️ 1.9.0
| Parameter | Vers. | |
|-----------|-------|---|
| `id` | 1.9.0 | |
| `id` | 1.9.0 | ✔️ |
#### `deletePodcastEpisode`
1.9.0
✔️ 1.9.0
| Parameter | Vers. | |
|-----------|-------|---|
| `id` | 1.9.0 | |
| `id` | 1.9.0 | ✔️ |
#### `downloadPodcastEpisode`
@ -619,35 +619,35 @@ No parameter
### Internet radio
#### `getInternetRadioStations`
1.9.0
✔️ 1.9.0
No parameter
#### `createInternetRadioStation`
1.16.0
✔️ 1.16.0
| Parameter | Vers. | |
|---------------|--------|---|
| `streamUrl` | 1.16.0 | |
| `name` | 1.16.0 | |
| `homepageUrl` | 1.16.0 | |
| `streamUrl` | 1.16.0 | ✔️ |
| `name` | 1.16.0 | ✔️ |
| `homepageUrl` | 1.16.0 | ✔️ |
#### `updateInternetRadioStation`
1.16.0
✔️ 1.16.0
| Parameter | Vers. | |
|---------------|--------|---|
| `id` | 1.16.0 | |
| `streamUrl` | 1.16.0 | |
| `name` | 1.16.0 | |
| `homepageUrl` | 1.16.0 | |
| `id` | 1.16.0 | ✔️ |
| `streamUrl` | 1.16.0 | ✔️ |
| `name` | 1.16.0 | ✔️ |
| `homepageUrl` | 1.16.0 | ✔️ |
#### `deleteInternetRadioStation`
1.16.0
✔️ 1.16.0
| Parameter | Vers. | |
|-----------|--------|---|
| `id` | 1.16.0 | |
| `id` | 1.16.0 | ✔️ |
### Chat

View File

@ -14,12 +14,13 @@ import uuid
from flask import request
from flask import Blueprint
from functools import wraps
from pony.orm import ObjectNotFound
from pony.orm import commit
from ..managers.user import UserManager
from .exceptions import Unauthorized
from .exceptions import Unauthorized, Forbidden
from .formatters import JSONFormatter, JSONPFormatter, XMLFormatter
api = Blueprint("api", __name__)
@ -104,6 +105,21 @@ def get_entity_id(cls, eid):
raise GenericError("Invalid ID")
def require_podcast(f):
@wraps(f)
def decorated(*args, **kwargs):
is_admin = request.user and request.user.admin
is_podcast = request.user and request.user.podcast
if not is_admin and not is_podcast:
raise Forbidden()
return f(*args, **kwargs)
return decorated
from .errors import *
from .system import *
@ -115,6 +131,7 @@ from .annotation import *
from .chat import *
from .search import *
from .playlists import *
from .podcast import *
from .jukebox import *
from .radio import *
from .unsupported import *

60
supysonic/api/podcast.py Normal file
View File

@ -0,0 +1,60 @@
# coding: utf-8
#
# This file is part of Supysonic.
# Supysonic is a Python implementation of the Subsonic server API.
#
# Copyright (C) 2020 Alban 'spl0k' Féron
#
# Distributed under terms of the GNU AGPLv3 license.
from flask import request
from ..db import PodcastChannel, PodcastEpisode
from . import api, get_entity, require_podcast
from .exceptions import Forbidden, MissingParameter, NotFound
@api.route("/getPodcasts.view", methods=["GET", "POST"])
def get_podcasts():
include_episodes, channel_id = map(request.values.get, ["includeEpisodes", "id"])
if channel_id:
channels = (get_entity(PodcastChannel),)
else:
channels = PodcastChannel.select().sort_by(PodcastChannel.url)
return request.formatter(
"podcasts",
dict(channel=[ch.as_subsonic_channel(include_episodes) for ch in channels]),
)
@api.route("/createPodcastChannel.view", methods=["GET", "POST"])
@require_podcast
def create_podcast_channel():
url = request.values["url"]
PodcastChannel(url=url)
return request.formatter.empty
@api.route("/deletePodcastChannel.view", methods=["GET", "POST"])
@require_podcast
def delete_podcast_channel():
res = get_entity(PodcastChannel)
res.delete()
return request.formatter.empty
@api.route("/deletePodcastEpisode.view", methods=["GET", "POST"])
@require_podcast
def delete_podcast_episode():
res = get_entity(PodcastEpisode)
res.delete()
return request.formatter.empty

View File

@ -357,6 +357,7 @@ class User(db.Entity):
admin = Required(bool, default=False)
jukebox = Required(bool, default=False)
podcast = Required(bool, default=False)
lastfm_session = Optional(str, 32, nullable=True)
lastfm_status = Required(
@ -389,7 +390,7 @@ class User(db.Entity):
playlistRole=True,
coverArtRole=False,
commentRole=False,
podcastRole=False,
podcastRole=self.admin or self.podcast,
streamRole=True,
jukeboxRole=self.admin or self.jukebox,
shareRole=False,
@ -572,6 +573,63 @@ class RadioStation(db.Entity):
return info
class PodcastChannel(db.Entity):
_table_ = "podcast_channel"
id = PrimaryKey(UUID, default=uuid4)
url = Required(str)
title = Optional(str)
description = Optional(str)
cover_art = Optional(str)
original_image_url = Optional(str)
status = Required(str, default="new")
error_message = Optional(str)
created = Required(datetime, precision=0, default=now)
last_fetched = Optional(datetime, precision=0)
episodes = Set(lambda: PodcastEpisode, lazy=True)
def as_subsonic_channel(self, include_episodes=False):
info = dict(
id=self.id,
url=self.url,
title=self.title,
description=self.description,
status=self.status,
errorMessage=self.error_message,
)
if include_episodes:
self.episodes.load()
info["episode"] = [ep.as_subsonic_episode() for ep in self.episodes]
return info
class PodcastEpisode(db.Entity):
_table_ = "podcast_episode"
id = PrimaryKey(UUID, default=uuid4)
channel = Required(PodcastChannel, column="channel_id")
stream_url = Optional(str)
file_path = Optional(str)
title = Optional(str)
description = Optional(str)
duration = Optional(str)
status = Required(str, default="new")
publish_date = Optional(datetime, precision=0, default=now)
created = Required(datetime, precision=0, default=now)
def as_subsonic_episode(self):
info = dict(
id=self.id,
isDir=False,
streamId="podcast:{}".format(self.id),
title=self.title,
description=self.description,
status=self.status,
publishDate=self.publish_date.isoformat(),
)
return info
def parse_uri(database_uri):
if not isinstance(database_uri, str):
raise TypeError("Expecting a string")

View File

@ -0,0 +1,29 @@
ALTER TABLE user ADD podcast BOOLEAN DEFAULT false NOT NULL;
CREATE TABLE IF NOT EXISTS podcast_channel (
id BINARY(16) PRIMARY KEY,
url VARCHAR(256) NOT NULL,
title VARCHAR(256),
description VARCHAR(256),
cover_art VARCHAR(256),
original_image_url VARCHAR(256),
status VARCHAR(16),
error_message VARCHAR(256),
created TIMESTAMP NOT NULL,
last_fetched TIMESTAMP
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS podcast_episode (
id BINARY(16) PRIMARY KEY,
stream_url VARCHAR(256),
file_path VARCHAR(256),
channel_id CHAR(36) NOT NULL,
title VARCHAR(256),
description VARCHAR(256),
duration VARCHAR(8),
status VARCHAR(16) NOT NULL,
publish_date DATETIME,
created DATETIME NOT NULL
FOREIGN KEY(channel_id) REFERENCES podcast_channel(id),
INDEX index_episode_channel_id_fk (channel_id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -0,0 +1,28 @@
ALTER TABLE user ADD podcast BOOLEAN DEFAULT false NOT NULL;
CREATE TABLE IF NOT EXISTS podcast_channel (
id UUID PRIMARY KEY,
url VARCHAR(256) NOT NULL,
title VARCHAR(256),
description VARCHAR(256),
cover_art VARCHAR(256),
original_image_url VARCHAR(256),
status VARCHAR(16),
error_message VARCHAR(256),
created TIMESTAMP NOT NULL,
last_fetched TIMESTAMP
);
CREATE TABLE IF NOT EXISTS podcast_episode (
id UUID PRIMARY KEY,
stream_url VARCHAR(256),
file_path VARCHAR(256),
channel_id CHAR(36) NOT NULL REFERENCES podcast_channel(id),
title VARCHAR(256),
description VARCHAR(256),
duration VARCHAR(8),
status VARCHAR(16) NOT NULL,
publish_date TIMESTAMP,
created TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS index_episode_channel_id_fk ON podcast_channel(id);

View File

@ -0,0 +1,28 @@
ALTER TABLE user ADD podcast BOOLEAN DEFAULT false NOT NULL;
CREATE TABLE IF NOT EXISTS podcast_channel (
id CHAR(36) PRIMARY KEY,
url VARCHAR(256) NOT NULL,
title VARCHAR(256),
description VARCHAR(256),
cover_art VARCHAR(256),
original_image_url VARCHAR(256),
status VARCHAR(16),
error_message VARCHAR(256),
created DATETIME NOT NULL,
last_fetched DATETIME
);
CREATE TABLE IF NOT EXISTS podcast_episode (
id CHAR(36) PRIMARY KEY,
stream_url VARCHAR(256),
file_path VARCHAR(256),
channel_id CHAR(36) NOT NULL,
title VARCHAR(256),
description VARCHAR(256),
duration VARCHAR(8),
status VARCHAR(16) NOT NULL,
publish_date DATETIME,
created DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS index_episode_channel_id_fk ON podcast_channel(id);

View File

@ -57,6 +57,7 @@ CREATE TABLE IF NOT EXISTS user (
salt CHAR(6) NOT NULL,
admin BOOLEAN NOT NULL,
jukebox BOOLEAN NOT NULL,
podcast BOOLEAN NOT NULL,
lastfm_session CHAR(32),
lastfm_status BOOLEAN NOT NULL,
last_play_id BINARY(16) REFERENCES track(id),
@ -158,3 +159,30 @@ CREATE TABLE IF NOT EXISTS radio_station (
created DATETIME NOT NULL
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS podcast_channel (
id BINARY(16) PRIMARY KEY,
url VARCHAR(256) NOT NULL,
title VARCHAR(256),
description VARCHAR(256),
cover_art VARCHAR(256),
original_image_url VARCHAR(256),
status VARCHAR(16),
error_message VARCHAR(256),
created TIMESTAMP NOT NULL,
last_fetched TIMESTAMP
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS podcast_episode (
id BINARY(16) PRIMARY KEY,
stream_url VARCHAR(256),
file_path VARCHAR(256),
channel_id CHAR(36) NOT NULL,
title VARCHAR(256),
description VARCHAR(256),
duration VARCHAR(8),
status VARCHAR(16) NOT NULL,
publish_date DATETIME,
created DATETIME NOT NULL,
FOREIGN KEY(channel_id) REFERENCES podcast_channel(id),
INDEX index_episode_channel_id_fk (channel_id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -57,6 +57,7 @@ CREATE TABLE IF NOT EXISTS "user" (
salt CHAR(6) NOT NULL,
admin BOOLEAN NOT NULL,
jukebox BOOLEAN NOT NULL,
podcast BOOLEAN NOT NULL,
lastfm_session CHAR(32),
lastfm_status BOOLEAN NOT NULL,
last_play_id UUID REFERENCES track,
@ -158,3 +159,29 @@ CREATE TABLE IF NOT EXISTS radio_station (
created TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS podcast_channel (
id UUID PRIMARY KEY,
url VARCHAR(256) NOT NULL,
title VARCHAR(256),
description VARCHAR(256),
cover_art VARCHAR(256),
original_image_url VARCHAR(256),
status VARCHAR(16),
error_message VARCHAR(256),
created TIMESTAMP NOT NULL,
last_fetched TIMESTAMP
);
CREATE TABLE IF NOT EXISTS podcast_episode (
id UUID PRIMARY KEY,
stream_url VARCHAR(256),
file_path VARCHAR(256),
channel_id CHAR(36) NOT NULL REFERENCES podcast_channel(id),
title VARCHAR(256),
description VARCHAR(256),
duration VARCHAR(8),
status VARCHAR(16) NOT NULL,
publish_date TIMESTAMP,
created TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS index_episode_channel_id_fk ON podcast_channel(id);

View File

@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS user (
salt CHAR(6) NOT NULL,
admin BOOLEAN NOT NULL,
jukebox BOOLEAN NOT NULL,
podcast BOOLEAN NOT NULL,
lastfm_session CHAR(32),
lastfm_status BOOLEAN NOT NULL,
last_play_id CHAR(36) REFERENCES track,
@ -160,3 +161,29 @@ CREATE TABLE IF NOT EXISTS radio_station (
created DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS podcast_channel (
id CHAR(36) PRIMARY KEY,
url VARCHAR(256) NOT NULL,
title VARCHAR(256),
description VARCHAR(256),
cover_art VARCHAR(256),
original_image_url VARCHAR(256),
status VARCHAR(16),
error_message VARCHAR(256),
created DATETIME NOT NULL,
last_fetched DATETIME
);
CREATE TABLE IF NOT EXISTS podcast_episode (
id CHAR(36) PRIMARY KEY,
stream_url VARCHAR(256),
file_path VARCHAR(256),
channel_id CHAR(36) NOT NULL,
title VARCHAR(256),
description VARCHAR(256),
duration VARCHAR(8),
status VARCHAR(16) NOT NULL,
publish_date DATETIME,
created DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS index_episode_channel_id_fk ON podcast_channel(id);

View File

@ -22,6 +22,7 @@ from .test_annotation import AnnotationTestCase
from .test_media import MediaTestCase
from .test_transcoding import TranscodingTestCase
from .test_radio import RadioStationTestCase
from .test_podcast import PodcastTestCase
def suite():
@ -40,5 +41,6 @@ def suite():
suite.addTest(unittest.makeSuite(MediaTestCase))
suite.addTest(unittest.makeSuite(TranscodingTestCase))
suite.addTest(unittest.makeSuite(RadioStationTestCase))
suite.addTest(unittest.makeSuite(PodcastTestCase))
return suite

170
tests/api/test_podcast.py Normal file
View File

@ -0,0 +1,170 @@
#!/usr/bin/env python
# coding: utf-8
#
# This file is part of Supysonic.
# Supysonic is a Python implementation of the Subsonic server API.
#
# Copyright (C) 2020 Alban 'spl0k' Féron
#
# Distributed under terms of the GNU AGPLv3 license.
import uuid
from pony.orm import db_session
from supysonic.db import PodcastChannel, PodcastEpisode
from unittest import skip
from .apitestbase import ApiTestBase
class PodcastTestCase(ApiTestBase):
_non_admin_user_ = {"u": "bob", "p": "B0b", "username": "alice"}
def setUp(self):
super(PodcastTestCase, self).setUp()
@db_session
def assertDbCountEqual(self, entity, count):
self.assertEqual(entity.select().count(), count)
def assertPodcastChannelEquals(self, channel, url, status, title='', description='', error_message=''):
self.assertEqual(channel.url, url)
self.assertEqual(channel.title, title)
self.assertEqual(channel.description, description)
self.assertEqual(channel.status, status)
self.assertEqual(channel.error_message, error_message)
def test_create_podcast_channel(self):
# test for non-admin access
self._make_request(
"createPodcastChannel",
self._non_admin_user_,
error=50
)
# check params
self._make_request("createPodcastChannel", error=10)
# create w/ required fields
url = "https://example.local/podcast_channel/create"
self._make_request("createPodcastChannel", {"url": url})
# the correct value is 2 because _make_request uses GET then POST
self.assertDbCountEqual(PodcastChannel, 2)
with db_session:
for channel in PodcastChannel.select():
self.assertPodcastChannelEquals(channel, url, "new")
def test_delete_podcast_channel(self):
# test for non-admin access
self._make_request(
"deletePodcastChannel",
self._non_admin_user_,
error=50
)
# check params
self._make_request("deletePodcastChannel", error=10)
self._make_request("deletePodcastChannel", {"id": 1}, error=0)
self._make_request("deletePodcastChannel", {"id": str(uuid.uuid4())}, error=70)
# delete
with db_session:
channel = PodcastChannel(
url="https://example.local/podcast/delete",
status="new",
)
self._make_request("deletePodcastChannel", {"id": channel.id}, skip_post=True)
self.assertDbCountEqual(PodcastChannel, 0)
def test_delete_podcast_episode(self):
# test for non-admin access
self._make_request(
"deletePodcastEpisode",
self._non_admin_user_,
error=50
)
# check params
self._make_request("deletePodcastEpisode", error=10)
self._make_request("deletePodcastEpisode", {"id": 1}, error=0)
self._make_request("deletePodcastEpisode", {"id": str(uuid.uuid4())}, error=70)
# delete
with db_session:
channel = PodcastChannel(
url="https://example.local/episode/delete",
status="new",
)
episode = channel.episodes.create(
description="Test Episode 1",
status="new",
)
channel.episodes.create(
description="Test Episode 2",
status="new",
)
# validate starting condition
self.assertDbCountEqual(PodcastEpisode, 2)
# validate delete of an episode
self._make_request("deletePodcastEpisode", {"id": episode.id}, skip_post=True)
self.assertDbCountEqual(PodcastEpisode, 1)
# test for cascading delete on PodcastChannel
self._make_request("deletePodcastChannel", {"id": channel.id}, skip_post=True)
self.assertDbCountEqual(PodcastChannel, 0)
self.assertDbCountEqual(PodcastEpisode, 0)
def test_get_podcasts(self):
test_range = 3
with db_session:
for x in range(test_range):
ch = PodcastChannel(
url="https://example.local/podcast-{}".format(x),
status="new",
)
for y in range(x + 1):
ch.episodes.create(description="episode {} for channel {}".format(y, x))
# verify data is stored
self.assertDbCountEqual(PodcastChannel, test_range)
# compare api response to inventory
rv, channels = self._make_request("getPodcasts", tag="podcasts")
self.assertEqual(len(channels), test_range)
# This order is guaranteed to work because the api returns in order by name.
# Test data is sequential by design.
for x in range(test_range):
channel = channels[x]
self.assertTrue(channel.get("url").endswith("podcast-{}".format(x)))
self.assertTrue(channel.get("status").endswith("new"))
# test for non-admin access
rv, channels = self._make_request(
"getPodcasts",
self._non_admin_user_,
tag="podcasts",
)
self.assertEqual(len(channels), test_range)
# test retrieving a podcast by id
for channel in channels:
rv, test_channels = self._make_request("getPodcasts", {"id": channel.get("id"), "includeEpisodes": True}, tag="podcasts", skip_post=True)
# expect to work with only 1
self.assertEqual(len(test_channels), 1)
test_channel = test_channels[0]
self.assertEqual(test_channel.get("id"), channel.get("id"))
# should have as many episodes as noted in the url
count = int(channel.get("url")[-1]) + 1
self.assertEqual(len(test_channel), count)