Initial commit: Radio Susan scripts, configs, and SFX
This commit is contained in:
Executable
+297
@@ -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()
|
||||
Reference in New Issue
Block a user