mirror of
https://github.com/spl0k/supysonic.git
synced 2024-12-22 17:06:17 +00:00
Implement podcast: create+delete+get channel(s); delete episode
This commit is contained in:
parent
b438bb0121
commit
dd4614d735
54
docs/api.md
54
docs/api.md
@ -78,13 +78,13 @@ or with version 1.8.0.
|
|||||||
| [`createShare`](#createshare) | | ❌ |
|
| [`createShare`](#createshare) | | ❌ |
|
||||||
| [`updateShare`](#updateshare) | | ❌ |
|
| [`updateShare`](#updateshare) | | ❌ |
|
||||||
| [`deleteShare`](#deleteshare) | | ❌ |
|
| [`deleteShare`](#deleteshare) | | ❌ |
|
||||||
| [`getPodcasts`](#getpodcasts) | | ❔ |
|
| [`getPodcasts`](#getpodcasts) | | ✔️ |
|
||||||
| [`getNewestPodcasts`](#getnewestpodcasts) | 1.14.0 | ❔ |
|
| [`getNewestPodcasts`](#getnewestpodcasts) | 1.14.0 | ❔ |
|
||||||
| [`refreshPodcasts`](#refreshpodcasts) | 1.9.0 | ❔ |
|
| [`refreshPodcasts`](#refreshpodcasts) | 1.9.0 | 📅 |
|
||||||
| [`createPodcastChannel`](#createpodcastchannel) | 1.9.0 | ❔ |
|
| [`createPodcastChannel`](#createpodcastchannel) | 1.9.0 | ✔️ |
|
||||||
| [`deletePodcastChannel`](#deletepodcastchannel) | 1.9.0 | ❔ |
|
| [`deletePodcastChannel`](#deletepodcastchannel) | 1.9.0 | ✔️ |
|
||||||
| [`deletePodcastEpisode`](#deletepodcastepisode) | 1.9.0 | ❔ |
|
| [`deletePodcastEpisode`](#deletepodcastepisode) | 1.9.0 | ✔️ |
|
||||||
| [`downloadPodcastEpisode`](#downloadpodcastepisode) | 1.9.0 | ❔ |
|
| [`downloadPodcastEpisode`](#downloadpodcastepisode) | 1.9.0 | 📅 |
|
||||||
| [`jukeboxControl`](#jukeboxcontrol) | | ✔️ |
|
| [`jukeboxControl`](#jukeboxcontrol) | | ✔️ |
|
||||||
| [`getInternetRadioStations`](#getinternetradiostations) | 1.9.0 | ✔️ |
|
| [`getInternetRadioStations`](#getinternetradiostations) | 1.9.0 | ✔️ |
|
||||||
| [`createInternetRadioStation`](#createinternetradiostation) | 1.16.0 | ✔️ |
|
| [`createInternetRadioStation`](#createinternetradiostation) | 1.16.0 | ✔️ |
|
||||||
@ -555,12 +555,12 @@ No parameter
|
|||||||
### Podcast
|
### Podcast
|
||||||
|
|
||||||
#### `getPodcasts`
|
#### `getPodcasts`
|
||||||
❔
|
✔️
|
||||||
|
|
||||||
| Parameter | Vers. | |
|
| Parameter | Vers. | |
|
||||||
|-------------------|-------|---|
|
|-------------------|-------|---|
|
||||||
| `includeEpisodes` | 1.9.0 | ❔ |
|
| `includeEpisodes` | 1.9.0 | ✔️ |
|
||||||
| `id` | 1.9.0 | ❔ |
|
| `id` | 1.9.0 | ✔️ |
|
||||||
|
|
||||||
#### `getNewestPodcasts`
|
#### `getNewestPodcasts`
|
||||||
❔ 1.14.0
|
❔ 1.14.0
|
||||||
@ -575,25 +575,25 @@ No parameter
|
|||||||
No parameter
|
No parameter
|
||||||
|
|
||||||
#### `createPodcastChannel`
|
#### `createPodcastChannel`
|
||||||
❔ 1.9.0
|
✔️ 1.9.0
|
||||||
|
|
||||||
| Parameter | Vers. | |
|
| Parameter | Vers. | |
|
||||||
|-----------|-------|---|
|
|-----------|-------|---|
|
||||||
| `url` | 1.9.0 | ❔ |
|
| `url` | 1.9.0 | ✔️ |
|
||||||
|
|
||||||
#### `deletePodcastChannel`
|
#### `deletePodcastChannel`
|
||||||
❔ 1.9.0
|
✔️ 1.9.0
|
||||||
|
|
||||||
| Parameter | Vers. | |
|
| Parameter | Vers. | |
|
||||||
|-----------|-------|---|
|
|-----------|-------|---|
|
||||||
| `id` | 1.9.0 | ❔ |
|
| `id` | 1.9.0 | ✔️ |
|
||||||
|
|
||||||
#### `deletePodcastEpisode`
|
#### `deletePodcastEpisode`
|
||||||
❔ 1.9.0
|
✔️ 1.9.0
|
||||||
|
|
||||||
| Parameter | Vers. | |
|
| Parameter | Vers. | |
|
||||||
|-----------|-------|---|
|
|-----------|-------|---|
|
||||||
| `id` | 1.9.0 | ❔ |
|
| `id` | 1.9.0 | ✔️ |
|
||||||
|
|
||||||
|
|
||||||
#### `downloadPodcastEpisode`
|
#### `downloadPodcastEpisode`
|
||||||
@ -619,35 +619,35 @@ No parameter
|
|||||||
### Internet radio
|
### Internet radio
|
||||||
|
|
||||||
#### `getInternetRadioStations`
|
#### `getInternetRadioStations`
|
||||||
❔ 1.9.0
|
✔️ 1.9.0
|
||||||
|
|
||||||
No parameter
|
No parameter
|
||||||
|
|
||||||
#### `createInternetRadioStation`
|
#### `createInternetRadioStation`
|
||||||
❔ 1.16.0
|
✔️ 1.16.0
|
||||||
|
|
||||||
| Parameter | Vers. | |
|
| Parameter | Vers. | |
|
||||||
|---------------|--------|---|
|
|---------------|--------|---|
|
||||||
| `streamUrl` | 1.16.0 | ❔ |
|
| `streamUrl` | 1.16.0 | ✔️ |
|
||||||
| `name` | 1.16.0 | ❔ |
|
| `name` | 1.16.0 | ✔️ |
|
||||||
| `homepageUrl` | 1.16.0 | ❔ |
|
| `homepageUrl` | 1.16.0 | ✔️ |
|
||||||
|
|
||||||
#### `updateInternetRadioStation`
|
#### `updateInternetRadioStation`
|
||||||
❔ 1.16.0
|
✔️ 1.16.0
|
||||||
|
|
||||||
| Parameter | Vers. | |
|
| Parameter | Vers. | |
|
||||||
|---------------|--------|---|
|
|---------------|--------|---|
|
||||||
| `id` | 1.16.0 | ❔ |
|
| `id` | 1.16.0 | ✔️ |
|
||||||
| `streamUrl` | 1.16.0 | ❔ |
|
| `streamUrl` | 1.16.0 | ✔️ |
|
||||||
| `name` | 1.16.0 | ❔ |
|
| `name` | 1.16.0 | ✔️ |
|
||||||
| `homepageUrl` | 1.16.0 | ❔ |
|
| `homepageUrl` | 1.16.0 | ✔️ |
|
||||||
|
|
||||||
#### `deleteInternetRadioStation`
|
#### `deleteInternetRadioStation`
|
||||||
❔ 1.16.0
|
✔️ 1.16.0
|
||||||
|
|
||||||
| Parameter | Vers. | |
|
| Parameter | Vers. | |
|
||||||
|-----------|--------|---|
|
|-----------|--------|---|
|
||||||
| `id` | 1.16.0 | ❔ |
|
| `id` | 1.16.0 | ✔️ |
|
||||||
|
|
||||||
### Chat
|
### Chat
|
||||||
|
|
||||||
|
@ -14,12 +14,13 @@ import uuid
|
|||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
|
from functools import wraps
|
||||||
from pony.orm import ObjectNotFound
|
from pony.orm import ObjectNotFound
|
||||||
from pony.orm import commit
|
from pony.orm import commit
|
||||||
|
|
||||||
from ..managers.user import UserManager
|
from ..managers.user import UserManager
|
||||||
|
|
||||||
from .exceptions import Unauthorized
|
from .exceptions import Unauthorized, Forbidden
|
||||||
from .formatters import JSONFormatter, JSONPFormatter, XMLFormatter
|
from .formatters import JSONFormatter, JSONPFormatter, XMLFormatter
|
||||||
|
|
||||||
api = Blueprint("api", __name__)
|
api = Blueprint("api", __name__)
|
||||||
@ -104,6 +105,21 @@ def get_entity_id(cls, eid):
|
|||||||
raise GenericError("Invalid ID")
|
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 .errors import *
|
||||||
|
|
||||||
from .system import *
|
from .system import *
|
||||||
@ -115,6 +131,7 @@ from .annotation import *
|
|||||||
from .chat import *
|
from .chat import *
|
||||||
from .search import *
|
from .search import *
|
||||||
from .playlists import *
|
from .playlists import *
|
||||||
|
from .podcast import *
|
||||||
from .jukebox import *
|
from .jukebox import *
|
||||||
from .radio import *
|
from .radio import *
|
||||||
from .unsupported import *
|
from .unsupported import *
|
||||||
|
60
supysonic/api/podcast.py
Normal file
60
supysonic/api/podcast.py
Normal 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
|
||||||
|
|
||||||
|
|
@ -357,6 +357,7 @@ class User(db.Entity):
|
|||||||
|
|
||||||
admin = Required(bool, default=False)
|
admin = Required(bool, default=False)
|
||||||
jukebox = Required(bool, default=False)
|
jukebox = Required(bool, default=False)
|
||||||
|
podcast = Required(bool, default=False)
|
||||||
|
|
||||||
lastfm_session = Optional(str, 32, nullable=True)
|
lastfm_session = Optional(str, 32, nullable=True)
|
||||||
lastfm_status = Required(
|
lastfm_status = Required(
|
||||||
@ -389,7 +390,7 @@ class User(db.Entity):
|
|||||||
playlistRole=True,
|
playlistRole=True,
|
||||||
coverArtRole=False,
|
coverArtRole=False,
|
||||||
commentRole=False,
|
commentRole=False,
|
||||||
podcastRole=False,
|
podcastRole=self.admin or self.podcast,
|
||||||
streamRole=True,
|
streamRole=True,
|
||||||
jukeboxRole=self.admin or self.jukebox,
|
jukeboxRole=self.admin or self.jukebox,
|
||||||
shareRole=False,
|
shareRole=False,
|
||||||
@ -572,6 +573,63 @@ class RadioStation(db.Entity):
|
|||||||
return info
|
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):
|
def parse_uri(database_uri):
|
||||||
if not isinstance(database_uri, str):
|
if not isinstance(database_uri, str):
|
||||||
raise TypeError("Expecting a string")
|
raise TypeError("Expecting a string")
|
||||||
|
29
supysonic/schema/migration/mysql/20200620.sql
Normal file
29
supysonic/schema/migration/mysql/20200620.sql
Normal 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;
|
28
supysonic/schema/migration/postgres/20200620.sql
Normal file
28
supysonic/schema/migration/postgres/20200620.sql
Normal 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);
|
28
supysonic/schema/migration/sqlite/20200620.sql
Normal file
28
supysonic/schema/migration/sqlite/20200620.sql
Normal 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);
|
@ -57,6 +57,7 @@ CREATE TABLE IF NOT EXISTS user (
|
|||||||
salt CHAR(6) NOT NULL,
|
salt CHAR(6) NOT NULL,
|
||||||
admin BOOLEAN NOT NULL,
|
admin BOOLEAN NOT NULL,
|
||||||
jukebox BOOLEAN NOT NULL,
|
jukebox BOOLEAN NOT NULL,
|
||||||
|
podcast BOOLEAN NOT NULL,
|
||||||
lastfm_session CHAR(32),
|
lastfm_session CHAR(32),
|
||||||
lastfm_status BOOLEAN NOT NULL,
|
lastfm_status BOOLEAN NOT NULL,
|
||||||
last_play_id BINARY(16) REFERENCES track(id),
|
last_play_id BINARY(16) REFERENCES track(id),
|
||||||
@ -158,3 +159,30 @@ CREATE TABLE IF NOT EXISTS radio_station (
|
|||||||
created DATETIME NOT NULL
|
created DATETIME NOT NULL
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
) 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;
|
||||||
|
@ -57,6 +57,7 @@ CREATE TABLE IF NOT EXISTS "user" (
|
|||||||
salt CHAR(6) NOT NULL,
|
salt CHAR(6) NOT NULL,
|
||||||
admin BOOLEAN NOT NULL,
|
admin BOOLEAN NOT NULL,
|
||||||
jukebox BOOLEAN NOT NULL,
|
jukebox BOOLEAN NOT NULL,
|
||||||
|
podcast BOOLEAN NOT NULL,
|
||||||
lastfm_session CHAR(32),
|
lastfm_session CHAR(32),
|
||||||
lastfm_status BOOLEAN NOT NULL,
|
lastfm_status BOOLEAN NOT NULL,
|
||||||
last_play_id UUID REFERENCES track,
|
last_play_id UUID REFERENCES track,
|
||||||
@ -158,3 +159,29 @@ CREATE TABLE IF NOT EXISTS radio_station (
|
|||||||
created TIMESTAMP NOT NULL
|
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);
|
||||||
|
@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS user (
|
|||||||
salt CHAR(6) NOT NULL,
|
salt CHAR(6) NOT NULL,
|
||||||
admin BOOLEAN NOT NULL,
|
admin BOOLEAN NOT NULL,
|
||||||
jukebox BOOLEAN NOT NULL,
|
jukebox BOOLEAN NOT NULL,
|
||||||
|
podcast BOOLEAN NOT NULL,
|
||||||
lastfm_session CHAR(32),
|
lastfm_session CHAR(32),
|
||||||
lastfm_status BOOLEAN NOT NULL,
|
lastfm_status BOOLEAN NOT NULL,
|
||||||
last_play_id CHAR(36) REFERENCES track,
|
last_play_id CHAR(36) REFERENCES track,
|
||||||
@ -160,3 +161,29 @@ CREATE TABLE IF NOT EXISTS radio_station (
|
|||||||
created DATETIME NOT NULL
|
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);
|
||||||
|
@ -22,6 +22,7 @@ from .test_annotation import AnnotationTestCase
|
|||||||
from .test_media import MediaTestCase
|
from .test_media import MediaTestCase
|
||||||
from .test_transcoding import TranscodingTestCase
|
from .test_transcoding import TranscodingTestCase
|
||||||
from .test_radio import RadioStationTestCase
|
from .test_radio import RadioStationTestCase
|
||||||
|
from .test_podcast import PodcastTestCase
|
||||||
|
|
||||||
|
|
||||||
def suite():
|
def suite():
|
||||||
@ -40,5 +41,6 @@ def suite():
|
|||||||
suite.addTest(unittest.makeSuite(MediaTestCase))
|
suite.addTest(unittest.makeSuite(MediaTestCase))
|
||||||
suite.addTest(unittest.makeSuite(TranscodingTestCase))
|
suite.addTest(unittest.makeSuite(TranscodingTestCase))
|
||||||
suite.addTest(unittest.makeSuite(RadioStationTestCase))
|
suite.addTest(unittest.makeSuite(RadioStationTestCase))
|
||||||
|
suite.addTest(unittest.makeSuite(PodcastTestCase))
|
||||||
|
|
||||||
return suite
|
return suite
|
||||||
|
170
tests/api/test_podcast.py
Normal file
170
tests/api/test_podcast.py
Normal 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)
|
Loading…
Reference in New Issue
Block a user