Initial commit: Radio Susan scripts, configs, and SFX
This commit is contained in:
+36
@@ -0,0 +1,36 @@
|
|||||||
|
# Virtual environment
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Databases (generated, contain runtime state)
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# Generated caches & playlists
|
||||||
|
genre_cache.json
|
||||||
|
playlist.json
|
||||||
|
playlist.m3u
|
||||||
|
track_state.json
|
||||||
|
track_history.json
|
||||||
|
playlists/
|
||||||
|
|
||||||
|
# Generated audio (TTS announcements, jingles are regenerated)
|
||||||
|
announcements/
|
||||||
|
jingles/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
*.log.*
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Runtime/cache
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Legacy/backup files
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# Secrets — Icecast password is in radio.liq and track_announce.liq
|
||||||
|
# These are tracked but the password should be moved to env var eventually
|
||||||
Executable
+213
@@ -0,0 +1,213 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Analyse audio tracks with Essentia. Run daily via cron.
|
||||||
|
Use venv: /var/lib/radio/venv/bin/python3 /var/lib/radio/analyse_tracks.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FEATURES_DB = "/var/lib/radio/audio_features.db"
|
||||||
|
MUSIC_ROOT = "/disks/Plex/Music/"
|
||||||
|
SUPPORTED = {".flac", ".mp3", ".ogg", ".opus", ".m4a", ".wav"}
|
||||||
|
|
||||||
|
# Energy percentile breakpoints from bulk analysis of ~6500 tracks.
|
||||||
|
# Maps raw log-energy values to even 0-1 distribution.
|
||||||
|
# Index 0 = 0th percentile, index 20 = 100th percentile (every 5%).
|
||||||
|
ENERGY_BREAKPOINTS = [
|
||||||
|
0.0, 0.7148, 0.7581, 0.7801, 0.7999, 0.8163, 0.8289, 0.8401,
|
||||||
|
0.8493, 0.8587, 0.8667, 0.8744, 0.8811, 0.8877, 0.8949, 0.9018,
|
||||||
|
0.9089, 0.9169, 0.9252, 0.9377, 1.0,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_energy(raw_log_energy: float) -> float:
|
||||||
|
"""Map a raw log-energy value to 0-1 using the library's percentile curve."""
|
||||||
|
bp = ENERGY_BREAKPOINTS
|
||||||
|
if raw_log_energy <= bp[0]:
|
||||||
|
return 0.0
|
||||||
|
if raw_log_energy >= bp[-1]:
|
||||||
|
return 1.0
|
||||||
|
# Find which bucket it falls in and interpolate
|
||||||
|
for i in range(1, len(bp)):
|
||||||
|
if raw_log_energy <= bp[i]:
|
||||||
|
frac = (raw_log_energy - bp[i - 1]) / (bp[i] - bp[i - 1]) if bp[i] != bp[i - 1] else 0.5
|
||||||
|
return round((i - 1 + frac) / (len(bp) - 1), 4)
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""Create features DB and table if needed."""
|
||||||
|
conn = sqlite3.connect(FEATURES_DB)
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS features (
|
||||||
|
path TEXT PRIMARY KEY,
|
||||||
|
energy REAL,
|
||||||
|
bpm REAL,
|
||||||
|
loudness REAL,
|
||||||
|
danceability REAL,
|
||||||
|
analysed_at REAL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def get_analysed_paths(conn):
|
||||||
|
"""Return set of already-analysed file paths."""
|
||||||
|
cur = conn.execute("SELECT path FROM features")
|
||||||
|
return {row[0] for row in cur}
|
||||||
|
|
||||||
|
|
||||||
|
def find_audio_files():
|
||||||
|
"""Walk music root for all supported audio files."""
|
||||||
|
root = Path(MUSIC_ROOT)
|
||||||
|
files = []
|
||||||
|
for p in root.rglob("*"):
|
||||||
|
if p.suffix.lower() in SUPPORTED and p.is_file():
|
||||||
|
files.append(str(p))
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def analyse_track(filepath):
|
||||||
|
"""Extract audio features using Essentia. Returns dict or None on error."""
|
||||||
|
try:
|
||||||
|
import essentia
|
||||||
|
import essentia.standard as es
|
||||||
|
except ImportError:
|
||||||
|
print("Error: essentia not installed. Install with: pip install essentia", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Essentia's bundled decoder segfaults on opus and struggles with some
|
||||||
|
# m4a/ogg files. Pre-decode these to a temp WAV via ffmpeg.
|
||||||
|
NEEDS_PREDECODE = {".opus", ".ogg", ".m4a"}
|
||||||
|
tmp_wav = None
|
||||||
|
load_path = filepath
|
||||||
|
|
||||||
|
if Path(filepath).suffix.lower() in NEEDS_PREDECODE:
|
||||||
|
try:
|
||||||
|
tmp_wav = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
||||||
|
tmp_wav.close()
|
||||||
|
subprocess.run(
|
||||||
|
["ffmpeg", "-y", "-i", filepath, "-ac", "1", "-ar", "22050", "-sample_fmt", "s16", "-f", "wav", tmp_wav.name],
|
||||||
|
capture_output=True, timeout=15,
|
||||||
|
)
|
||||||
|
load_path = tmp_wav.name
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Skipping (ffmpeg decode error): {e}", file=sys.stderr)
|
||||||
|
if tmp_wav and os.path.exists(tmp_wav.name):
|
||||||
|
os.unlink(tmp_wav.name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
sr = 22050 if tmp_wav else 44100
|
||||||
|
audio = es.MonoLoader(filename=load_path, sampleRate=sr)()
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Skipping (load error): {e}", file=sys.stderr)
|
||||||
|
if tmp_wav and os.path.exists(tmp_wav.name):
|
||||||
|
os.unlink(tmp_wav.name)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if tmp_wav and os.path.exists(tmp_wav.name):
|
||||||
|
os.unlink(tmp_wav.name)
|
||||||
|
|
||||||
|
# Energy — raw log-energy mapped through percentile curve
|
||||||
|
try:
|
||||||
|
raw_energy = es.Energy()(audio)
|
||||||
|
raw_log = min(1.0, math.log1p(raw_energy) / 15.0)
|
||||||
|
energy_norm = normalize_energy(raw_log)
|
||||||
|
energy_raw = raw_log
|
||||||
|
except Exception:
|
||||||
|
energy_norm = 0.5
|
||||||
|
energy_raw = 0.5
|
||||||
|
|
||||||
|
# BPM
|
||||||
|
try:
|
||||||
|
bpm, _, _, _, _ = es.RhythmExtractor2013()(audio)
|
||||||
|
if bpm < 30 or bpm > 300:
|
||||||
|
bpm = 0.0
|
||||||
|
except Exception:
|
||||||
|
bpm = 0.0
|
||||||
|
|
||||||
|
# Loudness
|
||||||
|
try:
|
||||||
|
loudness = es.Loudness()(audio)
|
||||||
|
except Exception:
|
||||||
|
loudness = 0.0
|
||||||
|
|
||||||
|
# Danceability
|
||||||
|
try:
|
||||||
|
danceability, _ = es.Danceability()(audio)
|
||||||
|
except Exception:
|
||||||
|
danceability = 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"energy": round(energy_norm, 4),
|
||||||
|
"energy_raw": round(energy_raw, 4),
|
||||||
|
"bpm": round(bpm, 2),
|
||||||
|
"loudness": round(loudness, 4),
|
||||||
|
"danceability": round(danceability, 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"Essentia track analyser — {time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
|
||||||
|
conn = init_db()
|
||||||
|
analysed = get_analysed_paths(conn)
|
||||||
|
print(f"Already analysed: {len(analysed)} tracks")
|
||||||
|
|
||||||
|
all_files = find_audio_files()
|
||||||
|
print(f"Found on disk: {len(all_files)} audio files")
|
||||||
|
|
||||||
|
pending = [f for f in all_files if f not in analysed]
|
||||||
|
print(f"To analyse: {len(pending)} new tracks")
|
||||||
|
|
||||||
|
if not pending:
|
||||||
|
print("Nothing to do.")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
done = 0
|
||||||
|
errors = 0
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
for i, filepath in enumerate(pending):
|
||||||
|
if (i + 1) % 100 == 0 or i == 0:
|
||||||
|
elapsed = time.time() - start
|
||||||
|
rate = (done / elapsed) if elapsed > 0 and done > 0 else 0
|
||||||
|
eta = ((len(pending) - i) / rate / 60) if rate > 0 else 0
|
||||||
|
print(f" [{i+1}/{len(pending)}] {rate:.1f} tracks/sec, ETA {eta:.0f} min")
|
||||||
|
|
||||||
|
features = analyse_track(filepath)
|
||||||
|
if features is None:
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO features (path, energy, energy_raw, bpm, loudness, danceability, analysed_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(filepath, features["energy"], features["energy_raw"], features["bpm"], features["loudness"], features["danceability"], time.time()),
|
||||||
|
)
|
||||||
|
if done % 50 == 0:
|
||||||
|
conn.commit()
|
||||||
|
done += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" DB error for {filepath}: {e}", file=sys.stderr)
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
elapsed = time.time() - start
|
||||||
|
print(f"Done: {done} analysed, {errors} errors, {elapsed:.1f}s total")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+202
@@ -0,0 +1,202 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate a track announcement TTS clip for Radio Susan.
|
||||||
|
Called by liquidsoap via request.dynamic between tracks.
|
||||||
|
Reads track_state.json (written by liquidsoap on_track) and
|
||||||
|
track_history.json (maintained here) to announce what just played.
|
||||||
|
|
||||||
|
Outputs an MP3 file path to stdout, or nothing if no announcement needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
VENV_BIN = Path("/var/lib/radio/venv/bin")
|
||||||
|
EDGE_TTS = VENV_BIN / "edge-tts"
|
||||||
|
ANNOUNCE_DIR = Path("/var/lib/radio/announcements")
|
||||||
|
ANNOUNCE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
STATE_FILE = Path("/var/lib/radio/track_state.json")
|
||||||
|
HISTORY_FILE = Path("/var/lib/radio/track_history.json")
|
||||||
|
|
||||||
|
VOICES = [
|
||||||
|
"en-US-GuyNeural",
|
||||||
|
"en-US-ChristopherNeural",
|
||||||
|
"en-GB-RyanNeural",
|
||||||
|
"en-US-AriaNeural",
|
||||||
|
"en-US-JennyNeural",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Station ID variations
|
||||||
|
STATION_IDS = [
|
||||||
|
"Radio Susan, ninety nine point oh.",
|
||||||
|
"You're listening to Radio Susan.",
|
||||||
|
"Radio Susan.",
|
||||||
|
"Susan Radio, always on.",
|
||||||
|
"This is Radio Susan.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def pick_voice(text):
|
||||||
|
h = int(hashlib.md5(text.encode()).hexdigest(), 16)
|
||||||
|
return VOICES[h % len(VOICES)]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_tts(text, outfile, voice=None):
|
||||||
|
if voice is None:
|
||||||
|
voice = pick_voice(text)
|
||||||
|
cmd = [
|
||||||
|
str(EDGE_TTS),
|
||||||
|
"--voice", voice,
|
||||||
|
"--rate", "+10%",
|
||||||
|
"--text", text,
|
||||||
|
"--write-media", outfile,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||||
|
return result.returncode == 0
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_audio(infile, outfile):
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
|
||||||
|
"-i", infile,
|
||||||
|
"-af", "loudnorm=I=-14:TP=-1:LRA=11,aformat=sample_rates=44100:channel_layouts=stereo",
|
||||||
|
"-b:a", "192k",
|
||||||
|
outfile,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||||
|
return result.returncode == 0
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def clean_title(s):
|
||||||
|
if not s or s == "Unknown":
|
||||||
|
return None
|
||||||
|
for ext in ('.flac', '.mp3', '.ogg', '.m4a', '.opus', '.wav'):
|
||||||
|
s = s.replace(ext, '')
|
||||||
|
s = s.strip()
|
||||||
|
# Strip leading track numbers like "01 -", "03.", "001 "
|
||||||
|
if len(s) > 3 and s[:2].isdigit() and s[2] in '. -':
|
||||||
|
s = s[3:].strip()
|
||||||
|
if len(s) > 4 and s[:3].isdigit() and s[3] in '. -':
|
||||||
|
s = s[4:].strip()
|
||||||
|
# Strip "Artist - " prefix if title already contains it
|
||||||
|
return s.strip() if s.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_track(entry):
|
||||||
|
"""Format a history entry as 'Artist - Title'."""
|
||||||
|
artist = entry.get("artist", "")
|
||||||
|
title = entry.get("title", "")
|
||||||
|
if artist and title:
|
||||||
|
return f"{artist} - {title}"
|
||||||
|
return clean_title(title or artist) or None
|
||||||
|
|
||||||
|
|
||||||
|
def load_history():
|
||||||
|
if HISTORY_FILE.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(HISTORY_FILE.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def save_history(history):
|
||||||
|
HISTORY_FILE.write_text(json.dumps(history[-20:]))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--state", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.state:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Read current track from liquidsoap's state file
|
||||||
|
current = None
|
||||||
|
if STATE_FILE.exists():
|
||||||
|
try:
|
||||||
|
current = json.loads(STATE_FILE.read_text())
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not current:
|
||||||
|
return
|
||||||
|
|
||||||
|
history = load_history()
|
||||||
|
|
||||||
|
# Check if this is the same track we already announced (avoid repeats)
|
||||||
|
current_key = f"{current.get('artist', '')}|{current.get('title', '')}"
|
||||||
|
if history and f"{history[-1].get('artist', '')}|{history[-1].get('title', '')}" == current_key:
|
||||||
|
return # Already announced this one
|
||||||
|
|
||||||
|
# Add current to history
|
||||||
|
history.append(current)
|
||||||
|
save_history(history)
|
||||||
|
|
||||||
|
# Need at least 2 tracks in history to announce (we announce what JUST played)
|
||||||
|
if len(history) < 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Announce the two most recent tracks before this one.
|
||||||
|
# With 2:1 rotation these roughly match what the listener just heard.
|
||||||
|
last = fmt_track(history[-2]) if len(history) >= 2 else None
|
||||||
|
prev = fmt_track(history[-3]) if len(history) >= 3 else None
|
||||||
|
|
||||||
|
if not last:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build announcement — keep it vague ("recently" not "just played")
|
||||||
|
station_id = random.choice(STATION_IDS)
|
||||||
|
|
||||||
|
if prev:
|
||||||
|
text = f"You've been listening to {last}, and {prev}. {station_id}"
|
||||||
|
else:
|
||||||
|
text = f"You've been listening to {last}. {station_id}"
|
||||||
|
|
||||||
|
# Cache based on content hash
|
||||||
|
content_hash = hashlib.md5(text.encode()).hexdigest()[:12]
|
||||||
|
cached = ANNOUNCE_DIR / f"announce_{content_hash}.mp3"
|
||||||
|
|
||||||
|
if cached.exists():
|
||||||
|
print(str(cached))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Generate TTS
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not generate_tts(text, tmp_path):
|
||||||
|
return
|
||||||
|
if normalize_audio(tmp_path, str(cached)):
|
||||||
|
print(str(cached))
|
||||||
|
else:
|
||||||
|
os.rename(tmp_path, str(cached))
|
||||||
|
print(str(cached))
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Cleanup old announcements (keep last 200)
|
||||||
|
announcements = sorted(ANNOUNCE_DIR.glob("announce_*.mp3"), key=lambda f: f.stat().st_mtime)
|
||||||
|
for old in announcements[:-200]:
|
||||||
|
old.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+185
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build genre cache from beets database + Essentia audio features. Run daily via cron.
|
||||||
|
|
||||||
|
Genre-level energy scores are computed from the mean Essentia energy of all
|
||||||
|
analysed tracks in that genre. Unanalysed tracks fall back to their genre
|
||||||
|
average, or 5 (neutral) if the genre has no analysed tracks yet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
BEETS_DB = "/home/susan/.config/beets/library.db"
|
||||||
|
FEATURES_DB = "/var/lib/radio/audio_features.db"
|
||||||
|
CACHE_PATH = "/var/lib/radio/genre_cache.json"
|
||||||
|
|
||||||
|
NEUTRAL_ENERGY = 5
|
||||||
|
|
||||||
|
# Genre → broad group (for DJ filtering / display)
|
||||||
|
GROUP_KEYWORDS = {
|
||||||
|
"chill": ["ambient", "downtempo", "chill", "drone", "new age", "lounge", "easy listening", "sleep", "meditation", "minimal"],
|
||||||
|
"jazz": ["jazz", "bossa nova", "bebop", "swing", "fusion"],
|
||||||
|
"classical": ["classical", "chamber", "orchestral", "piano", "baroque", "romantic"],
|
||||||
|
"electronic": ["techno", "house", "trance", "electro", "electronic", "idm", "ebm", "synth", "synthwave"],
|
||||||
|
"bass": ["dubstep", "drum and bass", "dnb", "jungle", "breakbeat", "garage", "uk garage", "dub", "bass"],
|
||||||
|
"rock": ["rock", "punk", "metal", "shoegaze", "post rock", "post-rock", "grunge", "alternative", "indie rock", "noise rock"],
|
||||||
|
"hiphop": ["hip hop", "hip-hop", "rap", "trap", "boom bap", "grime"],
|
||||||
|
"soul": ["soul", "r&b", "rnb", "funk", "disco", "motown", "gospel", "neo soul"],
|
||||||
|
"folk": ["folk", "acoustic", "singer-songwriter", "country", "bluegrass", "americana"],
|
||||||
|
"pop": ["pop", "synth pop", "synthpop", "dream pop", "new wave", "art pop", "indie pop"],
|
||||||
|
"world": ["afrobeat", "reggae", "latin", "world", "flamenco", "samba", "cumbia"],
|
||||||
|
"heavy": ["industrial", "noise", "hardcore", "metal", "thrash", "grindcore", "power electronics"],
|
||||||
|
"experimental": ["experimental", "avant-garde", "musique concrete", "free improvisation"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_genre(g):
|
||||||
|
return g.lower().replace("-", " ").replace("_", " ").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def essentia_to_scale(e):
|
||||||
|
"""Convert Essentia normalized energy (0.0–1.0) to 1–10 integer scale."""
|
||||||
|
return max(1, min(10, round(e * 9 + 1)))
|
||||||
|
|
||||||
|
|
||||||
|
def find_group(genre):
|
||||||
|
g = normalize_genre(genre)
|
||||||
|
for group, keywords in GROUP_KEYWORDS.items():
|
||||||
|
for kw in keywords:
|
||||||
|
if kw in g or g in kw:
|
||||||
|
return group
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
|
def load_essentia_features():
|
||||||
|
"""Load path → normalized energy from audio_features.db."""
|
||||||
|
if not os.path.exists(FEATURES_DB):
|
||||||
|
print(f"Warning: features DB not found at {FEATURES_DB}, all tracks will get energy {NEUTRAL_ENERGY}", file=sys.stderr)
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(f"file:{FEATURES_DB}?mode=ro", uri=True)
|
||||||
|
rows = conn.execute("SELECT path, energy FROM features").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return {row[0]: row[1] for row in rows}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: failed to read features DB: {e}", file=sys.stderr)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.path.exists(BEETS_DB):
|
||||||
|
print(f"Error: beets DB not found at {BEETS_DB}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
essentia_energies = load_essentia_features()
|
||||||
|
print(f"Loaded Essentia features for {len(essentia_energies)} tracks")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(f"file:{BEETS_DB}?mode=ro", uri=True)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT path, title, artist, album, albumartist, genre FROM items")
|
||||||
|
|
||||||
|
# First pass: collect per-track data, accumulate Essentia energies per genre
|
||||||
|
track_data = {}
|
||||||
|
genre_energies = {} # genre → [list of 1–10 scores from analysed tracks]
|
||||||
|
analysed_count = 0
|
||||||
|
|
||||||
|
for row in cur:
|
||||||
|
path = row["path"]
|
||||||
|
if isinstance(path, bytes):
|
||||||
|
try:
|
||||||
|
path = path.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
genre_raw = row["genre"] or ""
|
||||||
|
genre = normalize_genre(genre_raw) if genre_raw else "misc"
|
||||||
|
|
||||||
|
if path in essentia_energies:
|
||||||
|
energy = essentia_to_scale(essentia_energies[path])
|
||||||
|
analysed_count += 1
|
||||||
|
genre_energies.setdefault(genre, []).append(energy)
|
||||||
|
else:
|
||||||
|
energy = None # resolve in second pass
|
||||||
|
|
||||||
|
track_data[path] = {
|
||||||
|
"genre": genre,
|
||||||
|
"genre_raw": genre_raw,
|
||||||
|
"energy": energy,
|
||||||
|
"artist": row["artist"] or "",
|
||||||
|
"album": row["album"] or "",
|
||||||
|
"title": row["title"] or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Compute genre averages from Essentia data
|
||||||
|
genre_avg_energy = {}
|
||||||
|
for genre, scores in genre_energies.items():
|
||||||
|
genre_avg_energy[genre] = round(sum(scores) / len(scores))
|
||||||
|
|
||||||
|
print(f"Genre energies computed from Essentia for {len(genre_avg_energy)} genres")
|
||||||
|
|
||||||
|
# Second pass: fill unanalysed tracks with genre average or neutral
|
||||||
|
fallback_genre_avg = 0
|
||||||
|
fallback_neutral = 0
|
||||||
|
for info in track_data.values():
|
||||||
|
if info["energy"] is None:
|
||||||
|
if info["genre"] in genre_avg_energy:
|
||||||
|
info["energy"] = genre_avg_energy[info["genre"]]
|
||||||
|
fallback_genre_avg += 1
|
||||||
|
else:
|
||||||
|
info["energy"] = NEUTRAL_ENERGY
|
||||||
|
fallback_neutral += 1
|
||||||
|
|
||||||
|
# Build genre summary
|
||||||
|
all_genres = set(info["genre"] for info in track_data.values())
|
||||||
|
genres_out = {}
|
||||||
|
for genre in all_genres:
|
||||||
|
genres_out[genre] = {
|
||||||
|
"energy": genre_avg_energy.get(genre, NEUTRAL_ENERGY),
|
||||||
|
"group": find_group(genre),
|
||||||
|
"analysed_tracks": len(genre_energies.get(genre, [])),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build output tracks
|
||||||
|
tracks_out = {
|
||||||
|
path: {
|
||||||
|
"genre": info["genre"],
|
||||||
|
"energy": info["energy"],
|
||||||
|
"artist": info["artist"],
|
||||||
|
"album": info["album"],
|
||||||
|
"title": info["title"],
|
||||||
|
}
|
||||||
|
for path, info in track_data.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
cache = {
|
||||||
|
"genres": genres_out,
|
||||||
|
"tracks": tracks_out,
|
||||||
|
"built_at": time.time(),
|
||||||
|
"stats": {
|
||||||
|
"total_tracks": len(tracks_out),
|
||||||
|
"essentia_analysed": analysed_count,
|
||||||
|
"fallback_genre_avg": fallback_genre_avg,
|
||||||
|
"fallback_neutral": fallback_neutral,
|
||||||
|
"genres_with_essentia": len(genre_energies),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp = CACHE_PATH + ".tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
json.dump(cache, f)
|
||||||
|
os.replace(tmp, CACHE_PATH)
|
||||||
|
|
||||||
|
print(f"Built genre cache: {len(tracks_out)} tracks, {len(genres_out)} genres")
|
||||||
|
print(f" Essentia energy: {analysed_count} tracks")
|
||||||
|
print(f" Genre avg fallback: {fallback_genre_avg} tracks")
|
||||||
|
print(f" Neutral ({NEUTRAL_ENERGY}) fallback: {fallback_neutral} tracks")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
exec ffmpeg -hide_banner -loglevel error -i "$1" -f wav -ar 44100 -ac 2 -
|
||||||
Executable
+352
@@ -0,0 +1,352 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Shared core logic for Radio Susan DJ — used by radio_dj.py and generate_daily_playlist.py."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ── Paths ──────────────────────────────────────────────────────────────────────
|
||||||
|
BEETS_DB = "/home/susan/.config/beets/library.db"
|
||||||
|
NAVIDROME_DB = "/var/lib/navidrome/navidrome.db"
|
||||||
|
GENRE_CACHE = "/var/lib/radio/genre_cache.json"
|
||||||
|
FEATURES_DB = "/var/lib/radio/audio_features.db"
|
||||||
|
STATE_DB = "/var/lib/radio/dj_state.db"
|
||||||
|
MUSIC_ROOT = "/disks/Plex/Music/"
|
||||||
|
SUPPORTED = {".flac", ".mp3", ".ogg", ".opus", ".m4a", ".wav"}
|
||||||
|
|
||||||
|
# ── Energy curve (hour → target energy 1-10) ──────────────────────────────────
|
||||||
|
ENERGY_CURVE = {
|
||||||
|
0: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1,
|
||||||
|
7: 2, 8: 3,
|
||||||
|
9: 4, 10: 5, 11: 5,
|
||||||
|
12: 4, 13: 4,
|
||||||
|
14: 5, 15: 6, 16: 6,
|
||||||
|
17: 5, 18: 6,
|
||||||
|
19: 7, 20: 8, 21: 7,
|
||||||
|
22: 4, 23: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Show definitions ───────────────────────────────────────────────────────────
|
||||||
|
SHOWS = [
|
||||||
|
{"name": "Night Drift", "start_hour": 0, "end_hour": 7, "energy": "1", "description": "Ambient, drone, sleep"},
|
||||||
|
{"name": "Morning Calm", "start_hour": 7, "end_hour": 9, "energy": "2-3", "description": "Downtempo, chill, acoustic"},
|
||||||
|
{"name": "Late Morning", "start_hour": 9, "end_hour": 12, "energy": "4-5", "description": "Indie, jazz, trip-hop"},
|
||||||
|
{"name": "Lunch Break", "start_hour": 12, "end_hour": 14, "energy": "4", "description": "Relaxed, soul, dub"},
|
||||||
|
{"name": "Afternoon Session", "start_hour": 14, "end_hour": 17, "energy": "5-6", "description": "Electronic, funk, hip-hop"},
|
||||||
|
{"name": "Drive Time", "start_hour": 17, "end_hour": 19, "energy": "5-6", "description": "Mixed energy"},
|
||||||
|
{"name": "Evening Heat", "start_hour": 19, "end_hour": 22, "energy": "7-8", "description": "Techno, house, DnB, dubstep"},
|
||||||
|
{"name": "Wind Down", "start_hour": 22, "end_hour": 24, "energy": "2-4", "description": "Comedown, ambient"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Mode weights ───────────────────────────────────────────────────────────────
|
||||||
|
MODE_WEIGHTS = {
|
||||||
|
"shuffle": 40,
|
||||||
|
"deep_cuts": 20,
|
||||||
|
"new_to_library": 15,
|
||||||
|
"old_favourites": 15,
|
||||||
|
"full_album": 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Cooldowns ──────────────────────────────────────────────────────────────────
|
||||||
|
TRACK_COOLDOWN = 7 * 86400 # 7 days
|
||||||
|
ARTIST_COOLDOWN = 3 * 3600 # 3 hours
|
||||||
|
ALBUM_COOLDOWN = 24 * 3600 # 24 hours
|
||||||
|
|
||||||
|
# ── Genre energy keywords (fallback) ──────────────────────────────────────────
|
||||||
|
ENERGY_KEYWORDS = {
|
||||||
|
1: ["ambient", "drone", "new age", "sleep", "meditation"],
|
||||||
|
2: ["downtempo", "chill", "lounge", "easy listening", "bossa nova", "minimal"],
|
||||||
|
3: ["classical", "chamber", "piano", "acoustic"],
|
||||||
|
4: ["jazz", "soul", "trip hop", "lo-fi", "lofi", "dub", "blues"],
|
||||||
|
5: ["indie", "pop", "rock", "shoegaze", "post rock", "alternative", "folk", "dream pop"],
|
||||||
|
6: ["funk", "disco", "hip hop", "rap", "reggae", "afrobeat"],
|
||||||
|
7: ["house", "electronic", "electro", "garage", "dubstep", "idm"],
|
||||||
|
8: ["techno", "trance", "drum and bass", "dnb", "jungle", "breakbeat"],
|
||||||
|
9: ["industrial", "noise", "hardcore", "metal", "punk"],
|
||||||
|
10: ["speedcore", "gabber", "power electronics", "harsh noise"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Station ID variations ─────────────────────────────────────────────────────
|
||||||
|
STATION_IDS = [
|
||||||
|
"Radio Susan, ninety nine point oh.",
|
||||||
|
"You're listening to Radio Susan.",
|
||||||
|
"Radio Susan.",
|
||||||
|
"Susan Radio, always on.",
|
||||||
|
"This is Radio Susan.",
|
||||||
|
]
|
||||||
|
|
||||||
|
TTS_VOICES = [
|
||||||
|
"en-US-GuyNeural",
|
||||||
|
"en-US-ChristopherNeural",
|
||||||
|
"en-GB-RyanNeural",
|
||||||
|
"en-US-AriaNeural",
|
||||||
|
"en-US-JennyNeural",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def normalize_genre(g):
|
||||||
|
return g.lower().replace("-", " ").replace("_", " ").strip() if g else ""
|
||||||
|
|
||||||
|
|
||||||
|
def keyword_energy(genre):
|
||||||
|
g = normalize_genre(genre)
|
||||||
|
if not g:
|
||||||
|
return 5
|
||||||
|
for score in range(10, 0, -1):
|
||||||
|
for kw in ENERGY_KEYWORDS.get(score, []):
|
||||||
|
if kw in g or g in kw:
|
||||||
|
return score
|
||||||
|
return 5
|
||||||
|
|
||||||
|
|
||||||
|
def essentia_to_scale(e):
|
||||||
|
return max(1, min(10, round(e * 9 + 1)))
|
||||||
|
|
||||||
|
|
||||||
|
def open_ro(path):
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
c = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
||||||
|
c.row_factory = sqlite3.Row
|
||||||
|
return c
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def random_file_fallback():
|
||||||
|
root = Path(MUSIC_ROOT)
|
||||||
|
candidates = [str(p) for p in root.rglob("*") if p.suffix.lower() in SUPPORTED and p.is_file()]
|
||||||
|
return random.choice(candidates) if candidates else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_show_for_hour(hour):
|
||||||
|
for show in SHOWS:
|
||||||
|
if show["start_hour"] <= hour < show["end_hour"]:
|
||||||
|
return show
|
||||||
|
return SHOWS[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Track loading ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_tracks():
|
||||||
|
"""Load all track metadata. Returns list of dicts with path, artist, album, genre, energy, added, title, duration."""
|
||||||
|
tracks = {}
|
||||||
|
|
||||||
|
# Genre cache
|
||||||
|
cache = None
|
||||||
|
cache_age = float("inf")
|
||||||
|
if os.path.exists(GENRE_CACHE):
|
||||||
|
try:
|
||||||
|
with open(GENRE_CACHE, "r") as f:
|
||||||
|
cache = json.load(f)
|
||||||
|
cache_age = time.time() - cache.get("built_at", 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
use_cache = cache is not None and cache_age < 48 * 3600
|
||||||
|
|
||||||
|
# Beets DB
|
||||||
|
beets = open_ro(BEETS_DB)
|
||||||
|
beets_data = {}
|
||||||
|
if beets:
|
||||||
|
try:
|
||||||
|
for row in beets.execute("SELECT path, title, artist, album, albumartist, genre, added, length FROM items"):
|
||||||
|
p = row["path"]
|
||||||
|
if isinstance(p, bytes):
|
||||||
|
try:
|
||||||
|
p = p.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
beets_data[p] = {
|
||||||
|
"title": row["title"] or "",
|
||||||
|
"artist": row["artist"] or row["albumartist"] or "",
|
||||||
|
"album": row["album"] or "",
|
||||||
|
"genre": row["genre"] or "",
|
||||||
|
"added": row["added"] or 0,
|
||||||
|
"duration": row["length"] or 0,
|
||||||
|
}
|
||||||
|
beets.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Essentia features
|
||||||
|
features = {}
|
||||||
|
feat_conn = open_ro(FEATURES_DB)
|
||||||
|
if feat_conn:
|
||||||
|
try:
|
||||||
|
for row in feat_conn.execute("SELECT path, energy, bpm FROM features"):
|
||||||
|
features[row["path"]] = {"energy": row["energy"], "bpm": row["bpm"]}
|
||||||
|
feat_conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Merge
|
||||||
|
if use_cache:
|
||||||
|
for path, info in cache["tracks"].items():
|
||||||
|
t = {
|
||||||
|
"path": path,
|
||||||
|
"artist": info.get("artist", ""),
|
||||||
|
"album": info.get("album", ""),
|
||||||
|
"title": info.get("title", ""),
|
||||||
|
"genre": info.get("genre", "misc"),
|
||||||
|
"energy": info.get("energy", 5),
|
||||||
|
"added": 0,
|
||||||
|
"duration": 240,
|
||||||
|
}
|
||||||
|
if path in beets_data:
|
||||||
|
bd = beets_data[path]
|
||||||
|
t["added"] = bd["added"]
|
||||||
|
t["duration"] = bd["duration"] if bd["duration"] > 0 else 240
|
||||||
|
if not t["artist"]:
|
||||||
|
t["artist"] = bd["artist"]
|
||||||
|
if not t["title"]:
|
||||||
|
t["title"] = bd["title"]
|
||||||
|
if path in features:
|
||||||
|
t["energy"] = essentia_to_scale(features[path]["energy"])
|
||||||
|
tracks[path] = t
|
||||||
|
elif beets_data:
|
||||||
|
for path, bd in beets_data.items():
|
||||||
|
energy = keyword_energy(bd["genre"])
|
||||||
|
if path in features:
|
||||||
|
energy = essentia_to_scale(features[path]["energy"])
|
||||||
|
tracks[path] = {
|
||||||
|
"path": path,
|
||||||
|
"artist": bd["artist"],
|
||||||
|
"album": bd["album"],
|
||||||
|
"title": bd["title"],
|
||||||
|
"genre": normalize_genre(bd["genre"]) or "misc",
|
||||||
|
"energy": energy,
|
||||||
|
"added": bd["added"],
|
||||||
|
"duration": bd["duration"] if bd["duration"] > 0 else 240,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
root = Path(MUSIC_ROOT)
|
||||||
|
for p in root.rglob("*"):
|
||||||
|
if p.suffix.lower() in SUPPORTED and p.is_file():
|
||||||
|
sp = str(p)
|
||||||
|
parts = p.relative_to(MUSIC_ROOT).parts
|
||||||
|
artist = parts[0] if len(parts) > 0 else ""
|
||||||
|
album = parts[1] if len(parts) > 1 else ""
|
||||||
|
energy = 5
|
||||||
|
if sp in features:
|
||||||
|
energy = essentia_to_scale(features[sp]["energy"])
|
||||||
|
tracks[sp] = {
|
||||||
|
"path": sp, "artist": artist, "album": album,
|
||||||
|
"title": p.stem, "genre": "misc", "energy": energy,
|
||||||
|
"added": 0, "duration": 240,
|
||||||
|
}
|
||||||
|
|
||||||
|
return list(tracks.values())
|
||||||
|
|
||||||
|
|
||||||
|
def load_navidrome():
|
||||||
|
nav = open_ro(NAVIDROME_DB)
|
||||||
|
if not nav:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = {}
|
||||||
|
rows = nav.execute("""
|
||||||
|
SELECT mf.path, a.play_count, a.play_date, a.starred
|
||||||
|
FROM media_file mf
|
||||||
|
LEFT JOIN annotation a ON a.item_id = mf.id AND a.item_type = 'media_file'
|
||||||
|
""").fetchall()
|
||||||
|
for r in rows:
|
||||||
|
p = r["path"]
|
||||||
|
if isinstance(p, bytes):
|
||||||
|
try:
|
||||||
|
p = p.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
if not p.startswith("/"):
|
||||||
|
p = os.path.join(MUSIC_ROOT, p)
|
||||||
|
data[p] = {
|
||||||
|
"play_count": r["play_count"] or 0,
|
||||||
|
"play_date": r["play_date"] or "",
|
||||||
|
"starred": bool(r["starred"]),
|
||||||
|
}
|
||||||
|
nav.close()
|
||||||
|
return data
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Selection logic ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def filter_by_energy(tracks, target, tolerance=2):
|
||||||
|
pool = [t for t in tracks if abs(t["energy"] - target) <= tolerance]
|
||||||
|
while len(pool) < 20 and tolerance < 9:
|
||||||
|
tolerance += 1
|
||||||
|
pool = [t for t in tracks if abs(t["energy"] - target) <= tolerance]
|
||||||
|
return pool
|
||||||
|
|
||||||
|
|
||||||
|
def apply_cooldowns(pool, cooled_paths, cooled_artists, cooled_albums, last_genre):
|
||||||
|
result = []
|
||||||
|
for t in pool:
|
||||||
|
if t["path"] in cooled_paths:
|
||||||
|
continue
|
||||||
|
if t["artist"] and t["artist"].lower() in cooled_artists:
|
||||||
|
continue
|
||||||
|
if t["album"] and t["album"].lower() in cooled_albums:
|
||||||
|
continue
|
||||||
|
if last_genre and normalize_genre(t["genre"]) == last_genre:
|
||||||
|
continue
|
||||||
|
result.append(t)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def pick_mode():
|
||||||
|
modes = list(MODE_WEIGHTS.keys())
|
||||||
|
weights = list(MODE_WEIGHTS.values())
|
||||||
|
return random.choices(modes, weights=weights, k=1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def apply_mode(pool, mode, nav_data, now):
|
||||||
|
if mode == "deep_cuts" and nav_data:
|
||||||
|
deep = [t for t in pool if nav_data.get(t["path"], {}).get("play_count", 0) == 0]
|
||||||
|
if deep:
|
||||||
|
return deep, "deep_cuts"
|
||||||
|
|
||||||
|
elif mode == "new_to_library":
|
||||||
|
cutoff = now - 2 * 86400
|
||||||
|
new = [t for t in pool if t["added"] > cutoff]
|
||||||
|
if new:
|
||||||
|
return new, "new_to_library"
|
||||||
|
|
||||||
|
elif mode == "old_favourites" and nav_data:
|
||||||
|
scored = [(t, nav_data.get(t["path"], {}).get("play_count", 0)) for t in pool]
|
||||||
|
scored.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
top = [s[0] for s in scored[:max(10, len(scored) // 10)] if s[1] > 0]
|
||||||
|
if top:
|
||||||
|
return top, "old_favourites"
|
||||||
|
|
||||||
|
return pool, "shuffle"
|
||||||
|
|
||||||
|
|
||||||
|
def contrast_pick(candidates, recent_energies):
|
||||||
|
if not recent_energies or len(candidates) <= 1:
|
||||||
|
return random.choice(candidates)
|
||||||
|
|
||||||
|
avg_recent = sum(recent_energies) / len(recent_energies)
|
||||||
|
scored = [(t, abs(t["energy"] - avg_recent)) for t in candidates]
|
||||||
|
scored.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
top = scored[:min(3, len(scored))]
|
||||||
|
weights = [s[1] + 0.5 for s in top]
|
||||||
|
return random.choices([s[0] for s in top], weights=weights, k=1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def find_album_tracks(pool, min_tracks=3):
|
||||||
|
"""Group pool by album and return albums with min_tracks+ tracks."""
|
||||||
|
albums = {}
|
||||||
|
for t in pool:
|
||||||
|
if t["album"]:
|
||||||
|
key = (t["artist"].lower(), t["album"].lower())
|
||||||
|
albums.setdefault(key, []).append(t)
|
||||||
|
return {k: sorted(v, key=lambda t: t["path"]) for k, v in albums.items() if len(v) >= min_tracks}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
exec ffmpeg -hide_banner -loglevel error -f wav -i pipe:0 -f mp3 -b:a 192k pipe:1
|
||||||
Executable
+71
@@ -0,0 +1,71 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ezstream>
|
||||||
|
<servers>
|
||||||
|
<server>
|
||||||
|
<name>default</name>
|
||||||
|
<protocol>HTTP</protocol>
|
||||||
|
<hostname>127.0.0.1</hostname>
|
||||||
|
<port>8910</port>
|
||||||
|
<user>source</user>
|
||||||
|
<password>REDACTED</password>
|
||||||
|
<tls>None</tls>
|
||||||
|
<reconnect_attempts>20</reconnect_attempts>
|
||||||
|
</server>
|
||||||
|
</servers>
|
||||||
|
|
||||||
|
<streams>
|
||||||
|
<stream>
|
||||||
|
<mountpoint>/stream</mountpoint>
|
||||||
|
<intake>default</intake>
|
||||||
|
<server>default</server>
|
||||||
|
<public>No</public>
|
||||||
|
<format>MP3</format>
|
||||||
|
<encoder>mp3</encoder>
|
||||||
|
<stream_name>Radio Susan</stream_name>
|
||||||
|
<stream_url>https://radio.jihakuz.xyz</stream_url>
|
||||||
|
<stream_genre>Eclectic</stream_genre>
|
||||||
|
<stream_description>Personal radio station</stream_description>
|
||||||
|
<stream_bitrate>192</stream_bitrate>
|
||||||
|
<stream_samplerate>44100</stream_samplerate>
|
||||||
|
<stream_channels>2</stream_channels>
|
||||||
|
</stream>
|
||||||
|
</streams>
|
||||||
|
|
||||||
|
<intakes>
|
||||||
|
<intake>
|
||||||
|
<name>default</name>
|
||||||
|
<type>playlist</type>
|
||||||
|
<filename>/var/lib/radio/playlists/all.m3u</filename>
|
||||||
|
<shuffle>Yes</shuffle>
|
||||||
|
<stream_once>No</stream_once>
|
||||||
|
</intake>
|
||||||
|
</intakes>
|
||||||
|
|
||||||
|
<metadata>
|
||||||
|
<format_str>@a@ - @t@</format_str>
|
||||||
|
<normalize_strings>Yes</normalize_strings>
|
||||||
|
<no_updates>No</no_updates>
|
||||||
|
</metadata>
|
||||||
|
|
||||||
|
<decoders>
|
||||||
|
<decoder>
|
||||||
|
<name>ffmpeg-all</name>
|
||||||
|
<program>/var/lib/radio/decode.sh @T@</program>
|
||||||
|
<file_ext>.flac</file_ext>
|
||||||
|
<file_ext>.mp3</file_ext>
|
||||||
|
<file_ext>.ogg</file_ext>
|
||||||
|
<file_ext>.m4a</file_ext>
|
||||||
|
<file_ext>.opus</file_ext>
|
||||||
|
<file_ext>.wav</file_ext>
|
||||||
|
<file_ext>.wma</file_ext>
|
||||||
|
</decoder>
|
||||||
|
</decoders>
|
||||||
|
|
||||||
|
<encoders>
|
||||||
|
<encoder>
|
||||||
|
<name>mp3</name>
|
||||||
|
<format>MP3</format>
|
||||||
|
<program>/var/lib/radio/encode.sh</program>
|
||||||
|
</encoder>
|
||||||
|
</encoders>
|
||||||
|
</ezstream>
|
||||||
Executable
+421
@@ -0,0 +1,421 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate a full 24-hour playlist for Radio Susan.
|
||||||
|
Run at midnight via cron. Outputs playlist.json and playlist.m3u.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import grp
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
import dj_core
|
||||||
|
|
||||||
|
# ── Paths ──────────────────────────────────────────────────────────────────────
|
||||||
|
RADIO_DIR = Path("/var/lib/radio")
|
||||||
|
PLAYLIST_JSON = RADIO_DIR / "playlist.json"
|
||||||
|
PLAYLIST_M3U = RADIO_DIR / "playlist.m3u"
|
||||||
|
JINGLES_DIR = RADIO_DIR / "jingles"
|
||||||
|
ANNOUNCE_DIR = RADIO_DIR / "announcements"
|
||||||
|
VENV_BIN = RADIO_DIR / "venv" / "bin"
|
||||||
|
EDGE_TTS = VENV_BIN / "edge-tts"
|
||||||
|
|
||||||
|
ANNOUNCE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
DAY_SECONDS = 86400
|
||||||
|
DEFAULT_ANNOUNCE_DURATION = 8
|
||||||
|
DEFAULT_JINGLE_DURATION = 10
|
||||||
|
|
||||||
|
ANNOUNCE_INTERVAL = 3
|
||||||
|
JINGLE_INTERVAL = 5
|
||||||
|
|
||||||
|
|
||||||
|
def set_mediaserver_perms(path):
|
||||||
|
"""Set file group to mediaserver and perms to 664."""
|
||||||
|
try:
|
||||||
|
gid = grp.getgrnam("mediaserver").gr_gid
|
||||||
|
os.chown(str(path), -1, gid)
|
||||||
|
os.chmod(str(path), 0o664)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_jingle():
|
||||||
|
"""Pick a random jingle, return (path, duration)."""
|
||||||
|
jingles = list(JINGLES_DIR.glob("*.mp3"))
|
||||||
|
if not jingles:
|
||||||
|
return None, 0
|
||||||
|
j = random.choice(jingles)
|
||||||
|
dur = get_audio_duration(str(j))
|
||||||
|
return str(j), dur if dur > 0 else DEFAULT_JINGLE_DURATION
|
||||||
|
|
||||||
|
|
||||||
|
def get_audio_duration(path):
|
||||||
|
"""Get audio duration in seconds via ffprobe."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
|
||||||
|
"-of", "default=noprint_wrappers=1:nokey=1", path],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
return float(result.stdout.strip())
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def generate_announcement_tts(text, voice=None):
|
||||||
|
"""Generate TTS announcement, return cached path and duration."""
|
||||||
|
if voice is None:
|
||||||
|
h = int(hashlib.md5(text.encode()).hexdigest(), 16)
|
||||||
|
voice = dj_core.TTS_VOICES[h % len(dj_core.TTS_VOICES)]
|
||||||
|
|
||||||
|
content_hash = hashlib.md5(text.encode()).hexdigest()[:12]
|
||||||
|
cached = ANNOUNCE_DIR / f"announce_{content_hash}.mp3"
|
||||||
|
|
||||||
|
if cached.exists():
|
||||||
|
dur = get_audio_duration(str(cached))
|
||||||
|
return str(cached), dur if dur > 0 else DEFAULT_ANNOUNCE_DURATION
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(EDGE_TTS), "--voice", voice, "--rate", "+10%",
|
||||||
|
"--text", text, "--write-media", tmp_path],
|
||||||
|
capture_output=True, text=True, timeout=15
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None, 0
|
||||||
|
|
||||||
|
# Normalize
|
||||||
|
norm_result = subprocess.run(
|
||||||
|
["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
|
||||||
|
"-i", tmp_path,
|
||||||
|
"-af", "loudnorm=I=-14:TP=-1:LRA=11,aformat=sample_rates=44100:channel_layouts=stereo",
|
||||||
|
"-b:a", "192k", str(cached)],
|
||||||
|
capture_output=True, text=True, timeout=15
|
||||||
|
)
|
||||||
|
if norm_result.returncode != 0:
|
||||||
|
os.rename(tmp_path, str(cached))
|
||||||
|
|
||||||
|
set_mediaserver_perms(cached)
|
||||||
|
dur = get_audio_duration(str(cached))
|
||||||
|
return str(cached), dur if dur > 0 else DEFAULT_ANNOUNCE_DURATION
|
||||||
|
except Exception:
|
||||||
|
return None, 0
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def format_time(seconds):
|
||||||
|
"""Format seconds into HH:MM:SS."""
|
||||||
|
h = int(seconds // 3600)
|
||||||
|
m = int((seconds % 3600) // 60)
|
||||||
|
s = int(seconds % 60)
|
||||||
|
return f"{h:02d}:{m:02d}:{s:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_playlist():
|
||||||
|
"""Generate the full 24-hour playlist."""
|
||||||
|
print("Loading tracks...")
|
||||||
|
all_tracks = dj_core.load_tracks()
|
||||||
|
if not all_tracks:
|
||||||
|
print("ERROR: No tracks found!")
|
||||||
|
return None
|
||||||
|
|
||||||
|
nav_data = dj_core.load_navidrome()
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
print(f"Loaded {len(all_tracks)} tracks, {len(nav_data)} with play data")
|
||||||
|
|
||||||
|
# In-memory cooldown tracking
|
||||||
|
cooled_paths = set()
|
||||||
|
cooled_artists = {} # artist_lower → timestamp of last play
|
||||||
|
cooled_albums = {} # album_lower → timestamp of last play
|
||||||
|
last_genre = None
|
||||||
|
recent_energies = []
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
current_time = 0.0 # seconds from midnight
|
||||||
|
track_count = 0
|
||||||
|
tracks_since_announce = 0
|
||||||
|
tracks_since_jingle = 0
|
||||||
|
|
||||||
|
jingle_files = list(JINGLES_DIR.glob("*.mp3"))
|
||||||
|
|
||||||
|
# Recent track info for announcements
|
||||||
|
recent_tracks = []
|
||||||
|
|
||||||
|
print("Building playlist...")
|
||||||
|
|
||||||
|
while current_time < DAY_SECONDS:
|
||||||
|
hour = int(current_time // 3600) % 24
|
||||||
|
target_energy = dj_core.ENERGY_CURVE.get(hour, 5)
|
||||||
|
show = dj_core.get_show_for_hour(hour)
|
||||||
|
|
||||||
|
# ── Energy filter ─────────────────────────────────────────────
|
||||||
|
pool = dj_core.filter_by_energy(all_tracks, target_energy)
|
||||||
|
|
||||||
|
# ── Cooldown filter (in-memory) ───────────────────────────────
|
||||||
|
sim_now = now + current_time
|
||||||
|
active_paths = cooled_paths # 7-day cooldown, but within a day they're all active
|
||||||
|
active_artists = {a for a, ts in cooled_artists.items() if sim_now - ts < dj_core.ARTIST_COOLDOWN}
|
||||||
|
active_albums = {a for a, ts in cooled_albums.items() if sim_now - ts < dj_core.ALBUM_COOLDOWN}
|
||||||
|
|
||||||
|
filtered = dj_core.apply_cooldowns(pool, active_paths, active_artists, active_albums, last_genre)
|
||||||
|
|
||||||
|
if len(filtered) < 5:
|
||||||
|
# Relax cooldowns
|
||||||
|
filtered = dj_core.apply_cooldowns(pool, active_paths, set(), set(), None)
|
||||||
|
if len(filtered) < 5:
|
||||||
|
filtered = pool
|
||||||
|
if not filtered:
|
||||||
|
filtered = all_tracks
|
||||||
|
|
||||||
|
# ── Mode selection ────────────────────────────────────────────
|
||||||
|
mode = dj_core.pick_mode()
|
||||||
|
|
||||||
|
# Handle full_album
|
||||||
|
if mode == "full_album":
|
||||||
|
multi = dj_core.find_album_tracks(filtered)
|
||||||
|
if multi:
|
||||||
|
chosen_key = random.choice(list(multi.keys()))
|
||||||
|
album_tracks = multi[chosen_key]
|
||||||
|
for i, t in enumerate(album_tracks):
|
||||||
|
if current_time >= DAY_SECONDS:
|
||||||
|
break
|
||||||
|
dur = t["duration"] if t["duration"] > 0 else 240
|
||||||
|
entries.append({
|
||||||
|
"type": "track",
|
||||||
|
"path": t["path"],
|
||||||
|
"artist": t["artist"],
|
||||||
|
"title": t["title"],
|
||||||
|
"album": t["album"],
|
||||||
|
"genre": t["genre"],
|
||||||
|
"energy": t["energy"],
|
||||||
|
"duration": round(dur, 1),
|
||||||
|
"start_time": round(current_time, 1),
|
||||||
|
"start_formatted": format_time(current_time),
|
||||||
|
"mode": "full_album",
|
||||||
|
"show": show["name"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Update cooldowns
|
||||||
|
cooled_paths.add(t["path"])
|
||||||
|
if t["artist"]:
|
||||||
|
cooled_artists[t["artist"].lower()] = sim_now
|
||||||
|
if t["album"]:
|
||||||
|
cooled_albums[t["album"].lower()] = sim_now
|
||||||
|
last_genre = dj_core.normalize_genre(t["genre"])
|
||||||
|
recent_energies = (recent_energies + [t["energy"]])[-3:]
|
||||||
|
recent_tracks.append(t)
|
||||||
|
|
||||||
|
current_time += dur
|
||||||
|
track_count += 1
|
||||||
|
tracks_since_announce += 1
|
||||||
|
tracks_since_jingle += 1
|
||||||
|
|
||||||
|
# After album, maybe insert announcement
|
||||||
|
if tracks_since_announce >= ANNOUNCE_INTERVAL:
|
||||||
|
ann_entry = _make_announcement(recent_tracks, current_time)
|
||||||
|
if ann_entry:
|
||||||
|
entries.append(ann_entry)
|
||||||
|
current_time += ann_entry["duration"]
|
||||||
|
tracks_since_announce = 0
|
||||||
|
|
||||||
|
continue # Skip normal pick, already added album
|
||||||
|
else:
|
||||||
|
mode = "shuffle"
|
||||||
|
|
||||||
|
# ── Normal pick ───────────────────────────────────────────────
|
||||||
|
mode_pool, actual_mode = dj_core.apply_mode(filtered, mode, nav_data, now)
|
||||||
|
pick = dj_core.contrast_pick(mode_pool, recent_energies)
|
||||||
|
|
||||||
|
if not os.path.isfile(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:
|
||||||
|
continue
|
||||||
|
|
||||||
|
dur = pick["duration"] if pick["duration"] > 0 else 240
|
||||||
|
|
||||||
|
entries.append({
|
||||||
|
"type": "track",
|
||||||
|
"path": pick["path"],
|
||||||
|
"artist": pick["artist"],
|
||||||
|
"title": pick["title"],
|
||||||
|
"album": pick["album"],
|
||||||
|
"genre": pick["genre"],
|
||||||
|
"energy": pick["energy"],
|
||||||
|
"duration": round(dur, 1),
|
||||||
|
"start_time": round(current_time, 1),
|
||||||
|
"start_formatted": format_time(current_time),
|
||||||
|
"mode": actual_mode,
|
||||||
|
"show": show["name"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Update cooldowns
|
||||||
|
cooled_paths.add(pick["path"])
|
||||||
|
if pick["artist"]:
|
||||||
|
cooled_artists[pick["artist"].lower()] = sim_now
|
||||||
|
if pick["album"]:
|
||||||
|
cooled_albums[pick["album"].lower()] = sim_now
|
||||||
|
last_genre = dj_core.normalize_genre(pick["genre"])
|
||||||
|
recent_energies = (recent_energies + [pick["energy"]])[-3:]
|
||||||
|
recent_tracks.append(pick)
|
||||||
|
|
||||||
|
current_time += dur
|
||||||
|
track_count += 1
|
||||||
|
tracks_since_announce += 1
|
||||||
|
tracks_since_jingle += 1
|
||||||
|
|
||||||
|
# ── Insert announcement ───────────────────────────────────────
|
||||||
|
if tracks_since_announce >= ANNOUNCE_INTERVAL and current_time < DAY_SECONDS:
|
||||||
|
ann_entry = _make_announcement(recent_tracks, current_time)
|
||||||
|
if ann_entry:
|
||||||
|
entries.append(ann_entry)
|
||||||
|
current_time += ann_entry["duration"]
|
||||||
|
tracks_since_announce = 0
|
||||||
|
|
||||||
|
# ── Insert jingle ─────────────────────────────────────────────
|
||||||
|
if tracks_since_jingle >= JINGLE_INTERVAL and jingle_files and current_time < DAY_SECONDS:
|
||||||
|
j_path, j_dur = get_jingle()
|
||||||
|
if j_path:
|
||||||
|
entries.append({
|
||||||
|
"type": "jingle",
|
||||||
|
"path": j_path,
|
||||||
|
"duration": round(j_dur, 1),
|
||||||
|
"start_time": round(current_time, 1),
|
||||||
|
"start_formatted": format_time(current_time),
|
||||||
|
})
|
||||||
|
current_time += j_dur
|
||||||
|
tracks_since_jingle = 0
|
||||||
|
|
||||||
|
# Build show schedule with formatted times
|
||||||
|
shows = []
|
||||||
|
for s in dj_core.SHOWS:
|
||||||
|
shows.append({
|
||||||
|
"name": s["name"],
|
||||||
|
"start": f"{s['start_hour']:02d}:00",
|
||||||
|
"end": f"{s['end_hour']:02d}:00" if s["end_hour"] < 24 else "00:00",
|
||||||
|
"energy": s["energy"],
|
||||||
|
"description": s["description"],
|
||||||
|
})
|
||||||
|
|
||||||
|
playlist = {
|
||||||
|
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"total_tracks": track_count,
|
||||||
|
"total_entries": len(entries),
|
||||||
|
"total_duration_hours": round(current_time / 3600, 1),
|
||||||
|
"shows": shows,
|
||||||
|
"entries": entries,
|
||||||
|
}
|
||||||
|
|
||||||
|
return playlist
|
||||||
|
|
||||||
|
|
||||||
|
def _make_announcement(recent_tracks, current_time):
|
||||||
|
"""Build an announcement entry from recent tracks."""
|
||||||
|
if len(recent_tracks) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
last = recent_tracks[-1]
|
||||||
|
prev = recent_tracks[-2]
|
||||||
|
|
||||||
|
last_str = f"{last['artist']} - {last['title']}" if last["artist"] and last["title"] else None
|
||||||
|
prev_str = f"{prev['artist']} - {prev['title']}" if prev["artist"] and prev["title"] else None
|
||||||
|
|
||||||
|
if not last_str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
station_id = random.choice(dj_core.STATION_IDS)
|
||||||
|
|
||||||
|
if prev_str:
|
||||||
|
text = f"You've been listening to {last_str}, and {prev_str}. {station_id}"
|
||||||
|
else:
|
||||||
|
text = f"You've been listening to {last_str}. {station_id}"
|
||||||
|
|
||||||
|
path, dur = generate_announcement_tts(text)
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "announcement",
|
||||||
|
"path": path,
|
||||||
|
"text": text,
|
||||||
|
"duration": round(dur, 1),
|
||||||
|
"start_time": round(current_time, 1),
|
||||||
|
"start_formatted": format_time(current_time),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_outputs(playlist):
|
||||||
|
"""Write playlist.json and playlist.m3u."""
|
||||||
|
# JSON
|
||||||
|
with open(PLAYLIST_JSON, "w") as f:
|
||||||
|
json.dump(playlist, f, indent=2)
|
||||||
|
set_mediaserver_perms(PLAYLIST_JSON)
|
||||||
|
|
||||||
|
# M3U
|
||||||
|
with open(PLAYLIST_M3U, "w") as f:
|
||||||
|
f.write("#EXTM3U\n")
|
||||||
|
for entry in playlist["entries"]:
|
||||||
|
path = entry["path"]
|
||||||
|
dur = int(entry.get("duration", 0))
|
||||||
|
if entry["type"] == "track":
|
||||||
|
f.write(f"#EXTINF:{dur},{entry.get('artist', '')} - {entry.get('title', '')}\n")
|
||||||
|
elif entry["type"] == "announcement":
|
||||||
|
f.write(f"#EXTINF:{dur},Announcement\n")
|
||||||
|
elif entry["type"] == "jingle":
|
||||||
|
f.write(f"#EXTINF:{dur},Jingle\n")
|
||||||
|
f.write(f"{path}\n")
|
||||||
|
set_mediaserver_perms(PLAYLIST_M3U)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
start = time.time()
|
||||||
|
print(f"Radio Susan — Daily Playlist Generator")
|
||||||
|
print(f"Started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
playlist = build_playlist()
|
||||||
|
if not playlist:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"\nGenerating announcements...")
|
||||||
|
# Count announcements
|
||||||
|
ann_count = sum(1 for e in playlist["entries"] if e["type"] == "announcement")
|
||||||
|
jingle_count = sum(1 for e in playlist["entries"] if e["type"] == "jingle")
|
||||||
|
|
||||||
|
print(f"\nWriting outputs...")
|
||||||
|
write_outputs(playlist)
|
||||||
|
|
||||||
|
elapsed = time.time() - start
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" Playlist generated successfully!")
|
||||||
|
print(f" Tracks: {playlist['total_tracks']}")
|
||||||
|
print(f" Announcements: {ann_count}")
|
||||||
|
print(f" Jingles: {jingle_count}")
|
||||||
|
print(f" Total entries: {playlist['total_entries']}")
|
||||||
|
print(f" Duration: {playlist['total_duration_hours']}h")
|
||||||
|
print(f" Time taken: {elapsed:.1f}s")
|
||||||
|
print(f" Output: {PLAYLIST_JSON}")
|
||||||
|
print(f" Output: {PLAYLIST_M3U}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+292
@@ -0,0 +1,292 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Jingle Factory for Radio Susan
|
||||||
|
|
||||||
|
Phrases use {sfx_name} markers to insert sound effects at breakpoints.
|
||||||
|
Each phrase gets TTS'd with a random voice, then stitched with SFX.
|
||||||
|
Background ambience (static/noise) is layered underneath.
|
||||||
|
|
||||||
|
Usage: python3 generate_jingles.py [--list-sfx] [--preview PHRASE]
|
||||||
|
|
||||||
|
SFX are stored in /var/lib/radio/sfx/
|
||||||
|
Jingles output to /var/lib/radio/jingles/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
RADIO_DIR = Path("/var/lib/radio")
|
||||||
|
SFX_DIR = RADIO_DIR / "sfx"
|
||||||
|
JINGLES_DIR = RADIO_DIR / "jingles"
|
||||||
|
VENV_BIN = RADIO_DIR / "venv" / "bin"
|
||||||
|
EDGE_TTS = VENV_BIN / "edge-tts"
|
||||||
|
|
||||||
|
# Voices to randomly pick from — mix of dramatic, cheesy, authoritative
|
||||||
|
VOICES = [
|
||||||
|
"en-US-GuyNeural", # Passion — classic radio voice
|
||||||
|
"en-US-ChristopherNeural", # Authority — news anchor
|
||||||
|
"en-US-EricNeural", # Rational — deadpan
|
||||||
|
"en-US-BrianNeural", # Casual bro
|
||||||
|
"en-GB-RyanNeural", # British bloke
|
||||||
|
"en-GB-ThomasNeural", # Posh British
|
||||||
|
"en-US-AriaNeural", # Confident woman
|
||||||
|
"en-US-JennyNeural", # Friendly woman
|
||||||
|
"en-GB-SoniaNeural", # British woman
|
||||||
|
]
|
||||||
|
|
||||||
|
# Phrase definitions — {sfx_name} marks where sound effects go
|
||||||
|
# Add as many as you want!
|
||||||
|
PHRASES = [
|
||||||
|
# Classic station IDs
|
||||||
|
"You're listening to {crash} ninety nine point oh {synth_horn} Susan Radio!",
|
||||||
|
"This is {sweep} Susan Radio {crash} ninety nine point oh {reverb_hit} all day, all night",
|
||||||
|
"{synth_horn} You're tuned in to {crash} Susan Radio! {airhorn}",
|
||||||
|
"Susan Radio {reverb_hit} ninety nine point oh {crash} on your dial",
|
||||||
|
"{sweep} Don't touch that dial! {crash} Susan Radio {airhorn} ninety nine point oh!",
|
||||||
|
"Coming at you live {crash} this is {reverb_hit} Susan Radio! {synth_horn}",
|
||||||
|
"{sweep} Lock it in {crash} ninety nine point oh {reverb_hit} Susan Radio!",
|
||||||
|
# Chaotic ones
|
||||||
|
"{pirate_arr} Ahoy! {crash} You're aboard Susan Radio! {pirate_yaargh} Ninety nine point oh!",
|
||||||
|
"{scream} Susan Radio! {airhorn} Ninety nine point oh! {meow}",
|
||||||
|
"{countdown} Susan Radio is GO! {crash} Ninety nine point oh! {airhorn}",
|
||||||
|
"{demon} Susan Radio {crash} ninety nine point oh {scream} we never stop!",
|
||||||
|
"{text_tone} New message! {crash} You're listening to Susan Radio! {synth_horn}",
|
||||||
|
"{meow} Susan Radio {reverb_hit} ninety nine point oh {cashregister}",
|
||||||
|
"{pirate_plank} Walk the plank! {crash} Or just listen to Susan Radio! {pirate_arr} Ninety nine point oh!",
|
||||||
|
"{cashregister} Cha-ching! {sweep} Susan Radio! {airhorn} Ninety nine point oh!",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_sfx():
|
||||||
|
"""Generate built-in sound effects using ffmpeg."""
|
||||||
|
SFX_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
sfx_specs = {
|
||||||
|
"crash": {
|
||||||
|
# White noise burst with sharp attack, longer decay
|
||||||
|
"filter": "anoisesrc=d=1.5:c=white:a=0.8,afade=t=in:d=0.02,afade=t=out:st=0.3:d=1.2,lowpass=f=3000",
|
||||||
|
},
|
||||||
|
"airhorn": {
|
||||||
|
# Stacked sine waves with vibrato — longer blast
|
||||||
|
"filter": (
|
||||||
|
"sine=f=750:d=1.2,aformat=sample_rates=44100"
|
||||||
|
),
|
||||||
|
"filter_complex": (
|
||||||
|
"[0:a]volume=0.5[a];"
|
||||||
|
"anoisesrc=d=1.2:c=pink:a=0.15,aformat=sample_rates=44100[n];"
|
||||||
|
"sine=f=950:d=1.2,aformat=sample_rates=44100,volume=0.4[b];"
|
||||||
|
"[a][n]amix=inputs=2[m1];[m1][b]amix=inputs=2,afade=t=in:d=0.02,afade=t=out:st=0.7:d=0.5[out]"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"static": {
|
||||||
|
# Pink noise crackle — longer
|
||||||
|
"filter": "anoisesrc=d=1.2:c=pink:a=0.4,afade=t=in:d=0.1,afade=t=out:st=0.7:d=0.5,highpass=f=500",
|
||||||
|
},
|
||||||
|
"reverb_hit": {
|
||||||
|
# Impact with longer reverb tail
|
||||||
|
"filter": "anoisesrc=d=0.05:c=white:a=1.0,apad=whole_dur=2,aecho=0.8:0.88:80:0.5,afade=t=out:st=0.5:d=1.5",
|
||||||
|
},
|
||||||
|
"sweep": {
|
||||||
|
# Frequency sweep — longer with more movement
|
||||||
|
"filter": "sine=f=200:d=1.0,aformat=sample_rates=44100,afade=t=in:d=0.05,afade=t=out:st=0.5:d=0.5,vibrato=f=15:d=0.8",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, spec in sfx_specs.items():
|
||||||
|
outfile = SFX_DIR / f"{name}.wav"
|
||||||
|
if outfile.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "filter_complex" in spec:
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-f", "lavfi", "-i", spec["filter"],
|
||||||
|
"-filter_complex", spec["filter_complex"],
|
||||||
|
"-map", "[out]",
|
||||||
|
"-ar", "44100", "-ac", "2",
|
||||||
|
str(outfile)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-f", "lavfi", "-i", spec["filter"],
|
||||||
|
"-ar", "44100", "-ac", "2",
|
||||||
|
str(outfile)
|
||||||
|
]
|
||||||
|
|
||||||
|
subprocess.run(cmd, capture_output=True)
|
||||||
|
print(f" Generated SFX: {name}")
|
||||||
|
|
||||||
|
return {f.stem: str(f) for f in SFX_DIR.glob("*.wav")}
|
||||||
|
|
||||||
|
|
||||||
|
def tts_segment(text, voice, outfile):
|
||||||
|
"""Generate TTS for a text segment using edge-tts."""
|
||||||
|
cmd = [
|
||||||
|
str(EDGE_TTS),
|
||||||
|
"--voice", voice,
|
||||||
|
"--rate", "+15%", # Faster for radio energy
|
||||||
|
"--text", text,
|
||||||
|
"--write-media", outfile,
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f" TTS error: {result.stderr}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def stitch_jingle(segments, sfx_files, output_path):
|
||||||
|
"""
|
||||||
|
Stitch TTS segments and SFX together with background ambience.
|
||||||
|
segments: list of (type, path) where type is 'voice' or 'sfx'
|
||||||
|
"""
|
||||||
|
if not segments:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Build ffmpeg concat filter
|
||||||
|
inputs = []
|
||||||
|
filter_parts = []
|
||||||
|
|
||||||
|
for i, (stype, path) in enumerate(segments):
|
||||||
|
inputs.extend(["-i", path])
|
||||||
|
# Normalize all inputs to same format
|
||||||
|
filter_parts.append(f"[{i}:a]aformat=sample_rates=44100:channel_layouts=stereo,aresample=44100[s{i}]")
|
||||||
|
|
||||||
|
# Concat all segments
|
||||||
|
concat_inputs = "".join(f"[s{i}]" for i in range(len(segments)))
|
||||||
|
filter_parts.append(f"{concat_inputs}concat=n={len(segments)}:v=0:a=1[main]")
|
||||||
|
|
||||||
|
# Generate background static layer
|
||||||
|
filter_parts.append(
|
||||||
|
"anoisesrc=d=30:c=pink:a=0.04,aformat=sample_rates=44100:channel_layouts=stereo,"
|
||||||
|
"highpass=f=800,lowpass=f=4000[bg]"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mix main audio with background, trim to main length
|
||||||
|
filter_parts.append(
|
||||||
|
"[bg][main]amix=inputs=2:duration=shortest:weights=1 3,"
|
||||||
|
"afade=t=in:d=0.1,loudnorm=I=-14:TP=-1:LRA=11[out]"
|
||||||
|
)
|
||||||
|
|
||||||
|
filter_complex = ";\n".join(filter_parts)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
*inputs,
|
||||||
|
"-filter_complex", filter_complex,
|
||||||
|
"-map", "[out]",
|
||||||
|
"-ar", "44100", "-ac", "2", "-b:a", "192k",
|
||||||
|
str(output_path)
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f" FFmpeg error: {result.stderr[-500:]}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def parse_phrase(phrase):
|
||||||
|
"""Parse a phrase into segments: text parts and {sfx} markers."""
|
||||||
|
parts = re.split(r'\{(\w+)\}', phrase)
|
||||||
|
segments = []
|
||||||
|
for i, part in enumerate(parts):
|
||||||
|
part = part.strip()
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
if i % 2 == 0:
|
||||||
|
segments.append(("text", part))
|
||||||
|
else:
|
||||||
|
segments.append(("sfx", part))
|
||||||
|
return segments
|
||||||
|
|
||||||
|
|
||||||
|
def generate_one_jingle(phrase, voice, sfx_files, output_path):
|
||||||
|
"""Generate a single jingle from a phrase template."""
|
||||||
|
parsed = parse_phrase(phrase)
|
||||||
|
|
||||||
|
audio_segments = [] # (type, filepath)
|
||||||
|
tmpfiles = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for stype, content in parsed:
|
||||||
|
if stype == "text":
|
||||||
|
tmpfile = tempfile.mktemp(suffix=".mp3", dir="/tmp")
|
||||||
|
tmpfiles.append(tmpfile)
|
||||||
|
if tts_segment(content, voice, tmpfile):
|
||||||
|
audio_segments.append(("voice", tmpfile))
|
||||||
|
else:
|
||||||
|
print(f" Failed TTS for: {content}")
|
||||||
|
return False
|
||||||
|
elif stype == "sfx":
|
||||||
|
if content in sfx_files:
|
||||||
|
audio_segments.append(("sfx", sfx_files[content]))
|
||||||
|
else:
|
||||||
|
print(f" Unknown SFX: {content}")
|
||||||
|
|
||||||
|
if not audio_segments:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return stitch_jingle(audio_segments, sfx_files, output_path)
|
||||||
|
finally:
|
||||||
|
for f in tmpfiles:
|
||||||
|
try:
|
||||||
|
os.unlink(f)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
JINGLES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if "--list-sfx" in sys.argv:
|
||||||
|
sfx_files = generate_sfx()
|
||||||
|
print("Available SFX:", ", ".join(sorted(sfx_files.keys())))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Clear old jingles before regenerating
|
||||||
|
for old in JINGLES_DIR.glob("jingle_*.mp3"):
|
||||||
|
old.unlink()
|
||||||
|
|
||||||
|
print("Generating SFX...")
|
||||||
|
sfx_files = generate_sfx()
|
||||||
|
print(f" {len(sfx_files)} SFX ready: {', '.join(sorted(sfx_files.keys()))}")
|
||||||
|
|
||||||
|
# Generate multiple jingles with different voice/phrase combos
|
||||||
|
print("\nGenerating jingles...")
|
||||||
|
|
||||||
|
generated = 0
|
||||||
|
for i, phrase in enumerate(PHRASES):
|
||||||
|
voice = random.choice(VOICES)
|
||||||
|
output = JINGLES_DIR / f"jingle_{i+1:02d}.mp3"
|
||||||
|
|
||||||
|
print(f"\n [{i+1}/{len(PHRASES)}] Voice: {voice}")
|
||||||
|
print(f" Phrase: {phrase}")
|
||||||
|
|
||||||
|
if generate_one_jingle(phrase, voice, sfx_files, output):
|
||||||
|
# Get duration
|
||||||
|
probe = subprocess.run(
|
||||||
|
["ffprobe", "-hide_banner", "-show_entries", "format=duration",
|
||||||
|
"-of", "csv=p=0", str(output)],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
dur = float(probe.stdout.strip()) if probe.stdout.strip() else 0
|
||||||
|
print(f" ✓ {output.name} ({dur:.1f}s)")
|
||||||
|
generated += 1
|
||||||
|
else:
|
||||||
|
print(f" ✗ Failed")
|
||||||
|
|
||||||
|
print(f"\nDone! {generated}/{len(PHRASES)} jingles generated in {JINGLES_DIR}")
|
||||||
|
print("Reload liquidsoap to pick them up: sudo systemctl restart liquidsoap")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+602
@@ -0,0 +1,602 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate the static site for Radio Susan from playlist.json.
|
||||||
|
Outputs to /var/www/radio/. Run after generate_daily_playlist.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import grp
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PLAYLIST_JSON = Path("/var/lib/radio/playlist.json")
|
||||||
|
OUTPUT_DIR = Path("/var/www/radio")
|
||||||
|
STREAM_URL = "https://radio.jihakuz.xyz/stream"
|
||||||
|
|
||||||
|
|
||||||
|
def set_mediaserver_perms(path):
|
||||||
|
try:
|
||||||
|
gid = grp.getgrnam("mediaserver").gr_gid
|
||||||
|
os.chown(str(path), -1, gid)
|
||||||
|
os.chmod(str(path), 0o664)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def generate_html(playlist):
|
||||||
|
generated = playlist["generated_at"][:10]
|
||||||
|
total_tracks = playlist["total_tracks"]
|
||||||
|
duration = playlist["total_duration_hours"]
|
||||||
|
shows = playlist["shows"]
|
||||||
|
entries = playlist["entries"]
|
||||||
|
|
||||||
|
# Build tracks-only list for the schedule
|
||||||
|
tracks = [e for e in entries if e["type"] == "track"]
|
||||||
|
|
||||||
|
# Group tracks by show
|
||||||
|
show_tracks = {}
|
||||||
|
for t in tracks:
|
||||||
|
show_name = t.get("show", "Unknown")
|
||||||
|
show_tracks.setdefault(show_name, []).append(t)
|
||||||
|
|
||||||
|
# Mode stats
|
||||||
|
mode_counts = {}
|
||||||
|
for t in tracks:
|
||||||
|
m = t.get("mode", "unknown")
|
||||||
|
mode_counts[m] = mode_counts.get(m, 0) + 1
|
||||||
|
|
||||||
|
# Build show cards HTML
|
||||||
|
show_cards = ""
|
||||||
|
for show in shows:
|
||||||
|
name = show["name"]
|
||||||
|
trks = show_tracks.get(name, [])
|
||||||
|
show_cards += f"""
|
||||||
|
<div class="show-card" id="show-{name.lower().replace(' ', '-')}">
|
||||||
|
<div class="show-header">
|
||||||
|
<div class="show-time">{show['start']} — {show['end']}</div>
|
||||||
|
<h2 class="show-name">{name}</h2>
|
||||||
|
<div class="show-desc">{show['description']} · Energy {show['energy']} · {len(trks)} tracks</div>
|
||||||
|
</div>
|
||||||
|
<div class="show-tracklist">
|
||||||
|
{''.join(f'<div class="track" data-start="{t["start_time"]}">'
|
||||||
|
f'<span class="track-time">{t["start_formatted"][:5]}</span>'
|
||||||
|
f'<span class="track-artist">{_esc(t["artist"])}</span>'
|
||||||
|
f'<span class="track-sep">—</span>'
|
||||||
|
f'<span class="track-title">{_esc(t["title"])}</span>'
|
||||||
|
f'<span class="track-meta">{_esc(t.get("album", ""))}</span>'
|
||||||
|
f'<span class="track-mode">{t["mode"]}</span>'
|
||||||
|
f'</div>' for t in trks)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Radio Susan — 99.0</title>
|
||||||
|
<style>
|
||||||
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||||
|
|
||||||
|
:root {{
|
||||||
|
--bg: #0a0a0f;
|
||||||
|
--surface: #12121a;
|
||||||
|
--surface2: #1a1a26;
|
||||||
|
--border: #2a2a3a;
|
||||||
|
--text: #e0e0e8;
|
||||||
|
--text-dim: #7a7a8e;
|
||||||
|
--accent: #ff6b35;
|
||||||
|
--accent2: #ffd700;
|
||||||
|
--green: #4ade80;
|
||||||
|
--red: #f87171;
|
||||||
|
}}
|
||||||
|
|
||||||
|
body {{
|
||||||
|
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.container {{
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {{
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}}
|
||||||
|
|
||||||
|
.header h1 {{
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.header .freq {{
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--accent2);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.header .meta {{
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 1rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Now Playing */
|
||||||
|
.now-playing {{
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.now-playing .label {{
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.now-playing .live-dot {{
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--red);
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}}
|
||||||
|
|
||||||
|
@keyframes pulse {{
|
||||||
|
0%, 100% {{ opacity: 1; }}
|
||||||
|
50% {{ opacity: 0.3; }}
|
||||||
|
}}
|
||||||
|
|
||||||
|
.now-playing .track-name {{
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}}
|
||||||
|
|
||||||
|
.now-playing .track-album {{
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.now-playing .show-label {{
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--accent2);
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.listen-btn {{
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.6rem 2rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.listen-btn:hover {{
|
||||||
|
background: #ff8555;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Up Next */
|
||||||
|
.up-next {{
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.up-next h3 {{
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.up-next .next-track {{
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.up-next .next-time {{
|
||||||
|
color: var(--text-dim);
|
||||||
|
min-width: 50px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Stats bar */
|
||||||
|
.stats {{
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.stat {{
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.stat .stat-val {{
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}}
|
||||||
|
|
||||||
|
.stat .stat-label {{
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Show cards */
|
||||||
|
.show-card {{
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.show-header {{
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.show-time {{
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--accent2);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.show-name {{
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.show-desc {{
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.show-tracklist {{
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.show-card.open .show-tracklist {{
|
||||||
|
max-height: 5000px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.track {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.4rem 1.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-bottom: 1px solid var(--bg);
|
||||||
|
}}
|
||||||
|
|
||||||
|
.track:hover {{
|
||||||
|
background: var(--surface2);
|
||||||
|
}}
|
||||||
|
|
||||||
|
.track.active {{
|
||||||
|
background: var(--surface2);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
}}
|
||||||
|
|
||||||
|
.track-time {{
|
||||||
|
color: var(--text-dim);
|
||||||
|
min-width: 40px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.track-artist {{
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}}
|
||||||
|
|
||||||
|
.track-sep {{
|
||||||
|
color: var(--text-dim);
|
||||||
|
}}
|
||||||
|
|
||||||
|
.track-title {{
|
||||||
|
color: var(--text);
|
||||||
|
flex: 1;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.track-meta {{
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
max-width: 150px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.track-mode {{
|
||||||
|
font-size: 0.6rem;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {{
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 0;
|
||||||
|
margin-top: 2rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {{
|
||||||
|
.container {{ padding: 1rem; }}
|
||||||
|
.header h1 {{ font-size: 1.8rem; }}
|
||||||
|
.track-meta {{ display: none; }}
|
||||||
|
.stats {{ flex-wrap: wrap; gap: 0.5rem; }}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>RADIO SUSAN</h1>
|
||||||
|
<div class="freq">99.0 FM</div>
|
||||||
|
<div class="meta">Generated {generated} · {total_tracks} tracks · {duration}h</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="now-playing" id="now-playing">
|
||||||
|
<div class="label"><span class="live-dot"></span>NOW PLAYING</div>
|
||||||
|
<div class="track-name" id="np-track">Loading...</div>
|
||||||
|
<div class="track-album" id="np-album"></div>
|
||||||
|
<div class="show-label" id="np-show"></div>
|
||||||
|
<button class="listen-btn" onclick="togglePlay()">▶ LISTEN</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="up-next" id="up-next">
|
||||||
|
<h3>Up Next</h3>
|
||||||
|
<div id="next-tracks"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><div class="stat-val">{total_tracks}</div><div class="stat-label">Tracks</div></div>
|
||||||
|
<div class="stat"><div class="stat-val">{len(shows)}</div><div class="stat-label">Shows</div></div>
|
||||||
|
<div class="stat"><div class="stat-val">{mode_counts.get('full_album', 0)}</div><div class="stat-label">Album Tracks</div></div>
|
||||||
|
<div class="stat"><div class="stat-val">{mode_counts.get('deep_cuts', 0)}</div><div class="stat-label">Deep Cuts</div></div>
|
||||||
|
<div class="stat"><div class="stat-val">{mode_counts.get('new_to_library', 0)}</div><div class="stat-label">New</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="font-size: 1rem; margin-bottom: 1rem; color: var(--text-dim); letter-spacing: 0.1em;">TODAY'S SCHEDULE</h2>
|
||||||
|
|
||||||
|
{show_cards}
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
RADIO SUSAN · Powered by Liquidsoap + Icecast · Self-hosted on Susan
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<audio id="audio-player" preload="none">
|
||||||
|
<source src="{STREAM_URL}" type="audio/mpeg">
|
||||||
|
</audio>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const schedule = {json.dumps([e for e in entries if e['type'] == 'track'], separators=(',', ':'))};
|
||||||
|
|
||||||
|
function getScheduleSeconds() {{
|
||||||
|
const now = new Date();
|
||||||
|
return now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds();
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Find track in schedule by artist+title match
|
||||||
|
function findInSchedule(artist, title) {{
|
||||||
|
for (let i = 0; i < schedule.length; i++) {{
|
||||||
|
if (schedule[i].artist === artist && schedule[i].title === title) {{
|
||||||
|
return {{ track: schedule[i], index: i }};
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Fallback: find by time
|
||||||
|
function findByTime() {{
|
||||||
|
const secs = getScheduleSeconds();
|
||||||
|
let current = null;
|
||||||
|
let idx = 0;
|
||||||
|
for (let i = 0; i < schedule.length; i++) {{
|
||||||
|
if (schedule[i].start_time <= secs) {{
|
||||||
|
current = schedule[i];
|
||||||
|
idx = i;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
return current ? {{ track: current, index: idx }} : null;
|
||||||
|
}}
|
||||||
|
|
||||||
|
let lastNp = '';
|
||||||
|
|
||||||
|
async function updateNowPlaying() {{
|
||||||
|
// Poll live state from liquidsoap
|
||||||
|
let liveArtist = '', liveTitle = '';
|
||||||
|
try {{
|
||||||
|
const resp = await fetch('/now_playing.json?' + Date.now());
|
||||||
|
if (resp.ok) {{
|
||||||
|
const data = await resp.json();
|
||||||
|
liveArtist = data.artist || '';
|
||||||
|
liveTitle = data.title || '';
|
||||||
|
}}
|
||||||
|
}} catch(e) {{}}
|
||||||
|
|
||||||
|
const npKey = liveArtist + '|' + liveTitle;
|
||||||
|
|
||||||
|
// Update now playing display
|
||||||
|
if (liveArtist || liveTitle) {{
|
||||||
|
document.getElementById('np-track').textContent =
|
||||||
|
(liveArtist && liveTitle) ? liveArtist + ' \u2014 ' + liveTitle :
|
||||||
|
liveArtist || liveTitle;
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Try to find this track in the schedule for metadata + up next
|
||||||
|
let match = null;
|
||||||
|
if (liveArtist && liveTitle) {{
|
||||||
|
match = findInSchedule(liveArtist, liveTitle);
|
||||||
|
}}
|
||||||
|
if (!match) {{
|
||||||
|
match = findByTime();
|
||||||
|
}}
|
||||||
|
|
||||||
|
if (match) {{
|
||||||
|
const {{ track, index }} = match;
|
||||||
|
document.getElementById('np-album').textContent = track.album || '';
|
||||||
|
document.getElementById('np-show').textContent = track.show + ' \u00b7 ' + track.mode;
|
||||||
|
|
||||||
|
// Up next (from schedule position)
|
||||||
|
const nextDiv = document.getElementById('next-tracks');
|
||||||
|
nextDiv.innerHTML = '';
|
||||||
|
for (let i = index + 1; i < Math.min(index + 4, schedule.length); i++) {{
|
||||||
|
const t = schedule[i];
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'next-track';
|
||||||
|
div.innerHTML = '<span class="next-time">' + t.start_formatted.slice(0, 5) + '</span> '
|
||||||
|
+ '<span>' + t.artist + ' \u2014 ' + t.title + '</span>';
|
||||||
|
nextDiv.appendChild(div);
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Highlight active track
|
||||||
|
if (npKey !== lastNp) {{
|
||||||
|
document.querySelectorAll('.track.active').forEach(el => el.classList.remove('active'));
|
||||||
|
const allTracks = document.querySelectorAll('.track');
|
||||||
|
allTracks.forEach(el => {{
|
||||||
|
if (parseFloat(el.dataset.start) === track.start_time) {{
|
||||||
|
el.classList.add('active');
|
||||||
|
const card = el.closest('.show-card');
|
||||||
|
if (card && !card.classList.contains('open')) {{
|
||||||
|
card.classList.add('open');
|
||||||
|
}}
|
||||||
|
el.scrollIntoView({{ behavior: 'smooth', block: 'center' }});
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
lastNp = npKey;
|
||||||
|
}}
|
||||||
|
}} else {{
|
||||||
|
document.getElementById('np-album').textContent = '';
|
||||||
|
document.getElementById('np-show').textContent = '';
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Toggle show tracklists
|
||||||
|
document.querySelectorAll('.show-header').forEach(header => {{
|
||||||
|
header.addEventListener('click', () => {{
|
||||||
|
header.parentElement.classList.toggle('open');
|
||||||
|
}});
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Audio player
|
||||||
|
let playing = false;
|
||||||
|
function togglePlay() {{
|
||||||
|
const audio = document.getElementById('audio-player');
|
||||||
|
const btn = document.querySelector('.listen-btn');
|
||||||
|
if (playing) {{
|
||||||
|
audio.pause();
|
||||||
|
audio.src = '';
|
||||||
|
btn.textContent = '\u25b6 LISTEN';
|
||||||
|
playing = false;
|
||||||
|
}} else {{
|
||||||
|
audio.src = '{STREAM_URL}';
|
||||||
|
audio.play();
|
||||||
|
btn.textContent = '\u23f8 STOP';
|
||||||
|
playing = true;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Update every 10 seconds
|
||||||
|
updateNowPlaying();
|
||||||
|
setInterval(updateNowPlaying, 10000);
|
||||||
|
|
||||||
|
// Open current show by default
|
||||||
|
const timeMatch = findByTime();
|
||||||
|
if (timeMatch) {{
|
||||||
|
const showId = 'show-' + timeMatch.track.show.toLowerCase().replace(/ /g, '-');
|
||||||
|
const el = document.getElementById(showId);
|
||||||
|
if (el) el.classList.add('open');
|
||||||
|
}}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
def _esc(s):
|
||||||
|
"""Escape HTML entities."""
|
||||||
|
return (s or "").replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not PLAYLIST_JSON.exists():
|
||||||
|
print(f"ERROR: {PLAYLIST_JSON} not found. Run generate_daily_playlist.py first.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
playlist = json.load(open(PLAYLIST_JSON))
|
||||||
|
print(f"Generating site from playlist ({playlist['total_tracks']} tracks)...")
|
||||||
|
|
||||||
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate index.html
|
||||||
|
html = generate_html(playlist)
|
||||||
|
index_path = OUTPUT_DIR / "index.html"
|
||||||
|
index_path.write_text(html)
|
||||||
|
set_mediaserver_perms(index_path)
|
||||||
|
|
||||||
|
# Copy playlist.json for API access
|
||||||
|
api_path = OUTPUT_DIR / "playlist.json"
|
||||||
|
api_path.write_text(json.dumps(playlist, separators=(",", ":")))
|
||||||
|
set_mediaserver_perms(api_path)
|
||||||
|
|
||||||
|
print(f"Site generated at {OUTPUT_DIR}")
|
||||||
|
print(f" index.html: {index_path.stat().st_size / 1024:.1f}KB")
|
||||||
|
print(f" playlist.json: {api_path.stat().st_size / 1024:.1f}KB")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""One-off: generate playlist for remaining hours tonight.
|
||||||
|
Unlike the full generator, this resets start_time to 0 so Liquidsoap
|
||||||
|
plays from the top immediately instead of seeking to a time offset."""
|
||||||
|
import sys, time, json, os
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
from generate_daily_playlist import build_playlist, write_outputs
|
||||||
|
|
||||||
|
playlist = build_playlist()
|
||||||
|
if not playlist:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
now_secs = time.localtime().tm_hour * 3600 + time.localtime().tm_min * 60
|
||||||
|
entries = [e for e in playlist["entries"] if e["start_time"] >= now_secs]
|
||||||
|
|
||||||
|
# Rebase start times to 0 so the playlist plays sequentially from the top
|
||||||
|
offset = entries[0]["start_time"] if entries else 0
|
||||||
|
for e in entries:
|
||||||
|
e["start_time"] = round(e["start_time"] - offset, 1)
|
||||||
|
h = int(e["start_time"] // 3600)
|
||||||
|
m = int((e["start_time"] % 3600) // 60)
|
||||||
|
s = int(e["start_time"] % 60)
|
||||||
|
e["start_formatted"] = f"{h:02d}:{m:02d}:{s:02d}"
|
||||||
|
|
||||||
|
playlist["entries"] = entries
|
||||||
|
playlist["total_tracks"] = sum(1 for e in entries if e["type"] == "track")
|
||||||
|
playlist["total_entries"] = len(entries)
|
||||||
|
|
||||||
|
write_outputs(playlist)
|
||||||
|
|
||||||
|
ann_count = sum(1 for e in entries if e["type"] == "announcement")
|
||||||
|
jingle_count = sum(1 for e in entries if e["type"] == "jingle")
|
||||||
|
print(f"Tonight: {playlist['total_tracks']} tracks, {ann_count} announcements, {jingle_count} jingles from {now_secs/3600:.1f}h onwards")
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/liquidsoap
|
||||||
|
|
||||||
|
# Radio Susan — pre-generated daily playlist
|
||||||
|
# Tracks, announcements, and jingles are all baked into playlist.m3u
|
||||||
|
# Liquidsoap just plays it sequentially.
|
||||||
|
|
||||||
|
set("log.file.path", "/var/lib/radio/radio.log")
|
||||||
|
set("log.level", 3)
|
||||||
|
set("server.telnet", true)
|
||||||
|
set("server.telnet.port", 1234)
|
||||||
|
|
||||||
|
state_file = "/var/lib/radio/track_state.json"
|
||||||
|
playlist_file = "/var/lib/radio/playlist.m3u"
|
||||||
|
|
||||||
|
# Primary source: pre-generated daily playlist
|
||||||
|
music = playlist(
|
||||||
|
id="daily_playlist",
|
||||||
|
mode="normal",
|
||||||
|
reload=3600,
|
||||||
|
reload_mode="watch",
|
||||||
|
playlist_file
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback 1: live DJ (per-track)
|
||||||
|
def get_next_track() =
|
||||||
|
result = list.hd(default="", process.read.lines("/usr/bin/python3 /var/lib/radio/radio_dj.py"))
|
||||||
|
if result != "" then
|
||||||
|
log("DJ fallback picked: #{result}")
|
||||||
|
request.create(result)
|
||||||
|
else
|
||||||
|
log("DJ returned nothing")
|
||||||
|
null()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
dj_fallback = request.dynamic(id="dj_fallback", get_next_track)
|
||||||
|
|
||||||
|
# Fallback 2: static random playlist (last resort)
|
||||||
|
random_fallback = playlist(
|
||||||
|
id="random_fallback",
|
||||||
|
mode="randomize",
|
||||||
|
reload=3600,
|
||||||
|
"/var/lib/radio/playlists/all.m3u"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Chain: playlist → DJ → random
|
||||||
|
radio = fallback(id="Radio_Susan", track_sensitive=true, [music, dj_fallback, random_fallback])
|
||||||
|
|
||||||
|
# Write track metadata to state file on each new track
|
||||||
|
radio = source.on_track(radio, fun(m) -> begin
|
||||||
|
artist = m["artist"]
|
||||||
|
title = m["title"]
|
||||||
|
data = '{"artist":"#{artist}","title":"#{title}"}'
|
||||||
|
ignore(file.write(data=data, state_file))
|
||||||
|
log("Now playing: #{artist} - #{title}")
|
||||||
|
end)
|
||||||
|
|
||||||
|
radio = mksafe(radio)
|
||||||
|
|
||||||
|
output.icecast(
|
||||||
|
%mp3(bitrate=192),
|
||||||
|
host="localhost",
|
||||||
|
port=8910,
|
||||||
|
password="REDACTED",
|
||||||
|
mount="/stream",
|
||||||
|
name="Radio Susan",
|
||||||
|
description="Personal radio station",
|
||||||
|
genre="Eclectic",
|
||||||
|
url="https://radio.jihakuz.xyz",
|
||||||
|
radio
|
||||||
|
)
|
||||||
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()
|
||||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
+77
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/liquidsoap
|
||||||
|
|
||||||
|
# Radio Susan — with track announcements
|
||||||
|
|
||||||
|
set("log.file.path", "/var/lib/radio/radio.log")
|
||||||
|
set("log.level", 3)
|
||||||
|
set("server.telnet", true)
|
||||||
|
set("server.telnet.port", 1234)
|
||||||
|
set("ffmpeg.content_type.parse_metadata", false)
|
||||||
|
|
||||||
|
# State file for track metadata exchange
|
||||||
|
state_file = "/var/lib/radio/track_state.json"
|
||||||
|
|
||||||
|
# Track counter
|
||||||
|
track_count = ref(0)
|
||||||
|
|
||||||
|
music = playlist(
|
||||||
|
mode="randomize",
|
||||||
|
reload=3600,
|
||||||
|
reload_mode="watch",
|
||||||
|
"/var/lib/radio/playlists/all.m3u"
|
||||||
|
)
|
||||||
|
music = drop_video(music)
|
||||||
|
|
||||||
|
# Write track metadata to state file on each new track
|
||||||
|
music = on_track(fun(m) -> begin
|
||||||
|
track_count := !track_count + 1
|
||||||
|
artist = m["artist"]
|
||||||
|
title = m["title"]
|
||||||
|
count = !track_count
|
||||||
|
# Write JSON state file
|
||||||
|
data = '{"artist":"#{artist}","title":"#{title}","count":#{string_of(count)}}'
|
||||||
|
ignore(file.write(data=data, state_file))
|
||||||
|
log("Track #{string_of(count)}: #{artist} - #{title}")
|
||||||
|
end, music)
|
||||||
|
|
||||||
|
# Jingles
|
||||||
|
jingles = playlist(
|
||||||
|
mode="randomize",
|
||||||
|
"/var/lib/radio/jingles"
|
||||||
|
)
|
||||||
|
jingles = drop_video(jingles)
|
||||||
|
jingles = amplify(3.0, jingles)
|
||||||
|
|
||||||
|
# Dynamic announcements
|
||||||
|
def get_announcement() =
|
||||||
|
result = list.hd(default="", process.read.lines("/usr/bin/python3 /var/lib/radio/announce_tracks.py --state"))
|
||||||
|
if result != "" then
|
||||||
|
log("Announcement: #{result}")
|
||||||
|
request.create(result)
|
||||||
|
else
|
||||||
|
# Fallback to a random jingle
|
||||||
|
null()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
announcements = request.dynamic(get_announcement)
|
||||||
|
announcements = drop_video(announcements)
|
||||||
|
announcements = amplify(3.0, announcements)
|
||||||
|
|
||||||
|
# Rotate: 2 music tracks, then 1 announcement or jingle
|
||||||
|
radio = rotate(weights=[2, 1], [music, random(weights=[1, 1], [jingles, announcements])])
|
||||||
|
|
||||||
|
radio = mksafe(radio)
|
||||||
|
|
||||||
|
output.icecast(
|
||||||
|
%mp3(bitrate=192),
|
||||||
|
host="localhost",
|
||||||
|
port=8910,
|
||||||
|
password="REDACTED",
|
||||||
|
mount="/stream",
|
||||||
|
name="Radio Susan",
|
||||||
|
description="Personal radio station",
|
||||||
|
genre="Eclectic",
|
||||||
|
url="https://radio.jihakuz.xyz",
|
||||||
|
radio
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user