Updated ownership under liquidsoap user

This commit is contained in:
G. Gibson 2026-09-01 19:29:06 -07:00
commit 49ab0b725e
4 changed files with 354 additions and 156 deletions

View file

@ -1,19 +1,28 @@
#!/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.
annotated URI queue file for station.liq to consume.
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.
Concurrency model mirrors fetch_podcasts.py:
- Shares the same exclusive lockfile (state/radio.lock). If the fetcher is
still running, this run logs a skip and exits 0 instead of hitting a
locked database.
- Databases run in WAL mode with a busy timeout as a second safety net.
Selection: for each show, pick the earliest episode with played=0, write
playlists/<slug>.txt as a single annotated URI line, then mark it played.
Works identically for archived (local file_path) and live (enclosure_url)
shows because both store their episodes in the same table.
"""
import argparse
import fcntl
import json
import logging
import os
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parent
@ -23,23 +32,25 @@ STATE_DIR = None
SUBS_DB = None
PLAYED_DB = None
PODCASTS_DIR = None
PLAYLISTS_DIR = None
LOGS_DIR = None
PLAYLISTS_DIR = None
LOCK_FILE = 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
global STATE_DIR, SUBS_DB, PLAYED_DB, PODCASTS_DIR, LOGS_DIR, PLAYLISTS_DIR, LOCK_FILE
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"
PLAYLISTS_DIR = storage / "playlists"
LOCK_FILE = STATE_DIR / "radio.lock"
logging.basicConfig(
level=logging.INFO,
@ -50,125 +61,187 @@ logging.basicConfig(
)
log = logging.getLogger("update_playlists")
def log_error(msg):
log.error(msg)
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)
# ---------------------------------------------------------------------------
# Shared schema definitions (must match fetch_podcasts.py).
# ---------------------------------------------------------------------------
SHOWS_COLUMNS = {
"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'))",
}
EPISODES_COLUMNS = {
"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",
}
def _connect(db_path):
conn = sqlite3.connect(str(db_path), timeout=5.0)
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.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
return conn
def _ensure_columns(conn, table, columns):
exists = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)
).fetchone()
if exists is None:
defs = ",\n ".join(f"{name} {spec}" for name, spec in columns.items())
extra = ""
if table == "episodes":
extra = "\n ,UNIQUE(show_slug, guid)"
conn.execute(f"CREATE TABLE {table} (\n {defs}{extra}\n )")
else:
existing = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
for name, spec in columns.items():
if name not in existing:
col_default = spec.split("DEFAULT", 1)[1].strip() if "DEFAULT" in spec else "NULL"
conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {col_default}")
conn.commit()
def open_subs_db():
conn = _connect(SUBS_DB)
_ensure_columns(conn, "shows", SHOWS_COLUMNS)
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 = _connect(PLAYED_DB)
_ensure_columns(conn, "episodes", EPISODES_COLUMNS)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_episodes_show_played ON episodes (show_slug, played)"
)
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."""
# ---------------------------------------------------------------------------
# Mutual exclusion (shared with fetch_podcasts.py via the same lockfile).
# ---------------------------------------------------------------------------
_lock_fd = None
def acquire_lock():
global _lock_fd
STATE_DIR.mkdir(parents=True, exist_ok=True)
fd = os.open(str(LOCK_FILE), os.O_CREAT | os.O_RDWR, 0o644)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
os.close(fd)
return False
os.ftruncate(fd, 0)
os.write(fd, str(os.getpid()).encode())
_lock_fd = fd
return True
def release_lock():
global _lock_fd
if _lock_fd is not None:
try:
fcntl.flock(_lock_fd, fcntl.LOCK_UN)
os.close(_lock_fd)
finally:
_lock_fd = None
# ---------------------------------------------------------------------------
# Core logic
# ---------------------------------------------------------------------------
def _annotate_uri(runlength, title, uri):
"""Build a liquidsoap annotated URI line. Values must be double-quoted
and separated by commas; the whole annotation precedes a colon before
the URI. Escapes embedded double quotes minimally."""
def q(v):
return '"' + str(v).replace('"', '\\"') + '"'
ann = f"annotate:liq_runlength={q(runlength)},liq_title={q(title)}:"
return ann + uri
def select_next_episode(slug, played_db):
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",
"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 = ?",
"UPDATE episodes SET played=1, played_at=datetime('now') "
"WHERE show_slug=? AND guid=?",
(slug, guid),
)
played_db.commit()
def write_queue_file(slug, ep, archived):
if archived:
uri = ep["file_path"]
else:
uri = ep["enclosure_url"]
if not uri:
return None
line = _annotate_uri(ep["runlength"], ep["title"], uri)
out = PLAYLISTS_DIR / f"{slug}.txt"
out.write_text(line + "\n")
return out
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
updated = 0
for show in shows:
slug = show["slug"]
ep = select_unplayed_episode(slug, played_db)
archived = show["archived"] == 1
ep = select_next_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
out = write_queue_file(slug, ep, archived)
if out is None:
log.warning("No playable URI for %s (%s); skipping.", show["name"], slug)
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
updated += 1
log.info("Queued %s: %s -> %s", slug, ep["title"], out.name)
subs_db.close()
played_db.close()
log.info("=== Update complete: %d queued, %d skipped ===", queued, skipped)
log.info("=== Update complete: %d show(s) queued ===", updated)
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 = {}
shows = subs_db.execute("SELECT slug, name FROM shows ORDER BY name").fetchall()
result = {}
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}
counts = played_db.execute(
"SELECT COUNT(*) AS total, SUM(CASE WHEN played=0 THEN 1 ELSE 0 END) AS unplayed "
"FROM episodes WHERE show_slug=?",
(slug,),
).fetchone()
total = counts["total"] or 0
unplayed = counts["unplayed"] or 0
result[slug] = {"name": show["name"], "total": total, "unplayed": unplayed, "played": total - unplayed}
subs_db.close()
played_db.close()
print(json.dumps(summary, indent=2))
print(json.dumps(result, indent=2))
def main():
parser = argparse.ArgumentParser(description="Playlist updater for radio automation")
@ -181,10 +254,21 @@ def main():
PLAYLISTS_DIR.mkdir(parents=True, exist_ok=True)
_setup_logging()
if args.json:
json_summary()
else:
update_all()
# --json is read-only and doesn't need the write lock.
needs_lock = not args.json
if needs_lock and not acquire_lock():
log.info("Another radio process holds the lock; skipping this run.")
return
try:
if args.json:
json_summary()
else:
update_all()
finally:
if needs_lock:
release_lock()
if __name__ == "__main__":
main()