diff --git a/nrk_psapi/models/catalog.py b/nrk_psapi/models/catalog.py index 2c29f67..6a58ab2 100644 --- a/nrk_psapi/models/catalog.py +++ b/nrk_psapi/models/catalog.py @@ -213,7 +213,7 @@ def __pre_deserialize__(cls: type[T], d: T) -> T: d["type"] = t break - if isinstance(d.get("duration"), Duration): + if isinstance(d.get("duration"), Duration): # pragma: no cover d["duration"] = d["duration"].iso8601 if isinstance(d.get("duration"), dict) and d["duration"].get("iso8601"): d["duration"] = d["duration"]["iso8601"] @@ -223,6 +223,14 @@ def __pre_deserialize__(cls: type[T], d: T) -> T: def __post_deserialize__(cls: type[T], d: T) -> T: return d + @property + def season_id(self): + return self._links.season.id + + @property + def season_title(self): + return self._links.season.title + @dataclass class SeasonBase(BaseDataClassORJSONMixin): @@ -392,8 +400,14 @@ class PodcastSequential(Podcast): class SeasonLink(BaseDataClassORJSONMixin): """Represents a season link in the API response.""" - id: str - title: str + id: str | None = field(default=None, init=False) + href: str | None = None + name: str | None = None + title: str | None = None + series_type: SeriesType | None = field(default=None, metadata=field_options(alias="seriesType")) + + def __post_init__(self): + self.id = self.id or self.name @dataclass @@ -419,7 +433,7 @@ class Links(BaseDataClassORJSONMixin): next_links: list[Link] | None = field(default=None, metadata=field_options(alias="nextLinks")) playback: Link | None = None series: Link | None = None - season: Link | None = None + season: SeasonLink | None = None seasons: list[Link] | None = None custom_season: Link | None = field(default=None, metadata=field_options(alias="customSeason")) podcast: Link | None = None diff --git a/nrk_psapi/models/rss.py b/nrk_psapi/models/rss.py index c164ff8..2e867a1 100644 --- a/nrk_psapi/models/rss.py +++ b/nrk_psapi/models/rss.py @@ -3,7 +3,7 @@ from typing import NotRequired, TypedDict -class EpisodeChapter(TypedDict): # pragma: no cover +class EpisodeChapter(TypedDict): startTime: float title: NotRequired[str] img: NotRequired[str] diff --git a/nrk_psapi/rss/extensions.py b/nrk_psapi/rss/extensions.py index 6cd0d3c..9a0ec5d 100644 --- a/nrk_psapi/rss/extensions.py +++ b/nrk_psapi/rss/extensions.py @@ -19,9 +19,9 @@ def __init__(self, guid=None, license=None, locked=None, people=None, images=Non self.people = [] if people is None else people self.images = [] if images is None else images - if isinstance(self.people, PodcastPerson): + if isinstance(self.people, PodcastPerson): # pragma: no cover self.people = [self.people] - elif isinstance(self.people, str): + elif isinstance(self.people, str): # pragma: no cover self.people = [PodcastPerson(self.people)] def get_namespace(self): @@ -33,13 +33,13 @@ def get_namespace(self): def publish(self, handler): Extension.publish(self, handler) - if self.guid is not None: + if self.guid is not None: # pragma: no cover self._write_element("podcast:guid", self.guid) - if self.license is not None: + if self.license is not None: # pragma: no cover self._write_element("podcast:license", self.license) - if self.locked is not None: + if self.locked is not None: # pragma: no cover self._write_element("podcast:locked", self.locked) for person in self.people: @@ -48,7 +48,7 @@ def publish(self, handler): person.publish(self.handler) -class PodcastPerson(Serializable): # pragma: no cover +class PodcastPerson(Serializable): """Extension for Podcast Index Person metatags. More information at https://podcastindex.org/namespace/1.0#person. """ @@ -56,7 +56,7 @@ class PodcastPerson(Serializable): # pragma: no cover def __init__(self, name: str, role=None, group=None, img=None, href=None): Serializable.__init__(self) - if name is None: + if name is None: # pragma: no cover raise ElementRequiredError("name") self.name = name @@ -69,13 +69,13 @@ def publish(self, handler): Serializable.publish(self, handler) attrs = {} - if self.role is not None: + if self.role is not None: # pragma: no cover attrs["role"] = self.role - if self.group is not None: + if self.group is not None: # pragma: no cover attrs["group"] = self.group - if self.img is not None: + if self.img is not None: # pragma: no cover attrs["img"] = self.img - if self.href is not None: + if self.href is not None: # pragma: no cover attrs["href"] = self.href self._write_element("podcast:person", self.name, attrs) @@ -95,7 +95,7 @@ class PodcastImages(Serializable): def __init__(self, images: list[PodcastImagesImage]): Serializable.__init__(self) - if images is None: + if images is None: # pragma: no cover raise ElementRequiredError("images") self.images = images @@ -109,7 +109,7 @@ def publish(self, handler): self._write_element("podcast:images", None, attrs) -class PodcastSeason(Serializable): # pragma: no cover +class PodcastSeason(Serializable): """Extension for Podcast Index Season metatags. More information at https://podcastindex.org/namespace/1.0#season. """ @@ -117,7 +117,7 @@ class PodcastSeason(Serializable): # pragma: no cover def __init__(self, number: int, name=None): Serializable.__init__(self) - if number is None: + if number is None: # pragma: no cover raise ElementRequiredError("number") self.number = number @@ -165,9 +165,9 @@ class PodcastChapters(Serializable): def __init__(self, url=None, type_=None): Serializable.__init__(self) - if url is None: + if url is None: # pragma: no cover raise ElementRequiredError("url") - if type_ is None: + if type_ is None: # pragma: no cover raise ElementRequiredError("type") self.url = url diff --git a/nrk_psapi/rss/feed.py b/nrk_psapi/rss/feed.py index 2328553..b1f832f 100644 --- a/nrk_psapi/rss/feed.py +++ b/nrk_psapi/rss/feed.py @@ -24,6 +24,7 @@ PodcastImages, PodcastImagesImage, PodcastPerson, + PodcastSeason, ) if TYPE_CHECKING: @@ -65,15 +66,26 @@ async def build_episode_item(self, episode_id: str, series_data: PodcastSeries) file_stat = await self.api.fetch_file_info(episode_file.url) _LOGGER.debug("File stat: %s", file_stat) + item_attrs = {} + itunes_attrs = {} extensions = [] if episode.index_points: chapters_url = f"{self.base_url}/{series_data.id}/{episode.episode_id}/chapters.json" extensions.append(PodcastChapters(chapters_url, "application/json+chapters")) + if episode.season_id: + extensions.append(PodcastSeason(int(episode.season_id), episode.season_title)) + + if episode_image := get_image(episode.square_image): + extensions.append( + PodcastImages([PodcastImagesImage(i.url, i.width) for i in episode.square_image]) + ) + itunes_attrs["image"] = episode_image.url + return Item( title=episode.titles.title, description=episode.titles.subtitle, - guid=Guid(episode.id, isPermaLink=False), + guid=Guid(episode.episode_id, isPermaLink=False), enclosure=Enclosure( url=episode_file.url, type=file_stat["content_type"], @@ -89,9 +101,11 @@ async def build_episode_item(self, episode_id: str, series_data: PodcastSeries) iTunesItem( author=episode.contributors, duration=episode.duration.total_seconds(), + **itunes_attrs, ), *extensions, ], + **item_attrs, ) async def build_podcast_rss(self, podcast_id: str, limit: int | None = None) -> Feed: @@ -115,10 +129,10 @@ async def build_podcast_rss(self, podcast_id: str, limit: int | None = None) -> } itunes_attrs = {} extensions = [ - PodcastImages([PodcastImagesImage(i.url, i.width) for i in podcast.series.image]), + PodcastImages([PodcastImagesImage(i.url, i.width) for i in podcast.series.square_image]), ] - if series_image := get_image(podcast.series.image): + if series_image := get_image(podcast.series.square_image): feed_attrs["image"] = Image( url=series_image.url, width=series_image.width, @@ -155,7 +169,8 @@ async def build_podcast_rss(self, podcast_id: str, limit: int | None = None) -> *extensions, ], items=[ - await self.build_episode_item(episode.episode_id, series_data=podcast.series) for episode in episodes + await self.build_episode_item(episode.episode_id, series_data=podcast.series) + for episode in episodes ], **feed_attrs, ) diff --git a/tests/test_api.py b/tests/test_api.py index 1822272..10a45af 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -885,7 +885,7 @@ async def test_http_error429(aresponses: ResponsesMockServer): async def test_network_error(): """Test network error handling.""" async with aiohttp.ClientSession() as session: - with patch.object(session, 'request', side_effect=socket.gaierror): + with patch.object(session, "request", side_effect=socket.gaierror): nrk_api = NrkPodcastAPI(session=session, enable_cache=False) with pytest.raises(NrkPsApiConnectionError): assert await nrk_api._request("ipcheck") diff --git a/tests/test_rss.py b/tests/test_rss.py index 706f200..4851b61 100644 --- a/tests/test_rss.py +++ b/tests/test_rss.py @@ -24,6 +24,7 @@ ], ) async def test_build_rss_feed(aresponses: ResponsesMockServer, podcast_id: str): + episodes_fixture = load_fixture_json(f"radio_catalog_podcast_{podcast_id}_episodes_page1") aresponses.add( URL(PSAPI_BASE_URL).host, f"/radio/catalog/podcast/{podcast_id}", @@ -34,8 +35,33 @@ async def test_build_rss_feed(aresponses: ResponsesMockServer, podcast_id: str): URL(PSAPI_BASE_URL).host, f"/radio/catalog/podcast/{podcast_id}/episodes", "GET", - json_response(data=load_fixture_json(f"radio_catalog_podcast_{podcast_id}_episodes_page1")), + json_response(data=episodes_fixture), ) + for x, episode in enumerate(episodes_fixture["_embedded"]["episodes"]): + episode_data = episode.copy() + episode_data["duration"] = { + "seconds": 0, + "iso8601": episode["duration"], + "displayValue": "1 t 30 min", + } + episode_data["contributors"] = [ + { + "role": "Programleder", + "name": ["Tore Sagen"] if x < 2 else "Tore Sagen", + } + ] + if x < 2: + episode_data["indexPoints"] = [ + {"title": "Chapter 1", "startPoint": "PT1M56S", "partId": 0}, + {"title": "Chapter 2", "startPoint": "PT5M44S", "partId": 0}, + ] + aresponses.add( + URL(PSAPI_BASE_URL).host, + f"/radio/catalog/podcast/{podcast_id}/episodes/{episode_data["episodeId"]}", + "GET", + json_response(data=episode_data), + repeat=math.inf, + ) aresponses.add( URL(PSAPI_BASE_URL).host, re.compile(r"/playback/manifest/podcast/.*"), @@ -78,3 +104,9 @@ async def test_build_rss_feed(aresponses: ResponsesMockServer, podcast_id: str): xml = rss.rss() assert len(xml) > 0 + + episode_id = episodes_fixture["_embedded"]["episodes"][0]["episodeId"] + episode = await nrk_api.get_episode(podcast_id, episode_id) + chapters = await feed.build_episode_chapters(episode) + assert len(chapters) == 2 + assert all(isinstance(c, dict) for c in chapters)