/
/
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 and user status in parallel
240 episodes, in_progress, history = 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 )
245 in_progress_map = {ep.get("uuid"): ep for ep in in_progress}
246 history_map = {ep.get("uuid"): ep for ep in history}
247
248 for episode_data in episodes:
249 episode_item = self._convert_episode(episode_data, prov_podcast_id)
250 if episode_item:
251 self._enrich_episode_with_status(
252 episode_item, episode_data, in_progress_map, history_map
253 )
254 yield episode_item
255
256 async def browse(self, path: str) -> list[MediaItemType | BrowseFolder]:
257 """
258 Browse this provider's items.
259
260 :param path: The browse path to resolve.
261 """
262 item_path = path.split("://", 1)[1] if "://" in path else path
263
264 if not item_path:
265 # root level - special folders followed by subscribed podcasts
266 items: list[MediaItemType | BrowseFolder] = list(self._create_browse_folders())
267 for podcast_data in await self._client.get_subscribed_podcasts():
268 items.append(self._convert_podcast(podcast_data))
269 return items
270
271 if item_path in SPECIAL_FOLDERS:
272 return await self._get_special_folder_episodes(item_path)
273
274 return [episode async for episode in self.get_podcast_episodes(item_path)]
275
276 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
277 """
278 Get streamable URL and details for the given media item.
279
280 :param item_id: The episode item id (format: podcast_uuid:episode_uuid).
281 :param media_type: The media type of the item.
282 """
283 _, episode_uuid = item_id.split(":", 1)
284 episode_data = await self._client.get_episode_details(episode_uuid)
285
286 url = episode_data.get("url", "")
287 if not url:
288 raise MediaNotFoundError(f"No URL found for episode {item_id}")
289
290 return StreamDetails(
291 item_id=item_id,
292 provider=self.instance_id,
293 audio_format=AudioFormat(
294 content_type=ContentType.try_parse(episode_data.get("fileType", "audio/mpeg")),
295 ),
296 media_type=MediaType.PODCAST_EPISODE,
297 stream_type=StreamType.HTTP,
298 path=url,
299 duration=episode_data.get("duration"),
300 can_seek=True,
301 allow_seek=True,
302 )
303
304 @use_cache(3600 * 24)
305 async def search(
306 self, search_query: str, media_types: list[MediaType], limit: int = 5
307 ) -> SearchResults:
308 """
309 Search for podcasts.
310
311 :param search_query: The search query.
312 :param media_types: The media types to include in the search.
313 :param limit: The maximum number of items to return per type.
314 """
315 results = SearchResults()
316 if media_types and MediaType.PODCAST not in media_types:
317 return results
318 podcasts = await self._client.search_podcasts(search_query)
319 results.podcasts = [self._convert_podcast(podcast) for podcast in podcasts[:limit]]
320 return results
321
322 @use_cache(3600)
323 async def get_podcast_episode(self, prov_item_id: str) -> PodcastEpisode:
324 """
325 Get full details for a podcast episode.
326
327 :param prov_item_id: The episode item id (format: podcast_uuid:episode_uuid).
328 """
329 podcast_uuid, episode_uuid = prov_item_id.split(":", 1)
330 episode_data = await self._client.get_episode_details(episode_uuid)
331 episode_item = self._convert_episode(episode_data, podcast_uuid)
332 if episode_item is None:
333 raise MediaNotFoundError(f"Episode {episode_uuid} not found in podcast {podcast_uuid}")
334
335 # the API sends explicit nulls for these fields, so a dict default is not enough
336 played_up_to = episode_data.get("playedUpTo") or 0
337 duration = episode_data.get("duration") or 0
338 playing_status = episode_data.get("playingStatus", 1) # 1=unplayed, 2=in_progress, 3=played
339 if duration > 0:
340 episode_item.duration = duration
341 completed = playing_status == 3 or (
342 duration > 0 and (played_up_to / duration) > FULLY_PLAYED_THRESHOLD
343 )
344 episode_item.fully_played = completed
345 episode_item.resume_position_ms = 0 if completed else played_up_to * 1000
346
347 return episode_item
348
349 async def get_resume_position(
350 self, item_id: str, media_type: MediaType
351 ) -> tuple[bool, int, datetime | None]:
352 """
353 Return the (fully_played, position_ms, timestamp) resume point for an episode.
354
355 PocketCasts does not expose a reliable last-played timestamp, so the timestamp
356 is always None.
357
358 :param item_id: The episode item id (format: podcast_uuid:episode_uuid).
359 :param media_type: The media type (should be PODCAST_EPISODE).
360 """
361 _, episode_uuid = item_id.split(":", 1)
362
363 try:
364 in_progress = await self._client.get_in_progress_episodes()
365 except (ProviderUnavailableError, ResourceTemporarilyUnavailable, RetriesExhausted) as err:
366 # resume is best-effort; a transient failure should not break playback
367 LOGGER.warning("Could not fetch resume position for %s: %s", episode_uuid, err)
368 return (False, 0, None)
369
370 for ep in in_progress:
371 if ep.get("uuid") == episode_uuid:
372 played_up_to = int(ep.get("playedUpTo") or 0) # seconds from API
373 duration = int(ep.get("duration") or 0)
374 fully_played = duration > 0 and (played_up_to / duration) > FULLY_PLAYED_THRESHOLD
375 LOGGER.debug(
376 "Resume position for %s: %d ms (fully_played=%s)",
377 episode_uuid,
378 played_up_to * 1000,
379 fully_played,
380 )
381 return (fully_played, played_up_to * 1000, None)
382
383 LOGGER.debug("No in-progress entry for %s; resuming from start", episode_uuid)
384 return (False, 0, None)
385
386 async def on_played(
387 self,
388 media_type: MediaType,
389 prov_item_id: str,
390 fully_played: bool,
391 position: int,
392 media_item: MediaItemType,
393 is_playing: bool = False,
394 ) -> None:
395 """
396 Sync playback progress for a podcast episode back to Pocket Casts.
397
398 Called by the Queue controller when a track is played, stopped/skipped, and
399 periodically while playing.
400
401 :param media_type: The media type of the played item.
402 :param prov_item_id: The provider item id (format: podcast_uuid:episode_uuid).
403 :param fully_played: Whether the episode was played to the end.
404 :param position: Last known position in seconds.
405 :param media_item: The full media item details.
406 :param is_playing: Whether the episode is currently playing.
407 """
408 if media_type != MediaType.PODCAST_EPISODE or not isinstance(media_item, PodcastEpisode):
409 return
410 podcast_uuid, episode_uuid = prov_item_id.split(":", 1)
411
412 # MA reports fully_played=True when an episode is skipped/stopped, not only when it
413 # truly ends, so confirm completion against the real position before marking it played.
414 duration = media_item.duration or 0
415 completed = fully_played and duration > 0 and position >= duration * FULLY_PLAYED_THRESHOLD
416 if completed:
417 self._announced_episodes.discard(episode_uuid)
418 await self._client.mark_episode_played(podcast_uuid, episode_uuid)
419 await self._client.remove_from_up_next(episode_uuid)
420 await self._client.archive_episode(podcast_uuid, episode_uuid, archive=True)
421 elif position == 0 and not is_playing:
422 # the user explicitly marked the episode as unplayed
423 self._announced_episodes.discard(episode_uuid)
424 await self._client.mark_episode_unplayed(podcast_uuid, episode_uuid)
425 await self._client.archive_episode(podcast_uuid, episode_uuid, archive=False)
426 else:
427 # on_played fires every progress tick, so mirror the start to Up Next/history only
428 # once per session - re-announcing each tick would re-bump Up Next and spam the API.
429 # A resume within the same session is intentionally not re-announced.
430 if is_playing and episode_uuid not in self._announced_episodes:
431 self._announced_episodes.add(episode_uuid)
432 await self._announce_playback_start(podcast_uuid, episode_uuid, media_item)
433 await self._client.update_episode_progress(podcast_uuid, episode_uuid, position)
434
435 def _convert_podcast(self, podcast_data: dict[str, Any]) -> Podcast:
436 """
437 Convert raw Pocket Casts podcast data to a Podcast object.
438
439 :param podcast_data: Raw podcast data from the subscribed-list or full-podcast endpoint.
440 """
441 uuid = podcast_data["uuid"]
442 return Podcast(
443 item_id=uuid,
444 provider=self.instance_id,
445 name=podcast_data.get("title", ""),
446 provider_mappings={
447 ProviderMapping(
448 item_id=uuid,
449 provider_domain=self.domain,
450 provider_instance=self.instance_id,
451 )
452 },
453 metadata=MediaItemMetadata(
454 description=podcast_data.get("description"),
455 images=UniqueList(
456 [
457 MediaItemImage(
458 type=ImageType.THUMB,
459 path=f"https://static.pocketcasts.com/discover/images/280/{uuid}.jpg",
460 provider=self.instance_id,
461 remotely_accessible=True,
462 )
463 ]
464 ),
465 ),
466 )
467
468 def _convert_episode(
469 self, episode_data: dict[str, Any], podcast_uuid: str
470 ) -> PodcastEpisode | None:
471 """
472 Convert Pocket Casts episode data to a PodcastEpisode object.
473
474 Returns None when the data has no episode UUID to key on.
475
476 :param episode_data: Raw episode data dict from the API.
477 :param podcast_uuid: The UUID of the parent podcast.
478 """
479 episode_uuid = episode_data.get("uuid")
480 if not episode_uuid:
481 return None
482
483 # this is fed by two endpoints with different field schemas: the full-podcast JSON
484 # uses snake_case (file_type) while /user/episode uses camelCase (fileType,
485 # episodeNumber). Neither carries show notes or episode artwork, so the description is
486 # left empty and the parent podcast image is used for every episode.
487 item_id = f"{podcast_uuid}:{episode_uuid}"
488 file_type = episode_data.get("fileType") or episode_data.get("file_type", "audio/mpeg")
489 episode_item = PodcastEpisode(
490 item_id=item_id,
491 provider=self.instance_id,
492 name=episode_data.get("title", "Unknown Episode"),
493 podcast=ItemMapping(
494 media_type=MediaType.PODCAST,
495 item_id=podcast_uuid,
496 provider=self.instance_id,
497 name="",
498 ),
499 position=episode_data.get("episodeNumber", 0),
500 provider_mappings={
501 ProviderMapping(
502 item_id=item_id,
503 provider_domain=self.domain,
504 provider_instance=self.instance_id,
505 audio_format=AudioFormat(content_type=ContentType.try_parse(file_type)),
506 url=episode_data.get("url", ""),
507 )
508 },
509 )
510 if episode_data.get("duration"):
511 episode_item.duration = int(episode_data["duration"])
512 if title := episode_data.get("title"):
513 episode_item.metadata.label = title
514 episode_item.metadata.images = UniqueList(
515 [
516 MediaItemImage(
517 type=ImageType.THUMB,
518 path=f"https://static.pocketcasts.com/discover/images/280/{podcast_uuid}.jpg",
519 provider=self.instance_id,
520 remotely_accessible=True,
521 )
522 ]
523 )
524 return episode_item
525
526 def _enrich_episode_with_status(
527 self,
528 episode_item: PodcastEpisode,
529 episode_data: dict[str, Any],
530 in_progress_map: dict[str | None, dict[str, Any]],
531 history_map: dict[str | None, dict[str, Any]],
532 ) -> None:
533 """
534 Apply playback status to a PodcastEpisode from the in-progress/history data.
535
536 :param episode_item: The episode object to enrich in place.
537 :param episode_data: Raw episode data dict from the API.
538 :param in_progress_map: UUID-keyed map of in-progress episode data.
539 :param history_map: UUID-keyed map of listen-history episode data.
540 """
541 episode_uuid = episode_data.get("uuid")
542 # history is "recently played", not "completed" - rely on the entry's real progress,
543 # never on mere history membership. Both fields are always set so the library sync can
544 # clear a stale completed/resume value (it only updates when both are non-None).
545 status_data = in_progress_map.get(episode_uuid) or history_map.get(episode_uuid) or {}
546 # feeds that omit a duration yield an explicit null rather than a missing key, so
547 # coerce instead of relying on a dict default
548 played_up_to = status_data.get("playedUpTo") or 0
549 duration = status_data.get("duration") or episode_data.get("duration") or 0
550 completed = status_data.get("playingStatus") == 3 or (
551 duration > 0 and (played_up_to / duration) > FULLY_PLAYED_THRESHOLD
552 )
553 episode_item.fully_played = completed
554 episode_item.resume_position_ms = 0 if completed else played_up_to * 1000
555
556 async def _get_special_folder_episodes(
557 self, folder_name: str
558 ) -> list[MediaItemType | BrowseFolder]:
559 """
560 Get episodes for a special browse folder.
561
562 :param folder_name: Name of the special folder (up_next, new_releases, etc.)
563 """
564 folder_getters = {
565 "up_next": self._client.get_up_next_episodes,
566 "new_releases": self._client.get_new_releases,
567 "in_progress": self._client.get_in_progress_episodes,
568 "starred": self._client.get_starred_episodes,
569 "history": self._client.get_history,
570 }
571 episode_list = await folder_getters[folder_name]()
572
573 items: list[MediaItemType | BrowseFolder] = []
574 for episode_data in episode_list:
575 # the podcast reference is a string on some endpoints and an object on others
576 podcast_field = episode_data.get("podcast")
577 podcast_uuid: str | None
578 if isinstance(podcast_field, str):
579 podcast_uuid = podcast_field
580 elif isinstance(podcast_field, dict):
581 podcast_uuid = podcast_field.get("uuid")
582 else:
583 podcast_uuid = episode_data.get("podcastUuid")
584
585 if podcast_uuid and (episode_item := self._convert_episode(episode_data, podcast_uuid)):
586 items.append(episode_item)
587 return items
588
589 def _create_browse_folders(self) -> list[BrowseFolder]:
590 """Create special browse folders for root level."""
591 folders = [
592 ("up_next", "Up Next"),
593 ("new_releases", "New Releases"),
594 ("in_progress", "In Progress"),
595 ("starred", "Starred"),
596 ("history", "History"),
597 ]
598 return [
599 BrowseFolder(
600 item_id=folder_id,
601 provider=self.instance_id,
602 path=f"{self.instance_id}://{folder_id}",
603 name=name,
604 image=MediaItemImage(
605 type=ImageType.THUMB,
606 path=BROWSE_FOLDER_ICONS[folder_id],
607 provider=self.instance_id,
608 remotely_accessible=True,
609 ),
610 )
611 for folder_id, name in folders
612 ]
613
614 async def _announce_playback_start(
615 self, podcast_uuid: str, episode_uuid: str, episode: PodcastEpisode
616 ) -> None:
617 """
618 Mirror a playback start to Pocket Casts by adding the episode to Up Next and history.
619
620 :param podcast_uuid: The podcast UUID.
621 :param episode_uuid: The episode UUID.
622 :param episode: The episode that started playing.
623 """
624 # source the url from the already-loaded item so no extra API call is needed; filtered
625 # to our own mapping since merged library items can carry other providers' mappings
626 url = next(
627 (
628 mapping.url
629 for mapping in episode.provider_mappings
630 if mapping.provider_instance == self.instance_id and mapping.url
631 ),
632 "",
633 )
634 await self._client.play_now(
635 episode_uuid=episode_uuid, podcast_uuid=podcast_uuid, title=episode.name, url=url
636 )
637 await self._client.add_to_history(
638 episode_uuid=episode_uuid, podcast_uuid=podcast_uuid, title=episode.name, url=url
639 )
640