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:
profit 2026-03-13 19:01:33 -07:00
commit 3d635b742c
53 changed files with 7023 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
# Runtime data
state/
logs/
media/
# Portable toolchains
.local/
# Python
__pycache__/
*.pyc
*.pyo
venv/
.venv/
# Node / Frontend build
web/frontend/node_modules/
web/frontend/dist/
# OS
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
# Claude
.claude/

274
README.md Normal file
View File

@ -0,0 +1,274 @@
# Local Radio Station
A self-hosted personal radio station that streams music continuously with scheduled playlists, and automatically interrupts for new podcast episodes detected via RSS feeds.
## Architecture
```
RSS Feeds ──> [Python Poller] ──> SQLite + Download ──> queue/ directory
Liquidsoap reads: podcast queue > scheduled music > fallback
Icecast Server
VLC / Browser / Mobile
```
**Components:**
- **Icecast2** — HTTP streaming server, serves audio to listeners
- **Liquidsoap** — Audio engine handling source priority, scheduling, and encoding
- **Python Poller** — Long-running service that polls RSS feeds, downloads episodes, and manages the queue
- **SQLite** — Persistent state for episode tracking and deduplication
- **systemd** — Process supervision for all services
## Quick Start
### 1. Install (as root on Debian 13)
```bash
git clone <this-repo> /tmp/localradio-src
cd /tmp/localradio-src
bash scripts/setup.sh
```
### 2. Add Content
```bash
# Add music to schedule folders
cp ~/music/chill/*.mp3 /opt/localradio/media/music/morning/
cp ~/music/upbeat/*.mp3 /opt/localradio/media/music/day/
cp ~/music/ambient/*.mp3 /opt/localradio/media/music/night/
cp ~/music/mix/*.mp3 /opt/localradio/media/music/weekend/
# IMPORTANT: Add at least one file to fallback (prevents silence)
cp ~/music/safe/*.mp3 /opt/localradio/media/fallback/
```
### 3. Configure Feeds
Edit `/opt/localradio/config/feeds.yaml`:
```yaml
feeds:
- name: "My Podcast"
url: "https://feeds.example.com/mypodcast.xml"
enabled: true
priority: 10
max_episodes: 30
```
### 4. Start Services
```bash
systemctl start localradio-icecast
systemctl start localradio-stream
systemctl start localradio-poller
```
### 5. Listen
- **Browser:** `http://localhost:8000/stream`
- **VLC:** File > Open Network Stream > `http://localhost:8000/stream`
- **mpv:** `mpv http://localhost:8000/stream`
- **Mobile:** Use any app that supports HTTP streams (VLC, foobar2000, etc.) with the URL above
If listening from another device on your LAN, replace `localhost` with the server's IP and ensure the `bind-address` in `icecast.xml` is changed from `127.0.0.1` to `0.0.0.0`.
---
## Directory Structure
```
/opt/localradio/
├── config/
│ ├── icecast.xml # Icecast server config
│ ├── station.liq # Liquidsoap streaming config
│ ├── station.yaml # Schedule and audio settings
│ ├── feeds.yaml # RSS feed definitions
│ └── localradio.env # Environment overrides
├── scripts/
│ ├── poll_feeds.py # RSS poller daemon
│ ├── init_db.py # Database initialization
│ ├── enqueue_episode.py # Manually enqueue audio files
│ ├── queue_status.py # View queue and episode state
│ ├── validate_config.py # Validate all configs
│ ├── test_feed.py # Test an RSS feed URL
│ ├── healthcheck.sh # System health check
│ └── setup.sh # Initial installation
├── state/
│ ├── radio.db # SQLite database
│ └── queue/ # Symlinks for Liquidsoap queue
├── media/
│ ├── music/
│ │ ├── morning/ # Weekday 06:00-12:00
│ │ ├── day/ # Weekday 12:00-18:00
│ │ ├── night/ # Weekday 18:00-06:00
│ │ └── weekend/ # Saturday & Sunday
│ ├── podcasts/ # Downloaded podcast episodes
│ └── fallback/ # Emergency fallback audio
├── logs/
├── systemd/ # Service unit files
├── venv/ # Python virtual environment
└── requirements.txt
```
---
## Configuration Guide
### Music Schedule (`station.yaml`)
The `schedule` section defines which music folder plays at what time:
```yaml
schedule:
- name: "Weekday Morning"
days: [1, 2, 3, 4, 5] # 1=Monday ... 7=Sunday
start: "06:00"
end: "12:00"
folder: "morning" # Subfolder of media/music/
```
To change the schedule, edit `station.yaml` and the corresponding `switch()` block in `station.liq`. The YAML file documents intent; the `.liq` file is what Liquidsoap actually reads.
### Adding/Removing Feeds (`feeds.yaml`)
Add a feed:
```yaml
- name: "New Show"
url: "https://example.com/feed.xml"
enabled: true
priority: 10
max_episodes: 20
```
Remove a feed: delete the entry or set `enabled: false`.
Test a feed before adding:
```bash
/opt/localradio/venv/bin/python /opt/localradio/scripts/test_feed.py "https://example.com/feed.xml"
```
Changes take effect on the next poll cycle (no restart needed).
### Changing Bitrate/Format
In `station.liq`, find the `output.icecast()` block:
**MP3 320 kbps (default):**
```liquidsoap
output.icecast(
%mp3(bitrate=320, samplerate=44100, stereo=true),
...
)
```
**Opus (high quality):**
```liquidsoap
output.icecast(
%opus(bitrate=192, samplerate=48000, channels=2),
...
)
```
Restart the stream after changes: `systemctl restart localradio-stream`
### Poll Interval
Set in `/opt/localradio/config/localradio.env`:
```bash
POLL_INTERVAL=300 # seconds (default: 5 minutes)
```
Restart the poller: `systemctl restart localradio-poller`
---
## How the Queue and Dedup Logic Works
### Episode Discovery
1. The poller reads `feeds.yaml` for enabled feeds
2. Each feed is parsed via `feedparser`
3. For each entry, a GUID is extracted (from RSS `<guid>`, or hashed from the audio URL)
4. The GUID + feed name are checked against SQLite — if the pair exists, it's skipped
### Download and Enqueue
1. New episodes are downloaded to `media/podcasts/<feed-name>/`
2. Partial downloads use a `.part` suffix and are renamed on completion
3. A symlink is created in `state/queue/` with a timestamp prefix for FIFO ordering
4. The episode is recorded in both the `episodes` and `queue` tables
### Playback
1. Liquidsoap watches `state/queue/` as a playlist in `normal` (sequential) mode
2. When files appear, Liquidsoap plays them with priority over music
3. `track_sensitive=true` ensures music finishes its current track before podcast takeover
4. After the queue is empty, music resumes from the current schedule block
### Restart Safety
- SQLite WAL mode prevents corruption from unexpected shutdowns
- The poller checks for existing GUIDs on startup, so restarting never causes re-downloads
- Queue symlinks persist across restarts
- Broken symlinks (played episodes) are cleaned up each poll cycle
---
## Troubleshooting
### Stream is silent
1. Check fallback: `ls /opt/localradio/media/fallback/` — must have at least one audio file
2. Check music dirs: `ls /opt/localradio/media/music/morning/` (etc.)
3. Check Liquidsoap logs: `journalctl -u localradio-stream -f`
4. Run healthcheck: `bash /opt/localradio/scripts/healthcheck.sh`
### Podcasts not downloading
1. Check poller logs: `journalctl -u localradio-poller -f` or `cat /opt/localradio/logs/poller.log`
2. Test the feed: `/opt/localradio/venv/bin/python /opt/localradio/scripts/test_feed.py <url>`
3. Check the database: `/opt/localradio/venv/bin/python /opt/localradio/scripts/queue_status.py`
4. Verify feeds are `enabled: true` in `feeds.yaml`
### Icecast won't start
1. Check if port 8000 is in use: `ss -tlnp | grep 8000`
2. Check logs: `journalctl -u localradio-icecast -f`
3. Validate XML: `xmllint --noout /opt/localradio/config/icecast.xml`
### Liquidsoap errors
1. Test config syntax: `liquidsoap --check /opt/localradio/config/station.liq`
2. Check logs: `cat /opt/localradio/logs/liquidsoap.log`
3. Common issue: empty playlist directories — add files or ensure fallback has content
### Reset everything
```bash
systemctl stop localradio-poller localradio-stream localradio-icecast
rm /opt/localradio/state/radio.db
rm -f /opt/localradio/state/queue/*
/opt/localradio/venv/bin/python /opt/localradio/scripts/init_db.py
systemctl start localradio-icecast localradio-stream localradio-poller
```
---
## Service Management
```bash
# Start all
systemctl start localradio-icecast localradio-stream localradio-poller
# Stop all
systemctl stop localradio-poller localradio-stream localradio-icecast
# View logs
journalctl -u localradio-stream -f
journalctl -u localradio-poller -f
# Restart after config change
systemctl restart localradio-stream # for station.liq changes
systemctl restart localradio-poller # for feeds.yaml or env changes
```
## Manual Episode Enqueue
```bash
/opt/localradio/venv/bin/python /opt/localradio/scripts/enqueue_episode.py /path/to/episode.mp3 "Episode Title"
```
This creates a symlink in the queue directory. Liquidsoap picks it up and plays it with priority.

