diff --git a/docs/api.md b/docs/api.md index 0aa269d..545a298 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index c85831f..b6ea3ec 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -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 * diff --git a/supysonic/api/podcast.py b/supysonic/api/podcast.py new file mode 100644 index 0000000..889e32d --- /dev/null +++ b/supysonic/api/podcast.py @@ -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 + + diff --git a/supysonic/db.py b/supysonic/db.py index a2712a8..789fd45 100755 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -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") diff --git a/supysonic/schema/migration/mysql/20200620.sql b/supysonic/schema/migration/mysql/20200620.sql new file mode 100644 index 0000000..9eed7c3 --- /dev/null +++ b/supysonic/schema/migration/mysql/20200620.sql @@ -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; diff --git a/supysonic/schema/migration/postgres/20200620.sql b/supysonic/schema/migration/postgres/20200620.sql new file mode 100644 index 0000000..4265a9b --- /dev/null +++ b/supysonic/schema/migration/postgres/20200620.sql @@ -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); diff --git a/supysonic/schema/migration/sqlite/20200620.sql b/supysonic/schema/migration/sqlite/20200620.sql new file mode 100644 index 0000000..827557b --- /dev/null +++ b/supysonic/schema/migration/sqlite/20200620.sql @@ -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); diff --git a/supysonic/schema/mysql.sql b/supysonic/schema/mysql.sql index a4c1c96..0d209d2 100644 --- a/supysonic/schema/mysql.sql +++ b/supysonic/schema/mysql.sql @@ -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; diff --git a/supysonic/schema/postgres.sql b/supysonic/schema/postgres.sql index 47b301c..46a0530 100644 --- a/supysonic/schema/postgres.sql +++ b/supysonic/schema/postgres.sql @@ -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); diff --git a/supysonic/schema/sqlite.sql b/supysonic/schema/sqlite.sql index d2b905c..d1f8e97 100644 --- a/supysonic/schema/sqlite.sql +++ b/supysonic/schema/sqlite.sql @@ -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); diff --git a/tests/api/__init__.py b/tests/api/__init__.py index 3713050..e1b6b40 100644 --- a/tests/api/__init__.py +++ b/tests/api/__init__.py @@ -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 diff --git a/tests/api/test_podcast.py b/tests/api/test_podcast.py new file mode 100644 index 0000000..978ab68 --- /dev/null +++ b/tests/api/test_podcast.py @@ -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)