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:
commit
3d635b742c
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal 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
274
README.md
Normal 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
11
config/feeds.yaml
Normal 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
58
config/icecast.xml
Normal 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
10
config/localradio.env
Normal 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
156
config/station.liq
Normal 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
67
config/station.yaml
Normal 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
4
requirements.txt
Normal file
@ -0,0 +1,4 @@
|
||||
feedparser>=6.0
|
||||
requests>=2.28
|
||||
PyYAML>=6.0
|
||||
flask>=3.0
|
||||
11
run_web.py
Normal file
11
run_web.py
Normal 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)
|
||||
46
scripts/enqueue_episode.py
Normal file
46
scripts/enqueue_episode.py
Normal 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
142
scripts/healthcheck.sh
Normal 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
61
scripts/init_db.py
Normal 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
447
scripts/poll_feeds.py
Normal 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
83
scripts/queue_status.py
Normal 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
114
scripts/setup.sh
Normal 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
56
scripts/test_feed.py
Normal 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
167
scripts/validate_config.py
Normal 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()
|
||||
17
systemd/localradio-icecast.service
Normal file
17
systemd/localradio-icecast.service
Normal 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
|
||||
20
systemd/localradio-poller.service
Normal file
20
systemd/localradio-poller.service
Normal 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
|
||||
21
systemd/localradio-stream.service
Normal file
21
systemd/localradio-stream.service
Normal 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
|
||||
20
systemd/localradio-web.service
Normal file
20
systemd/localradio-web.service
Normal 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
0
web/__init__.py
Normal file
13
web/api/__init__.py
Normal file
13
web/api/__init__.py
Normal 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
63
web/api/episodes.py
Normal 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
127
web/api/feeds.py
Normal 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
48
web/api/logs.py
Normal 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
108
web/api/music.py
Normal 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
120
web/api/queue.py
Normal 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
50
web/api/schedule.py
Normal 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
88
web/api/status.py
Normal 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
179
web/api/stream.py
Normal 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
45
web/app.py
Normal 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
23
web/config.py
Normal 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
23
web/db.py
Normal 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
13
web/frontend/index.html
Normal 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
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
23
web/frontend/package.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "localradio-ui",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
11
web/frontend/postcss.config.js
Normal file
11
web/frontend/postcss.config.js
Normal 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
28
web/frontend/src/App.jsx
Normal 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
54
web/frontend/src/api.js
Normal file
@ -0,0 +1,54 @@
|
||||
const BASE = "/api";
|
||||
|
||||
async function fetchJSON(path, options = {}) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getStatus: () => fetchJSON("/status"),
|
||||
getQueue: () => fetchJSON("/queue"),
|
||||
enqueue: (data) =>
|
||||
fetchJSON("/queue", { method: "POST", body: JSON.stringify(data) }),
|
||||
dequeue: (id) => fetchJSON(`/queue/${id}`, { method: "DELETE" }),
|
||||
|
||||
getEpisodes: (params = {}) => {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return fetchJSON(`/episodes?${qs}`);
|
||||
},
|
||||
getEpisodeFeeds: () => fetchJSON("/episodes/feeds"),
|
||||
|
||||
getFeeds: () => fetchJSON("/feeds"),
|
||||
addFeed: (data) =>
|
||||
fetchJSON("/feeds", { method: "POST", body: JSON.stringify(data) }),
|
||||
updateFeed: (name, data) =>
|
||||
fetchJSON(`/feeds/${encodeURIComponent(name)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
deleteFeed: (name) =>
|
||||
fetchJSON(`/feeds/${encodeURIComponent(name)}`, { method: "DELETE" }),
|
||||
|
||||
getSchedule: () => fetchJSON("/schedule"),
|
||||
getMusic: () => fetchJSON("/music"),
|
||||
getMusicFiles: (folder) => fetchJSON(`/music/${encodeURIComponent(folder)}/files`),
|
||||
moveFile: (file, from_, to) =>
|
||||
fetchJSON("/music/move", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ file, from: from_, to }),
|
||||
}),
|
||||
getNextTrack: () => fetchJSON("/stream/next"),
|
||||
markDone: (id) => fetchJSON("/stream/done", { method: "POST", body: JSON.stringify({ id }) }),
|
||||
skip: () => fetchJSON("/stream/skip", { method: "POST" }),
|
||||
getLogs: (params = {}) => {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return fetchJSON(`/logs?${qs}`);
|
||||
},
|
||||
};
|
||||
216
web/frontend/src/components/AudioPlayer.jsx
Normal file
216
web/frontend/src/components/AudioPlayer.jsx
Normal file
@ -0,0 +1,216 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { api } from "../api";
|
||||
|
||||
function formatTime(s) {
|
||||
if (!s || !isFinite(s)) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default function AudioPlayer() {
|
||||
const audioRef = useRef(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(0.8);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [track, setTrack] = useState(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [seeking, setSeeking] = useState(false);
|
||||
const activeRef = useRef(false); // whether the player should be running
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current) audioRef.current.volume = volume;
|
||||
}, [volume]);
|
||||
|
||||
// Time update for progress bar
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const onTime = () => { if (!seeking) setProgress(audio.currentTime); };
|
||||
const onDur = () => setDuration(audio.duration);
|
||||
audio.addEventListener("timeupdate", onTime);
|
||||
audio.addEventListener("durationchange", onDur);
|
||||
return () => {
|
||||
audio.removeEventListener("timeupdate", onTime);
|
||||
audio.removeEventListener("durationchange", onDur);
|
||||
};
|
||||
}, [seeking]);
|
||||
|
||||
const loadAndPlay = useCallback(async () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !activeRef.current) return;
|
||||
setError(false);
|
||||
try {
|
||||
const next = await api.getNextTrack();
|
||||
setTrack(next);
|
||||
audio.src = `/api/stream/play/${next.id}`;
|
||||
audio.load();
|
||||
await audio.play();
|
||||
setPlaying(true);
|
||||
} catch {
|
||||
setError(true);
|
||||
setPlaying(false);
|
||||
// Retry after a pause
|
||||
if (activeRef.current) setTimeout(loadAndPlay, 3000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// When track ends, load next
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const onEnded = () => {
|
||||
if (track?.source === "queue" && track?.id) {
|
||||
api.markDone(track.id).catch(() => {});
|
||||
}
|
||||
loadAndPlay();
|
||||
};
|
||||
audio.addEventListener("ended", onEnded);
|
||||
return () => audio.removeEventListener("ended", onEnded);
|
||||
}, [track, loadAndPlay]);
|
||||
|
||||
const toggle = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (playing) {
|
||||
audio.pause();
|
||||
setPlaying(false);
|
||||
activeRef.current = false;
|
||||
} else {
|
||||
activeRef.current = true;
|
||||
if (audio.src && audio.src !== window.location.href) {
|
||||
audio.play().catch(() => {});
|
||||
setPlaying(true);
|
||||
} else {
|
||||
loadAndPlay();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const skip = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
audio.pause();
|
||||
if (track?.source === "queue") {
|
||||
api.skip().catch(() => {});
|
||||
}
|
||||
loadAndPlay();
|
||||
};
|
||||
|
||||
const handleSeek = (e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
setProgress(val);
|
||||
if (audioRef.current) audioRef.current.currentTime = val;
|
||||
setSeeking(false);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.muted = !muted;
|
||||
setMuted(!muted);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-radio-card border-t border-radio-border z-50">
|
||||
{/* Progress bar */}
|
||||
{playing && duration > 0 && (
|
||||
<div className="px-4 pt-1 flex items-center gap-2 text-xs text-radio-muted">
|
||||
<span className="w-10 text-right">{formatTime(progress)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 0}
|
||||
step="0.1"
|
||||
value={progress}
|
||||
onMouseDown={() => setSeeking(true)}
|
||||
onTouchStart={() => setSeeking(true)}
|
||||
onChange={handleSeek}
|
||||
className="flex-1 accent-radio-accent h-1"
|
||||
/>
|
||||
<span className="w-10">{formatTime(duration)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-4 py-2 flex items-center gap-4">
|
||||
<audio ref={audioRef} preload="auto" />
|
||||
|
||||
{/* Play/Pause */}
|
||||
<button onClick={toggle} className="btn btn-primary w-10 h-10 flex items-center justify-center rounded-full p-0">
|
||||
{playing ? (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="6" y="4" width="4" height="16" />
|
||||
<rect x="14" y="4" width="4" height="16" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<polygon points="5,3 19,12 5,21" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Skip */}
|
||||
<button onClick={skip} className="btn-ghost p-1 rounded" title="Skip track">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<polygon points="5,4 15,12 5,20" />
|
||||
<rect x="15" y="4" width="4" height="16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Equalizer / Status */}
|
||||
<div className="flex items-end gap-0.5 h-5">
|
||||
{playing && !error ? (
|
||||
<>
|
||||
<div className="eq-bar" />
|
||||
<div className="eq-bar" />
|
||||
<div className="eq-bar" />
|
||||
<div className="eq-bar" />
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-radio-muted">
|
||||
{error ? "Stream unavailable" : "Stopped"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Now Playing */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{track?.title || "Local Radio"}
|
||||
</div>
|
||||
<div className="text-xs text-radio-muted">
|
||||
{playing
|
||||
? track?.source === "queue" ? "Podcast" : "On Air"
|
||||
: error ? "Could not connect" : "Click play to listen"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Volume */}
|
||||
<button onClick={toggleMute} className="btn-ghost p-1 rounded">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
{muted ? (
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5zM23 9l-6 6M17 9l6 6" />
|
||||
) : (
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5zM15.54 8.46a5 5 0 010 7.07M19.07 4.93a10 10 0 010 14.14" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={muted ? 0 : volume}
|
||||
onChange={(e) => {
|
||||
setVolume(parseFloat(e.target.value));
|
||||
if (muted) setMuted(false);
|
||||
}}
|
||||
className="w-24 accent-radio-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
web/frontend/src/components/Dashboard.jsx
Normal file
123
web/frontend/src/components/Dashboard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
web/frontend/src/components/EpisodesPanel.jsx
Normal file
155
web/frontend/src/components/EpisodesPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
web/frontend/src/components/FeedsPanel.jsx
Normal file
180
web/frontend/src/components/FeedsPanel.jsx
Normal file
@ -0,0 +1,180 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "../api";
|
||||
|
||||
function AddFeedModal({ onClose, onAdded }) {
|
||||
const [form, setForm] = useState({ name: "", url: "", priority: 10, max_episodes: 20 });
|
||||
const [error, setError] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.addFeed(form);
|
||||
onAdded();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="card w-full max-w-md" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="font-semibold text-lg mb-4">Add Feed</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-radio-muted block mb-1">Name</label>
|
||||
<input
|
||||
className="input w-full"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="My Podcast"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-radio-muted block mb-1">RSS URL</label>
|
||||
<input
|
||||
className="input w-full"
|
||||
type="url"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||
placeholder="https://feeds.example.com/podcast.xml"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-radio-muted block mb-1">Priority</label>
|
||||
<input
|
||||
className="input w-full"
|
||||
type="number"
|
||||
value={form.priority}
|
||||
onChange={(e) => setForm({ ...form, priority: parseInt(e.target.value) || 10 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-radio-muted block mb-1">Max Episodes</label>
|
||||
<input
|
||||
className="input w-full"
|
||||
type="number"
|
||||
value={form.max_episodes}
|
||||
onChange={(e) => setForm({ ...form, max_episodes: parseInt(e.target.value) || 20 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="text-radio-danger text-sm">{error}</div>}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button type="submit" disabled={saving} className="btn btn-primary flex-1">
|
||||
{saving ? "Adding..." : "Add Feed"}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="btn btn-ghost flex-1">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FeedsPanel() {
|
||||
const [feeds, setFeeds] = useState([]);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const refresh = () => api.getFeeds().then(setFeeds).catch((e) => setError(e.message));
|
||||
|
||||
useEffect(() => { refresh(); }, []);
|
||||
|
||||
const handleToggle = async (name, enabled) => {
|
||||
try {
|
||||
await api.updateFeed(name, { enabled: !enabled });
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (name) => {
|
||||
if (!confirm(`Remove feed "${name}"? This won't delete downloaded episodes.`)) return;
|
||||
try {
|
||||
await api.deleteFeed(name);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Feeds</h1>
|
||||
<button onClick={() => setShowAdd(true)} className="btn btn-primary text-sm">
|
||||
Add Feed
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="card border-radio-danger/30 text-radio-danger mb-4 text-sm">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2 underline">dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feeds.length === 0 ? (
|
||||
<div className="card text-center py-12">
|
||||
<div className="text-radio-muted text-lg mb-2">No feeds configured</div>
|
||||
<div className="text-sm text-radio-muted">Add an RSS feed to start receiving podcast episodes.</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{feeds.map((feed) => (
|
||||
<div key={feed.name} className="card flex items-center gap-4">
|
||||
{/* Toggle */}
|
||||
<button
|
||||
onClick={() => handleToggle(feed.name, feed.enabled)}
|
||||
className={`w-12 h-6 rounded-full flex items-center transition-colors flex-shrink-0 ${
|
||||
feed.enabled ? "bg-radio-success" : "bg-radio-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-5 h-5 rounded-full bg-white transition-transform shadow ${
|
||||
feed.enabled ? "translate-x-6" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{feed.name}</div>
|
||||
<div className="text-xs text-radio-muted truncate">{feed.url}</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-radio-muted">
|
||||
<span>{feed.episode_count} episodes</span>
|
||||
<span>Priority: {feed.priority}</span>
|
||||
{feed.last_poll && (
|
||||
<span>Last poll: {new Date(feed.last_poll + "Z").toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<button
|
||||
onClick={() => handleDelete(feed.name)}
|
||||
className="btn btn-danger text-sm"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && <AddFeedModal onClose={() => setShowAdd(false)} onAdded={refresh} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
web/frontend/src/components/Layout.jsx
Normal file
85
web/frontend/src/components/Layout.jsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import AudioPlayer from "./AudioPlayer";
|
||||
import { api } from "../api";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: "/", label: "Dashboard", icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1" },
|
||||
{ to: "/queue", label: "Queue", icon: "M4 6h16M4 10h16M4 14h16M4 18h16" },
|
||||
{ to: "/episodes", label: "Episodes", icon: "M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" },
|
||||
{ to: "/feeds", label: "Feeds", icon: "M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-6 0a1 1 0 11-2 0 1 1 0 012 0z" },
|
||||
{ to: "/schedule", label: "Schedule", icon: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" },
|
||||
{ to: "/music", label: "Music", icon: "M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z" },
|
||||
{ to: "/logs", label: "Logs", icon: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" },
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
const [status, setStatus] = useState(null);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const poll = () => api.getStatus().then(setStatus).catch(() => {});
|
||||
poll();
|
||||
const id = setInterval(poll, 10000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const streamOnline = status?.stream?.online;
|
||||
const nowPlaying = status?.stream?.now_playing;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen pb-16">
|
||||
{/* Sidebar */}
|
||||
<aside className={`${sidebarOpen ? "w-56" : "w-16"} flex-shrink-0 bg-radio-card border-r border-radio-border flex flex-col transition-all duration-200`}>
|
||||
{/* Logo / Header */}
|
||||
<div className="p-4 border-b border-radio-border flex items-center gap-3">
|
||||
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="text-radio-accent hover:text-yellow-400 transition-colors">
|
||||
<svg className="w-7 h-7" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path d="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728M12 12h.01M8.464 15.536a5 5 0 010-7.072m7.072 0a5 5 0 010 7.072" />
|
||||
</svg>
|
||||
</button>
|
||||
{sidebarOpen && (
|
||||
<div>
|
||||
<div className="font-bold text-radio-accent text-sm">LOCAL RADIO</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className={`w-2 h-2 rounded-full ${streamOnline ? "bg-radio-success animate-pulse" : "bg-radio-danger"}`} />
|
||||
<span className="text-xs text-radio-muted">{streamOnline ? "On Air" : "Offline"}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 py-2">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === "/"}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
||||
isActive
|
||||
? "text-radio-accent bg-radio-accent/10 border-r-2 border-radio-accent"
|
||||
: "text-radio-muted hover:text-radio-text hover:bg-radio-border/20"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={item.icon} />
|
||||
</svg>
|
||||
{sidebarOpen && <span>{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<Outlet context={{ status }} />
|
||||
</main>
|
||||
|
||||
{/* Persistent audio player */}
|
||||
<AudioPlayer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
web/frontend/src/components/LogsPanel.jsx
Normal file
107
web/frontend/src/components/LogsPanel.jsx
Normal file
@ -0,0 +1,107 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { api } from "../api";
|
||||
|
||||
const LOG_FILES = ["poller", "liquidsoap", "icecast_access", "icecast_error"];
|
||||
|
||||
export default function LogsPanel() {
|
||||
const [logFile, setLogFile] = useState("poller");
|
||||
const [lines, setLines] = useState([]);
|
||||
const [lineCount, setLineCount] = useState(100);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [exists, setExists] = useState(true);
|
||||
const bottomRef = useRef(null);
|
||||
|
||||
const fetchLogs = () => {
|
||||
api.getLogs({ file: logFile, lines: lineCount })
|
||||
.then((data) => {
|
||||
setLines(data.lines);
|
||||
setExists(data.exists);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(fetchLogs, [logFile, lineCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const id = setInterval(fetchLogs, 5000);
|
||||
return () => clearInterval(id);
|
||||
}, [autoRefresh, logFile, lineCount]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [lines]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6 flex-wrap gap-3">
|
||||
<h1 className="text-2xl font-bold">Logs</h1>
|
||||
<div className="flex gap-3 items-center">
|
||||
<select
|
||||
value={logFile}
|
||||
onChange={(e) => setLogFile(e.target.value)}
|
||||
className="input text-sm"
|
||||
>
|
||||
{LOG_FILES.map((f) => (
|
||||
<option key={f} value={f}>{f}.log</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={lineCount}
|
||||
onChange={(e) => setLineCount(parseInt(e.target.value))}
|
||||
className="input text-sm"
|
||||
>
|
||||
<option value={50}>50 lines</option>
|
||||
<option value={100}>100 lines</option>
|
||||
<option value={200}>200 lines</option>
|
||||
</select>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
className="accent-radio-accent"
|
||||
/>
|
||||
Auto-refresh
|
||||
</label>
|
||||
|
||||
<button onClick={fetchLogs} className="btn btn-ghost text-sm">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-0">
|
||||
{!exists ? (
|
||||
<div className="p-8 text-center text-radio-muted">
|
||||
Log file <code>{logFile}.log</code> does not exist yet
|
||||
</div>
|
||||
) : lines.length === 0 ? (
|
||||
<div className="p-8 text-center text-radio-muted">
|
||||
Log file is empty
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-xs font-mono overflow-x-auto p-4 max-h-[70vh] overflow-y-auto leading-relaxed">
|
||||
{lines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`py-0.5 ${
|
||||
line.includes("ERROR") || line.includes("error")
|
||||
? "text-radio-danger"
|
||||
: line.includes("WARNING") || line.includes("warning")
|
||||
? "text-yellow-400"
|
||||
: "text-radio-muted"
|
||||
}`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
web/frontend/src/components/MusicPanel.jsx
Normal file
172
web/frontend/src/components/MusicPanel.jsx
Normal file
@ -0,0 +1,172 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "../api";
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function FolderCard({ folder, name, path, count, isFallback, allFolders, onRefresh }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [moving, setMoving] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadFiles = () => {
|
||||
setLoading(true);
|
||||
api.getMusicFiles(folder).then((d) => {
|
||||
setFiles(d.files);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
if (!expanded) loadFiles();
|
||||
setExpanded(!expanded);
|
||||
};
|
||||
|
||||
const handleMove = async (filename, destFolder) => {
|
||||
setMoving(filename);
|
||||
setError(null);
|
||||
try {
|
||||
await api.moveFile(filename, folder, destFolder);
|
||||
loadFiles();
|
||||
onRefresh();
|
||||
} catch (e) {
|
||||
setError(`${filename}: ${e.message}`);
|
||||
} finally {
|
||||
setMoving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const destinations = allFolders.filter((f) => f !== folder);
|
||||
|
||||
return (
|
||||
<div className={`card ${isFallback ? (count === 0 ? "border-radio-danger/50" : "border-radio-success/30") : ""}`}>
|
||||
<button onClick={toggle} className="w-full flex items-center justify-between text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className={`w-8 h-8 ${isFallback ? "text-radio-muted" : "text-radio-accent"}`} fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||
{isFallback ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z" />
|
||||
)}
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-medium capitalize">{name}</div>
|
||||
<div className="text-xs text-radio-muted">{isFallback ? "Emergency playlist" : path}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-2xl font-bold ${count > 0 ? (isFallback ? "text-radio-success" : "text-radio-text") : "text-radio-danger"}`}>
|
||||
{count}
|
||||
</span>
|
||||
<svg className={`w-4 h-4 text-radio-muted transition-transform ${expanded ? "rotate-180" : ""}`} fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{count === 0 && !expanded && (
|
||||
<div className="text-xs text-radio-danger mt-2">
|
||||
{isFallback
|
||||
? "Add at least one audio file to prevent silence when all other sources fail"
|
||||
: "No audio files — this schedule block will be silent"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-3 border-t border-radio-border pt-3">
|
||||
{loading ? (
|
||||
<div className="text-sm text-radio-muted">Loading...</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="text-sm text-radio-muted">No files</div>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{files.map((file) => (
|
||||
<li key={file.name} className="flex items-center gap-2 text-sm group">
|
||||
<svg className="w-4 h-4 text-radio-muted flex-shrink-0" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H2.25A.75.75 0 011.5 15V9a.75.75 0 01.75-.75h4.5z" />
|
||||
</svg>
|
||||
<span className="truncate flex-1" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="text-xs text-radio-muted flex-shrink-0">
|
||||
{formatSize(file.size)}
|
||||
</span>
|
||||
{moving === file.name ? (
|
||||
<span className="text-xs text-radio-accent flex-shrink-0">Moving...</span>
|
||||
) : (
|
||||
<select
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-xs bg-radio-bg border border-radio-border rounded px-1 py-0.5 text-radio-text flex-shrink-0"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value) handleMove(file.name, e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">Move to...</option>
|
||||
{destinations.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-xs text-radio-danger mt-2">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MusicPanel() {
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
const load = () => api.getMusic().then(setData).catch(() => {});
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
if (!data) return <div className="text-radio-muted">Loading...</div>;
|
||||
|
||||
const allFolders = [
|
||||
...data.directories.map((d) => d.folder),
|
||||
"fallback",
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Music Library</h1>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{data.directories.map((dir) => (
|
||||
<FolderCard
|
||||
key={dir.folder}
|
||||
folder={dir.folder}
|
||||
name={dir.folder}
|
||||
path={dir.path}
|
||||
count={dir.file_count}
|
||||
isFallback={false}
|
||||
allFolders={allFolders}
|
||||
onRefresh={load}
|
||||
/>
|
||||
))}
|
||||
|
||||
<FolderCard
|
||||
folder="fallback"
|
||||
name="Fallback"
|
||||
path={data.fallback.path}
|
||||
count={data.fallback.file_count}
|
||||
isFallback={true}
|
||||
allFolders={allFolders}
|
||||
onRefresh={load}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
web/frontend/src/components/QueuePanel.jsx
Normal file
128
web/frontend/src/components/QueuePanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
web/frontend/src/components/SchedulePanel.jsx
Normal file
117
web/frontend/src/components/SchedulePanel.jsx
Normal 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(", ")} · {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
10
web/frontend/src/main.jsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles/index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
95
web/frontend/src/styles/index.css
Normal file
95
web/frontend/src/styles/index.css
Normal file
@ -0,0 +1,95 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
background-color: #0f1419;
|
||||
color: #e2e8f0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0f1419;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #2a3040;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply bg-radio-card border border-radio-border rounded-lg p-4;
|
||||
}
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-150;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply bg-radio-accent text-black hover:bg-yellow-400;
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply text-radio-muted hover:text-radio-text;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background-color: rgba(42, 48, 64, 0.3);
|
||||
}
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2 rounded text-xs font-medium;
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.badge-green {
|
||||
background-color: rgba(34, 197, 94, 0.2);
|
||||
color: #4ade80;
|
||||
}
|
||||
.badge-red {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
.badge-yellow {
|
||||
background-color: rgba(234, 179, 8, 0.2);
|
||||
color: #facc15;
|
||||
}
|
||||
.badge-blue {
|
||||
background-color: rgba(59, 130, 246, 0.2);
|
||||
color: #60a5fa;
|
||||
}
|
||||
.input {
|
||||
@apply bg-radio-bg border border-radio-border rounded-lg px-3 py-2 text-radio-text;
|
||||
}
|
||||
.input::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 0 0 1px #f59e0b;
|
||||
}
|
||||
}
|
||||
|
||||
.eq-bar {
|
||||
width: 4px;
|
||||
background-color: #f59e0b;
|
||||
border-radius: 9999px;
|
||||
animation: eq-bounce 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
.eq-bar:nth-child(2) { animation-delay: 0.2s; }
|
||||
.eq-bar:nth-child(3) { animation-delay: 0.4s; }
|
||||
.eq-bar:nth-child(4) { animation-delay: 0.1s; }
|
||||
|
||||
@keyframes eq-bounce {
|
||||
0% { height: 4px; }
|
||||
100% { height: 16px; }
|
||||
}
|
||||
30
web/frontend/tailwind.config.js
Normal file
30
web/frontend/tailwind.config.js
Normal file
@ -0,0 +1,30 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
path.join(__dirname, "index.html"),
|
||||
path.join(__dirname, "src/**/*.{js,jsx}"),
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
radio: {
|
||||
bg: "#0f1419",
|
||||
card: "#1a1f2e",
|
||||
border: "#2a3040",
|
||||
accent: "#f59e0b",
|
||||
"accent-dim": "#b45309",
|
||||
text: "#e2e8f0",
|
||||
muted: "#64748b",
|
||||
success: "#22c55e",
|
||||
danger: "#ef4444",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
21
web/frontend/vite.config.js
Normal file
21
web/frontend/vite.config.js
Normal 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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user