from flask import Flask, Response, stream_with_context, jsonify, render_template, request
import random
from queue import Empty
from yt_radio import (
BITRATE_KBPS,
META_INTERVAL_SECONDS,
PLAYLIST,
RANDOMIZE_PLAYLIST,
RADIO_THREAD,
SITE_IMAGE,
SITE_TITLE,
NOW_PLAYING,
METADATA,
_ensure_metadata,
add_subscriber,
ensure_radio_running,
logger,
remove_subscriber,
)
app = Flask(__name__)
@app.route("/")
def home():
return render_template("index.html", title=SITE_TITLE, image_url=SITE_IMAGE)
@app.route("/playlist.m3u")
def playlist_route():
if not PLAYLIST:
return Response("#EXTM3U\n", mimetype="audio/x-mpegurl")
indices = list(range(len(PLAYLIST)))
if RANDOMIZE_PLAYLIST:
random.shuffle(indices)
lines = ["#EXTM3U"]
for i in indices:
try:
_ensure_metadata(i)
except Exception:
logger.debug("Failed to ensure metadata for index %s", i)
meta = METADATA.get(i, {})
title = meta.get("title", f"Track {i+1}")
artist = meta.get("artist", "Unknown")
duration = meta.get("duration", -1)
try:
duration_int = (
int(duration)
if isinstance(duration, (int, float, str)) and str(duration).isdigit()
else int(duration)
if isinstance(duration, int)
else -1
)
except Exception:
duration_int = -1
lines.append(f"#EXTINF:{duration_int},{artist} - {title}")
lines.append(PLAYLIST[i])
body = "\n".join(lines) + "\n"
return Response(body, mimetype="audio/x-mpegurl")
@app.route("/stream")
def stream():
ensure_radio_running()
sid, q = add_subscriber()
bytes_per_sec = (BITRATE_KBPS * 1000) // 8
metaint = bytes_per_sec * META_INTERVAL_SECONDS
def make_metadata_block(artist: str, title: str) -> bytes:
meta_str = f"StreamTitle='{artist} - {title}';"
meta_utf = meta_str.encode("utf-8", errors="replace")
blocks = (len(meta_utf) + 15) // 16
if blocks == 0:
return b"\x00"
padding = blocks * 16 - len(meta_utf)
return bytes([blocks]) + meta_utf + (b"\x00" * padding)
def generate():
bytes_since_meta = 0
current_index = None
try:
while True:
try:
item = q.get(timeout=5)
except Empty:
if RADIO_THREAD and not RADIO_THREAD.is_alive():
logger.warning("Producer stopped; restarting")
ensure_radio_running()
continue
if item and len(item) == 2:
chunk_index, chunk = item
else:
chunk_index, chunk = None, item
if chunk_index is not None and chunk_index != current_index:
meta = METADATA.get(
chunk_index,
{
"title": f"Track {chunk_index+1}",
"artist": "Unknown",
"duration": -1,
"id": "",
},
)
NOW_PLAYING["index"] = chunk_index
NOW_PLAYING["title"] = meta.get("title", "")
NOW_PLAYING["artist"] = meta.get("artist", "")
NOW_PLAYING["id"] = meta.get("id", "")
current_index = chunk_index
pos = 0
chunk_len = len(chunk)
if metaint <= 0:
yield chunk
continue
while pos < chunk_len:
remaining = metaint - bytes_since_meta
take = min(remaining, chunk_len - pos)
if take > 0:
yield chunk[pos : pos + take]
pos += take
bytes_since_meta += take
if bytes_since_meta >= metaint:
title = (NOW_PLAYING.get("title") or "").strip()
artist = (NOW_PLAYING.get("artist") or "").strip()
meta_block = make_metadata_block(artist, title)
yield meta_block
bytes_since_meta = 0
except GeneratorExit:
logger.info("Client disconnected (sid=%s)", sid)
finally:
remove_subscriber(sid)
headers = {
"icy-br": str(BITRATE_KBPS),
"icy-metaint": str(metaint),
"icy-name": SITE_TITLE or "yt_radio.py",
"icy-charset": "utf-8",
}
return Response(stream_with_context(generate()), mimetype="audio/mpeg", headers=headers)
@app.route("/now_playing")
def now_playing():
hx = request.headers.get("HX-Request")
accept = request.headers.get("Accept", "")
if (hx and hx.lower() == "true") or ("text/html" in accept and "application/json" not in accept):
title = NOW_PLAYING.get("title") or "Nothing"
artist = NOW_PLAYING.get("artist") or "Unknown"
vid = NOW_PLAYING.get("id") or ""
thumb_url = f"https://img.youtube.com/vi/{vid}/maxresdefault.jpg" if vid else (SITE_IMAGE or "")
return (
f''
f"