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
+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})