#!/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()