11
config/feeds.yaml Normal file
View File

@ -0,0 +1,11 @@
feeds:
- name: Example Podcast
url: https://feeds.example.com/podcast.xml
enabled: false
priority: 10
max_episodes: 20
- name: c
url: https://rss.libsyn.com/shows/467049/destinations/3949218.xml
enabled: true
priority: 10
max_episodes: 20

58
config/icecast.xml Normal file
View File

@ -0,0 +1,58 @@
<icecast>
<location>Local</location>
<admin>admin@localhost</admin>
<limits>
<clients>5</clients>
<sources>2</sources>
<queue-size>524288</queue-size>
<client-timeout>30</client-timeout>
<header-timeout>15</header-timeout>
<source-timeout>10</source-timeout>
<burst-on-connect>1</burst-on-connect>
<burst-size>65535</burst-size>
</limits>
<authentication>
<source-password>localradio_source</source-password>
<relay-password>localradio_relay</relay-password>
<admin-user>admin</admin-user>
<admin-password>localradio_admin</admin-password>
</authentication>
<hostname>localhost</hostname>
<listen-socket>
<port>8000</port>
<bind-address>127.0.0.1</bind-address>
</listen-socket>
<mount>
<mount-name>/stream</mount-name>
<fallback-mount>/fallback</fallback-mount>
<fallback-override>1</fallback-override>
<hidden>0</hidden>
<public>0</public>
</mount>
<fileserve>1</fileserve>
<paths>
<basedir>/usr/share/icecast2</basedir>
<logdir>/opt/localradio/logs</logdir>
<webroot>/usr/share/icecast2/web</webroot>
<adminroot>/usr/share/icecast2/admin</adminroot>
<alias source="/" destination="/status.xsl"/>
</paths>
<logging>
<accesslog>icecast_access.log</accesslog>
<errorlog>icecast_error.log</errorlog>
<loglevel>3</loglevel>
<logsize>10000</logsize>
</logging>
<security>
<chroot>0</chroot>
</security>
</icecast>

10
config/localradio.env Normal file
View File

@ -0,0 +1,10 @@
# Local Radio Environment Configuration
# Override defaults here. Loaded by systemd services.
LOCALRADIO_BASE=/opt/localradio
# RSS poll interval in seconds (default: 300 = 5 minutes)
POLL_INTERVAL=300
# Download timeout in seconds (default: 600)
DOWNLOAD_TIMEOUT=600

156
config/station.liq Normal file
View File

