Initial commit: Radio Susan scripts, configs, and SFX

This commit is contained in:
Caine
2026-03-07 12:52:55 +00:00
commit 01682c62c2
30 changed files with 2856 additions and 0 deletions
Executable
+297
View File
@@ -0,0 +1,297 @@
#!/usr/bin/env python3
"""Radio DJ — smart track picker for Liquidsoap.
Prints one absolute file path to stdout and exits.
All logging goes to /var/lib/radio/dj.log.
This is the per-track fallback. The preferred approach is generate_daily_playlist.py
which pre-generates a full day's playlist.
"""
import json
import logging
import logging.handlers
import os
import random
import sqlite3
import sys
import time
from pathlib import Path
sys.path.insert(0, os.path.dirname(__file__))
import dj_core
# ── Logging ────────────────────────────────────────────────────────────────────
LOG_PATH = "/var/lib/radio/dj.log"
os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
log = logging.getLogger("radio_dj")
log.setLevel(logging.DEBUG)
handler = logging.handlers.RotatingFileHandler(LOG_PATH, maxBytes=1_000_000, backupCount=3)
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
log.addHandler(handler)
HISTORY_PRUNE = 7 * 86400
# ── State DB ───────────────────────────────────────────────────────────────────
def init_state_db():
try:
conn = sqlite3.connect(dj_core.STATE_DB, timeout=5)
conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row
conn.executescript("""
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL, artist TEXT, album TEXT,
genre TEXT, energy INTEGER, mode TEXT, timestamp REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS album_queue (
id INTEGER PRIMARY KEY CHECK (id = 1),
album TEXT, tracks TEXT, current_index INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp);
CREATE INDEX IF NOT EXISTS idx_history_artist ON history(artist);
CREATE INDEX IF NOT EXISTS idx_history_path ON history(path);
""")
conn.commit()
return conn
except Exception as e:
log.error("State DB corrupt, recreating: %s", e)
try:
os.remove(dj_core.STATE_DB)
except OSError:
pass
conn = sqlite3.connect(dj_core.STATE_DB, timeout=5)
conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row
conn.executescript("""
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL, artist TEXT, album TEXT,
genre TEXT, energy INTEGER, mode TEXT, timestamp REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS album_queue (
id INTEGER PRIMARY KEY CHECK (id = 1),
album TEXT, tracks TEXT, current_index INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp);
CREATE INDEX IF NOT EXISTS idx_history_artist ON history(artist);
CREATE INDEX IF NOT EXISTS idx_history_path ON history(path);
""")
conn.commit()
return conn
def prune_history(state):
cutoff = time.time() - HISTORY_PRUNE
state.execute("DELETE FROM history WHERE timestamp < ?", (cutoff,))
state.commit()
def get_cooldowns(state):
now = time.time()
rows = state.execute(
"SELECT path, artist, album, genre, timestamp FROM history ORDER BY timestamp DESC"
).fetchall()
cooled_paths = set()
cooled_artists = set()
cooled_albums = set()
last_genre = None
for r in rows:
age = now - r["timestamp"]
if age < dj_core.TRACK_COOLDOWN:
cooled_paths.add(r["path"])
if age < dj_core.ARTIST_COOLDOWN and r["artist"]:
cooled_artists.add(r["artist"].lower())
if age < dj_core.ALBUM_COOLDOWN and r["album"]:
cooled_albums.add(r["album"].lower())
if rows:
last_genre = dj_core.normalize_genre(rows[0]["genre"]) if rows[0]["genre"] else None
return cooled_paths, cooled_artists, cooled_albums, last_genre
def get_recent_energies(state, n=3):
rows = state.execute(
"SELECT energy FROM history ORDER BY timestamp DESC LIMIT ?", (n,)
).fetchall()
return [r["energy"] for r in rows if r["energy"] is not None]
def record_pick(state, path, artist, album, genre, energy, mode):
state.execute(
"INSERT INTO history (path, artist, album, genre, energy, mode, timestamp) VALUES (?,?,?,?,?,?,?)",
(path, artist, album, genre, energy, mode, time.time()),
)
state.commit()
# ── Album queue ────────────────────────────────────────────────────────────────
def get_album_queue(state):
row = state.execute("SELECT album, tracks, current_index FROM album_queue WHERE id = 1").fetchone()
if row and row["tracks"]:
try:
tracks = json.loads(row["tracks"])
return row["album"], tracks, row["current_index"] or 0
except (json.JSONDecodeError, TypeError):
pass
return None, [], 0
def set_album_queue(state, album, tracks, idx):
state.execute(
"INSERT OR REPLACE INTO album_queue (id, album, tracks, current_index) VALUES (1, ?, ?, ?)",
(album, json.dumps(tracks), idx),
)
state.commit()
def clear_album_queue(state):
state.execute("DELETE FROM album_queue WHERE id = 1")
state.commit()
# ── Main ───────────────────────────────────────────────────────────────────────
def main():
try:
now = time.time()
hour = time.localtime().tm_hour
target_energy = dj_core.ENERGY_CURVE.get(hour, 5)
state = init_state_db()
prune_history(state)
# ── Check album queue first ───────────────────────────────────
album_name, album_tracks, album_idx = get_album_queue(state)
if album_tracks and album_idx < len(album_tracks):
next_path = album_tracks[album_idx]
if os.path.isfile(next_path):
set_album_queue(state, album_name, album_tracks, album_idx + 1)
all_tracks = dj_core.load_tracks()
meta = next((t for t in all_tracks if t["path"] == next_path), None)
if meta:
record_pick(state, next_path, meta["artist"], meta["album"],
meta["genre"], meta["energy"], "full_album")
log.info("ALBUM [%d/%d] %s%s%s (energy=%d)",
album_idx + 1, len(album_tracks),
meta["artist"], meta["title"], meta["album"], meta["energy"])
else:
record_pick(state, next_path, "", album_name, "", 5, "full_album")
log.info("ALBUM [%d/%d] %s", album_idx + 1, len(album_tracks), next_path)
if album_idx + 1 >= len(album_tracks):
clear_album_queue(state)
log.info("Album complete, resuming normal mode")
state.close()
print(next_path)
return
else:
log.warning("Album track missing: %s, clearing queue", next_path)
clear_album_queue(state)
# ── Load data ─────────────────────────────────────────────────
all_tracks = dj_core.load_tracks()
if not all_tracks:
log.error("No tracks found anywhere!")
fallback = dj_core.random_file_fallback()
if fallback:
print(fallback)
state.close()
return
nav_data = dj_core.load_navidrome()
cooled_paths, cooled_artists, cooled_albums, last_genre = get_cooldowns(state)
recent_energies = get_recent_energies(state)
log.info("Loaded %d tracks, %d with play data, target energy=%d (hour=%d)",
len(all_tracks), len(nav_data), target_energy, hour)
# ── Energy filter ─────────────────────────────────────────────
pool = dj_core.filter_by_energy(all_tracks, target_energy)
log.info("Energy pool: %d tracks (target=%d ±2)", len(pool), target_energy)
# ── Cooldown filter ───────────────────────────────────────────
pool = dj_core.apply_cooldowns(pool, cooled_paths, cooled_artists, cooled_albums, last_genre)
log.info("After cooldowns: %d tracks", len(pool))
if not pool:
log.warning("Pool empty after cooldowns, relaxing artist/album cooldowns")
pool = dj_core.filter_by_energy(all_tracks, target_energy)
pool = [t for t in pool if t["path"] not in cooled_paths]
if not pool:
log.warning("Still empty, using all tracks minus path cooldown")
pool = [t for t in all_tracks if t["path"] not in cooled_paths]
if not pool:
pool = all_tracks
# ── Mode selection ────────────────────────────────────────────
mode = dj_core.pick_mode()
log.info("Mode roll: %s", mode)
# Handle full_album
if mode == "full_album":
multi = dj_core.find_album_tracks(pool)
if multi:
chosen_key = random.choice(list(multi.keys()))
album_tks = multi[chosen_key]
paths = [t["path"] for t in album_tks]
first = album_tks[0]
set_album_queue(state, first["album"], paths, 1)
record_pick(state, first["path"], first["artist"], first["album"],
first["genre"], first["energy"], "full_album")
log.info("FULL ALBUM: %s%s (%d tracks, energy=%d)",
first["artist"], first["album"], len(paths), first["energy"])
state.close()
print(first["path"])
return
else:
log.info("full_album: no multi-track albums in pool, falling back to shuffle")
mode = "shuffle"
# ── Apply mode filter ─────────────────────────────────────────
mode_pool, actual_mode = dj_core.apply_mode(pool, mode, nav_data, now)
log.info("Mode %s%d candidates", actual_mode, len(mode_pool))
# ── Contrast pick ─────────────────────────────────────────────
pick = dj_core.contrast_pick(mode_pool, recent_energies)
# ── Verify file exists ────────────────────────────────────────
if not os.path.isfile(pick["path"]):
log.warning("Selected file missing: %s, trying another", pick["path"])
mode_pool = [t for t in mode_pool if os.path.isfile(t["path"])]
if mode_pool:
pick = random.choice(mode_pool)
else:
fallback = dj_core.random_file_fallback()
if fallback:
print(fallback)
state.close()
return
# ── Record and output ─────────────────────────────────────────
record_pick(state, pick["path"], pick["artist"], pick["album"],
pick["genre"], pick["energy"], actual_mode)
log.info("PICK: [%s] %s%s%s (genre=%s, energy=%d, pool=%d)",
actual_mode, pick["artist"], pick["title"], pick["album"],
pick["genre"], pick["energy"], len(mode_pool))
state.close()
print(pick["path"])
except Exception as e:
log.exception("Fatal error: %s", e)
fallback = dj_core.random_file_fallback()
if fallback:
print(fallback)
else:
print("/dev/null")
if __name__ == "__main__":
main()