radio/web/api/stream.py
profit 3d635b742c Initial commit: self-hosted personal radio station
Flask + React web UI with audio player, podcast queue, feed management,
episode browser, music library, schedule viewer, and log tail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:01:33 -07:00

180 lines
5.1 KiB
Python

"""Audio stream — serves individual tracks with seek support.
The frontend requests /api/stream/next to get the next track, then plays
it via /api/stream/play/<id>. This gives the browser proper Content-Length
and Range headers for seeking.
"""
import os
import random
import re
from flask import Blueprint, jsonify, request, send_file
from ..config import BASE_DIR
bp = Blueprint("stream", __name__)
MUSIC_DIR = os.path.join(BASE_DIR, "media", "music")
FALLBACK_DIR = os.path.join(BASE_DIR, "media", "fallback")
QUEUE_DIR = os.path.join(BASE_DIR, "state", "queue")
# Playlist state — shuffled list with current index
_playlist = []
_playlist_index = 0
def _find_mp3s():
"""Collect all MP3 files from music and fallback dirs."""
files = []
for root_dir in (MUSIC_DIR, FALLBACK_DIR):
if not os.path.isdir(root_dir):
continue
for dirpath, _dirs, filenames in os.walk(root_dir):
for f in filenames:
if f.lower().endswith(".mp3"):
files.append(os.path.join(dirpath, f))
return files
def _find_queue_files():
"""Collect queued podcast files (priority playback)."""
if not os.path.isdir(QUEUE_DIR):
return []
files = []
for f in sorted(os.listdir(QUEUE_DIR)):
full = os.path.join(QUEUE_DIR, f)
if os.path.isfile(full) and f.lower().endswith(".mp3"):
files.append(full)
return files
def _title_from_path(path):
"""Extract a display title from a file path."""
name = os.path.splitext(os.path.basename(path))[0]
# Strip queue prefix: 20260313_182201_000001_filename -> filename
queue_match = re.match(r"^\d{8}_\d{6}_\d{6}_(.+)$", name)
if queue_match:
name = queue_match.group(1)
# Strip leading track numbers like "01. " or "22. "
if len(name) > 3 and name[0:2].isdigit() and name[2] in "._ ":
name = name[3:].strip()
elif len(name) > 4 and name[0:3].isdigit() and name[3] in "._ ":
name = name[4:].strip()
name = name.replace("_", " ").replace("-", " ")
return name
def _make_track_id(path):
"""Create a safe ID from a path for URL use."""
# Use path relative to BASE_DIR
try:
rel = os.path.relpath(path, BASE_DIR)
except ValueError:
rel = os.path.basename(path)
return rel.replace("\\", "/")
def _resolve_track_id(track_id):
"""Resolve a track ID back to an absolute path, with safety checks."""
# Prevent path traversal
if ".." in track_id:
return None
path = os.path.normpath(os.path.join(BASE_DIR, track_id))
# Must be under BASE_DIR
if not path.startswith(os.path.normpath(str(BASE_DIR))):
return None
if not os.path.isfile(path):
return None
return path
def _next_track():
"""Get the next track to play. Queue first, then shuffled music."""
global _playlist, _playlist_index
# Check podcast queue first
queue_files = _find_queue_files()
if queue_files:
path = queue_files[0]
return {
"id": _make_track_id(path),
"title": _title_from_path(path),
"file": os.path.basename(path),
"source": "queue",
"size": os.path.getsize(path),
}
# Shuffled music
if not _playlist or _playlist_index >= len(_playlist):
_playlist = _find_mp3s()
if not _playlist:
return None
random.shuffle(_playlist)
_playlist_index = 0
path = _playlist[_playlist_index]
_playlist_index += 1
# Verify file still exists
if not os.path.isfile(path):
return _next_track()
return {
"id": _make_track_id(path),
"title": _title_from_path(path),
"file": os.path.basename(path),
"source": "music",
"size": os.path.getsize(path),
}
@bp.route("/stream/next")
def next_track():
"""Return metadata for the next track to play."""
track = _next_track()
if not track:
return jsonify({"error": "No tracks available"}), 404
return jsonify(track)
@bp.route("/stream/play/<path:track_id>")
def play_track(track_id):
"""Serve an MP3 file with Range support for seeking."""
path = _resolve_track_id(track_id)
if not path:
return jsonify({"error": "Track not found"}), 404
return send_file(
path,
mimetype="audio/mpeg",
conditional=True,
)
@bp.route("/stream/done", methods=["POST"])
def mark_done():
"""Remove a queue file after it finishes playing."""
data = request.get_json() or {}
track_id = data.get("id")
if not track_id:
return jsonify({"error": "Missing id"}), 400
path = _resolve_track_id(track_id)
if path and os.path.normpath(QUEUE_DIR) in os.path.normpath(path):
try:
os.remove(path)
except OSError:
pass
return jsonify({"ok": True})
@bp.route("/stream/skip", methods=["POST"])
def skip():
"""Skip the current queue item (user-initiated)."""
queue_files = _find_queue_files()
if queue_files:
try:
os.remove(queue_files[0])
except OSError:
pass
return jsonify({"ok": True})