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