radio/fetch_podcasts.py
2026-08-26 15:23:22 -07:00

394 lines
12 KiB
Python
Executable file

#!/usr/bin/env python3
"""
fetch_podcasts.py - Podcast subscription management and episode fetching
for the liquidsoap radio automation stack.
"""
import argparse
import json
import logging
import re
import shutil
import sqlite3
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from urllib.parse import quote
import feedparser
import requests
ROOT = Path(__file__).resolve().parent
CONFIG_PATH = ROOT / "config.json"
STATE_DIR = ROOT / "state"
SUBS_DB = STATE_DIR / "subscriptions.db"
PLAYED_DB = STATE_DIR / "played.db"
PODCASTS_DIR = ROOT / "podcasts"
LOGS_DIR = ROOT / "logs"
AUDIO_EXTS = {".mp3", ".m4a"}
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(LOGS_DIR / "fetch.log"),
logging.StreamHandler(sys.stdout),
],
)
log = logging.getLogger("fetch_podcasts")
def log_error(msg):
log.error(msg)
def load_config():
with open(CONFIG_PATH) as f:
return json.load(f)
def open_subs_db():
conn = sqlite3.connect(SUBS_DB)
conn.row_factory = sqlite3.Row
conn.execute("""
CREATE TABLE IF NOT EXISTS shows (
slug TEXT PRIMARY KEY,
name TEXT NOT NULL,
feed_url TEXT NOT NULL UNIQUE,
source TEXT DEFAULT 'manual',
opml_import INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
)
""")
conn.commit()
return conn
def open_played_db():
conn = sqlite3.connect(PLAYED_DB)
conn.row_factory = sqlite3.Row
conn.execute("""
CREATE TABLE IF NOT EXISTS episodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
show_slug TEXT NOT NULL,
guid TEXT NOT NULL,
title TEXT,
file_path TEXT,
duration_seconds INTEGER,
played_at TEXT DEFAULT (datetime('now')),
UNIQUE(show_slug, guid)
)
""")
conn.commit()
return conn
def slugify(name):
s = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
return s[:60] or "show"
def gpodder_sync(cfg):
g = cfg["gpodder"]
base = g["host"].rstrip("/")
username = g["username"]
password = g["password"]
url = f"{base}/subscriptions/{quote(username, safe='')}.opml"
print(f"--- Syncing subscriptions from {base} ---")
print(f"Fetching subscriptions for '{username}'...")
resp = requests.get(
url,
auth=(username, password),
headers={"User-Agent": "radio-automation/1.0"},
timeout=60,
)
if resp.status_code == 200:
body = resp.text
if not body.strip():
log_error("gPodder sync returned an empty body.")
return []
return parse_opml(body)
elif resp.status_code == 401:
log_error("gPodder sync failed: 401 Unauthorized. Check username/password in config.json.")
elif resp.status_code == 404:
log_error("gPodder sync failed: 404 Not Found. User may not exist or has no subscriptions.")
elif resp.status_code == 400:
log_error("gPodder sync failed: 400 Bad Request.")
else:
log_error(f"gPodder sync failed: unexpected response {resp.status_code}: {resp.text[:200]}")
return []
def parse_opml(xml_string):
shows = []
try:
root = ET.fromstring(xml_string)
except ET.ParseError as e:
log_error(f"Failed to parse OPML XML: {e}")
return []
for outline in root.iter("outline"):
feed_url = (outline.attrib.get("xmlUrl") or "").strip()
name = (outline.attrib.get("text") or "").strip()
if not feed_url or not re.match(r"^https?://", feed_url):
continue
shows.append({"name": name, "feed_url": feed_url})
return shows
def register_remote_shows(remote_shows):
db = open_subs_db()
added = 0
for show in remote_shows:
slug = slugify(show["name"])
existing = db.execute("SELECT slug FROM shows WHERE slug = ?", (slug,)).fetchone()
if existing is None:
db.execute(
"INSERT INTO shows (slug, name, feed_url, source, opml_import) VALUES (?, ?, ?, 'gpodder', 0)",
(slug, show["name"], show["feed_url"]),
)
log.info("Registered new show: %s (%s)", show["name"], slug)
added += 1
db.commit()
db.close()
return added
def prune_stale_shows(remote_shows):
db = open_subs_db()
remote_slugs = {slugify(s["name"]) for s in remote_shows}
stale = db.execute(
"SELECT slug, name FROM shows WHERE source = 'gpodder' AND opml_import = 0"
).fetchall()
removed = 0
for row in stale:
if row["slug"] not in remote_slugs:
remove_show_data(row["slug"])
db.execute("DELETE FROM shows WHERE slug = ?", (row["slug"],))
log.info("Pruned stale show: %s (%s)", row["name"], row["slug"])
removed += 1
db.commit()
db.close()
return removed
def extract_duration(entry):
dur = entry.get("media_duration") or entry.get("duration")
if dur:
try:
return int(float(dur))
except (ValueError, TypeError):
pass
iso = entry.get("iso_8601_duration")
if iso:
m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", iso)
if m:
h, mn, s = (int(g) if g else 0 for g in m.groups())
return h * 3600 + mn * 60 + s
enclosures = entry.get("enclosures") or []
if enclosures:
length = enclosures[0].get("length")
if length:
try:
return int(int(length) * 8 / 128000)
except ValueError:
pass
return None
def fetch_feed(feed_url):
try:
parsed = feedparser.parse(feed_url)
except Exception as e:
log_error(f"Feed parse error for {feed_url}: {e}")
return None
if parsed.bozo and not parsed.entries:
log_error(f"Bozo feed (no entries) for {feed_url}: {parsed.bozo_exception}")
return None
return parsed
def download_episode(url, dest_dir, filename):
dest = dest_dir / filename
if dest.exists():
return str(dest)
try:
with requests.get(url, stream=True, timeout=120) as r:
r.raise_for_status()
tmp = dest.with_suffix(dest.suffix + ".part")
with open(tmp, "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
tmp.rename(dest)
return str(dest)
except requests.RequestException as e:
log_error(f"Download failed for {url}: {e}")
return None
def safe_filename(title, fallback):
name = re.sub(r"[^\w\s.-]", "", title or "").strip().replace(" ", "_")
return (name[:120] or fallback) + ".mp3"
def fetch_show_episodes(slug, name, feed_url):
dest_dir = PODCASTS_DIR / slug
dest_dir.mkdir(parents=True, exist_ok=True)
parsed = fetch_feed(feed_url)
if parsed is None:
return 0
played_db = open_played_db()
seen = {
row["guid"]
for row in played_db.execute("SELECT guid FROM episodes WHERE show_slug = ?", (slug,))
}
subs_db = open_subs_db()
new_count = 0
for entry in parsed.entries:
guid = entry.get("id") or entry.get("link") or entry.get("title", "")
if guid in seen:
continue
enclosures = entry.get("enclosures") or []
if not enclosures:
continue
audio_url = enclosures[0].get("href")
if not audio_url:
continue
title = entry.get("title", "untitled")
filename = safe_filename(title, guid[-20:])
file_path = download_episode(audio_url, dest_dir, filename)
if file_path is None:
continue
duration = extract_duration(entry)
played_db.execute(
"INSERT OR IGNORE INTO episodes (show_slug, guid, title, file_path, duration_seconds, played_at) "
"VALUES (?, ?, ?, ?, ?, NULL)",
(slug, guid, title, file_path, duration),
)
new_count += 1
log.info(" New episode: %s [%s]", title, filename)
played_db.commit()
played_db.close()
subs_db.close()
return new_count
def fetch_all_episodes():
db = open_subs_db()
shows = db.execute("SELECT slug, name, feed_url FROM shows ORDER BY name").fetchall()
db.close()
total_new = 0
for show in shows:
log.info("--- Fetching: %s (%s) ---", show["name"], show["slug"])
try:
n = fetch_show_episodes(show["slug"], show["name"], show["feed_url"])
total_new += n
except Exception as e:
log_error(f"Unexpected error fetching {show['slug']}: {e}")
log.info("=== Fetch complete: %d new episode(s) ===", total_new)
def list_shows(detail=False):
db = open_subs_db()
rows = db.execute(
"SELECT slug, name, feed_url, source, opml_import FROM shows ORDER BY name"
).fetchall()
db.close()
if not rows:
print("No shows registered.")
return
print(f"{'SLUG':<30} {'PROTECTED':<10} NAME")
for r in rows:
prot = "yes" if r["opml_import"] else "no"
line = f"{r['slug']:<30} {prot:<10} {r['name']}"
if detail:
line += f"\n{'':<50} {r['feed_url']}"
print(line)
def add_show(feed_url):
parsed = fetch_feed(feed_url)
if parsed is None or not parsed.feed.get("title"):
log_error(f"Could not determine show title from {feed_url}")
return
name = parsed.feed["title"]
slug = slugify(name)
db = open_subs_db()
db.execute(
"INSERT OR IGNORE INTO shows (slug, name, feed_url, source, opml_import) VALUES (?, ?, ?, 'manual', 0)",
(slug, name, feed_url),
)
db.commit()
db.close()
log.info("Added show: %s (%s)", name, slug)
fetch_show_episodes(slug, name, feed_url)
def remove_show_data(slug):
pod_dir = PODCASTS_DIR / slug
if pod_dir.exists():
shutil.rmtree(pod_dir)
pls = ROOT / "playlists" / f"{slug}.pls"
if pls.exists():
pls.unlink()
def delete_show(slug):
db = open_subs_db()
row = db.execute("SELECT name FROM shows WHERE slug = ?", (slug,)).fetchone()
if row is None:
log_error(f"No show found with slug '{slug}'.")
return
remove_show_data(slug)
db.execute("DELETE FROM shows WHERE slug = ?", (slug,))
db.commit()
db.close()
played_db = open_played_db()
played_db.execute("DELETE FROM episodes WHERE show_slug = ?", (slug,))
played_db.commit()
played_db.close()
log.info("Deleted show: %s (%s)", row["name"], slug)
def import_opml(path):
try:
with open(path) as f:
content = f.read()
except OSError as e:
log_error(f"Cannot read OPML file: {e}")
return
shows = parse_opml(content)
db = open_subs_db()
for show in shows:
slug = slugify(show["name"])
db.execute(
"INSERT OR IGNORE INTO shows (slug, name, feed_url, source, opml_import) VALUES (?, ?, ?, 'opml', 1)",
(slug, show["name"], show["feed_url"]),
)
db.commit()
db.close()
log.info("OPML import: %d show(s) processed.", len(shows))
def run_fetch(config):
g = config["gpodder"]
if g.get("enable") is True:
remote = gpodder_sync(config)
if not remote:
log.warning("No subscriptions retrieved from gPodder; using local registry only.")
else:
added = register_remote_shows(remote)
pruned = prune_stale_shows(remote)
log.info("Sync: %d added, %d pruned.", added, pruned)
fetch_all_episodes()
def main():
parser = argparse.ArgumentParser(description="Podcast fetcher for radio automation")
parser.add_argument("--list-shows", action="store_true", help="List registered shows")
parser.add_argument("--detail", action="store_true", help="With --list-shows, show feed URLs")
parser.add_argument("--add-show", metavar="FEED_URL", help="Add a show from a feed URL")
parser.add_argument("--delete-show", metavar="SLUG", help="Delete a show and its data")
parser.add_argument("--import-opml", metavar="FILE", help="Import shows from an OPML file")
args = parser.parse_args()
STATE_DIR.mkdir(exist_ok=True)
LOGS_DIR.mkdir(exist_ok=True)
PODCASTS_DIR.mkdir(exist_ok=True)
config = load_config()
if args.list_shows:
list_shows(detail=args.detail)
elif args.add_show:
add_show(args.add_show)
elif args.delete_show:
delete_show(args.delete_show)
elif args.import_opml:
import_opml(args.import_opml)
else:
run_fetch(config)
if __name__ == "__main__":
main()