summaryrefslogtreecommitdiff
path: root/radio_dj.py
diff options
context:
space:
mode:
Diffstat (limited to 'radio_dj.py')
-rwxr-xr-xradio_dj.py297
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()