sermonaudio.py 1.0.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.
sapy/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """sapy: search and download sermons from SermonAudio.com."""
2
+
3
+ __version__ = "1.0.0"
sapy/api.py ADDED
@@ -0,0 +1,246 @@
1
+ """
2
+ Thin client for the SermonAudio v2 "node" API, the same one sermonaudio.com uses.
3
+
4
+ API key, in order of preference:
5
+ 1. $SAPY_API_KEY
6
+ 2. "api_key" in the config file (your own key)
7
+ 3. the site's public key, cached in ~/.config/sapy/site_key.txt and
8
+ re-read from the sermonaudio.com homepage whenever it stops working
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import threading
15
+ import time
16
+ from typing import Iterator, List, Optional
17
+
18
+ import requests
19
+
20
+ from . import config
21
+
22
+ API = "https://api.sermonaudio.com/v2/node"
23
+ SITE = "https://www.sermonaudio.com"
24
+ FEED = "https://feed.sermonaudio.com"
25
+ UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Safari/537.36"
26
+
27
+ session = requests.Session()
28
+ session.headers["User-Agent"] = UA
29
+ _key_lock = threading.Lock()
30
+ _api_key: Optional[str] = None # sent only to the API, never to the site, feed or media hosts
31
+
32
+
33
+ class ApiError(RuntimeError):
34
+ pass
35
+
36
+
37
+ # ---------- auth ----------
38
+
39
+ def _site_key_path():
40
+ return config.config_dir() / "site_key.txt"
41
+
42
+
43
+ _UUID = r"[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}"
44
+ _KEY_NAME = re.compile(r"(?:public|api)_?key", re.I)
45
+
46
+
47
+ def _key_from_nuxt_payload(html: str) -> Optional[str]:
48
+ """Nuxt 3 serializes state as a flat JSON array; dicts point at values by index."""
49
+ m = re.search(r'<script[^>]*id="__NUXT_DATA__"[^>]*>(.*?)</script>', html, re.S)
50
+ if not m:
51
+ return None
52
+ try:
53
+ data = json.loads(m.group(1))
54
+ except ValueError:
55
+ return None
56
+ for item in data:
57
+ if not isinstance(item, dict):
58
+ continue
59
+ for name, idx in item.items():
60
+ if _KEY_NAME.search(name) and isinstance(idx, int) and 0 <= idx < len(data):
61
+ value = data[idx]
62
+ if isinstance(value, str) and re.fullmatch(_UUID, value):
63
+ return value
64
+ return None
65
+
66
+
67
+ def _key_from_text(text: str) -> Optional[str]:
68
+ """apiKey:"…" / publicKey: '…' / "api_key": "…" inlined in HTML or JS."""
69
+ m = re.search(r'(?:api|public)_?key["\']?\s*[:=]\s*["\'](' + _UUID + ')', text, re.I)
70
+ return m.group(1) if m else None
71
+
72
+
73
+ def fetch_site_key() -> str:
74
+ """Find the public API key sermonaudio.com uses: page state first, then its JS bundles."""
75
+ resp = session.get(SITE + "/", timeout=30)
76
+ resp.raise_for_status()
77
+ html = resp.text
78
+ key = _key_from_nuxt_payload(html) or _key_from_text(html)
79
+ if not key:
80
+ for src in list(dict.fromkeys(re.findall(r'(?:src|href)="(/_nuxt/[^"]+\.js)"', html)))[:40]:
81
+ try:
82
+ key = _key_from_text(session.get(SITE + src, timeout=30).text)
83
+ except requests.RequestException:
84
+ continue
85
+ if key:
86
+ break
87
+ if not key:
88
+ raise ApiError("Could not find the API key on sermonaudio.com; the site layout may have changed. "
89
+ "You can set your own with: sapy config set api_key YOUR-KEY")
90
+ path = _site_key_path()
91
+ path.parent.mkdir(parents=True, exist_ok=True)
92
+ path.write_text(key, encoding="utf-8")
93
+ return key
94
+
95
+
96
+ def get_api_key(force_refresh: bool = False) -> str:
97
+ own = os.environ.get("SAPY_API_KEY") or config.load_config().get("api_key")
98
+ if own:
99
+ return own.strip()
100
+ if not force_refresh:
101
+ try:
102
+ cached = _site_key_path().read_text(encoding="utf-8").strip()
103
+ if cached:
104
+ return cached
105
+ except OSError:
106
+ pass
107
+ return fetch_site_key()
108
+
109
+
110
+ def _ensure_key(force_refresh: bool = False) -> str:
111
+ global _api_key
112
+ with _key_lock:
113
+ if force_refresh or not _api_key:
114
+ _api_key = get_api_key(force_refresh)
115
+ return _api_key
116
+
117
+
118
+ def _get(path: str, params: dict = None) -> dict:
119
+ key = _ensure_key()
120
+ url = f"{API}/{path.lstrip('/')}"
121
+ for attempt in range(4):
122
+ resp = session.get(url, params=params, headers={"X-API-Key": key}, timeout=30)
123
+ if resp.status_code == 401 and attempt == 0:
124
+ # Site key rotated; fetch a fresh one once. Your own key is never replaced.
125
+ if os.environ.get("SAPY_API_KEY") or config.load_config().get("api_key"):
126
+ raise ApiError("SermonAudio rejected your API key (HTTP 401).")
127
+ key = _ensure_key(force_refresh=True)
128
+ continue
129
+ if resp.status_code in (429, 500, 502, 503, 504) and attempt < 3:
130
+ time.sleep(2 ** attempt)
131
+ continue
132
+ if resp.status_code == 404:
133
+ return {}
134
+ if not resp.ok:
135
+ raise ApiError(f"HTTP {resp.status_code} from {url}")
136
+ return resp.json()
137
+ raise ApiError(f"Gave up on {url}")
138
+
139
+
140
+ # ---------- queries ----------
141
+
142
+ def search(query: str, sort_by: Optional[str] = None, page_size: int = 10) -> dict:
143
+ """Mixed search: speakerResults, broadcasterResults, seriesResults, sermonResults."""
144
+ params = {"query": query, "liteBroadcaster": "true", "pageSize": page_size}
145
+ if sort_by:
146
+ params["sortBy"] = sort_by
147
+ return _get("search", params)
148
+
149
+
150
+ def sermon(sermon_id: str) -> dict:
151
+ return _get(f"sermons/{sermon_id}")
152
+
153
+
154
+ def sermons(page_size: int = 100, limit: Optional[int] = None, **filters) -> Iterator[dict]:
155
+ """
156
+ Page through node/sermons. Useful filters: speakerID, broadcasterID,
157
+ series (takes the series ID), sermonIDs (comma list), searchKeyword,
158
+ sortBy (newest, oldest, ...).
159
+ """
160
+ seen = set()
161
+ page = 1
162
+ while True:
163
+ params = {"page": page, "pageSize": page_size, "liteBroadcaster": "true", **filters}
164
+ data = _get("sermons", params)
165
+ results = data.get("results") or []
166
+ for item in results:
167
+ sid = str(item.get("sermonID"))
168
+ if sid in seen:
169
+ continue
170
+ seen.add(sid)
171
+ yield item
172
+ if limit and len(seen) >= limit:
173
+ return
174
+ if len(results) < page_size or not data.get("next", True):
175
+ return
176
+ page += 1
177
+
178
+
179
+ def series(series_id: str) -> dict:
180
+ """Series details: title, count, broadcaster, earliest/latest dates."""
181
+ return _get(f"series/{series_id}")
182
+
183
+
184
+ def broadcaster_series(broadcaster_id: str) -> Iterator[dict]:
185
+ page = 1
186
+ while True:
187
+ data = _get(f"broadcasters/{broadcaster_id}/series", {"page": page, "pageSize": 100})
188
+ results = data.get("results") or []
189
+ yield from results
190
+ if len(results) < 100 or not data.get("next", True):
191
+ return
192
+ page += 1
193
+
194
+
195
+ def count_sermons(**filters) -> int:
196
+ data = _get("sermons", {"page": 1, "pageSize": 1, "liteBroadcaster": "true", **filters})
197
+ return int(data.get("totalCount") or 0)
198
+
199
+
200
+ def sermons_by_ids(ids: List[str]) -> List[dict]:
201
+ """Full records for a list of IDs, kept in the given order."""
202
+ found = {}
203
+ for i in range(0, len(ids), 50):
204
+ chunk = ids[i:i + 50]
205
+ for item in sermons(page_size=len(chunk), sermonIDs=",".join(chunk)):
206
+ found[str(item.get("sermonID"))] = item
207
+ return [found[i] for i in ids if i in found]
208
+
209
+
210
+ # ---------- series (public RSS feed, no key needed) ----------
211
+
212
+ FEED_CAP = 100 # the feed only carries the newest 100 sermons
213
+
214
+
215
+ def series_feed(series_id: str) -> dict:
216
+ """Returns {"title": ..., "items": [{"sermonID", "title", "speaker", "date"}]}, oldest first."""
217
+ import xml.etree.ElementTree as ET
218
+ from email.utils import parsedate_to_datetime
219
+
220
+ resp = session.get(f"{FEED}/{series_id}", timeout=30)
221
+ resp.raise_for_status()
222
+ root = ET.fromstring(resp.content)
223
+ channel = root.find("channel")
224
+ itunes = "{http://www.itunes.com/dtds/podcast-1.0.dtd}"
225
+ items = []
226
+ for it in channel.findall("item"):
227
+ link = " ".join(filter(None, [
228
+ it.findtext("link"), it.findtext("guid"),
229
+ (it.find("enclosure").get("url") if it.find("enclosure") is not None else ""),
230
+ ]))
231
+ m = re.search(r"(?:/sermons/|/media/[^/]+/[^/]+/|SID=)(\d{6,})", link)
232
+ if not m:
233
+ continue
234
+ date = ""
235
+ try:
236
+ date = parsedate_to_datetime(it.findtext("pubDate")).date().isoformat()
237
+ except (TypeError, ValueError):
238
+ pass
239
+ items.append({
240
+ "sermonID": m.group(1),
241
+ "title": (it.findtext("title") or "").strip(),
242
+ "speaker": (it.findtext(f"{itunes}author") or it.findtext("author") or "").strip(),
243
+ "date": date,
244
+ })
245
+ items.sort(key=lambda x: x["date"])
246
+ return {"title": (channel.findtext("title") or f"Series {series_id}").strip(), "items": items}