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