From 6ea8d3a3ab73470a4243b326483d7194a61bc566 Mon Sep 17 00:00:00 2001 From: "Bendik R. Brenne" Date: Sun, 28 Jul 2024 13:20:57 +0200 Subject: [PATCH] feat(api): add support for podcast seasons and enhance episode retrieval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces several new features and improvements to the NRK Podcast API: 1 Add get_podcast_season method to fetch specific podcast seasons 2 Enhance get_podcast_episodes to support optional season filtering 3 Implement new CLI commands for season and episode retrieval 4 Refactor and expand data models to better represent podcast structures: • Introduce enums for podcast types and series types • Create separate classes for standard and umbrella podcasts • Improve season and episode data structures --- nrk_psapi/api.py | 20 ++++++--- nrk_psapi/cli.py | 25 +++++++++++ nrk_psapi/models/catalog.py | 90 +++++++++++++++++++++++++++++-------- 3 files changed, 110 insertions(+), 25 deletions(-) diff --git a/nrk_psapi/api.py b/nrk_psapi/api.py index 9b449cb..b0bfa44 100644 --- a/nrk_psapi/api.py +++ b/nrk_psapi/api.py @@ -21,7 +21,7 @@ NrkPsApiError, NrkPsApiRateLimitError, ) -from .models.catalog import Episode, Podcast, Series +from .models.catalog import Episode, Podcast, Season, Series from .models.common import IpCheck from .models.metadata import PodcastMetadata from .models.pages import ( @@ -179,12 +179,20 @@ async def get_podcast(self, podcast_id: str) -> Podcast: return Podcast.from_dict(result) async def get_podcasts(self, podcast_ids: list[str]) -> list[Podcast]: - results: list[Podcast] = await asyncio.gather(*[self.get_podcast(podcast_id) for podcast_id in podcast_ids]) - return results - - async def get_podcast_episodes(self, podcast_id: str) -> list[Episode]: + results = await asyncio.gather(*[self.get_podcast(podcast_id) for podcast_id in podcast_ids]) + return list(results) + + async def get_podcast_season(self, podcast_id: str, season_id: str): + result = await self._request(f"radio/catalog/podcast/{podcast_id}/seasons/{season_id}") + return Season.from_dict(result) + + async def get_podcast_episodes(self, podcast_id: str, season_id: str | None = None) -> list[Episode]: + if season_id is not None: + uri = f"radio/catalog/podcast/{podcast_id}/seasons/{season_id}/episodes" + else: + uri = f"radio/catalog/podcast/{podcast_id}/episodes" result = await self._request_paged_all( - f"radio/catalog/podcast/{podcast_id}/episodes", + uri, items_key="_embedded.episodes", ) return [Episode.from_dict(e) for e in result] diff --git a/nrk_psapi/cli.py b/nrk_psapi/cli.py index 9511e57..1537df2 100644 --- a/nrk_psapi/cli.py +++ b/nrk_psapi/cli.py @@ -26,6 +26,16 @@ def main_parser() -> argparse.ArgumentParser: get_podcast_parser.add_argument('podcast_id', type=str, nargs="+", help='The podcast id(s).') get_podcast_parser.set_defaults(func=get_podcast) + get_podcast_season_parser = subparsers.add_parser('get_podcast_season', description='Get podcast season.') + get_podcast_season_parser.add_argument('podcast_id', type=str, help='The podcast id.') + get_podcast_season_parser.add_argument('season_id', type=str, help='The season id.') + get_podcast_season_parser.set_defaults(func=get_podcast_season) + + get_podcast_episodes_parser = subparsers.add_parser('get_podcast_episodes', description='Get podcast episodes.') + get_podcast_episodes_parser.add_argument('podcast_id', type=str, help='The podcast id.') + get_podcast_episodes_parser.add_argument('--season_id', type=str, required=False, help='The season id.') + get_podcast_episodes_parser.set_defaults(func=get_podcast_episodes) + get_episode_parser = subparsers.add_parser('get_episode', description='Get episode.') get_episode_parser.add_argument('podcast_id', type=str, help='The podcast id.') get_episode_parser.add_argument('episode_id', type=str, help='The episode id.') @@ -60,6 +70,21 @@ async def get_podcast(args): rprint(podcast) +async def get_podcast_season(args): + """Get podcast season.""" + async with NrkPodcastAPI() as client: + season = await client.get_podcast_season(args.podcast_id, args.season_id) + rprint(season) + + +async def get_podcast_episodes(args): + """Get podcast episodes.""" + async with NrkPodcastAPI() as client: + episodes = await client.get_podcast_episodes(args.podcast_id, args.season_id) + for episode in episodes: + rprint(episode) + + async def get_episode(args): """Get episode.""" async with NrkPodcastAPI() as client: diff --git a/nrk_psapi/models/catalog.py b/nrk_psapi/models/catalog.py index 0a75f93..5ba002f 100644 --- a/nrk_psapi/models/catalog.py +++ b/nrk_psapi/models/catalog.py @@ -2,22 +2,41 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta # noqa: TCH003 -from typing import Literal +from enum import Enum from isodate import duration_isoformat, parse_duration from mashumaro import field_options +from mashumaro.config import BaseConfig +from mashumaro.types import Discriminator from .common import BaseDataClassORJSONMixin -def deserialize_embedded(data, ret_type: Literal["episodes", "seasons"]) -> list[Episode | Season]: - """Deserialize embedded episodes/seasons.""" +class PodcastType(str, Enum): + PODCAST = "podcast" + CUSTOM_SEASON = "customSeason" - if ret_type == "seasons" and "seasons" in data: - return [Season.from_dict(d) for d in data["seasons"]] - if ret_type == "episodes" and "episodes" in data: - return [Episode.from_dict(d) for d in data["episodes"]["_embedded"]["episodes"]] - return [] + def __str__(self) -> str: + return str(self.value) + + +class SeriesType(str, Enum): + UMBRELLA = "umbrella" + STANDARD = "standard" + SEQUENTIAL = "sequential" + + def __str__(self) -> str: + return str(self.value) + + +class SeasonDisplayType(str, Enum): + MANUAL = "manual" + NUMBER = "number" + MONTH = "month" + YEAR = "year" + + def __str__(self) -> str: + return str(self.value) @dataclass @@ -92,20 +111,40 @@ class Episode(BaseDataClassORJSONMixin): @dataclass -class Season(BaseDataClassORJSONMixin): - """Represents a podcast season.""" +class SeasonBase(BaseDataClassORJSONMixin): + """Base class for a podcast season.""" _links: Links - id: str titles: Titles has_available_episodes: bool = field(metadata=field_options(alias="hasAvailableEpisodes")) episode_count: int = field(metadata=field_options(alias="episodeCount")) - episodes: EpisodesResponse image: list[Image] square_image: list[Image] = field(metadata=field_options(alias="squareImage")) backdrop_image: list[Image] = field(metadata=field_options(alias="backdropImage")) +@dataclass +class SeasonEmbedded(SeasonBase): + """Represents an embedded podcast season.""" + + id: str + + +@dataclass +class Season(SeasonBase): + """Represents a podcast season.""" + + series_type: SeriesType = field(metadata=field_options(alias="seriesType")) + type: PodcastType = field(metadata=field_options(alias="type")) + name: str + category: Category + episodes: list[Episode] = field( + metadata=field_options( + alias="_embedded", + deserialize=lambda x: [Episode.from_dict(d) for d in x["episodes"]["_embedded"]["episodes"]], + )) + + @dataclass class EpisodesResponse(BaseDataClassORJSONMixin): """Contains a list of embedded episodes.""" @@ -116,7 +155,7 @@ class EpisodesResponse(BaseDataClassORJSONMixin): alias="_embedded", deserialize=lambda x: x["episodes"], )) - series_type: str | None = field(default=None, metadata=field_options(alias="seriesType")) + series_type: SeriesType | None = field(default=None, metadata=field_options(alias="seriesType")) @dataclass @@ -135,23 +174,36 @@ class Podcast(BaseDataClassORJSONMixin): """Represents the main structure of the API response.""" _links: Links - series_type: str = field(metadata=field_options(alias="seriesType")) - type: str = field(metadata=field_options(alias="type")) - season_display_type: str = field(metadata=field_options(alias="seasonDisplayType")) + type: PodcastType = field(metadata=field_options(alias="type")) + season_display_type: SeasonDisplayType = field(metadata=field_options(alias="seasonDisplayType")) series: PodcastSeries + class Config(BaseConfig): + discriminator = Discriminator( + field="seriesType", + include_subtypes=True, + ) + + +@dataclass +class PodcastStandard(Podcast): + seriesType = "standard" # noqa: N815 episodes: list[Episode] = field( default_factory=list, metadata=field_options( alias="_embedded", - deserialize=lambda x: deserialize_embedded(x, "episodes"), + deserialize=lambda x: [Episode.from_dict(d) for d in x["episodes"]["_embedded"]["episodes"]], )) + +@dataclass +class PodcastUmbrella(Podcast): + seriesType = "umbrella" # noqa: N815 seasons: list[Season] = field( default_factory=list, metadata=field_options( alias="_embedded", - deserialize=lambda x: deserialize_embedded(x, "seasons"), + deserialize=lambda x: [SeasonEmbedded.from_dict(d) for d in x["seasons"]], )) @@ -252,7 +304,7 @@ class Series(BaseDataClassORJSONMixin): id: str series_id: str = field(metadata=field_options(alias="seriesId")) title: str - type: str + type: SeriesType images: list[Image] square_images: list[Image] = field(metadata=field_options(alias="squareImages")) season_id: str | None = field(default=None, metadata=field_options(alias="seasonId"))