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.
- applepodbar/__init__.py +1 -0
- applepodbar/__main__.py +6 -0
- applepodbar/amp_client.py +430 -0
- applepodbar/app.py +1107 -0
- applepodbar/catalog.py +269 -0
- applepodbar/credentials.py +98 -0
- applepodbar/local_library.py +75 -0
- applepodbar/login.py +101 -0
- applepodbar/models.py +160 -0
- applepodbar/playback.py +361 -0
- applepodbar/positions.py +100 -0
- applepodbar/rss_client.py +148 -0
- applepodbar/settings.py +227 -0
- applepodbar/tokens.py +127 -0
- applepodbar-0.1.0.dist-info/METADATA +144 -0
- applepodbar-0.1.0.dist-info/RECORD +19 -0
- applepodbar-0.1.0.dist-info/WHEEL +4 -0
- applepodbar-0.1.0.dist-info/entry_points.txt +3 -0
- applepodbar-0.1.0.dist-info/licenses/LICENSE +21 -0
applepodbar/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""applepodbar package."""
|
applepodbar/__main__.py
ADDED
|
@@ -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
|
+
)
|