@ -0,0 +1,156 @@
#!/usr/bin/liquidsoap
# =============================================================================
# Local Radio Station — Liquidsoap Configuration
# =============================================================================
# Source priority (highest to lowest):
# 1. Podcast queue (from queue/ directory)
# 2. Scheduled music (time/day-based playlists)
# 3. Emergency fallback (static safe playlist)
# =============================================================================
settings.log.file.path := "/opt/localradio/logs/liquidsoap.log"
settings.log.level := 3
# --- Paths -------------------------------------------------------------------
music_base = "/opt/localradio/media/music"
queue_dir = "/opt/localradio/state/queue"
fallback_dir = "/opt/localradio/media/fallback"
# --- Icecast output settings -------------------------------------------------
icecast_host = "localhost"
icecast_port = 8000
icecast_password = "localradio_source"
icecast_mount = "/stream"
# --- Emergency Fallback ------------------------------------------------------
# A safe playlist that always has content. Put at least one audio file in
# /opt/localradio/media/fallback/ to guarantee the stream never dies.
fallback_source = playlist(
mode = "randomize",
reload_mode = "watch",
fallback_dir
)
# --- Scheduled Music Sources --------------------------------------------------
# Each playlist watches its directory for changes. If files are added/removed,
# the playlist updates automatically.
morning_music = playlist(
mode = "randomize",
reload_mode = "watch",
"#{music_base}/morning"
)
day_music = playlist(
mode = "randomize",
reload_mode = "watch",
"#{music_base}/day"
)
night_music = playlist(
mode = "randomize",
reload_mode = "watch",
"#{music_base}/night"
)
weekend_music = playlist(
mode = "randomize",
reload_mode = "watch",
"#{music_base}/weekend"
)
# --- Time-based music schedule ------------------------------------------------
# switch() picks the first matching source based on time predicates.
# Predicates use: { <time range> } and weekday functions.
#
# Weekday numbers: 1w=Monday ... 5w=Friday, 6w=Saturday, 7w=Sunday
scheduled_music = switch(
track_sensitive = true,
[
# Weekend: all day Saturday and Sunday
({ 6w or 7w }, weekend_music),
# Weekday morning: 06:00 - 11:59
({ 1w-5w and 6h-11h }, morning_music),
# Weekday afternoon: 12:00 - 17:59
({ 1w-5w and 12h-17h }, day_music),
# Weekday evening/night: 18:00 - 05:59
({ 1w-5w and (18h-23h or 0h-5h) }, night_music),
]
)
# Add fallback so scheduled_music never fails
music_safe = fallback(
track_sensitive = true,
[scheduled_music, fallback_source]
)
# --- Podcast Queue Source -----------------------------------------------------
# Watches the queue directory. When files appear, they play with priority.
# After a file finishes, it is removed from the queue directory.
#
# request.queue() is not used here because we want filesystem-based control
# from the Python poller. Instead, we use playlist() in "normal" mode on the
# queue directory, which plays files in alphabetical order (we prefix filenames
# with timestamps to ensure FIFO ordering).
podcast_queue = playlist(
mode = "normal",
reload_mode = "watch",
queue_dir
)
# --- Source Priority Merge ----------------------------------------------------
# fallback() plays the first available source.
# track_sensitive = true means it waits for the current music track to finish
# before switching to a podcast (and vice versa).
radio = fallback(
track_sensitive = true,
[podcast_queue, music_safe]
)
# --- Metadata Logging ---------------------------------------------------------
radio = on_metadata(
fun(m) -> begin
title = m["title"]
artist = m["artist"]
log("Now playing: #{artist} - #{title}")
end,
radio
)
# --- Make the source infallible -----------------------------------------------
# mksafe() ensures the source never fails. If all sources are unavailable,
# it outputs silence rather than crashing.
radio = mksafe(radio)
# --- Output to Icecast --------------------------------------------------------
# MP3 320kbps stereo output.
# To switch to Opus, replace %mp3 with %opus and adjust bitrate.
output.icecast(
%mp3(
bitrate = 320,
samplerate = 44100,
stereo = true
),
host = icecast_host,
port = icecast_port,
password = icecast_password,
mount = icecast_mount,
name = "Local Radio",
description = "Personal automated radio station",
genre = "Various",
url = "http://localhost:8000",
radio
)

67
config/station.yaml Normal file
View File

@ -0,0 +1,67 @@
# Local Radio Station Configuration
station:
name: "Local Radio"
description: "Personal automated radio station"
icecast:
host: "localhost"
port: 8000
source_password: "localradio_source"
mount: "/stream"
audio:
# Output format: "mp3" or "opus"
format: "mp3"
# Bitrate in kbps (for mp3: 128, 192, 256, 320; for opus: 64-256)
bitrate: 320
samplerate: 44100
channels: 2
music:
base_path: "/opt/localradio/media/music"
fallback_path: "/opt/localradio/media/fallback"
# File extensions to include
extensions:
- "*.mp3"
- "*.flac"
- "*.ogg"
- "*.opus"
- "*.m4a"
- "*.wav"
# Schedule blocks define which music folder plays at what times.
# Times are in 24-hour format. Each block specifies:
# days: list of weekday numbers (1=Monday ... 7=Sunday)
# start: HH:MM
# end: HH:MM
# folder: subfolder name under music.base_path
schedule:
- name: "Weekday Morning"
days: [1, 2, 3, 4, 5]
start: "06:00"
end: "12:00"
folder: "morning"
- name: "Weekday Afternoon"
days: [1, 2, 3, 4, 5]
start: "12:00"
end: "18:00"
folder: "day"
- name: "Weekday Evening & Night"
days: [1, 2, 3, 4, 5]
start: "18:00"
end: "06:00"
folder: "night"
- name: "Weekend"
days: [6, 7]
start: "00:00"
end: "23:59"
folder: "weekend"
podcasts:
queue_dir: "/opt/localradio/state/queue"
download_dir: "/opt/localradio/media/podcasts"
db_path: "/opt/localradio/state/radio.db"

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
feedparser>=6.0
requests>=2.28
PyYAML>=6.0
flask>=3.0

11
run_web.py Normal file
View File

@ -0,0 +1,11 @@
"""Dev server entry point for the web UI."""
import sys
from pathlib import Path
# Ensure project root is on sys.path
sys.path.insert(0, str(Path(__file__).parent))
from web.app import app
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)

View File

