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>
180 lines
5.1 KiB
Python
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})
|