diff options
| author | Caine <caine@jihakuz.xyz> | 2026-03-07 12:52:55 +0000 |
|---|---|---|
| committer | Caine <caine@jihakuz.xyz> | 2026-03-07 12:52:55 +0000 |
| commit | 01682c62c2c9ea2f7f498544ee3aaa299c0c2423 (patch) | |
| tree | 82a86298eeed90bfba253bf03a4b3393b182b491 /radio_dj.py | |
Initial commit: Radio Susan scripts, configs, and SFX
Diffstat (limited to 'radio_dj.py')
| -rwxr-xr-x | radio_dj.py | 297 |
1 files changed, 297 insertions, 0 deletions
diff --git a/radio_dj.py b/radio_dj.py new file mode 100755 index 0000000..6bdb5a6 --- /dev/null +++ b/radio_dj.py @@ -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() |