@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""
Manually enqueue a local audio file for immediate playback.
Usage:
enqueue_episode.py /path/to/episode.mp3
enqueue_episode.py /path/to/episode.mp3 "Episode Title"
"""
import os
import sys
from datetime import datetime
from pathlib import Path
BASE_DIR = Path(os.environ.get("LOCALRADIO_BASE", "/opt/localradio"))
QUEUE_DIR = BASE_DIR / "state" / "queue"
def enqueue(file_path: str, label: str | None = None) -> None:
src = Path(file_path).resolve()
if not src.exists():
print(f"Error: file not found: {src}", file=sys.stderr)
sys.exit(1)
if not src.suffix.lower() in (".mp3", ".ogg", ".opus", ".flac", ".m4a", ".wav"):
print(f"Warning: '{src.suffix}' may not be a supported audio format")
QUEUE_DIR.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_name = src.name.replace(" ", "_")
link_name = f"{timestamp}_manual_{safe_name}"
link_path = QUEUE_DIR / link_name
link_path.symlink_to(src)
print(f"Enqueued: {link_name}")
if label:
print(f" Label: {label}")
print(f" Target: {src}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <audio-file> [title]", file=sys.stderr)
sys.exit(1)
title = sys.argv[2] if len(sys.argv) > 2 else None
enqueue(sys.argv[1], title)

142
scripts/healthcheck.sh Normal file
View File

@ -0,0 +1,142 @@
#!/usr/bin/env bash
# =============================================================================
# Local Radio Station — Health Check
# Reports status of all components
# =============================================================================
set -euo pipefail
INSTALL_DIR="${LOCALRADIO_BASE:-/opt/localradio}"
ICECAST_URL="http://localhost:8000/status.xsl"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
ok() { echo -e " ${GREEN}[OK]${NC} $1"; }
warn() { echo -e " ${YELLOW}[WARN]${NC} $1"; }
fail() { echo -e " ${RED}[FAIL]${NC} $1"; }
echo "=== Local Radio Health Check ==="
echo ""
# --- Service Status -----------------------------------------------------------
echo "Services:"
for svc in localradio-icecast localradio-stream localradio-poller; do
if systemctl is-active --quiet "$svc" 2>/dev/null; then
ok "$svc is running"
else
fail "$svc is NOT running"
fi
done
echo ""
# --- Icecast Connectivity -----------------------------------------------------
echo "Icecast:"
if curl -s --max-time 3 "$ICECAST_URL" > /dev/null 2>&1; then
ok "Icecast responding at $ICECAST_URL"
else
fail "Icecast not responding"
fi
# Check if stream mount is active
if curl -s --max-time 3 -o /dev/null -w "%{http_code}" "http://localhost:8000/stream" 2>/dev/null | grep -q "200"; then
ok "Stream mount /stream is active"
else
warn "Stream mount /stream not active (may need listeners)"
fi
echo ""
# --- Music Directories --------------------------------------------------------
echo "Music directories:"
for dir in morning day night weekend; do
full_path="$INSTALL_DIR/media/music/$dir"
if [ -d "$full_path" ]; then
count=$(find "$full_path" -type f \( -name "*.mp3" -o -name "*.flac" -o -name "*.ogg" -o -name "*.opus" -o -name "*.m4a" -o -name "*.wav" \) 2>/dev/null | wc -l)
if [ "$count" -gt 0 ]; then
ok "$dir: $count audio files"
else
warn "$dir: empty (no audio files)"
fi
else
fail "$dir: directory missing"
fi
done
echo ""
# --- Fallback -----------------------------------------------------------------
echo "Fallback:"
fallback_dir="$INSTALL_DIR/media/fallback"
if [ -d "$fallback_dir" ]; then
count=$(find "$fallback_dir" -type f \( -name "*.mp3" -o -name "*.flac" -o -name "*.ogg" -o -name "*.opus" -o -name "*.m4a" -o -name "*.wav" \) 2>/dev/null | wc -l)
if [ "$count" -gt 0 ]; then
ok "Fallback: $count audio files"
else
fail "Fallback: EMPTY — stream will go silent if all music dirs are empty!"
fi
else
fail "Fallback directory missing"
fi
echo ""
# --- Queue Status -------------------------------------------------------------
echo "Podcast queue:"
queue_dir="$INSTALL_DIR/state/queue"
if [ -d "$queue_dir" ]; then
queue_count=$(find "$queue_dir" -maxdepth 1 -type l -o -type f 2>/dev/null | wc -l)
if [ "$queue_count" -gt 0 ]; then
ok "$queue_count episode(s) in queue"
echo " Queue contents:"
ls -1 "$queue_dir" 2>/dev/null | head -10 | while read -r f; do
echo " - $f"
done
else
ok "Queue empty (music playing)"
fi
else
warn "Queue directory missing"
fi
echo ""
# --- Database -----------------------------------------------------------------
echo "Database:"
db_path="$INSTALL_DIR/state/radio.db"
if [ -f "$db_path" ]; then
ep_count=$(sqlite3 "$db_path" "SELECT COUNT(*) FROM episodes;" 2>/dev/null || echo "?")
queued=$(sqlite3 "$db_path" "SELECT COUNT(*) FROM queue WHERE played=0;" 2>/dev/null || echo "?")
feeds=$(sqlite3 "$db_path" "SELECT COUNT(*) FROM feed_state;" 2>/dev/null || echo "?")
ok "Database exists: $ep_count episodes tracked, $queued queued, $feeds feeds polled"
else
warn "Database not found (run init_db.py)"
fi
echo ""
# --- Feeds Config -------------------------------------------------------------
echo "Feeds config:"
feeds_file="$INSTALL_DIR/config/feeds.yaml"
if [ -f "$feeds_file" ]; then
ok "feeds.yaml exists"
else
fail "feeds.yaml not found"
fi
echo ""
# --- Recent Logs --------------------------------------------------------------
echo "Recent poller log:"
log_file="$INSTALL_DIR/logs/poller.log"
if [ -f "$log_file" ]; then
tail -5 "$log_file" | while read -r line; do
echo " $line"
done
else
warn "No poller log yet"
fi
echo ""
echo "=== Health check complete ==="

61
scripts/init_db.py Normal file
View File

@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Initialize the SQLite database for the local radio station."""
import sqlite3
import sys
from pathlib import Path
DEFAULT_DB_PATH = Path("/opt/localradio/state/radio.db")
def init_db(db_path: Path = DEFAULT_DB_PATH) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
cur = conn.cursor()
cur.executescript("""
CREATE TABLE IF NOT EXISTS episodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feed_name TEXT NOT NULL,
guid TEXT NOT NULL,
title TEXT,
url TEXT NOT NULL,
pub_date TEXT,
discovered TEXT NOT NULL DEFAULT (datetime('now')),
downloaded INTEGER NOT NULL DEFAULT 0,
file_path TEXT,
queued INTEGER NOT NULL DEFAULT 0,
played INTEGER NOT NULL DEFAULT 0,
UNIQUE(feed_name, guid)
);
CREATE INDEX IF NOT EXISTS idx_episodes_feed
ON episodes(feed_name);
CREATE INDEX IF NOT EXISTS idx_episodes_queued
ON episodes(queued);
CREATE TABLE IF NOT EXISTS queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
episode_id INTEGER NOT NULL REFERENCES episodes(id),
position INTEGER NOT NULL,
enqueued TEXT NOT NULL DEFAULT (datetime('now')),
played INTEGER NOT NULL DEFAULT 0,
UNIQUE(episode_id)
);
CREATE TABLE IF NOT EXISTS feed_state (
feed_name TEXT PRIMARY KEY,
last_poll TEXT,
last_etag TEXT,
last_modified TEXT
);
""")
conn.commit()
conn.close()
print(f"Database initialized at {db_path}")
if __name__ == "__main__":
path = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_DB_PATH
init_db(path)

447
scripts/poll_feeds.py Normal file
View File

