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:
2026-03-13 19:01:33 -07:00
co-authored by Claude Opus 4.6
commit 3d635b742c
53 changed files with 7023 additions and 0 deletions
View File
+13
View File
@@ -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",
]
+63
View File
@@ -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])
+127
View File
@@ -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"})
+48
View File
@@ -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),
})
+108
View File
@@ -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})
+120
View File
@@ -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"})
+50
View File
@@ -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)
+88
View File
@@ -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,
})
+179
View File
@@ -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})
+45
View File
@@ -0,0 +1,45 @@
"""Flask application for the Local Radio web dashboard."""
import os
from flask import Flask, send_from_directory
from .config import STATIC_DIR
from .db import close_db
def create_app():
app = Flask(__name__, static_folder=str(STATIC_DIR), static_url_path="")
# Register teardown
app.teardown_appcontext(close_db)
# Register API blueprints
from .api import status_bp, queue_bp, episodes_bp, feeds_bp, schedule_bp, music_bp, logs_bp, stream_bp
app.register_blueprint(status_bp, url_prefix="/api")
app.register_blueprint(queue_bp, url_prefix="/api")
app.register_blueprint(episodes_bp, url_prefix="/api")
app.register_blueprint(feeds_bp, url_prefix="/api")
app.register_blueprint(schedule_bp, url_prefix="/api")
app.register_blueprint(music_bp, url_prefix="/api")
app.register_blueprint(logs_bp, url_prefix="/api")
app.register_blueprint(stream_bp, url_prefix="/api")
# SPA catch-all: serve index.html for any non-API route
@app.route("/", defaults={"path": ""})
@app.route("/<path:path>")
def serve_spa(path):
static = app.static_folder
if static and path and os.path.exists(os.path.join(static, path)):
return send_from_directory(static, path)
if static and os.path.exists(os.path.join(static, "index.html")):
return send_from_directory(static, "index.html")
return "<h1>Local Radio</h1><p>Frontend not built. Run: cd web/frontend && npm run build</p>", 200
return app
app = create_app()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)
+23
View File
@@ -0,0 +1,23 @@
"""Configuration for the Local Radio web UI."""
import os
from pathlib import Path
# In dev mode, auto-detect base dir from project structure
_default_base = "/opt/localradio"
_project_root = Path(__file__).resolve().parent.parent
if (_project_root / "config" / "station.yaml").exists():
_default_base = str(_project_root)
BASE_DIR = Path(os.environ.get("LOCALRADIO_BASE", _default_base))
DB_PATH = BASE_DIR / "state" / "radio.db"
QUEUE_DIR = BASE_DIR / "state" / "queue"
FEEDS_CONFIG = BASE_DIR / "config" / "feeds.yaml"
STATION_CONFIG = BASE_DIR / "config" / "station.yaml"
MUSIC_BASE = BASE_DIR / "media" / "music"
FALLBACK_DIR = BASE_DIR / "media" / "fallback"
PODCAST_DIR = BASE_DIR / "media" / "podcasts"
LOG_DIR = BASE_DIR / "logs"
STATIC_DIR = Path(__file__).parent / "frontend" / "dist"
+23
View File
@@ -0,0 +1,23 @@
"""SQLite database helpers for the web UI."""
import sqlite3
from flask import g
from .config import DB_PATH
def get_db() -> sqlite3.Connection:
if "db" not in g:
g.db = sqlite3.connect(str(DB_PATH), timeout=5)
g.db.row_factory = sqlite3.Row
g.db.execute("PRAGMA journal_mode=WAL")
g.db.execute("PRAGMA foreign_keys=ON")
g.db.execute("PRAGMA busy_timeout=5000")
return g.db
def close_db(e=None):
db = g.pop("db", None)
if db is not None:
db.close()
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Local Radio</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📻</text></svg>" />
</head>
<body class="bg-radio-bg text-radio-text">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2754
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "localradio-ui",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^6.0.0"
}
}
+11
View File
@@ -0,0 +1,11 @@
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default {
plugins: {
tailwindcss: { config: path.join(__dirname, "tailwind.config.js") },
autoprefixer: {},
},
};
+28
View File
@@ -0,0 +1,28 @@
import { createHashRouter, RouterProvider } from "react-router-dom";
import Layout from "./components/Layout";
import Dashboard from "./components/Dashboard";
import QueuePanel from "./components/QueuePanel";
import EpisodesPanel from "./components/EpisodesPanel";
import FeedsPanel from "./components/FeedsPanel";
import SchedulePanel from "./components/SchedulePanel";
import MusicPanel from "./components/MusicPanel";
import LogsPanel from "./components/LogsPanel";
const router = createHashRouter([
{
element: <Layout />,
children: [
{ index: true, element: <Dashboard /> },
{ path: "queue", element: <QueuePanel /> },
{ path: "episodes", element: <EpisodesPanel /> },
{ path: "feeds", element: <FeedsPanel /> },
{ path: "schedule", element: <SchedulePanel /> },
{ path: "music", element: <MusicPanel /> },
{ path: "logs", element: <LogsPanel /> },
],
},
]);
export default function App() {
return <RouterProvider router={router} />;
}
+54
View File
@@ -0,0 +1,54 @@
const BASE = "/api";
async function fetchJSON(path, options = {}) {
const res = await fetch(`${BASE}${path}`, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
return res.json();
}
export const api = {
getStatus: () => fetchJSON("/status"),
getQueue: () => fetchJSON("/queue"),
enqueue: (data) =>
fetchJSON("/queue", { method: "POST", body: JSON.stringify(data) }),
dequeue: (id) => fetchJSON(`/queue/${id}`, { method: "DELETE" }),
getEpisodes: (params = {}) => {
const qs = new URLSearchParams(params).toString();
return fetchJSON(`/episodes?${qs}`);
},
getEpisodeFeeds: () => fetchJSON("/episodes/feeds"),
getFeeds: () => fetchJSON("/feeds"),
addFeed: (data) =>
fetchJSON("/feeds", { method: "POST", body: JSON.stringify(data) }),
updateFeed: (name, data) =>
fetchJSON(`/feeds/${encodeURIComponent(name)}`, {
method: "PUT",
body: JSON.stringify(data),
}),
deleteFeed: (name) =>
fetchJSON(`/feeds/${encodeURIComponent(name)}`, { method: "DELETE" }),
getSchedule: () => fetchJSON("/schedule"),
getMusic: () => fetchJSON("/music"),
getMusicFiles: (folder) => fetchJSON(`/music/${encodeURIComponent(folder)}/files`),
moveFile: (file, from_, to) =>
fetchJSON("/music/move", {
method: "POST",
body: JSON.stringify({ file, from: from_, to }),
}),
getNextTrack: () => fetchJSON("/stream/next"),
markDone: (id) => fetchJSON("/stream/done", { method: "POST", body: JSON.stringify({ id }) }),
skip: () => fetchJSON("/stream/skip", { method: "POST" }),
getLogs: (params = {}) => {
const qs = new URLSearchParams(params).toString();
return fetchJSON(`/logs?${qs}`);
},
};
+216
View File
@@ -0,0 +1,216 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { api } from "../api";
function formatTime(s) {
if (!s || !isFinite(s)) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, "0")}`;
}
export default function AudioPlayer() {
const audioRef = useRef(null);
const [playing, setPlaying] = useState(false);
const [volume, setVolume] = useState(0.8);
const [muted, setMuted] = useState(false);
const [error, setError] = useState(false);
const [track, setTrack] = useState(null);
const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(0);
const [seeking, setSeeking] = useState(false);
const activeRef = useRef(false); // whether the player should be running
useEffect(() => {
if (audioRef.current) audioRef.current.volume = volume;
}, [volume]);
// Time update for progress bar
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const onTime = () => { if (!seeking) setProgress(audio.currentTime); };
const onDur = () => setDuration(audio.duration);
audio.addEventListener("timeupdate", onTime);
audio.addEventListener("durationchange", onDur);
return () => {
audio.removeEventListener("timeupdate", onTime);
audio.removeEventListener("durationchange", onDur);
};
}, [seeking]);
const loadAndPlay = useCallback(async () => {
const audio = audioRef.current;
if (!audio || !activeRef.current) return;
setError(false);
try {
const next = await api.getNextTrack();
setTrack(next);
audio.src = `/api/stream/play/${next.id}`;
audio.load();
await audio.play();
setPlaying(true);
} catch {
setError(true);
setPlaying(false);
// Retry after a pause
if (activeRef.current) setTimeout(loadAndPlay, 3000);
}
}, []);
// When track ends, load next
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const onEnded = () => {
if (track?.source === "queue" && track?.id) {
api.markDone(track.id).catch(() => {});
}
loadAndPlay();
};
audio.addEventListener("ended", onEnded);
return () => audio.removeEventListener("ended", onEnded);
}, [track, loadAndPlay]);
const toggle = () => {
const audio = audioRef.current;
if (!audio) return;
if (playing) {
audio.pause();
setPlaying(false);
activeRef.current = false;
} else {
activeRef.current = true;
if (audio.src && audio.src !== window.location.href) {
audio.play().catch(() => {});
setPlaying(true);
} else {
loadAndPlay();
}
}
};
const skip = () => {
const audio = audioRef.current;
if (!audio) return;
audio.pause();
if (track?.source === "queue") {
api.skip().catch(() => {});
}
loadAndPlay();
};
const handleSeek = (e) => {
const val = parseFloat(e.target.value);
setProgress(val);
if (audioRef.current) audioRef.current.currentTime = val;
setSeeking(false);
};
const toggleMute = () => {
if (audioRef.current) {
audioRef.current.muted = !muted;
setMuted(!muted);
}
};
return (
<div className="fixed bottom-0 left-0 right-0 bg-radio-card border-t border-radio-border z-50">
{/* Progress bar */}
{playing && duration > 0 && (
<div className="px-4 pt-1 flex items-center gap-2 text-xs text-radio-muted">
<span className="w-10 text-right">{formatTime(progress)}</span>
<input
type="range"
min="0"
max={duration || 0}
step="0.1"
value={progress}
onMouseDown={() => setSeeking(true)}
onTouchStart={() => setSeeking(true)}
onChange={handleSeek}
className="flex-1 accent-radio-accent h-1"
/>
<span className="w-10">{formatTime(duration)}</span>
</div>
)}
<div className="px-4 py-2 flex items-center gap-4">
<audio ref={audioRef} preload="auto" />
{/* Play/Pause */}
<button onClick={toggle} className="btn btn-primary w-10 h-10 flex items-center justify-center rounded-full p-0">
{playing ? (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="4" width="4" height="16" />
<rect x="14" y="4" width="4" height="16" />
</svg>
) : (
<svg className="w-5 h-5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<polygon points="5,3 19,12 5,21" />
</svg>
)}
</button>
{/* Skip */}
<button onClick={skip} className="btn-ghost p-1 rounded" title="Skip track">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<polygon points="5,4 15,12 5,20" />
<rect x="15" y="4" width="4" height="16" />
</svg>
</button>
{/* Equalizer / Status */}
<div className="flex items-end gap-0.5 h-5">
{playing && !error ? (
<>
<div className="eq-bar" />
<div className="eq-bar" />
<div className="eq-bar" />
<div className="eq-bar" />
</>
) : (
<span className="text-xs text-radio-muted">
{error ? "Stream unavailable" : "Stopped"}
</span>
)}
</div>
{/* Now Playing */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{track?.title || "Local Radio"}
</div>
<div className="text-xs text-radio-muted">
{playing
? track?.source === "queue" ? "Podcast" : "On Air"
: error ? "Could not connect" : "Click play to listen"}
</div>
</div>
{/* Volume */}
<button onClick={toggleMute} className="btn-ghost p-1 rounded">
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
{muted ? (
<path d="M11 5L6 9H2v6h4l5 4V5zM23 9l-6 6M17 9l6 6" />
) : (
<path d="M11 5L6 9H2v6h4l5 4V5zM15.54 8.46a5 5 0 010 7.07M19.07 4.93a10 10 0 010 14.14" />
)}
</svg>
</button>
<input
type="range"
min="0"
max="1"
step="0.05"
value={muted ? 0 : volume}
onChange={(e) => {
setVolume(parseFloat(e.target.value));
if (muted) setMuted(false);
}}
className="w-24 accent-radio-accent"
/>
</div>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { useState, useEffect } from "react";
import { useOutletContext, Link } from "react-router-dom";
import { api } from "../api";
function StatCard({ label, value, sub, color = "text-radio-text" }) {
return (
<div className="card">
<div className="text-xs text-radio-muted uppercase tracking-wide">{label}</div>
<div className={`text-2xl font-bold mt-1 ${color}`}>{value}</div>
{sub && <div className="text-xs text-radio-muted mt-1">{sub}</div>}
</div>
);
}
export default function Dashboard() {
const { status } = useOutletContext();
const [queue, setQueue] = useState([]);
const [recentEpisodes, setRecentEpisodes] = useState([]);
useEffect(() => {
api.getQueue().then(setQueue).catch(() => {});
api.getEpisodes({ per_page: 5 }).then((d) => setRecentEpisodes(d.episodes)).catch(() => {});
}, []);
const stream = status?.stream || {};
return (
<div>
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
{/* Now Playing */}
<div className="card mb-6 border-radio-accent/30">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full flex-shrink-0 ${stream.online ? "bg-radio-success animate-pulse" : "bg-radio-danger"}`} />
<div className="flex-1 min-w-0">
<div className="text-xs text-radio-muted uppercase tracking-wide">Now Playing</div>
<div className="text-lg font-semibold truncate mt-0.5">
{stream.now_playing || "Nothing playing"}
</div>
</div>
{status?.current_block && (
<div className="badge badge-yellow">
{status.current_block.name}
</div>
)}
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<StatCard
label="Stream"
value={stream.online ? "Online" : "Offline"}
color={stream.online ? "text-radio-success" : "text-radio-danger"}
/>
<StatCard label="Tracks" value={stream.tracks ?? 0} />
<StatCard label="Queue Depth" value={status?.queue_depth ?? 0} />
<StatCard
label="Episodes"
value={status?.total_episodes ?? 0}
sub={`${status?.active_feeds ?? 0} feeds`}
/>
</div>
<div className="grid md:grid-cols-2 gap-6">
{/* Queue Preview */}
<div className="card">
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold">Podcast Queue</h2>
<Link to="/queue" className="text-xs text-radio-accent hover:text-yellow-400">
View all
</Link>
</div>
{queue.length === 0 ? (
<p className="text-sm text-radio-muted">Queue empty — music is playing</p>
) : (
<ul className="space-y-2">
{queue.slice(0, 5).map((item) => (
<li key={item.id} className="flex items-center gap-2 text-sm">
<span className="text-radio-accent font-mono text-xs w-6">#{item.position}</span>
<span className="truncate">{item.title}</span>
<span className="text-radio-muted text-xs ml-auto flex-shrink-0">{item.feed_name}</span>
</li>
))}
{queue.length > 5 && (
<li className="text-xs text-radio-muted">+{queue.length - 5} more</li>
)}
</ul>
)}
</div>
{/* Recent Episodes */}
<div className="card">
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold">Recent Episodes</h2>
<Link to="/episodes" className="text-xs text-radio-accent hover:text-yellow-400">
View all
</Link>
</div>
{recentEpisodes.length === 0 ? (
<p className="text-sm text-radio-muted">No episodes yet</p>
) : (
<ul className="space-y-2">
{recentEpisodes.map((ep) => (
<li key={ep.id} className="text-sm">
<div className="flex items-center gap-2">
<span className="truncate">{ep.title}</span>
<div className="flex gap-1 ml-auto flex-shrink-0">
{ep.downloaded ? <span className="badge badge-green">DL</span> : null}
{ep.queued ? <span className="badge badge-yellow">Q</span> : null}
{ep.played ? <span className="badge badge-blue">P</span> : null}
</div>
</div>
<div className="text-xs text-radio-muted">{ep.feed_name}</div>
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,155 @@
import { useState, useEffect } from "react";
import { api } from "../api";
export default function EpisodesPanel() {
const [data, setData] = useState({ episodes: [], total: 0, page: 1, pages: 1 });
const [feedFilter, setFeedFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [feedNames, setFeedNames] = useState([]);
const [page, setPage] = useState(1);
const [enqueuing, setEnqueuing] = useState(null);
const handleEnqueue = async (ep) => {
if (!ep.downloaded) return;
setEnqueuing(ep.id);
try {
await api.enqueue({ episode_id: ep.id });
fetchEpisodes();
} catch (e) {
alert(e.message);
} finally {
setEnqueuing(null);
}
};
const fetchEpisodes = () => {
const params = { page, per_page: 20 };
if (feedFilter) params.feed = feedFilter;
if (statusFilter) params.status = statusFilter;
api.getEpisodes(params).then(setData).catch(() => {});
};
useEffect(() => {
api.getEpisodeFeeds().then(setFeedNames).catch(() => {});
}, []);
useEffect(fetchEpisodes, [page, feedFilter, statusFilter]);
return (
<div>
<h1 className="text-2xl font-bold mb-6">Episodes</h1>
{/* Filters */}
<div className="flex gap-3 mb-4 flex-wrap">
<select
value={feedFilter}
onChange={(e) => { setFeedFilter(e.target.value); setPage(1); }}
className="input text-sm"
>
<option value="">All feeds</option>
{feedNames.map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
className="input text-sm"
>
<option value="">All statuses</option>
<option value="downloaded">Downloaded</option>
<option value="queued">Queued</option>
<option value="played">Played</option>
</select>
<span className="text-sm text-radio-muted self-center ml-auto">
{data.total} episode{data.total !== 1 ? "s" : ""}
</span>
</div>
{/* Table */}
<div className="card overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-radio-muted text-xs uppercase border-b border-radio-border">
<th className="text-left py-2 px-2">Title</th>
<th className="text-left py-2 px-2">Feed</th>
<th className="text-left py-2 px-2">Published</th>
<th className="text-left py-2 px-2">Status</th>
</tr>
</thead>
<tbody>
{data.episodes.length === 0 ? (
<tr>
<td colSpan="4" className="text-center py-8 text-radio-muted">
No episodes found
</td>
</tr>
) : (
data.episodes.map((ep) => (
<tr
key={ep.id}
className={`border-b border-radio-border/50 hover:bg-radio-border/10 ${ep.downloaded ? "cursor-pointer" : ""}`}
onClick={() => handleEnqueue(ep)}
title={ep.downloaded ? (ep.queued ? "Already in queue" : "Click to add to queue") : ""}
>
<td className="py-2 px-2 max-w-xs truncate">{ep.title}</td>
<td className="py-2 px-2 text-radio-muted">{ep.feed_name}</td>
<td className="py-2 px-2 text-radio-muted whitespace-nowrap">
{ep.pub_date ? new Date(ep.pub_date).toLocaleDateString() : "-"}
</td>
<td className="py-2 px-2">
<div className="flex items-center gap-1">
{ep.downloaded ? <span className="badge badge-green">DL</span> : null}
{ep.queued ? <span className="badge badge-yellow">Q</span> : null}
{ep.played ? <span className="badge badge-blue">P</span> : null}
{ep.downloaded && !ep.queued && (
<button
className="ml-1 text-radio-accent hover:text-radio-accent/80 disabled:opacity-50"
disabled={enqueuing === ep.id}
onClick={(e) => { e.stopPropagation(); handleEnqueue(ep); }}
title="Add to queue"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
</button>
)}
{enqueuing === ep.id && (
<span className="text-xs text-radio-accent ml-1">Queuing...</span>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{data.pages > 1 && (
<div className="flex items-center justify-center gap-2 mt-4">
<button
disabled={page <= 1}
onClick={() => setPage(page - 1)}
className="btn btn-ghost text-sm disabled:opacity-30"
>
Previous
</button>
<span className="text-sm text-radio-muted">
Page {data.page} of {data.pages}
</span>
<button
disabled={page >= data.pages}
onClick={() => setPage(page + 1)}
className="btn btn-ghost text-sm disabled:opacity-30"
>
Next
</button>
</div>
)}
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
import { useState, useEffect } from "react";
import { api } from "../api";
function AddFeedModal({ onClose, onAdded }) {
const [form, setForm] = useState({ name: "", url: "", priority: 10, max_episodes: 20 });
const [error, setError] = useState(null);
const [saving, setSaving] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
setError(null);
try {
await api.addFeed(form);
onAdded();
onClose();
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
<div className="card w-full max-w-md" onClick={(e) => e.stopPropagation()}>
<h2 className="font-semibold text-lg mb-4">Add Feed</h2>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="text-xs text-radio-muted block mb-1">Name</label>
<input
className="input w-full"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="My Podcast"
required
/>
</div>
<div>
<label className="text-xs text-radio-muted block mb-1">RSS URL</label>
<input
className="input w-full"
type="url"
value={form.url}
onChange={(e) => setForm({ ...form, url: e.target.value })}
placeholder="https://feeds.example.com/podcast.xml"
required
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-radio-muted block mb-1">Priority</label>
<input
className="input w-full"
type="number"
value={form.priority}
onChange={(e) => setForm({ ...form, priority: parseInt(e.target.value) || 10 })}
/>
</div>
<div>
<label className="text-xs text-radio-muted block mb-1">Max Episodes</label>
<input
className="input w-full"
type="number"
value={form.max_episodes}
onChange={(e) => setForm({ ...form, max_episodes: parseInt(e.target.value) || 20 })}
/>
</div>
</div>
{error && <div className="text-radio-danger text-sm">{error}</div>}
<div className="flex gap-2 pt-2">
<button type="submit" disabled={saving} className="btn btn-primary flex-1">
{saving ? "Adding..." : "Add Feed"}
</button>
<button type="button" onClick={onClose} className="btn btn-ghost flex-1">
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
export default function FeedsPanel() {
const [feeds, setFeeds] = useState([]);
const [showAdd, setShowAdd] = useState(false);
const [error, setError] = useState(null);
const refresh = () => api.getFeeds().then(setFeeds).catch((e) => setError(e.message));
useEffect(() => { refresh(); }, []);
const handleToggle = async (name, enabled) => {
try {
await api.updateFeed(name, { enabled: !enabled });
refresh();
} catch (e) {
setError(e.message);
}
};
const handleDelete = async (name) => {
if (!confirm(`Remove feed "${name}"? This won't delete downloaded episodes.`)) return;
try {
await api.deleteFeed(name);
refresh();
} catch (e) {
setError(e.message);
}
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Feeds</h1>
<button onClick={() => setShowAdd(true)} className="btn btn-primary text-sm">
Add Feed
</button>
</div>
{error && (
<div className="card border-radio-danger/30 text-radio-danger mb-4 text-sm">
{error}
<button onClick={() => setError(null)} className="ml-2 underline">dismiss</button>
</div>
)}
{feeds.length === 0 ? (
<div className="card text-center py-12">
<div className="text-radio-muted text-lg mb-2">No feeds configured</div>
<div className="text-sm text-radio-muted">Add an RSS feed to start receiving podcast episodes.</div>
</div>
) : (
<div className="space-y-3">
{feeds.map((feed) => (
<div key={feed.name} className="card flex items-center gap-4">
{/* Toggle */}
<button
onClick={() => handleToggle(feed.name, feed.enabled)}
className={`w-12 h-6 rounded-full flex items-center transition-colors flex-shrink-0 ${
feed.enabled ? "bg-radio-success" : "bg-radio-border"
}`}
>
<span
className={`w-5 h-5 rounded-full bg-white transition-transform shadow ${
feed.enabled ? "translate-x-6" : "translate-x-0.5"
}`}
/>
</button>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="font-medium">{feed.name}</div>
<div className="text-xs text-radio-muted truncate">{feed.url}</div>
<div className="flex gap-3 mt-1 text-xs text-radio-muted">
<span>{feed.episode_count} episodes</span>
<span>Priority: {feed.priority}</span>
{feed.last_poll && (
<span>Last poll: {new Date(feed.last_poll + "Z").toLocaleString()}</span>
)}
</div>
</div>
{/* Actions */}
<button
onClick={() => handleDelete(feed.name)}
className="btn btn-danger text-sm"
>
Remove
</button>
</div>
))}
</div>
)}
{showAdd && <AddFeedModal onClose={() => setShowAdd(false)} onAdded={refresh} />}
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { useState, useEffect } from "react";
import { NavLink, Outlet } from "react-router-dom";
import AudioPlayer from "./AudioPlayer";
import { api } from "../api";
const NAV_ITEMS = [
{ to: "/", label: "Dashboard", icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1" },
{ to: "/queue", label: "Queue", icon: "M4 6h16M4 10h16M4 14h16M4 18h16" },
{ to: "/episodes", label: "Episodes", icon: "M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" },
{ to: "/feeds", label: "Feeds", icon: "M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-6 0a1 1 0 11-2 0 1 1 0 012 0z" },
{ to: "/schedule", label: "Schedule", icon: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" },
{ to: "/music", label: "Music", icon: "M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z" },
{ to: "/logs", label: "Logs", icon: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" },
];
export default function Layout() {
const [status, setStatus] = useState(null);
const [sidebarOpen, setSidebarOpen] = useState(true);
useEffect(() => {
const poll = () => api.getStatus().then(setStatus).catch(() => {});
poll();
const id = setInterval(poll, 10000);
return () => clearInterval(id);
}, []);
const streamOnline = status?.stream?.online;
const nowPlaying = status?.stream?.now_playing;
return (
<div className="flex h-screen pb-16">
{/* Sidebar */}
<aside className={`${sidebarOpen ? "w-56" : "w-16"} flex-shrink-0 bg-radio-card border-r border-radio-border flex flex-col transition-all duration-200`}>
{/* Logo / Header */}
<div className="p-4 border-b border-radio-border flex items-center gap-3">
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="text-radio-accent hover:text-yellow-400 transition-colors">
<svg className="w-7 h-7" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728M12 12h.01M8.464 15.536a5 5 0 010-7.072m7.072 0a5 5 0 010 7.072" />
</svg>
</button>
{sidebarOpen && (
<div>
<div className="font-bold text-radio-accent text-sm">LOCAL RADIO</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className={`w-2 h-2 rounded-full ${streamOnline ? "bg-radio-success animate-pulse" : "bg-radio-danger"}`} />
<span className="text-xs text-radio-muted">{streamOnline ? "On Air" : "Offline"}</span>
</div>
</div>
)}
</div>
{/* Nav */}
<nav className="flex-1 py-2">
{NAV_ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === "/"}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
isActive
? "text-radio-accent bg-radio-accent/10 border-r-2 border-radio-accent"
: "text-radio-muted hover:text-radio-text hover:bg-radio-border/20"
}`
}
>
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d={item.icon} />
</svg>
{sidebarOpen && <span>{item.label}</span>}
</NavLink>
))}
</nav>
</aside>
{/* Main content */}
<main className="flex-1 overflow-y-auto p-6">
<Outlet context={{ status }} />
</main>
{/* Persistent audio player */}
<AudioPlayer />
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { useState, useEffect, useRef } from "react";
import { api } from "../api";
const LOG_FILES = ["poller", "liquidsoap", "icecast_access", "icecast_error"];
export default function LogsPanel() {
const [logFile, setLogFile] = useState("poller");
const [lines, setLines] = useState([]);
const [lineCount, setLineCount] = useState(100);
const [autoRefresh, setAutoRefresh] = useState(false);
const [exists, setExists] = useState(true);
const bottomRef = useRef(null);
const fetchLogs = () => {
api.getLogs({ file: logFile, lines: lineCount })
.then((data) => {
setLines(data.lines);
setExists(data.exists);
})
.catch(() => {});
};
useEffect(fetchLogs, [logFile, lineCount]);
useEffect(() => {
if (!autoRefresh) return;
const id = setInterval(fetchLogs, 5000);
return () => clearInterval(id);
}, [autoRefresh, logFile, lineCount]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [lines]);
return (
<div>
<div className="flex items-center justify-between mb-6 flex-wrap gap-3">
<h1 className="text-2xl font-bold">Logs</h1>
<div className="flex gap-3 items-center">
<select
value={logFile}
onChange={(e) => setLogFile(e.target.value)}
className="input text-sm"
>
{LOG_FILES.map((f) => (
<option key={f} value={f}>{f}.log</option>
))}
</select>
<select
value={lineCount}
onChange={(e) => setLineCount(parseInt(e.target.value))}
className="input text-sm"
>
<option value={50}>50 lines</option>
<option value={100}>100 lines</option>
<option value={200}>200 lines</option>
</select>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="accent-radio-accent"
/>
Auto-refresh
</label>
<button onClick={fetchLogs} className="btn btn-ghost text-sm">
Refresh
</button>
</div>
</div>
<div className="card p-0">
{!exists ? (
<div className="p-8 text-center text-radio-muted">
Log file <code>{logFile}.log</code> does not exist yet
</div>
) : lines.length === 0 ? (
<div className="p-8 text-center text-radio-muted">
Log file is empty
</div>
) : (
<pre className="text-xs font-mono overflow-x-auto p-4 max-h-[70vh] overflow-y-auto leading-relaxed">
{lines.map((line, i) => (
<div
key={i}
className={`py-0.5 ${
line.includes("ERROR") || line.includes("error")
? "text-radio-danger"
: line.includes("WARNING") || line.includes("warning")
? "text-yellow-400"
: "text-radio-muted"
}`}
>
{line}
</div>
))}
<div ref={bottomRef} />
</pre>
)}
</div>
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
import { useState, useEffect } from "react";
import { api } from "../api";
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`;
}
function FolderCard({ folder, name, path, count, isFallback, allFolders, onRefresh }) {
const [expanded, setExpanded] = useState(false);
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [moving, setMoving] = useState(null);
const [error, setError] = useState(null);
const loadFiles = () => {
setLoading(true);
api.getMusicFiles(folder).then((d) => {
setFiles(d.files);
setLoading(false);
}).catch(() => setLoading(false));
};
const toggle = () => {
if (!expanded) loadFiles();
setExpanded(!expanded);
};
const handleMove = async (filename, destFolder) => {
setMoving(filename);
setError(null);
try {
await api.moveFile(filename, folder, destFolder);
loadFiles();
onRefresh();
} catch (e) {
setError(`${filename}: ${e.message}`);
} finally {
setMoving(null);
}
};
const destinations = allFolders.filter((f) => f !== folder);
return (
<div className={`card ${isFallback ? (count === 0 ? "border-radio-danger/50" : "border-radio-success/30") : ""}`}>
<button onClick={toggle} className="w-full flex items-center justify-between text-left">
<div className="flex items-center gap-3">
<svg className={`w-8 h-8 ${isFallback ? "text-radio-muted" : "text-radio-accent"}`} fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
{isFallback ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z" />
)}
</svg>
<div>
<div className="font-medium capitalize">{name}</div>
<div className="text-xs text-radio-muted">{isFallback ? "Emergency playlist" : path}</div>
</div>
</div>
<div className="flex items-center gap-3">
<span className={`text-2xl font-bold ${count > 0 ? (isFallback ? "text-radio-success" : "text-radio-text") : "text-radio-danger"}`}>
{count}
</span>
<svg className={`w-4 h-4 text-radio-muted transition-transform ${expanded ? "rotate-180" : ""}`} fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{count === 0 && !expanded && (
<div className="text-xs text-radio-danger mt-2">
{isFallback
? "Add at least one audio file to prevent silence when all other sources fail"
: "No audio files — this schedule block will be silent"}
</div>
)}
{expanded && (
<div className="mt-3 border-t border-radio-border pt-3">
{loading ? (
<div className="text-sm text-radio-muted">Loading...</div>
) : files.length === 0 ? (
<div className="text-sm text-radio-muted">No files</div>
) : (
<ul className="space-y-1">
{files.map((file) => (
<li key={file.name} className="flex items-center gap-2 text-sm group">
<svg className="w-4 h-4 text-radio-muted flex-shrink-0" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H2.25A.75.75 0 011.5 15V9a.75.75 0 01.75-.75h4.5z" />
</svg>
<span className="truncate flex-1" title={file.name}>
{file.name}
</span>
<span className="text-xs text-radio-muted flex-shrink-0">
{formatSize(file.size)}
</span>
{moving === file.name ? (
<span className="text-xs text-radio-accent flex-shrink-0">Moving...</span>
) : (
<select
className="opacity-0 group-hover:opacity-100 transition-opacity text-xs bg-radio-bg border border-radio-border rounded px-1 py-0.5 text-radio-text flex-shrink-0"
value=""
onChange={(e) => {
if (e.target.value) handleMove(file.name, e.target.value);
}}
>
<option value="">Move to...</option>
{destinations.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
)}
</li>
))}
</ul>
)}
{error && (
<div className="text-xs text-radio-danger mt-2">{error}</div>
)}
</div>
)}
</div>
);
}
export default function MusicPanel() {
const [data, setData] = useState(null);
const load = () => api.getMusic().then(setData).catch(() => {});
useEffect(() => { load(); }, []);
if (!data) return <div className="text-radio-muted">Loading...</div>;
const allFolders = [
...data.directories.map((d) => d.folder),
"fallback",
];
return (
<div>
<h1 className="text-2xl font-bold mb-6">Music Library</h1>
<div className="grid md:grid-cols-2 gap-4">
{data.directories.map((dir) => (
<FolderCard
key={dir.folder}
folder={dir.folder}
name={dir.folder}
path={dir.path}
count={dir.file_count}
isFallback={false}
allFolders={allFolders}
onRefresh={load}
/>
))}
<FolderCard
folder="fallback"
name="Fallback"
path={data.fallback.path}
count={data.fallback.file_count}
isFallback={true}
allFolders={allFolders}
onRefresh={load}
/>
</div>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { useState, useEffect } from "react";
import { api } from "../api";
export default function QueuePanel() {
const [queue, setQueue] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showEnqueue, setShowEnqueue] = useState(false);
const [episodes, setEpisodes] = useState([]);
const refresh = () => {
setLoading(true);
api.getQueue()
.then(setQueue)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
};
useEffect(refresh, []);
const handleDequeue = async (id) => {
try {
await api.dequeue(id);
refresh();
} catch (e) {
setError(e.message);
}
};
const openEnqueueModal = async () => {
try {
const data = await api.getEpisodes({ status: "downloaded", per_page: 50 });
setEpisodes(data.episodes.filter((ep) => !ep.queued));
setShowEnqueue(true);
} catch (e) {
setError(e.message);
}
};
const handleEnqueue = async (episodeId) => {
try {
await api.enqueue({ episode_id: episodeId });
setShowEnqueue(false);
refresh();
} catch (e) {
setError(e.message);
}
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Podcast Queue</h1>
<button onClick={openEnqueueModal} className="btn btn-primary text-sm">
Enqueue Episode
</button>
</div>
{error && (
<div className="card border-radio-danger/30 text-radio-danger mb-4 text-sm">
{error}
<button onClick={() => setError(null)} className="ml-2 underline">dismiss</button>
</div>
)}
{loading ? (
<div className="text-radio-muted">Loading...</div>
) : queue.length === 0 ? (
<div className="card text-center py-12">
<div className="text-radio-muted text-lg mb-2">Queue is empty</div>
<div className="text-sm text-radio-muted">Music is playing. Podcasts will appear here when new episodes are detected.</div>
</div>
) : (
<div className="space-y-2">
{queue.map((item) => (
<div key={item.id} className="card flex items-center gap-4">
<span className="text-radio-accent font-mono text-lg w-8 text-center">
{item.position}
</span>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{item.title}</div>
<div className="text-sm text-radio-muted">{item.feed_name}</div>
<div className="text-xs text-radio-muted">
Enqueued: {new Date(item.enqueued + "Z").toLocaleString()}
</div>
</div>
<button
onClick={() => handleDequeue(item.id)}
className="btn btn-danger text-sm"
>
Remove
</button>
</div>
))}
</div>
)}
{/* Enqueue Modal */}
{showEnqueue && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => setShowEnqueue(false)}>
<div className="card w-full max-w-lg max-h-[80vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<h2 className="font-semibold text-lg mb-4">Enqueue Episode</h2>
{episodes.length === 0 ? (
<p className="text-radio-muted text-sm">No downloaded episodes available to enqueue.</p>
) : (
<ul className="space-y-2">
{episodes.map((ep) => (
<li key={ep.id} className="flex items-center gap-3 p-2 rounded hover:bg-radio-border/20">
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{ep.title}</div>
<div className="text-xs text-radio-muted">{ep.feed_name}</div>
</div>
<button onClick={() => handleEnqueue(ep.id)} className="btn btn-primary text-xs py-1 px-3">
Add
</button>
</li>
))}
</ul>
)}
<button onClick={() => setShowEnqueue(false)} className="btn btn-ghost mt-4 w-full text-sm">
Cancel
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,117 @@
import { useState, useEffect } from "react";
import { api } from "../api";
const HOURS = Array.from({ length: 24 }, (_, i) => i);
const DAYS = [
{ num: 1, name: "Mon" },
{ num: 2, name: "Tue" },
{ num: 3, name: "Wed" },
{ num: 4, name: "Thu" },
{ num: 5, name: "Fri" },
{ num: 6, name: "Sat" },
{ num: 7, name: "Sun" },
];
const FOLDER_COLORS = {
morning: "bg-yellow-500/30 border-yellow-500/50",
day: "bg-orange-500/30 border-orange-500/50",
night: "bg-indigo-500/30 border-indigo-500/50",
weekend: "bg-emerald-500/30 border-emerald-500/50",
};
function getBlockForHour(blocks, dayNum, hour) {
const timeStr = `${String(hour).padStart(2, "0")}:00`;
for (const block of blocks) {
if (!block.days.includes(dayNum)) continue;
const start = block.start;
const end = block.end;
if (start <= end) {
if (timeStr >= start && timeStr < end) return block;
} else {
if (timeStr >= start || timeStr < end) return block;
}
}
return null;
}
export default function SchedulePanel() {
const [blocks, setBlocks] = useState([]);
useEffect(() => {
api.getSchedule().then(setBlocks).catch(() => {});
}, []);
return (
<div>
<h1 className="text-2xl font-bold mb-6">Schedule</h1>
{/* Legend */}
<div className="flex gap-4 mb-4 flex-wrap">
{blocks.map((b) => (
<div key={b.name} className="flex items-center gap-2">
<div className={`w-4 h-4 rounded border ${FOLDER_COLORS[b.folder] || "bg-gray-500/30 border-gray-500/50"}`} />
<span className="text-sm">
{b.name}
{b.active && <span className="badge badge-green ml-2">Active</span>}
</span>
</div>
))}
</div>
{/* Grid */}
<div className="card overflow-x-auto">
<div className="min-w-[700px]">
{/* Hour headers */}
<div className="flex">
<div className="w-12 flex-shrink-0" />
{HOURS.map((h) => (
<div key={h} className="flex-1 text-center text-xs text-radio-muted py-1">
{h}
</div>
))}
</div>
{/* Day rows */}
{DAYS.map((day) => (
<div key={day.num} className="flex items-center">
<div className="w-12 flex-shrink-0 text-xs text-radio-muted font-medium text-right pr-2 py-1">
{day.name}
</div>
{HOURS.map((h) => {
const block = getBlockForHour(blocks, day.num, h);
const colorClass = block
? FOLDER_COLORS[block.folder] || "bg-gray-500/30 border-gray-500/50"
: "bg-radio-border/20 border-transparent";
return (
<div
key={h}
className={`flex-1 h-8 border ${colorClass} ${block?.active ? "ring-1 ring-radio-accent" : ""}`}
title={block ? `${block.name} (${block.folder})` : "No schedule"}
/>
);
})}
</div>
))}
</div>
</div>
{/* Block details */}
<div className="grid md:grid-cols-2 gap-3 mt-6">
{blocks.map((b) => (
<div key={b.name} className={`card ${b.active ? "border-radio-accent/50" : ""}`}>
<div className="flex items-center justify-between">
<span className="font-medium">{b.name}</span>
{b.active && <span className="badge badge-green">Active</span>}
</div>
<div className="text-sm text-radio-muted mt-1">
{b.day_names.join(", ")} &middot; {b.start} - {b.end}
</div>
<div className="text-xs text-radio-muted mt-1">
Folder: <code className="text-radio-accent">{b.folder}/</code>
</div>
</div>
))}
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles/index.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+95
View File
@@ -0,0 +1,95 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
background-color: #0f1419;
color: #e2e8f0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #0f1419;
}
::-webkit-scrollbar-thumb {
background: #2a3040;
border-radius: 4px;
}
@layer components {
.card {
@apply bg-radio-card border border-radio-border rounded-lg p-4;
}
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-150;
}
.btn-primary {
@apply bg-radio-accent text-black hover:bg-yellow-400;
}
.btn-danger {
background-color: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.btn-danger:hover {
background-color: rgba(239, 68, 68, 0.3);
}
.btn-ghost {
@apply text-radio-muted hover:text-radio-text;
}
.btn-ghost:hover {
background-color: rgba(42, 48, 64, 0.3);
}
.badge {
@apply inline-flex items-center px-2 rounded text-xs font-medium;
padding-top: 2px;
padding-bottom: 2px;
}
.badge-green {
background-color: rgba(34, 197, 94, 0.2);
color: #4ade80;
}
.badge-red {
background-color: rgba(239, 68, 68, 0.2);
color: #f87171;
}
.badge-yellow {
background-color: rgba(234, 179, 8, 0.2);
color: #facc15;
}
.badge-blue {
background-color: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
.input {
@apply bg-radio-bg border border-radio-border rounded-lg px-3 py-2 text-radio-text;
}
.input::placeholder {
color: #64748b;
}
.input:focus {
outline: none;
border-color: #f59e0b;
box-shadow: 0 0 0 1px #f59e0b;
}
}
.eq-bar {
width: 4px;
background-color: #f59e0b;
border-radius: 9999px;
animation: eq-bounce 0.8s ease-in-out infinite alternate;
}
.eq-bar:nth-child(2) { animation-delay: 0.2s; }
.eq-bar:nth-child(3) { animation-delay: 0.4s; }
.eq-bar:nth-child(4) { animation-delay: 0.1s; }
@keyframes eq-bounce {
0% { height: 4px; }
100% { height: 16px; }
}
+30
View File
@@ -0,0 +1,30 @@
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** @type {import('tailwindcss').Config} */
export default {
content: [
path.join(__dirname, "index.html"),
path.join(__dirname, "src/**/*.{js,jsx}"),
],
theme: {
extend: {
colors: {
radio: {
bg: "#0f1419",
card: "#1a1f2e",
border: "#2a3040",
accent: "#f59e0b",
"accent-dim": "#b45309",
text: "#e2e8f0",
muted: "#64748b",
success: "#22c55e",
danger: "#ef4444",
},
},
},
},
plugins: [],
};
+21
View File
@@ -0,0 +1,21 @@
import path from "path";
import { fileURLToPath } from "url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
root: __dirname,
plugins: [react()],
build: {
outDir: "dist",
emptyOutDir: true,
},
server: {
port: 5173,
proxy: {
"/api": "http://localhost:5000",
},
},
});