mirror of
https://github.com/mistergibson/radio.git
synced 2026-09-08 22:09:51 -07:00
190 lines
6 KiB
Python
Executable file
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()
|