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 +3 -0
- sapy/api.py +246 -0
- sapy/cli.py +566 -0
- sapy/config.py +62 -0
- sapy/download.py +403 -0
- sapy/resolve.py +73 -0
- sermonaudio_py-1.0.0.dist-info/METADATA +131 -0
- sermonaudio_py-1.0.0.dist-info/RECORD +12 -0
- sermonaudio_py-1.0.0.dist-info/WHEEL +5 -0
- sermonaudio_py-1.0.0.dist-info/entry_points.txt +2 -0
- sermonaudio_py-1.0.0.dist-info/licenses/LICENSE +674 -0
- sermonaudio_py-1.0.0.dist-info/top_level.txt +1 -0
sapy/__init__.py
ADDED
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}
|