@ -0,0 +1,447 @@
#!/usr/bin/env python3
"""
RSS Feed Poller for Local Radio Station.
Polls configured RSS feeds, detects new episodes, downloads audio,
and enqueues them for Liquidsoap playback.
Designed to run as a long-lived systemd service.
"""
import hashlib
import logging
import os
import re
import shutil
import signal
import sqlite3
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
import feedparser
import requests
import yaml
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
BASE_DIR = Path(os.environ.get("LOCALRADIO_BASE", "/opt/localradio"))
CONFIG_DIR = BASE_DIR / "config"
STATE_DIR = BASE_DIR / "state"
QUEUE_DIR = STATE_DIR / "queue"
PODCAST_DIR = BASE_DIR / "media" / "podcasts"
DB_PATH = STATE_DIR / "radio.db"
FEEDS_CONFIG = CONFIG_DIR / "feeds.yaml"
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "300")) # seconds
DOWNLOAD_TIMEOUT = int(os.environ.get("DOWNLOAD_TIMEOUT", "600")) # seconds
USER_AGENT = "LocalRadio/1.0 (Personal Podcast Poller)"
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
LOG_DIR = BASE_DIR / "logs"
LOG_DIR.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(LOG_DIR / "poller.log"),
],
)
log = logging.getLogger("poller")
# ---------------------------------------------------------------------------
# Graceful shutdown
# ---------------------------------------------------------------------------
_shutdown = False
def _handle_signal(signum, frame):
global _shutdown
log.info("Received signal %s, shutting down gracefully...", signum)
_shutdown = True
signal.signal(signal.SIGTERM, _handle_signal)
signal.signal(signal.SIGINT, _handle_signal)
# ---------------------------------------------------------------------------
# Database helpers
# ---------------------------------------------------------------------------
def get_db() -> sqlite3.Connection:
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def episode_exists(conn: sqlite3.Connection, feed_name: str, guid: str) -> bool:
row = conn.execute(
"SELECT 1 FROM episodes WHERE feed_name=? AND guid=?", (feed_name, guid)
).fetchone()
return row is not None
def insert_episode(
conn: sqlite3.Connection,
feed_name: str,
guid: str,
title: str,
url: str,
pub_date: str | None,
) -> int:
cur = conn.execute(
"""INSERT INTO episodes (feed_name, guid, title, url, pub_date)
VALUES (?, ?, ?, ?, ?)""",
(feed_name, guid, title, url, pub_date),
)
conn.commit()
return cur.lastrowid
def mark_downloaded(conn: sqlite3.Connection, episode_id: int, file_path: str) -> None:
conn.execute(
"UPDATE episodes SET downloaded=1, file_path=? WHERE id=?",
(file_path, episode_id),
)
conn.commit()
def enqueue_episode(conn: sqlite3.Connection, episode_id: int) -> None:
# Position = next available integer
row = conn.execute("SELECT COALESCE(MAX(position), 0) + 1 FROM queue").fetchone()
next_pos = row[0]
conn.execute(
"INSERT OR IGNORE INTO queue (episode_id, position) VALUES (?, ?)",
(episode_id, next_pos),
)
conn.execute("UPDATE episodes SET queued=1 WHERE id=?", (episode_id,))
conn.commit()
def get_feed_state(conn: sqlite3.Connection, feed_name: str) -> dict | None:
row = conn.execute(
"SELECT * FROM feed_state WHERE feed_name=?", (feed_name,)
).fetchone()
return dict(row) if row else None
def update_feed_state(
conn: sqlite3.Connection,
feed_name: str,
etag: str | None = None,
modified: str | None = None,
) -> None:
conn.execute(
"""INSERT INTO feed_state (feed_name, last_poll, last_etag, last_modified)
VALUES (?, datetime('now'), ?, ?)
ON CONFLICT(feed_name) DO UPDATE SET
last_poll=datetime('now'),
last_etag=COALESCE(?, last_etag),
last_modified=COALESCE(?, last_modified)""",
(feed_name, etag, modified, etag, modified),
)
conn.commit()
# ---------------------------------------------------------------------------
# Feed parsing
# ---------------------------------------------------------------------------
def load_feeds() -> list[dict]:
with open(FEEDS_CONFIG) as f:
data = yaml.safe_load(f)
feeds = data.get("feeds", [])
return [f for f in feeds if f.get("enabled", True)]
def extract_audio_url(entry) -> str | None:
"""Extract the audio enclosure URL from a feed entry."""
# Check enclosures first
for enc in getattr(entry, "enclosures", []):
etype = enc.get("type", "")
if etype.startswith("audio/") or enc.get("url", "").endswith(
(".mp3", ".m4a", ".ogg", ".opus", ".wav", ".flac")
):
return enc.get("url") or enc.get("href")
# Check media content
for media in getattr(entry, "media_content", []):
mtype = media.get("type", "")
if mtype.startswith("audio/"):
return media.get("url")
# Check links
for link in getattr(entry, "links", []):
if link.get("type", "").startswith("audio/"):
return link.get("href")
return None
def extract_guid(entry) -> str:
"""Get a stable unique identifier for an entry."""
if hasattr(entry, "id") and entry.id:
return entry.id
url = extract_audio_url(entry)
if url:
return hashlib.sha256(url.encode()).hexdigest()
title = getattr(entry, "title", "")
return hashlib.sha256(title.encode()).hexdigest()
def extract_pub_date(entry) -> str | None:
if hasattr(entry, "published_parsed") and entry.published_parsed:
try:
return datetime(*entry.published_parsed[:6], tzinfo=timezone.utc).isoformat()
except Exception:
pass
return getattr(entry, "published", None)
# ---------------------------------------------------------------------------
# Downloading
# ---------------------------------------------------------------------------
def sanitize_filename(name: str) -> str:
name = re.sub(r'[<>:"/\\|?*]', "_", name)
name = re.sub(r"\s+", "_", name)
return name[:200]
def download_episode(url: str, feed_name: str, title: str) -> Path | None:
"""Download an audio file. Returns the local path or None on failure."""
feed_dir = PODCAST_DIR / sanitize_filename(feed_name)
feed_dir.mkdir(parents=True, exist_ok=True)
# Determine filename from URL or title
parsed = urlparse(url)
url_filename = Path(parsed.path).name
if not url_filename or len(url_filename) < 3:
url_filename = sanitize_filename(title) + ".mp3"
dest = feed_dir / sanitize_filename(url_filename)
if dest.exists():
log.info("File already exists: %s", dest)
return dest
tmp = dest.with_suffix(dest.suffix + ".part")
try:
log.info("Downloading: %s", url)
with requests.get(
url,
stream=True,
timeout=DOWNLOAD_TIMEOUT,
headers={"User-Agent": USER_AGENT},
) as resp:
resp.raise_for_status()
with open(tmp, "wb") as f:
for chunk in resp.iter_content(chunk_size=65536):
if _shutdown:
log.warning("Shutdown during download, aborting")
tmp.unlink(missing_ok=True)
return None
f.write(chunk)
tmp.rename(dest)
log.info("Downloaded: %s (%.1f MB)", dest, dest.stat().st_size / 1048576)
return dest
except Exception:
log.exception("Failed to download %s", url)
tmp.unlink(missing_ok=True)
return None
# ---------------------------------------------------------------------------
# Queue management
# ---------------------------------------------------------------------------
def link_to_queue(file_path: Path, episode_id: int) -> None:
"""Create a symlink in the queue directory for Liquidsoap to pick up.
Filename is prefixed with a timestamp to ensure FIFO ordering when
Liquidsoap reads the directory in alphabetical order.
"""
QUEUE_DIR.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
link_name = f"{timestamp}_{episode_id:06d}_{file_path.name}"
link_path = QUEUE_DIR / link_name
try:
link_path.symlink_to(file_path.resolve())
log.info("Queued (symlink): %s -> %s", link_name, file_path)
except (OSError, FileExistsError):
# Symlinks may fail on Windows without admin; fall back to hard link
try:
link_path.hardlink_to(file_path.resolve())
log.info("Queued (hardlink): %s -> %s", link_name, file_path)
except (OSError, FileExistsError):
# Last resort: copy the file
shutil.copy2(str(file_path), str(link_path))
log.info("Queued (copy): %s -> %s", link_name, file_path)
def cleanup_played_queue_files(conn: sqlite3.Connection) -> None:
"""Remove queue symlinks for files that no longer exist (already played).
Liquidsoap in 'normal' mode plays files and moves on. We detect played
files by checking if the symlink target is gone or if the file was consumed.
This is a simple approach: remove broken symlinks.
"""
if not QUEUE_DIR.exists():
return
for item in sorted(QUEUE_DIR.iterdir()):
if item.is_symlink() and not item.resolve().exists():
log.info("Removing broken queue link: %s", item.name)
item.unlink()
# ---------------------------------------------------------------------------
# Main poll cycle
# ---------------------------------------------------------------------------
def poll_feed(conn: sqlite3.Connection, feed_cfg: dict) -> int:
"""Poll a single feed. Returns number of new episodes found."""
name = feed_cfg["name"]
url = feed_cfg["url"]
new_count = 0
log.info("Polling feed: %s", name)
state = get_feed_state(conn, name)
kwargs = {}
if state:
if state.get("last_etag"):
kwargs["etag"] = state["last_etag"]
if state.get("last_modified"):
kwargs["modified"] = state["last_modified"]
try:
feed = feedparser.parse(url, agent=USER_AGENT, **kwargs)
except Exception:
log.exception("Failed to parse feed: %s", name)
return 0
if feed.bozo and not feed.entries:
log.warning("Feed error for %s: %s", name, feed.bozo_exception)
return 0
# Update feed state
etag = getattr(feed, "etag", None)
modified = getattr(feed, "modified", None)
update_feed_state(conn, name, etag, modified)
if feed.status == 304:
log.info("Feed %s: not modified", name)
return 0
for entry in feed.entries:
guid = extract_guid(entry)
if episode_exists(conn, name, guid):
continue
audio_url = extract_audio_url(entry)
if not audio_url:
log.debug("No audio URL in entry: %s", getattr(entry, "title", "unknown"))
continue
title = getattr(entry, "title", "Untitled")
pub_date = extract_pub_date(entry)
episode_id = insert_episode(conn, name, guid, title, audio_url, pub_date)
log.info("New episode: [%s] %s", name, title)
# Download
file_path = download_episode(audio_url, name, title)
if _shutdown:
return new_count
if file_path:
mark_downloaded(conn, episode_id, str(file_path))
enqueue_episode(conn, episode_id)
link_to_queue(file_path, episode_id)
new_count += 1
else:
log.error("Skipping episode (download failed): %s", title)
return new_count
def poll_all_feeds() -> None:
feeds = load_feeds()
if not feeds:
log.warning("No enabled feeds configured in %s", FEEDS_CONFIG)
return
conn = get_db()
try:
total_new = 0
for feed_cfg in feeds:
if _shutdown:
break
try:
total_new += poll_feed(conn, feed_cfg)
except Exception:
log.exception("Error processing feed: %s", feed_cfg.get("name"))
cleanup_played_queue_files(conn)
if total_new > 0:
log.info("Poll complete: %d new episode(s) enqueued", total_new)
else:
log.info("Poll complete: no new episodes")
finally:
conn.close()
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
log.info("Local Radio RSS Poller starting (interval=%ds)", POLL_INTERVAL)
log.info("Config: %s", FEEDS_CONFIG)
log.info("Database: %s", DB_PATH)
log.info("Queue dir: %s", QUEUE_DIR)
# Ensure directories exist
QUEUE_DIR.mkdir(parents=True, exist_ok=True)
PODCAST_DIR.mkdir(parents=True, exist_ok=True)
# Ensure DB is initialized
from init_db import init_db
init_db(DB_PATH)
while not _shutdown:
try:
poll_all_feeds()
except Exception:
log.exception("Unexpected error in poll cycle")
# Sleep in small increments so we can respond to signals
for _ in range(POLL_INTERVAL):
if _shutdown:
break
time.sleep(1)
log.info("Poller shut down cleanly")
if __name__ == "__main__":
main()

