summaryrefslogtreecommitdiff
path: root/generate_site.py
diff options
context:
space:
mode:
Diffstat (limited to 'generate_site.py')
-rwxr-xr-xgenerate_site.py602
1 files changed, 602 insertions, 0 deletions
diff --git a/generate_site.py b/generate_site.py
new file mode 100755
index 0000000..4930374
--- /dev/null
+++ b/generate_site.py
@@ -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("&", "&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()