603 lines
18 KiB
Python
Executable File
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("&", "&").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()
|