83
scripts/queue_status.py Normal file
View File

@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Show the current podcast queue and episode history."""
import os
import sqlite3
import sys
from pathlib import Path
BASE_DIR = Path(os.environ.get("LOCALRADIO_BASE", "/opt/localradio"))
DB_PATH = BASE_DIR / "state" / "radio.db"
QUEUE_DIR = BASE_DIR / "state" / "queue"
def show_queue():
if not DB_PATH.exists():
print("Database not found. Run init_db.py first.")
sys.exit(1)
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
# Current queue
rows = conn.execute("""
SELECT q.position, e.feed_name, e.title, e.pub_date, e.file_path, q.enqueued
FROM queue q
JOIN episodes e ON e.id = q.episode_id
WHERE q.played = 0
ORDER BY q.position
""").fetchall()
print("=== Podcast Queue ===")
if rows:
for row in rows:
print(f" [{row['position']:3d}] {row['feed_name']}: {row['title']}")
print(f" Published: {row['pub_date'] or 'unknown'}")
print(f" Enqueued: {row['enqueued']}")
else:
print(" (empty — music is playing)")
# Queue directory
print(f"\n=== Queue Directory ({QUEUE_DIR}) ===")
if QUEUE_DIR.exists():
files = sorted(QUEUE_DIR.iterdir())
if files:
for f in files:
target = f" -> {os.readlink(f)}" if f.is_symlink() else ""
print(f" {f.name}{target}")
else:
print(" (empty)")
else:
print(" (directory missing)")
# Recent episodes
recent = conn.execute("""
SELECT feed_name, title, pub_date, downloaded, queued, played, discovered
FROM episodes
ORDER BY discovered DESC
LIMIT 15
""").fetchall()
print("\n=== Recent Episodes (last 15) ===")
for row in recent:
status_flags = []
if row["downloaded"]:
status_flags.append("DL")
if row["queued"]:
status_flags.append("Q")
if row["played"]:
status_flags.append("P")
status = ",".join(status_flags) or "-"
print(f" [{status:6s}] {row['feed_name']}: {row['title']}")
# Feed state
feeds = conn.execute("SELECT * FROM feed_state ORDER BY feed_name").fetchall()
print("\n=== Feed State ===")
for f in feeds:
print(f" {f['feed_name']}: last polled {f['last_poll'] or 'never'}")
conn.close()
if __name__ == "__main__":
show_queue()

114
scripts/setup.sh Normal file
View File

@ -0,0 +1,114 @@
#!/usr/bin/env bash
# =============================================================================
# Local Radio Station — Initial Setup Script
# Run as root on Debian 13
# =============================================================================
set -euo pipefail
INSTALL_DIR="/opt/localradio"
RADIO_USER="localradio"
RADIO_GROUP="localradio"
echo "=== Local Radio Station Setup ==="
# --- Install system packages --------------------------------------------------
echo "[1/7] Installing system packages..."
apt-get update
apt-get install -y \
icecast2 \
liquidsoap \
python3 \
python3-venv \
python3-pip \
ffmpeg \
sqlite3
# --- Create system user ------------------------------------------------------
echo "[2/7] Creating system user..."
if ! id "$RADIO_USER" &>/dev/null; then
useradd --system --home-dir "$INSTALL_DIR" --shell /usr/sbin/nologin "$RADIO_USER"
echo " Created user: $RADIO_USER"
else
echo " User $RADIO_USER already exists"
fi
# --- Create directory structure -----------------------------------------------
echo "[3/7] Creating directory structure..."
mkdir -p "$INSTALL_DIR"/{config,scripts,state/queue,media/music/{morning,day,night,weekend},media/podcasts,media/fallback,logs}
# --- Copy project files -------------------------------------------------------
echo "[4/7] Copying project files..."
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cp "$SCRIPT_DIR/config/station.liq" "$INSTALL_DIR/config/"
cp "$SCRIPT_DIR/config/icecast.xml" "$INSTALL_DIR/config/"
cp "$SCRIPT_DIR/config/station.yaml" "$INSTALL_DIR/config/"
cp "$SCRIPT_DIR/config/localradio.env" "$INSTALL_DIR/config/"
# Only copy feeds.yaml if it doesn't already exist (preserve user edits)
if [ ! -f "$INSTALL_DIR/config/feeds.yaml" ]; then
cp "$SCRIPT_DIR/config/feeds.yaml" "$INSTALL_DIR/config/"
fi
cp "$SCRIPT_DIR/scripts/"*.py "$INSTALL_DIR/scripts/"
cp "$SCRIPT_DIR/scripts/"*.sh "$INSTALL_DIR/scripts/" 2>/dev/null || true
cp "$SCRIPT_DIR/requirements.txt" "$INSTALL_DIR/"
# Copy web UI
cp -r "$SCRIPT_DIR/web" "$INSTALL_DIR/"
# --- Set up Python virtual environment ----------------------------------------
echo "[5/7] Setting up Python virtual environment..."
python3 -m venv "$INSTALL_DIR/venv"
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip
"$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt"
# --- Build frontend -----------------------------------------------------------
echo "[5b/7] Building frontend..."
if command -v npm &>/dev/null; then
cd "$INSTALL_DIR/web/frontend"
npm install
npm run build
cd -
echo " Frontend built successfully"
else
echo " WARNING: npm not found. Install Node.js and run:"
echo " cd $INSTALL_DIR/web/frontend && npm install && npm run build"
fi
# --- Initialize database ------------------------------------------------------
echo "[6/7] Initializing database..."
PYTHONPATH="$INSTALL_DIR/scripts" "$INSTALL_DIR/venv/bin/python" \
"$INSTALL_DIR/scripts/init_db.py" "$INSTALL_DIR/state/radio.db"
# --- Set ownership ------------------------------------------------------------
echo "[7/7] Setting file ownership..."
chown -R "$RADIO_USER:$RADIO_GROUP" "$INSTALL_DIR"
# Make scripts executable
chmod +x "$INSTALL_DIR/scripts/"*.sh 2>/dev/null || true
chmod +x "$INSTALL_DIR/scripts/"*.py
# --- Install systemd services -------------------------------------------------
echo ""
echo "=== Installing systemd services ==="
cp "$SCRIPT_DIR/systemd/"*.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable localradio-icecast localradio-stream localradio-poller localradio-web
echo ""
echo "=== Setup Complete ==="
echo ""
echo "Next steps:"
echo " 1. Add music files to $INSTALL_DIR/media/music/{morning,day,night,weekend}/"
echo " 2. Add at least one audio file to $INSTALL_DIR/media/fallback/"
echo " 3. Edit $INSTALL_DIR/config/feeds.yaml to add your podcast RSS feeds"
echo " 4. Review $INSTALL_DIR/config/icecast.xml passwords"
echo " 5. Start services:"
echo " systemctl start localradio-icecast"
echo " systemctl start localradio-stream"
echo " systemctl start localradio-poller"
echo " systemctl start localradio-web"
echo " 6. Listen at: http://localhost:8000/stream"
echo " 7. Web dashboard: http://localhost:5000"
echo ""

56
scripts/test_feed.py Normal file
View File

@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Test downloading from a specific RSS feed without affecting the database."""
import sys
import feedparser
def test_feed(url: str) -> None:
print(f"Fetching: {url}\n")
feed = feedparser.parse(url)
if feed.bozo and not feed.entries:
print(f"ERROR: Feed parse error: {feed.bozo_exception}")
sys.exit(1)
print(f"Feed title: {feed.feed.get('title', 'N/A')}")
print(f"Entries found: {len(feed.entries)}")
print()
for i, entry in enumerate(feed.entries[:5]):
print(f"--- Entry {i + 1} ---")
print(f" Title: {getattr(entry, 'title', 'N/A')}")
print(f" Published: {getattr(entry, 'published', 'N/A')}")
print(f" ID: {getattr(entry, 'id', 'N/A')}")
# Find audio
audio_url = None
for enc in getattr(entry, "enclosures", []):
etype = enc.get("type", "")
if etype.startswith("audio/"):
audio_url = enc.get("url") or enc.get("href")
print(f" Audio: {audio_url}")
print(f" Type: {etype}")
print(f" Length: {enc.get('length', 'N/A')} bytes")
break
if not audio_url:
for media in getattr(entry, "media_content", []):
if media.get("type", "").startswith("audio/"):
audio_url = media.get("url")
print(f" Audio: {audio_url}")
break
if not audio_url:
print(" Audio: (none found)")
print()
if __name__ == "__main__":
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <rss-feed-url>")
print(f"Example: {sys.argv[0]} https://feeds.example.com/podcast.xml")
sys.exit(1)
test_feed(sys.argv[1])

