Files
radio-susan/generate_site.py
T

603 lines
18 KiB
Python
Executable File

#!/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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
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()