/
/
/
1"""Pocket Casts music provider for Music Assistant."""
2
3from __future__ import annotations
4
5import asyncio
6import logging
7from collections.abc import AsyncGenerator
8from typing import TYPE_CHECKING, Any
9
10from music_assistant_models.enums import (
11 ContentType,
12 ImageType,
13 MediaType,
14 ProviderFeature,
15 StreamType,
16)
17from music_assistant_models.errors import (
18 LoginFailed,
19 MediaNotFoundError,
20 ProviderUnavailableError,
21 ResourceTemporarilyUnavailable,
22 RetriesExhausted,
23)
24from music_assistant_models.media_items import (
25 AudioFormat,
26 BrowseFolder,
27 ItemMapping,
28 MediaItemImage,
29 MediaItemMetadata,
30 MediaItemType,
31 Podcast,
32 PodcastEpisode,
33 ProviderMapping,
34 SearchResults,
35 UniqueList,
36)
37from music_assistant_models.streamdetails import StreamDetails
38
39from music_assistant import MusicAssistant
40from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME
41from music_assistant.controllers.cache import use_cache
42from music_assistant.helpers.podcast_parsers import rank_episodes_by_date
43from music_assistant.models.music_provider import MusicProvider
44
45from .api_client import PocketCastsClient
46
47if TYPE_CHECKING:
48 from datetime import datetime
49
50 from music_assistant_models.config_entries import ConfigEntry, ProviderConfig
51 from music_assistant_models.provider import ProviderManifest
52
53 from music_assistant.models import ProviderInstanceType
54
55LOGGER = logging.getLogger(__name__)
56
57FULLY_PLAYED_THRESHOLD = 0.9
58SPECIAL_FOLDERS = ("up_next", "new_releases", "in_progress", "starred", "history")
59
60SUPPORTED_FEATURES = {
61 ProviderFeature.LIBRARY_PODCASTS,
62 ProviderFeature.BROWSE,
63 ProviderFeature.SEARCH,
64 ProviderFeature.LIBRARY_PODCASTS_EDIT,
65}
66
67BROWSE_FOLDER_ICONS: dict[str, str] = {
68 "up_next": (
69 "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
70 "ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPHBhdGggZmls"
71 "bD0iIzhGOTdBNCIgZD0iTTMgMi45NzE1OUMzIDIuNTY0OSAzLjQ1OTY4IDIuMzI4MzQgMy43OTA2Mi"
72 "AyLjU2NDcyTDkuNDMwMzkgNi41OTMxM0M5LjcwOTU2IDYuNzkyNTQgOS43MDk1NiA3LjIwNzQ1IDku"
73 "NDMwMzkgNy40MDY4NkwzLjc5MDYyIDExLjQzNTNDMy40NTk2OSAxMS42NzE2IDMgMTEuNDM1MSAzID"
74 "ExLjAyODRWMi45NzE1OVoiLz4KICAgIDxwYXRoIGZpbGw9IiM4Rjk3QTQiIG9wYWNpdHk9IjAuNS"
75 "IgZD0iTTEyIDdDMTIgNi40NDc3MiAxMi40NDc3IDYgMTMgNkgyMUMyMS41NTIzIDYgMjIgNi40NDc3"
76 "MiAyMiA3QzIyIDcuNTUyMjggMjEuNTUyMyA4IDIxIDhIMTNDMTIuNDQ3NyA4IDEyIDcuNTUyMjggMT"
77 "IgN1pNOSAxMkM5IDExLjQ0NzcgOS40NDc3MiAxMSAxMCAxMUgyMUMyMS41NTIzIDExIDIyIDExLjQ0Nz"
78 "cgMjIgMTJDMjIgMTIuNTUyMyAyMS41NTIzIDEzIDIxIDEzSDEwQzkuNDQ3NzIgMTMgOSAxMi41NTIz"
79 "IDkgMTJaTTEwIDE2QzkuNDQ3NzIgMTYgOSAxNi40NDc3IDkgMTdDOSAxNy41NTIzIDkuNDQ3NzIgMT"
80 "ggMTAgMThIMjFDMjEuNTUyMyAxOCAyMiAxNy41NTIzIDIyIDE3QzIyIDE2LjQ0NzcgMjEuNTUyMyAx"
81 "NiAyMSAxNkgxMFoiLz4KPC9zdmc+Cg=="
82 ),
83 "new_releases": (
84 "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
85 "ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPGcgZmlsbD0i"
86 "IzhGOTdBNCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwgMSkiPgogICAgICAgIDxwYXRoIGQ9Ik0xNC"
87 "43OTM1IDQuNTU3MzVMMTUuNzc0MSAwLjc4OTIwOUMxNS45Njg4IDAuMDQxMDUwOSAxNy4wMzExIDAu"
88 "MDQxMDcxOSAxNy4yMjU4IDAuNzg5MjM3TDE4LjIwNjIgNC41NTczMkMxOC4yNzcgNC44MjkxNSAxOC"
89 "40OTM3IDUuMDM4NjggMTguNzY3NyA1LjEwMDIzTDIxLjc0MTkgNS43NjgyM0MyMi41MjI4IDUuOTQz"
90 "NjIgMjIuNTIyOCA3LjA1NjM5IDIxLjc0MTkgNy4yMzE3N0wxOC43Njc3IDcuODk5NzdDMTguNDkzNy"
91 "A3Ljk2MTMyIDE4LjI3NyA4LjE3MDg1IDE4LjIwNjIgOC40NDI2OEwxNy4yMjU4IDEyLjIxMDhDMTcu"
92 "MDMxMSAxMi45NTg5IDE1Ljk2ODggMTIuOTU4OSAxNS43NzQxIDEyLjIxMDhMMTQuNzkzNSA4LjQ0Mj"
93 "Y1QzE0LjcyMjcgOC4xNzA4MyAxNC41MDYxIDcuOTYxMzIgMTQuMjMyIDcuODk5NzdMMTEuMjU4IDcu"
94 "MjMxNzdDMTAuNDc3MSA3LjA1NjM5IDEwLjQ3NzEgNS45NDM2MiAxMS4yNTggNS43NjgyNEwxNC4yMz"
95 "IgNS4xMDAyM0MxNC41MDYxIDUuMDM4NjggMTQuNzIyNyA0LjgyOTE3IDE0Ljc5MzUgNC41NTczNVoi"
96 "Lz4KICAgICAgICA8cGF0aCBvcGFjaXR5PSIwLjgiIGQ9Ik01LjI5OTQgOS4yNjgzTDYuMDMwNjYgNy"
97 "4yNzc2M0M2LjE5MTEyIDYuODQwODUgNi44MDg4OCA2Ljg0MDg1IDYuOTY5MzQgNy4yNzc2M0w3Ljcw"
98 "MDYgOS4yNjgzQzcuNzU0MjUgOS40MTQzNSA3Ljg3Mjg0IDkuNTI3MSA4LjAyMTQxIDkuNTczMzJMOS"
99 "40NjU0MSAxMC4wMjI2QzkuOTM0MDMgMTAuMTY4MyA5LjkzNDAzIDEwLjgzMTYgOS40NjU0MSAxMC45"
100 "Nzc0TDguMDIxNCAxMS40MjY3QzcuODcyODMgMTEuNDcyOSA3Ljc1NDI1IDExLjU4NTYgNy43MDA2ID"
101 "ExLjczMTdMNi45NjkzMyAxMy43MjI0QzYuODA4ODggMTQuMTU5MiA2LjE5MTEyIDE0LjE1OTIgNi4w"
102 "MzA2NiAxMy43MjI0TDUuMjk5NCAxMS43MzE3QzUuMjQ1NzUgMTEuNTg1NiA1LjEyNzE3IDExLjQ3Mj"
103 "kgNC45Nzg2IDExLjQyNjdMMy41MzQ1OSAxMC45Nzc0QzMuMDY1OTcgMTAuODMxNiAzLjA2NTk3IDEw"
104 "LjE2ODMgMy41MzQ1OSAxMC4wMjI2TDQuOTc4NTkgOS41NzMzMkM1LjEyNzE2IDkuNTI3MSA1LjI0NT"
105 "c1IDkuNDE0MzUgNS4yOTk0IDkuMjY4M1oiLz4KICAgICAgICA8cGF0aCBvcGFjaXR5PSIwLjYiIGQ9"
106 "Ik0xMC42ODgyIDE2LjAxNEwxMS41MjQ3IDEzLjQ1NDNDMTEuNjc0OSAxMi45OTQ3IDEyLjMyNTEgMT"
107 "IuOTk0NyAxMi40NzUzIDEzLjQ1NDNMMTMuMzExOCAxNi4wMTRDMTMuMzYwNCAxNi4xNjI3IDEzLjQ3"
108 "NTcgMTYuMjggMTMuNjIzNSAxNi4zMzEyTDE1LjYzNSAxNy4wMjc1QzE2LjA4MzYgMTcuMTgyOCAxNi"
109 "4wODM2IDE3LjgxNzIgMTUuNjM1IDE3Ljk3MjVMMTMuNjIzNSAxOC42Njg4QzEzLjQ3NTcgMTguNzIg"
110 "MTMuMzYwNCAxOC44MzczIDEzLjMxMTggMTguOTg2TDEyLjQ3NTMgMjEuNTQ1N0MxMi4zMjUxIDIyLj"
111 "AwNTMgMTEuNjc0OSAyMi4wMDUzIDExLjUyNDcgMjEuNTQ1N0wxMC42ODgyIDE4Ljk4NkMxMC42Mzk2"
112 "IDE4LjgzNzMgMTAuNTI0MyAxOC43MiAxMC4zNzY1IDE4LjY2ODhMOC4zNjQ5OCAxNy45NzI1QzcuOT"
113 "E2MzggMTcuODE3MiA3LjkxNjM5IDE3LjE4MjggOC4zNjQ5OCAxNy4wMjc1TDEwLjM3NjUgMTYuMzMx"
114 "MkMxMC41MjQzIDE2LjI4IDEwLjYzOTYgMTYuMTYyNyAxMC42ODgyIDE2LjAxNFoiLz4KICAgIDwvZz"
115 "4KPC9zdmc+Cg=="
116 ),
117 "in_progress": (
118 "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
119 "ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPHBhdGggZmls"
120 "bD0iIzhGOTdBNCIgb3BhY2l0eT0iMC41IiBkPSJNNCAxMkM0IDE2LjQxODMgNy41ODE3MiAyMCAxMi"
121 "AyMEMxNi40MTgzIDIwIDIwIDE2LjQxODMgMjAgMTJDMjAgNy41ODE3MiAxNi40MTgzIDQgMTIgNEM3"
122 "LjU4MTcyIDQgNCA3LjU4MTcyIDQgMTJaTTE4IDEyQzE4IDE1LjMxMzcgMTUuMzEzNyAxOCAxMiAxOE"
123 "M4LjY4NjI5IDE4IDYgMTUuMzEzNyA2IDEyQzYgOC42ODYyOSA4LjY4NjI5IDYgMTIgNkMxNS4zMTM3"
124 "IDYgMTggOC42ODYyOSAxOCAxMloiLz4KICAgIDxwYXRoIGZpbGw9IiM4Rjk3QTQiIGQ9Ik0xNi45Mj"
125 "UzIDE4LjMwNDFDMjAuNDA2OSAxNS41ODM5IDIxLjAyNDMgMTAuNTU2NCAxOC4zMDQxIDcuMDc0NzJD"
126 "MTYuODI2OSA1LjE4Mzk0IDE0LjYxNjcgNC4wODMyNyAxMi4yNjUgNC4wMDQyNEMxMS43MTMxIDMuOT"
127 "g1NjkgMTEuMjUwNiA0LjQxODExIDExLjIzMiA0Ljk3MDA4QzExLjIxMzUgNS41MjIwNiAxMS42NDU5"
128 "IDUuOTg0NTYgMTIuMTk3OSA2LjAwMzExQzEzLjk2MzkgNi4wNjI0NiAxNS42MTkzIDYuODg2ODYgMT"
129 "YuNzI4MSA4LjMwNjA1QzE4Ljc2ODIgMTAuOTE3MyAxOC4zMDUyIDE0LjY4OCAxNS42OTQgMTYuNzI4"
130 "MUMxNS4yNTg4IDE3LjA2ODEgMTUuMTgxNiAxNy42OTY1IDE1LjUyMTYgMTguMTMxOEMxNS44NjE2ID"
131 "E4LjU2NyAxNi40OTAxIDE4LjY0NDEgMTYuOTI1MyAxOC4zMDQxWiIvPgo8L3N2Zz4K"
132 ),
133 "starred": (
134 "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
135 "ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPHBhdGggZmls"
136 "bD0iIzhGOTdBNCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMSwgLTAuNSkiIGQ9Ik0xMS41ODMxIDE3Lj"
137 "Q3NzZMOC4wNzM4NCAxOS4yOTMzQzcuMDk0MTMgMTkuODAwMiA2LjQ0NjgxIDE5LjMxOTcgNi42MjQ2"
138 "NiAxOC4yNDA0TDcuMjY3MDcgMTQuMzQxOEw0LjQ1NTgxIDExLjU2NTRDMy42NzA5NyAxMC43OTAzID"
139 "MuOTI3OTEgMTAuMDI2MiA1LjAwOTM1IDkuODYxNzdMOC45MTU2NSA5LjI2ODAxTDEwLjY4NzUgNS43"
140 "MzYzOEMxMS4xODIxIDQuNzUwNDIgMTEuOTg4MiA0Ljc1ODY3IDEyLjQ3ODggNS43MzYzOEwxNC4yNT"
141 "A2IDkuMjY4MDFMMTguMTU2OSA5Ljg2MTc3QzE5LjI0NzQgMTAuMDI3NSAxOS40ODg3IDEwLjc5Njgg"
142 "MTguNzEwNCAxMS41NjU0TDE1Ljg5OTIgMTQuMzQxOEwxNi41NDE2IDE4LjI0MDRDMTYuNzIwOSAxOS"
143 "4zMjg4IDE2LjA2MzkgMTkuNzk2IDE1LjA5MjQgMTkuMjkzM0wxMS41ODMxIDE3LjQ3NzZaIi8+Cjwv"
144 "c3ZnPgo="
145 ),
146 "history": (
147 "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIH"
148 "ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0MCIgaGVpZ2h0PSIyNDAiPgogICAgPHBhdGggZmls"
149 "bD0iIzhGOTdBNCIgb3BhY2l0eT0iMC41IiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMC41IDEzQz"
150 "EwLjUgMTMuNTUyMyAxMC45NDc3IDE0IDExLjUgMTRIMTQuNUMxNS4wNTIzIDE0IDE1LjUgMTMuNTUy"
151 "MyAxNS41IDEzQzE1LjUgMTIuNDQ3NyAxNS4wNTIzIDEyIDE0LjUgMTJIMTIuNUwxMi41IDEwQzEyLj"
152 "UgOS40NDc3MiAxMi4wNTIzIDkgMTEuNSA5QzEwLjk0NzcgOSAxMC41IDkuNDQ3NzIgMTAuNSAxMFYx"
153 "M1oiLz4KICAgIDxwYXRoIGZpbGw9IiM4Rjk3QTQiIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1y"
154 "dWxlPSJldmVub2RkIiBkPSJNMTIgMThDMTUuMzEzNyAxOCAxOCAxNS4zMTM3IDE4IDEyQzE4IDguNj"
155 "g2MjkgMTUuMzEzNyA2IDEyIDZDOC42ODYyOSA2IDYgOC42ODYyOSA2IDEyQzYgMTUuMzEzNyA4LjY4"
156 "NjI5IDE4IDEyIDE4Wk0xMiAyMEMxNi40MTgzIDIwIDIwIDE2LjQxODMgMjAgMTJDMjAgNy41ODE3Mi"
157 "AxNi40MTgzIDQgMTIgNEM3LjU4MTcyIDQgNCA3LjU4MTcyIDQgMTJDNCAxNi40MTgzIDcuNTgxNzIg"
158 "MjAgMTIgMjBaIi8+Cjwvc3ZnPgo="
159 ),
160}
161
162
163async def setup(
164 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
165) -> ProviderInstanceType:
166 """Initialize provider(instance) with given configuration."""
167 return PocketCastsProvider(mass, manifest, config, SUPPORTED_FEATURES)
168
169
170class PocketCastsProvider(MusicProvider):
171 """Provider for Pocket Casts podcast service."""
172
173 _client: PocketCastsClient
174 # episode uuids already mirrored to Pocket Casts Up Next/history this session, keyed as a
175 # set since multi-room playback can have several episodes in progress on one instance
176 _announced_episodes: set[str]
177
178 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
179 """Return Config entries to configure this provider."""
180 return ()
181
182 async def handle_async_init(self) -> None:
183 """Handle async initialization of the provider."""
184 email = self.get_setup_value(CONF_USERNAME)
185 password = self.get_setup_value(CONF_PASSWORD)
186 if not email or not password:
187 raise LoginFailed("Email and password are required for Pocket Casts")
188 self._announced_episodes = set()
189 self._client = PocketCastsClient(self.mass.http_session, self.logger)
190 await self._client.login(str(email), str(password))
191
192 async def get_library_podcasts(self) -> AsyncGenerator[Podcast]:
193 """Get all podcasts from the user's library."""
194 for podcast_data in await self._client.get_subscribed_podcasts():
195 yield self._convert_podcast(podcast_data)
196
197 async def library_add(self, item: MediaItemType) -> bool:
198 """
199 Subscribe to a podcast.
200
201 :param item: The media item to add to the library.
202 """
203 if not isinstance(item, Podcast):
204 return await super().library_add(item)
205 await self._client.subscribe_podcast(item.item_id)
206 return True
207
208 async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
209 """
210 Unsubscribe from a podcast.
211
212 :param prov_item_id: The provider item ID to remove from the library.
213 :param media_type: The media type of the item.
214 """
215 if media_type != MediaType.PODCAST:
216 return await super().library_remove(prov_item_id, media_type)
217 await self._client.unsubscribe_podcast(prov_item_id)
218 return True
219
220 @use_cache(3600 * 24)
221 async def get_podcast(self, prov_podcast_id: str) -> Podcast:
222 """
223 Get full details for a podcast.
224
225 :param prov_podcast_id: The provider podcast id.
226 """
227 podcast_data = await self._client.get_podcast(prov_podcast_id)
228 if not podcast_data:
229 raise MediaNotFoundError(
230 f"podcast://{prov_podcast_id} not found on provider {self.domain}"
231 )
232 return self._convert_podcast(podcast_data)
233
234 async def get_podcast_episodes(self, prov_podcast_id: str) -> AsyncGenerator[PodcastEpisode]:
235 """
236 Get all episodes for a podcast, enriched with playback status.
237
238 :param prov_podcast_id: The provider podcast id.
239 """
240 # fetch episode metadata, user status and show notes in parallel
241 (podcast_name, episodes), in_progress, history, show_notes = await asyncio.gather(
242 self._client.get_podcast_episodes(prov_podcast_id),
243 self._client.get_in_progress_episodes(),
244 self._client.get_history(),
245 self._get_show_notes(prov_podcast_id),
246 )
247 in_progress_map = {ep.get("uuid"): ep for ep in in_progress}
248 history_map = {ep.get("uuid"): ep for ep in history}
249
250 # the full-podcast payload carries no episode number, so rank on the publication date
251 positions = rank_episodes_by_date([ep.get("published") or None for ep in episodes])
252 for position, episode_data in zip(positions, episodes, strict=True):
253 episode_item = self._convert_episode(
254 episode_data,
255 prov_podcast_id,
256 show_notes.get(episode_data.get("uuid", "")),
257 podcast_name,
258 position=position,
259 )
260 if episode_item:
261 self._enrich_episode_with_status(
262 episode_item, episode_data, in_progress_map, history_map
263 )
264 yield episode_item
265
266 async def browse(self, path: str) -> list[MediaItemType | BrowseFolder]:
267 """
268 Browse this provider's items.
269
270 :param path: The browse path to resolve.
271 """
272 item_path = path.split("://", 1)[1] if "://" in path else path
273
274 if not item_path:
275 # root level - special folders followed by subscribed podcasts
276 items: list[MediaItemType | BrowseFolder] = list(self._create_browse_folders())
277 for podcast_data in await self._client.get_subscribed_podcasts():
278 items.append(self._convert_podcast(podcast_data))
279 return items
280
281 if item_path in SPECIAL_FOLDERS:
282 return await self._get_special_folder_episodes(item_path)
283
284 return [episode async for episode in self.get_podcast_episodes(item_path)]
285
286 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
287 """
288 Get streamable URL and details for the given media item.
289
290 :param item_id: The episode item id (format: podcast_uuid:episode_uuid).
291 :param media_type: The media type of the item.
292 """
293 _, episode_uuid = item_id.split(":", 1)
294 episode_data = await self._client.get_episode_details(episode_uuid)
295
296 url = episode_data.get("url", "")
297 if not url:
298 raise MediaNotFoundError(f"No URL found for episode {item_id}")
299
300 return StreamDetails(
301 item_id=item_id,
302 provider=self.instance_id,
303 audio_format=AudioFormat(
304 content_type=ContentType.try_parse(episode_data.get("fileType", "audio/mpeg")),
305 ),
306 media_type=MediaType.PODCAST_EPISODE,
307 stream_type=StreamType.HTTP,
308 path=url,
309 duration=episode_data.get("duration"),
310 can_seek=True,
311 allow_seek=True,
312 )
313
314 @use_cache(3600 * 24)
315 async def search(
316 self, search_query: str, media_types: list[MediaType], limit: int = 5
317 ) -> SearchResults:
318 """
319 Search for podcasts.
320
321 :param search_query: The search query.
322 :param media_types: The media types to include in the search.
323 :param limit: The maximum number of items to return per type.
324 """
325 results = SearchResults()
326 if media_types and MediaType.PODCAST not in media_types:
327 return results
328 podcasts = await self._client.search_podcasts(search_query)
329 results.podcasts = [self._convert_podcast(podcast) for podcast in podcasts[:limit]]
330 return results
331
332 @use_cache(3600)
333 async def get_podcast_episode(self, prov_item_id: str) -> PodcastEpisode:
334 """
335 Get full details for a podcast episode.
336
337 :param prov_item_id: The episode item id (format: podcast_uuid:episode_uuid).
338 """
339 podcast_uuid, episode_uuid = prov_item_id.split(":", 1)
340 episode_data, show_notes, podcast_name = await asyncio.gather(
341 self._client.get_episode_details(episode_uuid),
342 self._get_show_notes(podcast_uuid),
343 self._get_podcast_name(podcast_uuid),
344 )
345 episode_item = self._convert_episode(
346 episode_data, podcast_uuid, show_notes.get(episode_uuid), podcast_name
347 )
348 if episode_item is None:
349 raise MediaNotFoundError(f"Episode {episode_uuid} not found in podcast {podcast_uuid}")
350
351 # the API sends explicit nulls for these fields, so a dict default is not enough
352 played_up_to = episode_data.get("playedUpTo") or 0
353 duration = episode_data.get("duration") or 0
354 playing_status = episode_data.get("playingStatus", 1) # 1=unplayed, 2=in_progress, 3=played
355 if duration > 0:
356 episode_item.duration = duration
357 completed = playing_status == 3 or (
358 duration > 0 and (played_up_to / duration) > FULLY_PLAYED_THRESHOLD
359 )
360 episode_item.fully_played = completed
361 episode_item.resume_position_ms = 0 if completed else played_up_to * 1000
362
363 return episode_item
364
365 async def get_resume_position(
366 self, item_id: str, media_type: MediaType
367 ) -> tuple[bool, int, datetime | None]:
368 """
369 Return the (fully_played, position_ms, timestamp) resume point for an episode.
370
371 PocketCasts does not expose a reliable last-played timestamp, so the timestamp
372 is always None.
373
374 :param item_id: The episode item id (format: podcast_uuid:episode_uuid).
375 :param media_type: The media type (should be PODCAST_EPISODE).
376 """
377 _, episode_uuid = item_id.split(":", 1)
378
379 try:
380 in_progress = await self._client.get_in_progress_episodes()
381 except (ProviderUnavailableError, ResourceTemporarilyUnavailable, RetriesExhausted) as err:
382 # resume is best-effort; a transient failure should not break playback
383 LOGGER.warning("Could not fetch resume position for %s: %s", episode_uuid, err)
384 return (False, 0, None)
385
386 for ep in in_progress:
387 if ep.get("uuid") == episode_uuid:
388 played_up_to = int(ep.get("playedUpTo") or 0) # seconds from API
389 duration = int(ep.get("duration") or 0)
390 fully_played = duration > 0 and (played_up_to / duration) > FULLY_PLAYED_THRESHOLD
391 LOGGER.debug(
392 "Resume position for %s: %d ms (fully_played=%s)",
393 episode_uuid,
394 played_up_to * 1000,
395 fully_played,
396 )
397 return (fully_played, played_up_to * 1000, None)
398
399 LOGGER.debug("No in-progress entry for %s; resuming from start", episode_uuid)
400 return (False, 0, None)
401
402 async def on_played(
403 self,
404 media_type: MediaType,
405 prov_item_id: str,
406 fully_played: bool,
407 position: int,
408 media_item: MediaItemType,
409 is_playing: bool = False,
410 ) -> None:
411 """
412 Sync playback progress for a podcast episode back to Pocket Casts.
413
414 Called by the Queue controller when a track is played, stopped/skipped, and
415 periodically while playing.
416
417 :param media_type: The media type of the played item.
418 :param prov_item_id: The provider item id (format: podcast_uuid:episode_uuid).
419 :param fully_played: Whether the episode was played to the end.
420 :param position: Last known position in seconds.
421 :param media_item: The full media item details.
422 :param is_playing: Whether the episode is currently playing.
423 """
424 if media_type != MediaType.PODCAST_EPISODE or not isinstance(media_item, PodcastEpisode):
425 return
426 podcast_uuid, episode_uuid = prov_item_id.split(":", 1)
427
428 # MA reports fully_played=True when an episode is skipped/stopped, not only when it
429 # truly ends, so confirm completion against the real position before marking it played.
430 duration = media_item.duration or 0
431 completed = fully_played and duration > 0 and position >= duration * FULLY_PLAYED_THRESHOLD
432 if completed:
433 self._announced_episodes.discard(episode_uuid)
434 await self._client.mark_episode_played(podcast_uuid, episode_uuid)
435 await self._client.remove_from_up_next(episode_uuid)
436 await self._client.archive_episode(podcast_uuid, episode_uuid, archive=True)
437 elif position == 0 and not is_playing:
438 # the user explicitly marked the episode as unplayed
439 self._announced_episodes.discard(episode_uuid)
440 await self._client.mark_episode_unplayed(podcast_uuid, episode_uuid)
441 await self._client.archive_episode(podcast_uuid, episode_uuid, archive=False)
442 else:
443 # on_played fires every progress tick, so mirror the start to Up Next/history only
444 # once per session - re-announcing each tick would re-bump Up Next and spam the API.
445 # A resume within the same session is intentionally not re-announced.
446 if is_playing and episode_uuid not in self._announced_episodes:
447 self._announced_episodes.add(episode_uuid)
448 await self._announce_playback_start(podcast_uuid, episode_uuid, media_item)
449 await self._client.update_episode_progress(podcast_uuid, episode_uuid, position)
450
451 def _convert_podcast(self, podcast_data: dict[str, Any]) -> Podcast:
452 """
453 Convert raw Pocket Casts podcast data to a Podcast object.
454
455 :param podcast_data: Raw podcast data from the subscribed-list or full-podcast endpoint.
456 """
457 uuid = podcast_data["uuid"]
458 return Podcast(
459 item_id=uuid,
460 provider=self.instance_id,
461 name=podcast_data.get("title", ""),
462 publisher=podcast_data.get("author"),
463 provider_mappings={
464 ProviderMapping(
465 item_id=uuid,
466 provider_domain=self.domain,
467 provider_instance=self.instance_id,
468 )
469 },
470 metadata=MediaItemMetadata(
471 description=podcast_data.get("description"),
472 images=UniqueList(
473 [
474 MediaItemImage(
475 type=ImageType.THUMB,
476 path=f"https://static.pocketcasts.com/discover/images/280/{uuid}.jpg",
477 provider=self.instance_id,
478 remotely_accessible=True,
479 )
480 ]
481 ),
482 ),
483 )
484
485 async def _get_podcast_name(self, prov_podcast_id: str) -> str:
486 """Return a podcast's name, empty when it cannot be looked up."""
487 # the podcast lookup is cached, so this is one call per podcast per day at most
488 try:
489 return (await self.get_podcast(prov_podcast_id)).name
490 except (
491 MediaNotFoundError,
492 LoginFailed,
493 ProviderUnavailableError,
494 ResourceTemporarilyUnavailable,
495 RetriesExhausted,
496 ) as err:
497 self.logger.debug("Could not retrieve podcast name for %s: %s", prov_podcast_id, err)
498 return ""
499
500 async def _get_show_notes(self, prov_podcast_id: str) -> dict[str, dict[str, Any]]:
501 """Return show notes per episode uuid, empty when they cannot be read."""
502 # show notes are supplementary, so a failure here must never break episode
503 # resolution. The failure itself is not cached, so the next call tries again.
504 try:
505 return await self._fetch_show_notes(prov_podcast_id)
506 except (
507 LoginFailed,
508 ProviderUnavailableError,
509 ResourceTemporarilyUnavailable,
510 RetriesExhausted,
511 ) as err:
512 self.logger.debug("Could not retrieve show notes for %s: %s", prov_podcast_id, err)
513 return {}
514
515 @use_cache(3600 * 24)
516 async def _fetch_show_notes(self, prov_podcast_id: str) -> dict[str, dict[str, Any]]:
517 """Return show notes per episode uuid for the given podcast."""
518 return await self._client.get_show_notes(prov_podcast_id)
519
520 def _convert_episode(
521 self,
522 episode_data: dict[str, Any],
523 podcast_uuid: str,
524 show_notes: dict[str, Any] | None = None,
525 podcast_name: str = "",
526 position: int = 0,
527 ) -> PodcastEpisode | None:
528 """
529 Convert episode data to a PodcastEpisode, or None when it carries no episode uuid.
530
531 :param position: The episode's listing position. Defaults to 0 (unknown).
532 """
533 episode_uuid = episode_data.get("uuid")
534 if not episode_uuid:
535 return None
536
537 # this is fed by two endpoints with different field schemas: the full-podcast JSON
538 # uses snake_case (file_type) while /user/episode uses camelCase (fileType,
539 # episodeNumber). Neither carries the description or artwork, which is what the
540 # separate show notes lookup is for.
541 item_id = f"{podcast_uuid}:{episode_uuid}"
542 file_type = episode_data.get("fileType") or episode_data.get("file_type", "audio/mpeg")
543 episode_item = PodcastEpisode(
544 item_id=item_id,
545 provider=self.instance_id,
546 name=episode_data.get("title", "Unknown Episode"),
547 podcast=ItemMapping(
548 media_type=MediaType.PODCAST,
549 item_id=podcast_uuid,
550 provider=self.instance_id,
551 name=podcast_name,
552 ),
553 position=position,
554 provider_mappings={
555 ProviderMapping(
556 item_id=item_id,
557 provider_domain=self.domain,
558 provider_instance=self.instance_id,
559 audio_format=AudioFormat(content_type=ContentType.try_parse(file_type)),
560 url=episode_data.get("url", ""),
561 )
562 },
563 )
564 if episode_data.get("duration"):
565 episode_item.duration = int(episode_data["duration"])
566 if title := episode_data.get("title"):
567 episode_item.metadata.label = title
568 details = show_notes or {}
569 if description := details.get("description"):
570 episode_item.metadata.description = description
571 # only about half the episodes have their own artwork, the rest keep the podcast cover
572 image_url = details.get("image") or (
573 f"https://static.pocketcasts.com/discover/images/280/{podcast_uuid}.jpg"
574 )
575 episode_item.metadata.images = UniqueList(
576 [
577 MediaItemImage(
578 type=ImageType.THUMB,
579 path=image_url,
580 provider=self.instance_id,
581 remotely_accessible=True,
582 )
583 ]
584 )
585 return episode_item
586
587 def _enrich_episode_with_status(
588 self,
589 episode_item: PodcastEpisode,
590 episode_data: dict[str, Any],
591 in_progress_map: dict[str | None, dict[str, Any]],
592 history_map: dict[str | None, dict[str, Any]],
593 ) -> None:
594 """
595 Apply playback status to a PodcastEpisode from the in-progress/history data.
596
597 :param episode_item: The episode object to enrich in place.
598 :param episode_data: Raw episode data dict from the API.
599 :param in_progress_map: UUID-keyed map of in-progress episode data.
600 :param history_map: UUID-keyed map of listen-history episode data.
601 """
602 episode_uuid = episode_data.get("uuid")
603 # history is "recently played", not "completed" - rely on the entry's real progress,
604 # never on mere history membership. Both fields are always set so the library sync can
605 # clear a stale completed/resume value (it only updates when both are non-None).
606 status_data = in_progress_map.get(episode_uuid) or history_map.get(episode_uuid) or {}
607 # feeds that omit a duration yield an explicit null rather than a missing key, so
608 # coerce instead of relying on a dict default
609 played_up_to = status_data.get("playedUpTo") or 0
610 duration = status_data.get("duration") or episode_data.get("duration") or 0
611 completed = status_data.get("playingStatus") == 3 or (
612 duration > 0 and (played_up_to / duration) > FULLY_PLAYED_THRESHOLD
613 )
614 episode_item.fully_played = completed
615 episode_item.resume_position_ms = 0 if completed else played_up_to * 1000
616
617 async def _get_special_folder_episodes(
618 self, folder_name: str
619 ) -> list[MediaItemType | BrowseFolder]:
620 """
621 Get episodes for a special browse folder.
622
623 :param folder_name: Name of the special folder (up_next, new_releases, etc.)
624 """
625 folder_getters = {
626 "up_next": self._client.get_up_next_episodes,
627 "new_releases": self._client.get_new_releases,
628 "in_progress": self._client.get_in_progress_episodes,
629 "starred": self._client.get_starred_episodes,
630 "history": self._client.get_history,
631 }
632 episode_list = await folder_getters[folder_name]()
633
634 # (episode, podcast uuid, podcast name) per episode, the name empty when the folder
635 # payload does not carry it
636 resolved: list[tuple[dict[str, Any], str, str]] = []
637 for episode_data in episode_list:
638 # the podcast reference is a string on some endpoints and an object on others
639 podcast_field = episode_data.get("podcast")
640 podcast_uuid: str | None
641 if isinstance(podcast_field, str):
642 podcast_uuid = podcast_field
643 elif isinstance(podcast_field, dict):
644 podcast_uuid = podcast_field.get("uuid")
645 else:
646 podcast_uuid = episode_data.get("podcastUuid")
647
648 if not podcast_uuid:
649 continue
650 # these folders mix podcasts, so the name is not known up front. Take it from the
651 # payload where that carries it, in either of the two shapes
652 payload_name = podcast_field.get("title") if isinstance(podcast_field, dict) else None
653 resolved.append(
654 (
655 episode_data,
656 podcast_uuid,
657 payload_name or episode_data.get("podcastTitle") or "",
658 )
659 )
660
661 # every remaining name costs a full-podcast fetch, so look them up once per podcast and
662 # all at once: serialising them would stall the browse for as long as the folder is deep
663 missing = list({uuid for _, uuid, name in resolved if not name})
664 looked_up = await asyncio.gather(*(self._get_podcast_name(uuid) for uuid in missing))
665 names = dict(zip(missing, looked_up, strict=True))
666
667 items: list[MediaItemType | BrowseFolder] = []
668 for episode_data, podcast_uuid, podcast_name in resolved:
669 if episode_item := self._convert_episode(
670 episode_data,
671 podcast_uuid,
672 show_notes=None,
673 podcast_name=podcast_name or names.get(podcast_uuid, ""),
674 ):
675 items.append(episode_item)
676 return items
677
678 def _create_browse_folders(self) -> list[BrowseFolder]:
679 """Create special browse folders for root level."""
680 folders = [
681 ("up_next", "Up Next"),
682 ("new_releases", "New Releases"),
683 ("in_progress", "In Progress"),
684 ("starred", "Starred"),
685 ("history", "History"),
686 ]
687 return [
688 BrowseFolder(
689 item_id=folder_id,
690 provider=self.instance_id,
691 path=f"{self.instance_id}://{folder_id}",
692 name=name,
693 image=MediaItemImage(
694 type=ImageType.THUMB,
695 path=BROWSE_FOLDER_ICONS[folder_id],
696 provider=self.instance_id,
697 remotely_accessible=True,
698 ),
699 )
700 for folder_id, name in folders
701 ]
702
703 async def _announce_playback_start(
704 self, podcast_uuid: str, episode_uuid: str, episode: PodcastEpisode
705 ) -> None:
706 """
707 Mirror a playback start to Pocket Casts by adding the episode to Up Next and history.
708
709 :param podcast_uuid: The podcast UUID.
710 :param episode_uuid: The episode UUID.
711 :param episode: The episode that started playing.
712 """
713 # source the url from the already-loaded item so no extra API call is needed; filtered
714 # to our own mapping since merged library items can carry other providers' mappings
715 url = next(
716 (
717 mapping.url
718 for mapping in episode.provider_mappings
719 if mapping.provider_instance == self.instance_id and mapping.url
720 ),
721 "",
722 )
723 await self._client.play_now(
724 episode_uuid=episode_uuid, podcast_uuid=podcast_uuid, title=episode.name, url=url
725 )
726 await self._client.add_to_history(
727 episode_uuid=episode_uuid, podcast_uuid=podcast_uuid, title=episode.name, url=url
728 )
729