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,13 @@
|
||||
from .status import bp as status_bp
|
||||
from .queue import bp as queue_bp
|
||||
from .episodes import bp as episodes_bp
|
||||
from .feeds import bp as feeds_bp
|
||||
from .schedule import bp as schedule_bp
|
||||
from .music import bp as music_bp
|
||||
from .logs import bp as logs_bp
|
||||
from .stream import bp as stream_bp
|
||||
|
||||
__all__ = [
|
||||
"status_bp", "queue_bp", "episodes_bp", "feeds_bp",
|
||||
"schedule_bp", "music_bp", "logs_bp", "stream_bp",
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""GET /api/episodes — paginated episode browser."""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from ..db import get_db
|
||||
|
||||
bp = Blueprint("episodes", __name__)
|
||||
|
||||
|
||||
@bp.route("/episodes")
|
||||
def list_episodes():
|
||||
db = get_db()
|
||||
page = request.args.get("page", 1, type=int)
|
||||
per_page = request.args.get("per_page", 20, type=int)
|
||||
per_page = min(per_page, 100)
|
||||
feed = request.args.get("feed")
|
||||
status = request.args.get("status")
|
||||
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if feed:
|
||||
conditions.append("feed_name = ?")
|
||||
params.append(feed)
|
||||
|
||||
if status == "downloaded":
|
||||
conditions.append("downloaded = 1")
|
||||
elif status == "queued":
|
||||
conditions.append("queued = 1")
|
||||
elif status == "played":
|
||||
conditions.append("played = 1")
|
||||
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
|
||||
total = db.execute(
|
||||
f"SELECT COUNT(*) as c FROM episodes {where}", params
|
||||
).fetchone()["c"]
|
||||
|
||||
offset = (page - 1) * per_page
|
||||
rows = db.execute(
|
||||
f"""SELECT * FROM episodes {where}
|
||||
ORDER BY discovered DESC
|
||||
LIMIT ? OFFSET ?""",
|
||||
params + [per_page, offset],
|
||||
).fetchall()
|
||||
|
||||
return jsonify({
|
||||
"episodes": [dict(r) for r in rows],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/episodes/feeds")
|
||||
def list_episode_feeds():
|
||||
"""Return distinct feed names for filter dropdowns."""
|
||||
db = get_db()
|
||||
rows = db.execute(
|
||||
"SELECT DISTINCT feed_name FROM episodes ORDER BY feed_name"
|
||||
).fetchall()
|
||||
return jsonify([r["feed_name"] for r in rows])
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Feed management API — CRUD on feeds.yaml."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import yaml
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from ..config import FEEDS_CONFIG
|
||||
from ..db import get_db
|
||||
|
||||
bp = Blueprint("feeds", __name__)
|
||||
|
||||
|
||||
def _read_feeds():
|
||||
if not FEEDS_CONFIG.exists():
|
||||
return []
|
||||
with open(FEEDS_CONFIG) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
return data.get("feeds", [])
|
||||
|
||||
|
||||
def _write_feeds(feeds):
|
||||
data = {"feeds": feeds}
|
||||
# Atomic write: temp file then rename
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=str(FEEDS_CONFIG.parent), suffix=".yaml.tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
os.replace(tmp_path, str(FEEDS_CONFIG))
|
||||
except Exception:
|
||||
os.unlink(tmp_path)
|
||||
raise
|
||||
|
||||
|
||||
@bp.route("/feeds")
|
||||
def list_feeds():
|
||||
feeds = _read_feeds()
|
||||
db = get_db()
|
||||
|
||||
result = []
|
||||
for feed in feeds:
|
||||
name = feed.get("name", "")
|
||||
state = db.execute(
|
||||
"SELECT last_poll FROM feed_state WHERE feed_name=?", (name,)
|
||||
).fetchone()
|
||||
ep_count = db.execute(
|
||||
"SELECT COUNT(*) as c FROM episodes WHERE feed_name=?", (name,)
|
||||
).fetchone()["c"]
|
||||
|
||||
result.append({
|
||||
**feed,
|
||||
"last_poll": state["last_poll"] if state else None,
|
||||
"episode_count": ep_count,
|
||||
})
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.route("/feeds", methods=["POST"])
|
||||
def add_feed():
|
||||
data = request.get_json()
|
||||
if not data or not data.get("name") or not data.get("url"):
|
||||
return jsonify({"error": "name and url are required"}), 400
|
||||
|
||||
feeds = _read_feeds()
|
||||
|
||||
# Check for duplicate name
|
||||
for f in feeds:
|
||||
if f.get("name") == data["name"]:
|
||||
return jsonify({"error": "Feed with this name already exists"}), 409
|
||||
|
||||
new_feed = {
|
||||
"name": data["name"],
|
||||
"url": data["url"],
|
||||
"enabled": data.get("enabled", True),
|
||||
"priority": data.get("priority", 10),
|
||||
"max_episodes": data.get("max_episodes", 20),
|
||||
}
|
||||
|
||||
feeds.append(new_feed)
|
||||
_write_feeds(feeds)
|
||||
|
||||
return jsonify(new_feed), 201
|
||||
|
||||
|
||||
@bp.route("/feeds/<name>", methods=["PUT"])
|
||||
def update_feed(name):
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "JSON body required"}), 400
|
||||
|
||||
feeds = _read_feeds()
|
||||
found = False
|
||||
|
||||
for feed in feeds:
|
||||
if feed.get("name") == name:
|
||||
if "enabled" in data:
|
||||
feed["enabled"] = data["enabled"]
|
||||
if "url" in data:
|
||||
feed["url"] = data["url"]
|
||||
if "priority" in data:
|
||||
feed["priority"] = data["priority"]
|
||||
if "max_episodes" in data:
|
||||
feed["max_episodes"] = data["max_episodes"]
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
return jsonify({"error": "Feed not found"}), 404
|
||||
|
||||
_write_feeds(feeds)
|
||||
return jsonify({"status": "updated"})
|
||||
|
||||
|
||||
@bp.route("/feeds/<name>", methods=["DELETE"])
|
||||
def delete_feed(name):
|
||||
feeds = _read_feeds()
|
||||
new_feeds = [f for f in feeds if f.get("name") != name]
|
||||
|
||||
if len(new_feeds) == len(feeds):
|
||||
return jsonify({"error": "Feed not found"}), 404
|
||||
|
||||
_write_feeds(new_feeds)
|
||||
return jsonify({"status": "deleted"})
|
||||
@@ -0,0 +1,48 @@
|
||||
"""GET /api/logs — tail log files."""
|
||||
|
||||
import os
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from ..config import LOG_DIR
|
||||
|
||||
bp = Blueprint("logs", __name__)
|
||||
|
||||
ALLOWED_LOGS = {"poller", "liquidsoap", "icecast_access", "icecast_error"}
|
||||
|
||||
|
||||
@bp.route("/logs")
|
||||
def get_logs():
|
||||
log_name = request.args.get("file", "poller")
|
||||
lines_count = request.args.get("lines", 50, type=int)
|
||||
lines_count = min(lines_count, 500)
|
||||
|
||||
if log_name not in ALLOWED_LOGS:
|
||||
return jsonify({"error": f"Unknown log file. Allowed: {ALLOWED_LOGS}"}), 400
|
||||
|
||||
log_path = LOG_DIR / f"{log_name}.log"
|
||||
|
||||
if not log_path.exists():
|
||||
return jsonify({"lines": [], "file": log_name, "exists": False})
|
||||
|
||||
# Read last N lines efficiently
|
||||
try:
|
||||
with open(log_path, "rb") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
size = f.tell()
|
||||
# Read up to 1MB from the end
|
||||
read_size = min(size, 1024 * 1024)
|
||||
f.seek(max(0, size - read_size))
|
||||
content = f.read().decode("utf-8", errors="replace")
|
||||
|
||||
all_lines = content.splitlines()
|
||||
tail = all_lines[-lines_count:]
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
return jsonify({
|
||||
"lines": tail,
|
||||
"file": log_name,
|
||||
"exists": True,
|
||||
"total_lines": len(all_lines),
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Music library API — list dirs, list files, move files between folders."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from ..config import MUSIC_BASE, FALLBACK_DIR
|
||||
|
||||
bp = Blueprint("music", __name__)
|
||||
|
||||
AUDIO_EXTENSIONS = {".mp3", ".flac", ".ogg", ".opus", ".m4a", ".wav"}
|
||||
|
||||
|
||||
def _get_folders():
|
||||
"""Return dict mapping folder name -> absolute path."""
|
||||
folders = {}
|
||||
if MUSIC_BASE.exists():
|
||||
for d in sorted(MUSIC_BASE.iterdir()):
|
||||
if d.is_dir():
|
||||
folders[d.name] = d
|
||||
folders["fallback"] = FALLBACK_DIR
|
||||
return folders
|
||||
|
||||
|
||||
def _count_audio_files(directory):
|
||||
if not directory.exists():
|
||||
return 0
|
||||
return sum(
|
||||
1 for f in directory.rglob("*")
|
||||
if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS
|
||||
)
|
||||
|
||||
|
||||
def _list_audio_files(directory):
|
||||
if not directory.exists():
|
||||
return []
|
||||
files = []
|
||||
for f in sorted(directory.iterdir()):
|
||||
if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS:
|
||||
files.append({
|
||||
"name": f.name,
|
||||
"size": f.stat().st_size,
|
||||
})
|
||||
return files
|
||||
|
||||
|
||||
@bp.route("/music")
|
||||
def list_music():
|
||||
dirs = []
|
||||
if MUSIC_BASE.exists():
|
||||
for d in sorted(MUSIC_BASE.iterdir()):
|
||||
if d.is_dir():
|
||||
dirs.append({
|
||||
"folder": d.name,
|
||||
"path": str(d),
|
||||
"file_count": _count_audio_files(d),
|
||||
})
|
||||
|
||||
fallback_count = _count_audio_files(FALLBACK_DIR)
|
||||
|
||||
return jsonify({
|
||||
"directories": dirs,
|
||||
"fallback": {
|
||||
"path": str(FALLBACK_DIR),
|
||||
"file_count": fallback_count,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/music/<folder>/files")
|
||||
def list_files(folder):
|
||||
folders = _get_folders()
|
||||
if folder not in folders:
|
||||
return jsonify({"error": "Unknown folder"}), 404
|
||||
files = _list_audio_files(folders[folder])
|
||||
return jsonify({"folder": folder, "files": files})
|
||||
|
||||
|
||||
@bp.route("/music/move", methods=["POST"])
|
||||
def move_file():
|
||||
data = request.get_json()
|
||||
src_folder = data.get("from")
|
||||
dst_folder = data.get("to")
|
||||
filename = data.get("file")
|
||||
|
||||
if not all([src_folder, dst_folder, filename]):
|
||||
return jsonify({"error": "Missing from, to, or file"}), 400
|
||||
|
||||
folders = _get_folders()
|
||||
if src_folder not in folders or dst_folder not in folders:
|
||||
return jsonify({"error": "Unknown folder"}), 404
|
||||
|
||||
if os.sep in filename or "/" in filename or ".." in filename:
|
||||
return jsonify({"error": "Invalid filename"}), 400
|
||||
|
||||
src_path = folders[src_folder] / filename
|
||||
dst_path = folders[dst_folder] / filename
|
||||
|
||||
if not src_path.exists():
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
if dst_path.exists():
|
||||
return jsonify({"error": "File already exists in destination"}), 409
|
||||
|
||||
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(src_path), str(dst_path))
|
||||
return jsonify({"ok": True, "moved": filename, "from": src_folder, "to": dst_folder})
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Queue management API — GET/POST/DELETE /api/queue."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from ..config import QUEUE_DIR, PODCAST_DIR
|
||||
from ..db import get_db
|
||||
|
||||
bp = Blueprint("queue", __name__)
|
||||
|
||||
|
||||
@bp.route("/queue")
|
||||
def list_queue():
|
||||
db = get_db()
|
||||
rows = db.execute("""
|
||||
SELECT q.id, q.position, q.enqueued, q.played,
|
||||
e.id as episode_id, e.feed_name, e.title, e.pub_date, e.file_path
|
||||
FROM queue q
|
||||
JOIN episodes e ON e.id = q.episode_id
|
||||
WHERE q.played = 0
|
||||
ORDER BY q.position
|
||||
""").fetchall()
|
||||
|
||||
return jsonify([dict(r) for r in rows])
|
||||
|
||||
|
||||
@bp.route("/queue", methods=["POST"])
|
||||
def enqueue():
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "JSON body required"}), 400
|
||||
|
||||
episode_id = data.get("episode_id")
|
||||
file_path = data.get("file_path")
|
||||
|
||||
db = get_db()
|
||||
|
||||
if episode_id:
|
||||
row = db.execute(
|
||||
"SELECT * FROM episodes WHERE id=? AND downloaded=1", (episode_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({"error": "Episode not found or not downloaded"}), 404
|
||||
file_path = row["file_path"]
|
||||
elif file_path:
|
||||
if not os.path.exists(file_path):
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
else:
|
||||
return jsonify({"error": "Provide episode_id or file_path"}), 400
|
||||
|
||||
# Create queue entry if episode_id is available
|
||||
if episode_id:
|
||||
existing = db.execute(
|
||||
"SELECT 1 FROM queue WHERE episode_id=? AND played=0", (episode_id,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
return jsonify({"error": "Episode already in queue"}), 409
|
||||
|
||||
next_pos = db.execute(
|
||||
"SELECT COALESCE(MAX(position), 0) + 1 as p FROM queue"
|
||||
).fetchone()["p"]
|
||||
db.execute(
|
||||
"INSERT INTO queue (episode_id, position) VALUES (?, ?)",
|
||||
(episode_id, next_pos),
|
||||
)
|
||||
db.execute(
|
||||
"UPDATE episodes SET queued=1, played=0 WHERE id=?", (episode_id,)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Create symlink in queue directory
|
||||
QUEUE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
src = Path(file_path)
|
||||
eid = episode_id or 0
|
||||
link_name = f"{timestamp}_{eid:06d}_{src.name}"
|
||||
link_path = QUEUE_DIR / link_name
|
||||
|
||||
try:
|
||||
link_path.symlink_to(src.resolve())
|
||||
except (OSError, FileExistsError):
|
||||
try:
|
||||
link_path.hardlink_to(src.resolve())
|
||||
except (OSError, FileExistsError):
|
||||
shutil.copy2(str(src), str(link_path))
|
||||
|
||||
return jsonify({"status": "enqueued", "link": link_name}), 201
|
||||
|
||||
|
||||
@bp.route("/queue/<int:queue_id>", methods=["DELETE"])
|
||||
def dequeue(queue_id):
|
||||
db = get_db()
|
||||
|
||||
row = db.execute(
|
||||
"SELECT q.*, e.id as eid FROM queue q JOIN episodes e ON e.id=q.episode_id WHERE q.id=?",
|
||||
(queue_id,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return jsonify({"error": "Queue item not found"}), 404
|
||||
|
||||
# Remove symlink from queue directory
|
||||
eid = row["eid"]
|
||||
if QUEUE_DIR.exists():
|
||||
pattern = re.compile(rf"_0*{eid}_")
|
||||
for item in QUEUE_DIR.iterdir():
|
||||
if pattern.search(item.name):
|
||||
item.unlink(missing_ok=True)
|
||||
break
|
||||
|
||||
db.execute("DELETE FROM queue WHERE id=?", (queue_id,))
|
||||
db.execute("UPDATE episodes SET queued=0 WHERE id=?", (eid,))
|
||||
db.commit()
|
||||
|
||||
return jsonify({"status": "removed"})
|
||||
@@ -0,0 +1,50 @@
|
||||
"""GET /api/schedule — schedule blocks with active indicator."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import yaml
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
from ..config import STATION_CONFIG
|
||||
|
||||
bp = Blueprint("schedule", __name__)
|
||||
|
||||
DAY_NAMES = {1: "Mon", 2: "Tue", 3: "Wed", 4: "Thu", 5: "Fri", 6: "Sat", 7: "Sun"}
|
||||
|
||||
|
||||
@bp.route("/schedule")
|
||||
def get_schedule():
|
||||
try:
|
||||
with open(STATION_CONFIG) as f:
|
||||
config = yaml.safe_load(f)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
now = datetime.now()
|
||||
current_day = now.isoweekday()
|
||||
current_time = now.strftime("%H:%M")
|
||||
|
||||
blocks = []
|
||||
for block in config.get("schedule", []):
|
||||
days = block.get("days", [])
|
||||
start = block.get("start", "00:00")
|
||||
end = block.get("end", "23:59")
|
||||
|
||||
is_active = False
|
||||
if current_day in days:
|
||||
if start <= end:
|
||||
is_active = start <= current_time <= end
|
||||
else:
|
||||
is_active = current_time >= start or current_time <= end
|
||||
|
||||
blocks.append({
|
||||
"name": block.get("name"),
|
||||
"days": days,
|
||||
"day_names": [DAY_NAMES.get(d, str(d)) for d in days],
|
||||
"start": start,
|
||||
"end": end,
|
||||
"folder": block.get("folder"),
|
||||
"active": is_active,
|
||||
})
|
||||
|
||||
return jsonify(blocks)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""GET /api/status — stream status, now playing, queue depth, current schedule."""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
import yaml
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
from ..config import BASE_DIR, STATION_CONFIG
|
||||
from ..db import get_db
|
||||
|
||||
bp = Blueprint("status", __name__)
|
||||
|
||||
|
||||
def _get_current_schedule_block():
|
||||
"""Determine which schedule block is active right now."""
|
||||
try:
|
||||
with open(STATION_CONFIG) as f:
|
||||
config = yaml.safe_load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
now = datetime.now()
|
||||
current_day = now.isoweekday() # 1=Mon ... 7=Sun
|
||||
current_time = now.strftime("%H:%M")
|
||||
|
||||
for block in config.get("schedule", []):
|
||||
if current_day not in block.get("days", []):
|
||||
continue
|
||||
start = block.get("start", "00:00")
|
||||
end = block.get("end", "23:59")
|
||||
|
||||
# Handle overnight blocks (e.g., 18:00 - 06:00)
|
||||
if start <= end:
|
||||
if start <= current_time <= end:
|
||||
return block
|
||||
else:
|
||||
if current_time >= start or current_time <= end:
|
||||
return block
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_stream_status():
|
||||
"""Check if music files are available for streaming."""
|
||||
music_dir = os.path.join(BASE_DIR, "media", "music")
|
||||
fallback_dir = os.path.join(BASE_DIR, "media", "fallback")
|
||||
count = 0
|
||||
for d in (music_dir, fallback_dir):
|
||||
if not os.path.isdir(d):
|
||||
continue
|
||||
for dirpath, _dirs, filenames in os.walk(d):
|
||||
count += sum(1 for f in filenames if f.lower().endswith(".mp3"))
|
||||
return {
|
||||
"online": count > 0,
|
||||
"tracks": count,
|
||||
}
|
||||
|
||||
|
||||
@bp.route("/status")
|
||||
def status():
|
||||
db = get_db()
|
||||
|
||||
queue_depth = db.execute(
|
||||
"SELECT COUNT(*) as c FROM queue WHERE played=0"
|
||||
).fetchone()["c"]
|
||||
|
||||
total_episodes = db.execute(
|
||||
"SELECT COUNT(*) as c FROM episodes"
|
||||
).fetchone()["c"]
|
||||
|
||||
active_feeds = db.execute(
|
||||
"SELECT COUNT(*) as c FROM feed_state"
|
||||
).fetchone()["c"]
|
||||
|
||||
stream = _get_stream_status()
|
||||
block = _get_current_schedule_block()
|
||||
|
||||
return jsonify({
|
||||
"stream": stream,
|
||||
"queue_depth": queue_depth,
|
||||
"total_episodes": total_episodes,
|
||||
"active_feeds": active_feeds,
|
||||
"current_block": {
|
||||
"name": block["name"],
|
||||
"folder": block["folder"],
|
||||
} if block else None,
|
||||
})
|
||||
@@ -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