radio/update_playlists.py
2026-09-01 15:27:17 -07:00

190 lines
6 KiB
Python
Executable file

#!/usr/bin/env python3
"""
update_playlists.py - Select the next unplayed episode per show and write an
annotated URI line for station.liq to consume. Runs via cron hourly at :30.
Selection is driven by the episodes table (keyed on guid + played flag), not
by scanning the filesystem, so it works identically for archived shows
(local file_path) and live shows (remote enclosure_url). All data under the
storage path from config.json.
"""
import argparse
import json
import logging
import sqlite3
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
CONFIG_PATH = ROOT / "config.json"
STATE_DIR = None
SUBS_DB = None
PLAYED_DB = None
PODCASTS_DIR = None
PLAYLISTS_DIR = None
LOGS_DIR = None
def load_config():
with open(CONFIG_PATH) as f:
return json.load(f)
def init_paths():
global STATE_DIR, SUBS_DB, PLAYED_DB, PODCASTS_DIR, PLAYLISTS_DIR, LOGS_DIR
cfg = load_config()
storage = Path(cfg["storage"]).expanduser().resolve()
STATE_DIR = storage / "state"
SUBS_DB = STATE_DIR / "subscriptions.db"
PLAYED_DB = STATE_DIR / "played.db"
PODCASTS_DIR = storage / "podcasts"
PLAYLISTS_DIR = storage / "playlists"
LOGS_DIR = storage / "logs"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
],
)
log = logging.getLogger("update_playlists")
def _setup_logging():
fh = logging.FileHandler(LOGS_DIR / "update.log")
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
log.addHandler(fh)
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,
guid TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
feed_url TEXT NOT NULL UNIQUE,
source TEXT DEFAULT 'manual',
opml_import INTEGER DEFAULT 0,
archived INTEGER DEFAULT 1,
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,
enclosure_url TEXT,
runlength INTEGER,
played INTEGER DEFAULT 0,
played_at TEXT,
UNIQUE(show_slug, guid)
)
""")
conn.commit()
return conn
def select_unplayed_episode(slug, played_db):
"""Pick the next unplayed episode for a show, keyed by guid.
Archived -> local file_path; Live -> remote enclosure_url."""
row = played_db.execute(
"SELECT guid, title, file_path, enclosure_url, runlength "
"FROM episodes WHERE show_slug = ? AND played = 0 ORDER BY id ASC LIMIT 1",
(slug,),
).fetchone()
return row
def annotated_uri(ep):
"""Build the annotate: URI line station.liq consumes.
Archived -> local file path; Live -> remote enclosure URL."""
uri = ep["file_path"] or ep["enclosure_url"]
if not uri:
return None
rl = int(ep["runlength"] or 0)
title = (ep["title"] or "").replace('"', "'")
return f'annotate:liq_runlength="{rl}",liq_title="{title}":{uri}'
def write_queue_line(slug, line, out_path):
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(line + "\n")
def mark_as_played(slug, guid, played_db):
played_db.execute(
"UPDATE episodes SET played = 1, played_at = datetime('now') WHERE show_slug = ? AND guid = ?",
(slug, guid),
)
played_db.commit()
def update_all():
subs_db = open_subs_db()
played_db = open_played_db()
shows = subs_db.execute("SELECT slug, name, archived FROM shows ORDER BY name").fetchall()
queued = 0
skipped = 0
for show in shows:
slug = show["slug"]
ep = select_unplayed_episode(slug, played_db)
if ep is None:
log.info("%s: no unplayed episodes, skipping.", slug)
skipped += 1
continue
line = annotated_uri(ep)
if line is None:
log.info("%s: episode %s has no usable URI, skipping.", slug, ep["guid"])
skipped += 1
continue
out_txt = PLAYLISTS_DIR / f"{slug}.txt"
write_queue_line(slug, line, out_txt)
mark_as_played(slug, ep["guid"], played_db)
kind = "downloaded" if ep["file_path"] else "live"
log.info("%s: queued %s [%s] runlength=%ss", slug, ep["title"], kind, int(ep["runlength"] or 0))
queued += 1
subs_db.close()
played_db.close()
log.info("=== Update complete: %d queued, %d skipped ===", queued, skipped)
def json_summary():
subs_db = open_subs_db()
played_db = open_played_db()
shows = subs_db.execute("SELECT slug FROM shows ORDER BY name").fetchall()
summary = {}
for show in shows:
slug = show["slug"]
total = played_db.execute(
"SELECT COUNT(*) as c FROM episodes WHERE show_slug = ?", (slug,)
).fetchone()["c"]
played = played_db.execute(
"SELECT COUNT(*) as c FROM episodes WHERE show_slug = ? AND played = 1", (slug,)
).fetchone()["c"]
summary[slug] = {"total_episodes": total, "played_count": played, "unplayed": total - played}
subs_db.close()
played_db.close()
print(json.dumps(summary, indent=2))
def main():
parser = argparse.ArgumentParser(description="Playlist updater for radio automation")
parser.add_argument("--json", action="store_true", help="Emit JSON summary and exit")
args = parser.parse_args()
init_paths()
STATE_DIR.mkdir(parents=True, exist_ok=True)
LOGS_DIR.mkdir(parents=True, exist_ok=True)
PLAYLISTS_DIR.mkdir(parents=True, exist_ok=True)
_setup_logging()
if args.json:
json_summary()
else:
update_all()
if __name__ == "__main__":
main()