aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2026-03-04 18:59:05 -0800
committerPinapelz <yukais@pinapelz.com>2026-03-04 18:59:05 -0800
commitc89e8daebbe4ad130aaf538332e84f9e86687ddd (patch)
tree81805a1f64571a19565b4bf1a38c384114a91fe4
parent32eace347a09f169bc87f983b3282871a5ed09f6 (diff)
refactor/create routes moduleHEADmain
-rw-r--r--README.md2
-rw-r--r--routes.py181
-rw-r--r--yt_radio.py186
3 files changed, 199 insertions, 170 deletions
diff --git a/README.md b/README.md
index ed97d00..0b25ddc 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ Takes a YouTube playlist and converts it to an audio stream, similar to internet
First set the environment variables as per `.env.template`, then just run it with gunicorn or something else (gunicorn comes bundled as part of the deps here)
```bash
uv sync
-uv run gunicorn yt_radio:app --bind 0.0.0.0:8000 -k gthread --threads 50 --workers 1 --timeout 0 --keep-alive 5
+uv run gunicorn routes:app --bind 0.0.0.0:8000 -k gthread --threads 50 --workers 1 --timeout 0 --keep-alive 5
```
> Note that `--timeout 0` is a strict requirement if using `/stream` endpoint due to Gunicorn's default timeout policy
diff --git a/routes.py b/routes.py
new file mode 100644
index 0000000..31e4b44
--- /dev/null
+++ b/routes.py
@@ -0,0 +1,181 @@
+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'<img src="{thumb_url}" alt="Cover" style="width:300px;height:300px;object-fit:cover;display:block;margin:0 auto 12px;">'
+ f"<div>{artist} — {title}</div>"
+ )
+ return jsonify(NOW_PLAYING)
+
+
+@app.route("/tracks")
+def tracks():
+ track_list = []
+ for i, url in enumerate(PLAYLIST):
+ meta = METADATA.get(i, {"title": f"Track {i+1}", "artist": "Unknown", "duration": -1})
+ track_list.append(
+ {
+ "index": i,
+ "title": meta["title"],
+ "artist": meta["artist"],
+ "duration": meta["duration"],
+ "url": url,
+ }
+ )
+ return jsonify(track_list)
diff --git a/yt_radio.py b/yt_radio.py
index ca3e1e0..7d582af 100644
--- a/yt_radio.py
+++ b/yt_radio.py
@@ -1,4 +1,3 @@
-from flask import Flask, Response, stream_with_context, jsonify, render_template, request
from yt_dlp import YoutubeDL
from file_util import _load_urls_from_file, _create_or_get_cache, _save_cache
import subprocess
@@ -9,16 +8,16 @@ import os
from dotenv import load_dotenv
import logging
import time
-from queue import Queue, Empty
+from queue import Queue
import uuid
import tempfile
+
load_dotenv()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
-app = Flask(__name__)
BITRATE_KBPS = int(os.environ.get("BITRATE_KBPS", "192"))
BURST_SECONDS = int(os.environ.get("BURST_SECONDS", "10")) # allow pre-buffering of 10 sec on /stream
@@ -50,9 +49,12 @@ CHUNK_SIZE = 8192
QUEUE_MAX_CHUNKS = 256
def convert_playlist_to_links(link: str):
+ # Loading a .radio file
if link.endswith(".radio"):
logger.info(".radio file specified, loading from local file")
return _load_urls_from_file(link)
+
+ # Pull YouTube list of URLs from YouTube Playlist
ydl_opts = {
"quiet": True,
"extract_flat": True,
@@ -64,26 +66,15 @@ def convert_playlist_to_links(link: str):
info = ydl.extract_info(link, download=False)
except Exception:
logger.exception("yt-dlp failed to extract playlist info for %s", link)
- return urls
+ exit(1)
+
entries = info.get("entries") if isinstance(info, dict) else None
if isinstance(entries, list):
logger.info("Playlist info returned %d entries", len(entries))
else:
- if isinstance(info, dict):
- vid = info.get("id") or info.get("url") or info.get("webpage_url")
- if vid:
- if isinstance(vid, str) and vid.startswith("http"):
- urls.append(vid)
- logger.info("Single video found for playlist URL, added: %s", vid)
- else:
- constructed = f"https://www.youtube.com/watch?v={vid}"
- urls.append(constructed)
- logger.info("Single video id found for playlist URL, constructed: %s", constructed)
- else:
- logger.warning("No entries found in playlist info for %s", link)
- else:
- logger.warning("Unexpected playlist info format for %s: %r", link, type(info))
- return urls
+ logger.error("Could not find list of links. Are you providing a YouTube Playlist URL?")
+ exit(1)
+
for idx, entry in enumerate(entries, start=1):
entry_id = None
if isinstance(entry, dict):
@@ -121,6 +112,7 @@ def fetch_metadata(index, url):
except Exception:
logger.exception("Failed to use cached metadata for %s, will refetch", url)
+ # Get metadata via yt-dlp
try:
result = subprocess.run(
["yt-dlp", "--dump-json", url],
@@ -131,6 +123,7 @@ def fetch_metadata(index, url):
timeout=30,
)
if result.returncode != 0 or not result.stdout:
+ logger.error("Failed to get metadata from yt-dlp, you may or may not be throttled!")
raise RuntimeError(f"yt-dlp failed for {url}: {result.stderr.strip()}")
data = json.loads(result.stdout)
METADATA[index] = {
@@ -139,7 +132,7 @@ def fetch_metadata(index, url):
"duration": data.get("duration", -1),
"id": data.get("id", ""),
}
- with _CACHE_LOCK:
+ with _CACHE_LOCK: # get lock
_CACHE[url] = {
"title": data.get("title"),
"uploader": data.get("uploader"),
@@ -148,6 +141,7 @@ def fetch_metadata(index, url):
}
_save_cache(_CACHE, CACHE_FILE)
except Exception:
+ # Even if we fail to get meta we may be able to stream music still? So don't exit
METADATA[index] = {
"title": f"Track {index+1}",
"artist": "Unknown",
@@ -157,6 +151,7 @@ def fetch_metadata(index, url):
logger.debug("Failed to fetch metadata for index %s, using fallback", index)
+# Bootstrap
PLAYLIST = convert_playlist_to_links(PLAYLIST_URL)
PRELOAD_COUNT = min(4, len(PLAYLIST))
_preload_indices = random.sample(range(len(PLAYLIST)), PRELOAD_COUNT) if PLAYLIST else []
@@ -175,12 +170,8 @@ def _ensure_metadata(index):
def _stream_track(index):
url = PLAYLIST[index]
_ensure_metadata(index)
- # make a shallow local copy so metadata can't be mutated by other threads while streaming
meta = dict(METADATA.get(index, {"title": f"Track {index+1}", "artist": "Unknown", "duration": -1, "id": ""}))
-
logger.info("Now playing [%d/%d]: %s - %s", index + 1, len(PLAYLIST), meta.get("artist", ""), meta.get("title", ""))
-
- # capture subprocess stderr to temp files so we can log diagnostics on failures
ytdlp_err = tempfile.TemporaryFile()
ffmpeg_err = tempfile.TemporaryFile()
@@ -217,7 +208,7 @@ def _stream_track(index):
try:
while True:
if ffmpeg.stdout is None:
- logger.warn("No stdout available from FFMPEG")
+ logger.warning("No stdout available from FFMPEG")
break
chunk = ffmpeg.stdout.read(8192)
if not chunk:
@@ -304,7 +295,6 @@ def _radio_loop():
while not RADIO_STOP.is_set():
if not SUBSCRIBER_EVENT.wait(timeout=1):
continue
-
if not PLAYLIST:
logger.error("Playlist is empty, cannot stream")
time.sleep(1)
@@ -316,7 +306,6 @@ def _radio_loop():
index = random.choice(available)
played.append(index)
-
try:
for chunk in _stream_track(index):
if RADIO_STOP.is_set():
@@ -341,149 +330,8 @@ def ensure_radio_running():
RADIO_THREAD.start()
logger.info("Radio producer started; listeners will share the same track")
-
ensure_radio_running()
-@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 isinstance(item, tuple) 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'<img src="{thumb_url}" alt="Cover" style="width:300px;height:300px;object-fit:cover;display:block;margin:0 auto 12px;"><div>{artist} — {title}</div>'
- return jsonify(NOW_PLAYING)
-
-
-
-
-@app.route("/tracks")
-def tracks():
- track_list = []
- for i, url in enumerate(PLAYLIST):
- meta = METADATA.get(i, {"title": f"Track {i+1}", "artist": "Unknown", "duration": -1})
- track_list.append({
- "index": i,
- "title": meta["title"],
- "artist": meta["artist"],
- "duration": meta["duration"],
- "url": url,
- })
- return jsonify(track_list)
-
-
if __name__ == "__main__":
+ from routes import app
app.run(host="0.0.0.0", port=8000, threaded=True)
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage