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>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
"""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})
|
||||
Reference in New Issue
Block a user