diff --git a/tower/lisael_refresh.py b/tower/lisael_refresh.py index c7550d1..8e92a2e 100644 --- a/tower/lisael_refresh.py +++ b/tower/lisael_refresh.py @@ -1,111 +1,34 @@ #!/usr/bin/env python3 -"""Weekly refresh: latest Radio France youth episodes -> staging dir that -lisael_server.py pushes to the box. Atomic swap so a failed run keeps the old set.""" -import hashlib, io, json, os, shutil, ssl, subprocess, sys, tempfile, urllib.request -import xml.etree.ElementTree as ET -from PIL import Image - -STAGING = os.environ.get("LISAEL_STAGING", "/home/clems/lisael-content/podcasts") -LVGLIMG = os.environ.get("LISAEL_LVGLIMG", "/home/clems/lisael-content/LVGLImage.py") -UA = {"User-Agent": "Mozilla/5.0"} -NS = {"itunes": "http://www.itunes.com/dtds/podcast-1.0.dtd"} -FEEDS = { - "oli": ("Une histoire et Oli", "https://radiofrance-podcast.net/podcast09/rss_19721.xml", 3), - "odyssees": ("Les Odyssees", "https://radiofrance-podcast.net/podcast09/rss_20108.xml", 3), - "salutinfo": ("Salut l'info", "https://radiofrance-podcast.net/podcast09/rss_20689.xml", 2), - "bestioles": ("Bestioles", - "https://radiofrance-podcast.net/podcast09/35099478-7c72-4f9e-a6de-1b928400e9e5/" - "podcast_a80ecbd5-df3d-4c9d-bee7-4e3d9efc1974.xml", 2), -} -SUBS = {"’": "'", "“": '"', "”": '"', "–": "-", - "—": "-", "…": "...", "«": '"', "»": '"'} - -def fetch(u, t=180): - return urllib.request.urlopen(urllib.request.Request(u, headers=UA), - timeout=t, context=ssl.create_default_context()).read() - -def ext_for(url, mime): - u = url.split("?")[0].lower() - return "m4a" if (u.endswith(".m4a") or "mp4" in (mime or "") or "x-m4a" in (mime or "")) else "mp3" - -def cover_url(root): - el = root.find(".//channel/itunes:image", NS) - if el is not None and el.get("href"): - return el.get("href") - el = root.find(".//channel/image/url") - return el.text if el is not None else None - -def clean(s): - for a, b in SUBS.items(): - s = s.replace(a, b) - return " ".join(s.split()).strip() +"""Weekly refresh: for each configured feed, add the latest N missing episodes to +the staging set. ADDITIVE — never wipes manual selections or uploads.""" +import lisael_content as C def main(): - tmp = tempfile.mkdtemp(prefix="lisael_stg_") - manifest = [] - for key, (show, rss, n) in FEEDS.items(): + added = 0 + for fd in C.load_feeds(): + key, show, rss, n = fd["key"], fd["name"], fd["rss"], int(fd.get("n", 2)) try: - root = ET.fromstring(fetch(rss)) + eps, cov = C.fetch_episodes(rss, limit=n) except Exception as e: - print("RSS fail", key, e); continue - eps = [] - for i, it in enumerate(root.findall(".//item")[:n], 1): - enc = it.find("enclosure") - if enc is None or not enc.get("url"): + print("RSS fail", key, e, flush=True); continue + cover_bytes = None + if cov: + try: + cover_bytes = C.fetch(cov) + except Exception: + cover_bytes = None + staged = set(C.staging_files()) + for ep in eps: + if C.episode_fname(key, ep["url"]) in staged: continue - url = enc.get("url") - # Unique per episode (enclosure URL is stable+unique) so a new episode - # = a new filename = the box detects it as missing. Positional names - # (oli_01) would hide a new "latest" episode behind the same name. - # The "_a48" tag marks the transcode profile: a 20+ MB original would - # take minutes over the box's ~100 KB/s link, so we re-encode to mono - # 48 kbps MP3 (plenty for spoken-word kids podcasts) — files drop to a - # few MB and each push completes well under the server timeout. The tag - # also gives the file a fresh name, so the box re-pulls the light - # version and the old heavy original becomes an unreferenced orphan. - h = hashlib.sha1(url.encode()).hexdigest()[:8] - fname = f"{key}_{h}_a48.mp3" - raw = os.path.join(tmp, f".raw_{key}_{h}") try: - open(raw, "wb").write(fetch(url)) + C.add_episode(key, show, cover_bytes, ep["url"], ep["title"]) + cover_bytes = None # cover only needs writing once per show + added += 1 + print("added", C.episode_fname(key, ep["url"]), flush=True) except Exception as e: - print("dl fail", fname, e); continue - out = os.path.join(tmp, fname) - rc = subprocess.run( - ["ffmpeg", "-y", "-loglevel", "error", "-i", raw, - "-ac", "1", "-b:a", "48k", "-c:a", "libmp3lame", out], - capture_output=True).returncode - try: - os.remove(raw) - except OSError: - pass - if rc != 0 or not os.path.exists(out): - print("transcode fail", fname); continue - eps.append({"file": fname, "title": clean(it.findtext("title") or f"{show} {i}")}) - if not eps: - continue - cover = "" - try: - cu = cover_url(root) - if cu: - im = Image.open(io.BytesIO(fetch(cu))).convert("RGB").resize((160, 160)) - png = os.path.join(tmp, f"{key}.png"); im.save(png) - subprocess.run([sys.executable, LVGLIMG, "--ofmt", "BIN", "--cf", "RGB565", - "-o", tmp, png], check=True) - cover = f"{key}.bin"; os.remove(png) - except Exception as e: - print("cover fail", key, e) - manifest.append({"show": show, "cover": cover, "episodes": eps}) - - json.dump(manifest, open(os.path.join(tmp, "manifest.json"), "w", encoding="utf-8"), - ensure_ascii=False, indent=2) - os.makedirs(os.path.dirname(STAGING), exist_ok=True) - bak = STAGING + ".old" - if os.path.exists(STAGING): - shutil.rmtree(bak, ignore_errors=True); os.rename(STAGING, bak) - os.rename(tmp, STAGING) - shutil.rmtree(bak, ignore_errors=True) - print(f"staging OK: {len(manifest)} shows, {len(os.listdir(STAGING))} files in {STAGING}") + print("add fail", key, e, flush=True) + print(f"refresh done: +{added} episodes, {len(C.staging_files())} files", flush=True) if __name__ == "__main__": main()