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