Files
lisael-box/tower/lisael_content.py
T
Clément Saillant ec2dd3f24f feat(tower): cap shows at latest N per feed
The additive refresh accumulated episodes without bound (Oli reached 13).
Tag each manifest episode with a source (auto/pick/upload); the refresh now
prunes "auto" episodes that aged out of the feed's latest N while keeping
hand-picked and uploaded ones. Feeds default to N=5.
2026-06-15 08:40:43 +02:00

280 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Shared content logic for the Lisael Box on Tower: feeds config, RSS, transcode,
covers, staging/manifest ops, box state, push. Imported by lisael_refresh.py (cron)
and lisael_server.py (web)."""
import hashlib, io, json, os, ssl, subprocess, sys, tempfile, threading, urllib.request
import xml.etree.ElementTree as ET
from PIL import Image
BASE = os.environ.get("LISAEL_BASE", "/home/clems/lisael-content")
STAGING = os.environ.get("LISAEL_STAGING", os.path.join(BASE, "podcasts"))
LVGLIMG = os.environ.get("LISAEL_LVGLIMG", os.path.join(BASE, "LVGLImage.py"))
FEEDS_JSON = os.path.join(BASE, "feeds.json")
BOX_JSON = os.path.join(BASE, "box-state.json")
BOX_PORT = int(os.environ.get("LISAEL_BOX_PORT", "8080"))
UA = {"User-Agent": "Mozilla/5.0"}
NS = {"itunes": "http://www.itunes.com/dtds/podcast-1.0.dtd"}
SUBS = {"": "'", "": '"', "": '"', "": "-",
"": "-", "": "...", "«": '"', "»": '"'}
DEFAULT_FEEDS = [
{"key": "oli", "name": "Une histoire et Oli", "rss": "https://radiofrance-podcast.net/podcast09/rss_19721.xml", "n": 3},
{"key": "odyssees", "name": "Les Odyssees", "rss": "https://radiofrance-podcast.net/podcast09/rss_20108.xml", "n": 3},
{"key": "salutinfo", "name": "Salut l'info", "rss": "https://radiofrance-podcast.net/podcast09/rss_20689.xml", "n": 2},
{"key": "bestioles", "name": "Bestioles", "rss": "https://radiofrance-podcast.net/podcast09/35099478-7c72-4f9e-a6de-1b928400e9e5/podcast_a80ecbd5-df3d-4c9d-bee7-4e3d9efc1974.xml", "n": 2},
]
def fetch(u, t=180):
return urllib.request.urlopen(urllib.request.Request(u, headers=UA), timeout=t,
context=ssl.create_default_context()).read()
def clean(s):
for a, b in SUBS.items():
s = s.replace(a, b)
return " ".join(s.split()).strip()
def _atomic_write_bytes(path, data):
d = os.path.dirname(path) or "."
os.makedirs(d, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=d)
with os.fdopen(fd, "wb") as f:
f.write(data)
os.replace(tmp, path)
def _atomic_write_json(path, obj):
_atomic_write_bytes(path, json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8"))
# ---- feeds config ----
def load_feeds():
if os.path.exists(FEEDS_JSON):
try:
return json.load(open(FEEDS_JSON, encoding="utf-8"))
except Exception:
pass
save_feeds(DEFAULT_FEEDS)
return [dict(f) for f in DEFAULT_FEEDS]
def save_feeds(feeds):
_atomic_write_json(FEEDS_JSON, feeds)
def add_feed(key, name, rss, n=2):
feeds = load_feeds()
feeds = [f for f in feeds if f["key"] != key]
feeds.append({"key": key, "name": name, "rss": rss, "n": int(n)})
save_feeds(feeds)
return feeds
def remove_feed(key):
save_feeds([f for f in load_feeds() if f["key"] != key])
# ---- RSS ----
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 fetch_episodes(rss, limit=None):
"""Return ([{url,title,duration,guid}], cover_href)."""
root = ET.fromstring(fetch(rss))
items = root.findall(".//item")
if limit:
items = items[:limit]
out = []
for it in items:
enc = it.find("enclosure")
if enc is None or not enc.get("url"):
continue
out.append({
"url": enc.get("url"),
"title": clean(it.findtext("title") or "Episode"),
"duration": (it.findtext("itunes:duration", namespaces=NS) or "").strip(),
"guid": (it.findtext("guid") or enc.get("url")),
})
return out, cover_url(root)
# ---- transcode / cover ----
def episode_fname(key, url):
return f"{key}_{hashlib.sha1(url.encode()).hexdigest()[:8]}_a48.mp3"
def transcode(raw_path, out_path):
rc = subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", raw_path,
"-ac", "1", "-b:a", "48k", "-c:a", "libmp3lame", out_path],
capture_output=True).returncode
return rc == 0 and os.path.exists(out_path)
def make_cover(img_bytes, key):
"""160x160 RGB565 .bin into STAGING via LVGLImage. Returns '<key>.bin' or ''."""
try:
im = Image.open(io.BytesIO(img_bytes)).convert("RGB").resize((160, 160))
os.makedirs(STAGING, exist_ok=True)
with tempfile.TemporaryDirectory() as td:
png = os.path.join(td, f"{key}.png"); im.save(png)
subprocess.run([sys.executable, LVGLIMG, "--ofmt", "BIN", "--cf", "RGB565",
"-o", td, png], check=True, capture_output=True)
binp = os.path.join(td, f"{key}.bin")
if os.path.exists(binp):
os.replace(binp, os.path.join(STAGING, f"{key}.bin"))
return f"{key}.bin"
except Exception as e:
print("cover fail", key, e, flush=True)
return ""
# ---- staging / manifest ----
def staging_files():
if not os.path.isdir(STAGING):
return []
return sorted(f for f in os.listdir(STAGING) if not f.startswith("."))
def manifest_load():
p = os.path.join(STAGING, "manifest.json")
if os.path.exists(p):
try:
return json.load(open(p, encoding="utf-8"))
except Exception:
pass
return []
def manifest_save(m):
_atomic_write_json(os.path.join(STAGING, "manifest.json"), m)
def _manifest_upsert(m, show, cover, file, title, src="auto"):
# src: "auto" (weekly refresh), "pick" (hand-selected), "upload" (custom file).
# Only "auto" episodes are eligible for pruning by the cap.
for s in m:
if s["show"] == show:
if cover and not s.get("cover"):
s["cover"] = cover
for e in s["episodes"]:
if e["file"] == file:
e["title"] = title
if src != "auto": # a manual pick/upload wins over auto
e["src"] = src
return
s["episodes"].append({"file": file, "title": title, "src": src})
return
m.append({"show": show, "cover": cover or "",
"episodes": [{"file": file, "title": title, "src": src}]})
def add_episode(key, show, cover_bytes, url, title, src="auto"):
"""Download + transcode an episode into STAGING + upsert manifest. Idempotent by fname.
cover_bytes: raw image bytes for the show cover, or None. src: auto|pick."""
os.makedirs(STAGING, exist_ok=True)
fname = episode_fname(key, url)
out = os.path.join(STAGING, fname)
if not os.path.exists(out):
with tempfile.TemporaryDirectory() as td:
raw = os.path.join(td, "raw"); open(raw, "wb").write(fetch(url))
tmp_out = os.path.join(td, fname)
if not transcode(raw, tmp_out):
raise RuntimeError(f"transcode failed {fname}")
os.replace(tmp_out, out)
cover = make_cover(cover_bytes, key) if cover_bytes else ""
m = manifest_load()
_manifest_upsert(m, show, cover, fname, clean(title), src)
manifest_save(m)
return fname
def add_upload(show, src_bytes, title, key="perso"):
os.makedirs(STAGING, exist_ok=True)
h = hashlib.sha1(src_bytes[:65536] + str(len(src_bytes)).encode()).hexdigest()[:8]
fname = f"{key}_{h}_a48.mp3"
out = os.path.join(STAGING, fname)
with tempfile.TemporaryDirectory() as td:
raw = os.path.join(td, "raw"); open(raw, "wb").write(src_bytes)
tmp_out = os.path.join(td, fname)
if not transcode(raw, tmp_out):
raise RuntimeError("transcode failed")
os.replace(tmp_out, out)
m = manifest_load()
_manifest_upsert(m, show, "", fname, clean(title), "upload")
manifest_save(m)
return fname
def prune_show(show, keep_files):
"""Cap a feed show: drop its 'auto' episodes whose file is not in keep_files
(the current latest-N from the feed). 'pick'/'upload' episodes are always kept.
Deletes the staged file too. Returns the number removed."""
m = manifest_load()
removed = 0
for s in m:
if s["show"] != show:
continue
kept = []
for e in s["episodes"]:
if e.get("src", "auto") != "auto" or e["file"] in keep_files:
kept.append(e)
else:
p = os.path.join(STAGING, e["file"])
if os.path.exists(p):
os.remove(p)
removed += 1
s["episodes"] = kept
m = [s for s in m if s["episodes"]]
manifest_save(m)
return removed
def remove_file(name):
if "/" in name or ".." in name or name.startswith("."):
raise ValueError("bad name")
p = os.path.join(STAGING, name)
if os.path.exists(p):
os.remove(p)
m = manifest_load()
for s in m:
s["episodes"] = [e for e in s["episodes"] if e["file"] != name]
m = [s for s in m if s["episodes"]]
manifest_save(m)
# ---- box state + push ----
def box_state_load():
if os.path.exists(BOX_JSON):
try:
return json.load(open(BOX_JSON, encoding="utf-8"))
except Exception:
pass
return {}
def box_state_save(st):
_atomic_write_json(BOX_JSON, st)
def compute_delta(staging, have):
haveset = set(have)
files = [f for f in staging if f != "manifest.json" and f not in haveset]
if files and "manifest.json" in staging:
files.append("manifest.json")
return files
def push_file(ip, name):
data = open(os.path.join(STAGING, name), "rb").read()
url = f"http://{ip}:{BOX_PORT}/put?name=podcasts/{name}"
with urllib.request.urlopen(urllib.request.Request(url, data=data, method="POST"),
timeout=300) as r:
return r.status
_busy = set()
_busy_lock = threading.Lock()
def push_batch(ip, delta):
ok = 0
for f in delta:
try:
push_file(ip, f); ok += 1
except Exception as e:
print("push fail", f, e, flush=True)
print(f"push done {ip} {ok}/{len(delta)}", flush=True)
with _busy_lock:
_busy.discard(ip)
def try_push(ip, delta):
"""Start a background push unless one already runs for ip. Returns started?"""
if not delta:
return False
with _busy_lock:
if ip in _busy:
return False
_busy.add(ip)
threading.Thread(target=push_batch, args=(ip, delta), daemon=True).start()
return True