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