"""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/. 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/") 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})