applepodbar 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """applepodbar package."""
@@ -0,0 +1,6 @@
1
+ """Run applepodbar as a module."""
2
+
3
+ from .app import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,430 @@
1
+ """Read the Apple Podcasts catalog through the web player's AMP API.
2
+
3
+ No credentials are embedded in the package. The developer JWT comes from the
4
+ current web bundle; user credentials, when available, are read from settings.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterator, Mapping
10
+ from pathlib import Path
11
+ from typing import Any, Self
12
+ from urllib.parse import urlsplit
13
+
14
+ import httpx
15
+
16
+ from applepodbar.models import Episode, Show
17
+ from applepodbar.settings import TOKENS_PATH, clear_media_user_token, load_tokens
18
+ from applepodbar.tokens import WEB_ORIGIN, DeveloperTokenProvider
19
+
20
+ API_BASE = "https://amp-api.podcasts.apple.com/v1/"
21
+
22
+
23
+ class AmpClientError(RuntimeError):
24
+ """An API outage, malformed response, or invalid endpoint (not a login issue)."""
25
+
26
+
27
+ class AuthRequired(AmpClientError):
28
+ """The signed-in session is missing or expired; run applepodbar login."""
29
+
30
+
31
+ def _artwork(attributes: Mapping[str, Any], size: int = 300) -> str | None:
32
+ art = attributes.get("artwork")
33
+ if not isinstance(art, dict) or not isinstance(art.get("url"), str):
34
+ return None
35
+ return (
36
+ art["url"]
37
+ .replace("{w}", str(size))
38
+ .replace("{h}", str(size))
39
+ .replace("{f}", "jpg")
40
+ )
41
+
42
+
43
+ def _string(value: Any) -> str:
44
+ return value if isinstance(value, str) else ""
45
+
46
+
47
+ def parse_show(record: Mapping[str, Any]) -> Show:
48
+ """Map an AMP show resource into the shared UI boundary."""
49
+
50
+ attributes = record.get("attributes") or {}
51
+ return Show(
52
+ id=str(record["id"]),
53
+ title=_string(attributes.get("name")),
54
+ author=_string(attributes.get("artistName")),
55
+ description=_string(attributes.get("description")),
56
+ artwork_url=_artwork(attributes),
57
+ )
58
+
59
+
60
+ def parse_episode(record: Mapping[str, Any]) -> Episode:
61
+ """Map an AMP episode; paid/FairPlay HLS is never passed to mpv."""
62
+
63
+ attributes = record.get("attributes") or {}
64
+ offers = attributes.get("offers") or []
65
+ paid = any(
66
+ offer.get("kind") == "get" and offer.get("type") not in (None, "STDQ")
67
+ for offer in offers
68
+ if isinstance(offer, dict)
69
+ )
70
+ asset = attributes.get("assetUrl")
71
+ if not isinstance(asset, str) or not asset.startswith(("https://", "http://")):
72
+ asset = None
73
+ # The paid stream is FairPlay; even an exposed HLS URL cannot be played.
74
+ if paid or (asset and "/hls/playlist.m3u8" in asset):
75
+ asset = None
76
+ duration = attributes.get("durationInMilliseconds")
77
+ if isinstance(duration, bool) or not isinstance(duration, int):
78
+ duration = 0
79
+ relationships = record.get("relationships") or {}
80
+ podcast = (relationships.get("podcast") or {}).get("data") or []
81
+ parent = podcast[0] if podcast else {}
82
+ return Episode(
83
+ id=str(record["id"]),
84
+ title=_string(attributes.get("name")),
85
+ show_id=str(parent.get("id", "")),
86
+ show_title=_string((parent.get("attributes") or {}).get("name")),
87
+ description=_string(attributes.get("description")),
88
+ asset_url=asset,
89
+ duration_ms=max(0, duration),
90
+ guid=_string(attributes.get("guid")),
91
+ release_date=_string(attributes.get("releaseDateTime")),
92
+ artwork_url=_artwork(attributes),
93
+ is_paid=paid,
94
+ subscription_url=_string(attributes.get("subscriptionUrl")) or None,
95
+ )
96
+
97
+
98
+ class AmpClient:
99
+ """Synchronous catalog transport; construct in a worker, not the UI thread."""
100
+
101
+ def __init__(
102
+ self,
103
+ *,
104
+ storefront: str = "us",
105
+ client: httpx.Client | None = None,
106
+ tokens_path: Path = TOKENS_PATH,
107
+ ) -> None:
108
+ self.storefront = storefront
109
+ self._owns_client = client is None
110
+ self.http = client or httpx.Client(timeout=15)
111
+ self.tokens_path = tokens_path
112
+ self.provider = DeveloperTokenProvider(
113
+ self.http, storefront=storefront, tokens_path=tokens_path
114
+ )
115
+
116
+ def close(self) -> None:
117
+ """Release an owned connection pool."""
118
+
119
+ if self._owns_client:
120
+ self.http.close()
121
+
122
+ def __enter__(self) -> Self:
123
+ return self
124
+
125
+ def __exit__(self, *_args: object) -> None:
126
+ self.close()
127
+
128
+ def get(
129
+ self, path: str, params: Mapping[str, str | int] | None = None
130
+ ) -> dict[str, Any]:
131
+ """GET an AMP path, refreshing the developer token once on a 401."""
132
+
133
+ return self._request("GET", path, params)
134
+
135
+ def put(
136
+ self,
137
+ path: str,
138
+ params: Mapping[str, str | int],
139
+ body: Mapping[str, Any],
140
+ ) -> dict[str, Any]:
141
+ """PUT an authenticated AMP resource with the same error handling."""
142
+
143
+ return self._request("PUT", path, params, body)
144
+
145
+ def post(self, path: str) -> dict[str, Any]:
146
+ """POST an empty-body Library mutation."""
147
+
148
+ return self._request("POST", path)
149
+
150
+ def delete(self, path: str) -> dict[str, Any]:
151
+ """DELETE a Library resource."""
152
+
153
+ return self._request("DELETE", path)
154
+
155
+ def _request(
156
+ self,
157
+ method: str,
158
+ path: str,
159
+ params: Mapping[str, str | int] | None = None,
160
+ body: Mapping[str, Any] | None = None,
161
+ ) -> dict[str, Any]:
162
+ """Validate destination before forwarding either credential to AMP."""
163
+
164
+ if path.startswith("https://"):
165
+ url = path
166
+ elif path.startswith("/v1/"):
167
+ url = "https://amp-api.podcasts.apple.com" + path
168
+ else:
169
+ url = API_BASE + path.lstrip("/")
170
+ parsed = urlsplit(url)
171
+ if (
172
+ parsed.scheme != "https"
173
+ or parsed.netloc != "amp-api.podcasts.apple.com"
174
+ or not parsed.path.startswith("/v1/")
175
+ ):
176
+ raise AmpClientError("AMP pagination URL has an unexpected host or path.")
177
+ user_token = load_tokens(self.tokens_path).media_user_token
178
+ if parsed.path.startswith("/v1/me/") and not user_token:
179
+ raise AuthRequired("Sign in with `applepodbar login` to use your Library.")
180
+
181
+ for attempt in range(2):
182
+ token = self.provider.get(force=attempt > 0)
183
+ headers = {"Authorization": f"Bearer {token}", "Origin": WEB_ORIGIN}
184
+ if user_token:
185
+ headers["media-user-token"] = user_token
186
+ try:
187
+ response = self.http.request(
188
+ method, url, params=params, headers=headers, json=body
189
+ )
190
+ except httpx.HTTPError as exc:
191
+ raise AmpClientError("Apple Podcasts API is unavailable.") from exc
192
+ if response.status_code == 401 and attempt == 0:
193
+ continue
194
+ if response.status_code >= 400:
195
+ code = ""
196
+ try:
197
+ code = str(response.json().get("errors", [{}])[0].get("code", ""))
198
+ except (ValueError, TypeError, AttributeError, IndexError, KeyError):
199
+ pass
200
+ if parsed.path.startswith("/v1/me/") and (
201
+ response.status_code == 401
202
+ or (response.status_code == 403 and code == "40300")
203
+ ):
204
+ clear_media_user_token(self.tokens_path)
205
+ raise AuthRequired(
206
+ "Apple Podcasts sign-in expired; run `applepodbar login` again."
207
+ )
208
+ raise AmpClientError(
209
+ f"Apple Podcasts API returned {response.status_code} (code {code or 'unknown'})."
210
+ )
211
+ if response.status_code == 204 or not response.content:
212
+ return {}
213
+ try:
214
+ result = response.json()
215
+ except ValueError as exc:
216
+ raise AmpClientError("Apple Podcasts returned invalid JSON.") from exc
217
+ if not isinstance(result, dict):
218
+ raise AmpClientError("Apple Podcasts returned an unexpected response.")
219
+ return result
220
+ raise AssertionError("unreachable")
221
+
222
+ def _catalog(self, suffix: str) -> str:
223
+ return f"catalog/{self.storefront}/{suffix}"
224
+
225
+ def _me_items(
226
+ self, path: str, *, limit: int = 25, max_pages: int = 4
227
+ ) -> list[dict[str, Any]]:
228
+ """Read signed-in resources, capping each interactive fetch at 100 items.
229
+
230
+ Most /me routes reject a limit above 25 with HTTP 400/40005.
231
+ """
232
+
233
+ next_path = f"me/{path}"
234
+ params: Mapping[str, str | int] | None = {"limit": limit}
235
+ seen: set[str] = set()
236
+ result: list[dict[str, Any]] = []
237
+ for _ in range(max_pages):
238
+ if next_path in seen:
239
+ raise AmpClientError("Apple Podcasts returned a pagination loop.")
240
+ seen.add(next_path)
241
+ payload = self.get(next_path, params=params)
242
+ data = payload.get("data", [])
243
+ if not isinstance(data, list):
244
+ raise AmpClientError("Apple Podcasts returned invalid library data.")
245
+ result.extend(data)
246
+ link = payload.get("next")
247
+ if not link:
248
+ break
249
+ if not isinstance(link, str):
250
+ raise AmpClientError("Apple Podcasts returned an invalid next link.")
251
+ next_path = link
252
+ params = None
253
+ return result
254
+
255
+ def follow_show(self, show_id: str) -> None:
256
+ """Follow a show in the signed-in Apple Library."""
257
+
258
+ self.post(f"me/library/podcasts/{show_id}")
259
+
260
+ def unfollow_show(self, show_id: str) -> None:
261
+ """Remove a show from the signed-in Apple Library."""
262
+
263
+ self.delete(f"me/library/podcasts/{show_id}")
264
+
265
+ def save_episode(self, episode_id: str) -> None:
266
+ """Save an episode in the signed-in Apple Library."""
267
+
268
+ self.post(f"me/library/podcast-episodes/{episode_id}")
269
+
270
+ def unsave_episode(self, episode_id: str) -> None:
271
+ """Remove a saved episode from the signed-in Apple Library."""
272
+
273
+ self.delete(f"me/library/podcast-episodes/{episode_id}")
274
+
275
+ def library_shows(self) -> list[Show]:
276
+ """Return followed podcasts."""
277
+
278
+ return [parse_show(item) for item in self._me_items("library/podcasts")]
279
+
280
+ def saved_episodes(self) -> list[Episode]:
281
+ """Return episodes explicitly saved to Library."""
282
+
283
+ return [
284
+ parse_episode(item) for item in self._me_items("library/podcast-episodes")
285
+ ]
286
+
287
+ def up_next(self) -> list[Episode]:
288
+ """Return the user's Apple Up Next queue."""
289
+
290
+ return [parse_episode(item) for item in self._me_items("library/next-episodes")]
291
+
292
+ def latest_episodes(self) -> list[Episode]:
293
+ """Return the latest episodes from followed podcasts."""
294
+
295
+ return [
296
+ parse_episode(item) for item in self._me_items("library/latest-episodes")
297
+ ]
298
+
299
+ def updated_episodes(self) -> list[Episode]:
300
+ """Return Library's recently updated episodes."""
301
+
302
+ return [
303
+ parse_episode(item)
304
+ for item in self._me_items("library/recent/updated/podcast-episodes")
305
+ ]
306
+
307
+ def recently_played(self) -> list[Episode]:
308
+ """Return episodes played recently on the user's devices."""
309
+
310
+ return [
311
+ parse_episode(item)
312
+ for item in self._me_items("recent/played/podcast-episodes")
313
+ ]
314
+
315
+ def library_channels(self) -> list[dict[str, Any]]:
316
+ """Return followed podcast-channel resources."""
317
+
318
+ return self._me_items("library/podcast-channels")
319
+
320
+ def library_stations(self) -> list[dict[str, Any]]:
321
+ """Return Library station resources."""
322
+
323
+ return self._me_items("library/podcast-stations")
324
+
325
+ def account_storefront(self) -> str:
326
+ """Return the logged-in user's country code from /me/storefront."""
327
+
328
+ payload = self.get("me/storefront")
329
+ data = payload.get("data", [])
330
+ if not isinstance(data, list) or not data:
331
+ raise AmpClientError("Apple Podcasts returned no user storefront.")
332
+ return str(data[0]["id"]).lower()
333
+
334
+ def _pages(
335
+ self, path: str, params: Mapping[str, str | int] | None = None
336
+ ) -> Iterator[dict[str, Any]]:
337
+ """Yield resources, following AMP's `next` links until exhausted."""
338
+
339
+ seen: set[str] = set()
340
+ while path:
341
+ if path in seen:
342
+ raise AmpClientError("Apple Podcasts returned a pagination loop.")
343
+ seen.add(path)
344
+ payload = self.get(path, params=params)
345
+ data = payload.get("data", [])
346
+ if not isinstance(data, list):
347
+ raise AmpClientError("Apple Podcasts returned invalid catalog data.")
348
+ yield from data
349
+ next_url = payload.get("next")
350
+ if next_url is not None and not isinstance(next_url, str):
351
+ raise AmpClientError("Apple Podcasts returned an invalid next link.")
352
+ path = next_url or ""
353
+ params = None
354
+
355
+ def search(
356
+ self,
357
+ term: str,
358
+ *,
359
+ types: str = "podcasts,podcast-episodes,podcast-channels",
360
+ limit: int = 25,
361
+ ) -> dict[str, Any]:
362
+ """Search all requested AMP resource types (raw grouped results)."""
363
+
364
+ return self.get(
365
+ self._catalog("search"),
366
+ {
367
+ "term": term,
368
+ "types": types,
369
+ "limit": limit,
370
+ "with": "entitlements,hlsVideo",
371
+ },
372
+ )
373
+
374
+ def search_shows(self, term: str, *, limit: int = 25) -> list[Show]:
375
+ """Search podcast shows and normalize their records."""
376
+
377
+ payload = self.search(term, types="podcasts", limit=limit)
378
+ return [
379
+ parse_show(item)
380
+ for item in payload.get("results", {}).get("podcasts", {}).get("data", [])
381
+ ]
382
+
383
+ def show(self, show_id: str) -> Show:
384
+ """Fetch one podcast show."""
385
+
386
+ data = self.get(
387
+ self._catalog(f"podcasts/{show_id}"), {"with": "entitlements,hlsVideo"}
388
+ )["data"]
389
+ return parse_show(data[0])
390
+
391
+ def episodes(self, show_id: str, *, limit: int = 100) -> list[Episode]:
392
+ """Fetch all episodes of a show, following `next` pagination."""
393
+
394
+ return [
395
+ parse_episode(item)
396
+ for item in self._pages(
397
+ self._catalog(f"podcasts/{show_id}/episodes"),
398
+ {"limit": limit, "with": "entitlements,hlsVideo"},
399
+ )
400
+ ]
401
+
402
+ def episode(self, episode_id: str) -> Episode:
403
+ """Fetch one episode."""
404
+
405
+ data = self.get(
406
+ self._catalog(f"podcast-episodes/{episode_id}"),
407
+ {"with": "entitlements,hlsVideo"},
408
+ )["data"]
409
+ return parse_episode(data[0])
410
+
411
+ def charts(self, *, types: str = "podcasts", limit: int = 25) -> dict[str, Any]:
412
+ """Fetch catalog charts."""
413
+
414
+ return self.get(
415
+ self._catalog("charts"),
416
+ {"types": types, "limit": limit, "with": "entitlements,hlsVideo"},
417
+ )
418
+
419
+ def genres(self) -> dict[str, Any]:
420
+ """Fetch catalog genres."""
421
+
422
+ return self.get(self._catalog("genres"))
423
+
424
+ def editorial_groupings(self) -> dict[str, Any]:
425
+ """Fetch the web player's editorial groupings."""
426
+
427
+ return self.get(
428
+ f"editorial/{self.storefront}/groupings",
429
+ {"platform": "web", "name": "podcasts"},
430
+ )