167
scripts/validate_config.py Normal file
View File

@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Validate all configuration files for the local radio station."""
import os
import sys
from pathlib import Path
import yaml
BASE_DIR = Path(os.environ.get("LOCALRADIO_BASE", "/opt/localradio"))
errors = []
warnings = []
def err(msg):
errors.append(msg)
print(f" ERROR: {msg}")
def warn(msg):
warnings.append(msg)
print(f" WARN: {msg}")
def ok(msg):
print(f" OK: {msg}")
def validate_feeds():
print("=== Validating feeds.yaml ===")
path = BASE_DIR / "config" / "feeds.yaml"
if not path.exists():
err(f"Missing: {path}")
return
with open(path) as f:
data = yaml.safe_load(f)
if not data or "feeds" not in data:
err("feeds.yaml must have a 'feeds' key")
return
feeds = data["feeds"]
enabled_count = 0
for i, feed in enumerate(feeds):
if not feed.get("name"):
err(f"Feed [{i}]: missing 'name'")
if not feed.get("url"):
err(f"Feed [{i}]: missing 'url'")
elif not feed["url"].startswith(("http://", "https://")):
err(f"Feed '{feed.get('name', i)}': URL must start with http:// or https://")
if feed.get("enabled", True):
enabled_count += 1
if enabled_count == 0:
warn("No feeds are enabled")
else:
ok(f"{enabled_count} feed(s) enabled")
def validate_station():
print("\n=== Validating station.yaml ===")
path = BASE_DIR / "config" / "station.yaml"
if not path.exists():
err(f"Missing: {path}")
return
with open(path) as f:
data = yaml.safe_load(f)
if not data.get("schedule"):
err("station.yaml must have a 'schedule' section")
return
for block in data["schedule"]:
name = block.get("name", "unnamed")
if not block.get("folder"):
err(f"Schedule '{name}': missing 'folder'")
if not block.get("days"):
err(f"Schedule '{name}': missing 'days'")
if not block.get("start") or not block.get("end"):
err(f"Schedule '{name}': missing 'start' or 'end'")
ok(f"{len(data['schedule'])} schedule block(s) defined")
fmt = data.get("audio", {}).get("format", "mp3")
bitrate = data.get("audio", {}).get("bitrate", 320)
ok(f"Audio: {fmt} @ {bitrate} kbps")
def validate_directories():
print("\n=== Validating directories ===")
required = [
"config",
"scripts",
"state",
"state/queue",
"media/music/morning",
"media/music/day",
"media/music/night",
"media/music/weekend",
"media/podcasts",
"media/fallback",
"logs",
]
for d in required:
full = BASE_DIR / d
if full.exists():
ok(f"{d}/")
else:
err(f"Missing directory: {d}/")
# Check fallback has content
fallback = BASE_DIR / "media" / "fallback"
if fallback.exists():
audio_exts = {".mp3", ".flac", ".ogg", ".opus", ".m4a", ".wav"}
files = [f for f in fallback.iterdir() if f.suffix.lower() in audio_exts]
if not files:
warn("media/fallback/ has no audio files — stream may go silent!")
else:
ok(f"media/fallback/ has {len(files)} audio file(s)")
def validate_icecast():
print("\n=== Validating icecast.xml ===")
path = BASE_DIR / "config" / "icecast.xml"
if not path.exists():
err(f"Missing: {path}")
return
content = path.read_text()
if "localradio_source" in content:
warn("icecast.xml still uses default source password — change it for security")
if "localradio_admin" in content:
warn("icecast.xml still uses default admin password — change it for security")
ok("icecast.xml exists and is parseable")
def validate_liquidsoap():
print("\n=== Validating station.liq ===")
path = BASE_DIR / "config" / "station.liq"
if not path.exists():
err(f"Missing: {path}")
return
ok("station.liq exists")
def main():
print(f"Validating configuration at: {BASE_DIR}\n")
validate_feeds()
validate_station()
validate_directories()
validate_icecast()
validate_liquidsoap()
print("\n" + "=" * 50)
if errors:
print(f"RESULT: {len(errors)} error(s), {len(warnings)} warning(s)")
sys.exit(1)
elif warnings:
print(f"RESULT: OK with {len(warnings)} warning(s)")
else:
print("RESULT: All checks passed")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,17 @@
[Unit]
Description=Local Radio - Icecast Streaming Server
After=network.target
Wants=network.target
[Service]
Type=simple
ExecStart=/usr/bin/icecast2 -c /opt/localradio/config/icecast.xml
Restart=always
RestartSec=5
User=localradio
Group=localradio
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,20 @@
[Unit]
Description=Local Radio - RSS Feed Poller
After=localradio-stream.service
Wants=localradio-stream.service
[Service]
Type=simple
ExecStart=/opt/localradio/venv/bin/python /opt/localradio/scripts/poll_feeds.py
Restart=always
RestartSec=30
User=localradio
Group=localradio
EnvironmentFile=-/opt/localradio/config/localradio.env
Environment=LOCALRADIO_BASE=/opt/localradio
Environment=PYTHONPATH=/opt/localradio/scripts
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,21 @@
[Unit]
Description=Local Radio - Liquidsoap Stream Engine
After=localradio-icecast.service
Requires=localradio-icecast.service
[Service]
Type=simple
ExecStart=/usr/bin/liquidsoap /opt/localradio/config/station.liq
Restart=always
RestartSec=10
User=localradio
Group=localradio
EnvironmentFile=-/opt/localradio/config/localradio.env
StandardOutput=journal
StandardError=journal
# Give Liquidsoap time to initialize
TimeoutStartSec=30
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,20 @@
[Unit]
Description=Local Radio - Web Dashboard
After=localradio-stream.service
Wants=localradio-stream.service
[Service]
Type=simple
ExecStart=/opt/localradio/venv/bin/python -m web.app
Restart=always
RestartSec=10
User=localradio
Group=localradio
EnvironmentFile=-/opt/localradio/config/localradio.env
Environment=LOCALRADIO_BASE=/opt/localradio
WorkingDirectory=/opt/localradio
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

0
web/__init__.py Normal file
View File

13
web/api/__init__.py Normal file
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
web/api/episodes.py Normal file
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
web/api/feeds.py Normal file
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
web/api/logs.py Normal file
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
web/api/music.py Normal file
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
web/api/queue.py Normal file
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
web/api/schedule.py Normal file
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
web/api/status.py Normal file
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
web/api/stream.py Normal file
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
web/app.py Normal file
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
web/config.py Normal file
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
web/db.py Normal file
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
web/frontend/index.html Normal file
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
web/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
web/frontend/package.json Normal file
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"
}
}

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
web/frontend/src/App.jsx Normal file
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
web/frontend/src/api.js Normal file
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}`);
},
};

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

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

View File

@ -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>
);
}

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

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

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

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

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

View File

@ -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
web/frontend/src/main.jsx Normal file
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>
);

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; }
}

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: [],
};

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",
},
},
});