diff options
Diffstat (limited to 'generate_site.py')
| -rwxr-xr-x | generate_site.py | 602 |
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("&", "&").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() |
