Skip to content

Commit

Permalink
refactor(models): update season link handling and add season properties
Browse files Browse the repository at this point in the history
- Add season_id and season_title properties to Episode class
- Modify SeasonLink class with optional fields and post-init id handling
- Update RSS feed generation to include season information
- Add episode image support in RSS feed items
- Remove unnecessary pragma markers
  • Loading branch information
bendikrb committed Nov 6, 2024
1 parent 816a3c5 commit 810243e
Show file tree
Hide file tree
Showing 6 changed files with 88 additions and 27 deletions.
22 changes: 18 additions & 4 deletions nrk_psapi/models/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion nrk_psapi/models/rss.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
32 changes: 16 additions & 16 deletions nrk_psapi/rss/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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:
Expand All @@ -48,15 +48,15 @@ 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.
"""

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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -109,15 +109,15 @@ 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.
"""

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
Expand Down Expand Up @@ -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
Expand Down
23 changes: 19 additions & 4 deletions nrk_psapi/rss/feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
PodcastImages,
PodcastImagesImage,
PodcastPerson,
PodcastSeason,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -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"],
Expand All @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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,
)
2 changes: 1 addition & 1 deletion tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
34 changes: 33 additions & 1 deletion tests/test_rss.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand All @@ -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/.*"),
Expand Down Expand Up @@ -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)

0 comments on commit 810243e

Please sign in to comment.