Initial commit: self-hosted personal radio station

Flask + React web UI with audio player, podcast queue, feed management,
episode browser, music library, schedule viewer, and log tail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 19:01:33 -07:00
co-authored by Claude Opus 4.6
commit 3d635b742c
53 changed files with 7023 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
feeds:
- name: Example Podcast
url: https://feeds.example.com/podcast.xml
enabled: false
priority: 10
max_episodes: 20
- name: c
url: https://rss.libsyn.com/shows/467049/destinations/3949218.xml
enabled: true
priority: 10
max_episodes: 20
+58
View File
@@ -0,0 +1,58 @@
<icecast>
<location>Local</location>
<admin>admin@localhost</admin>
<limits>
<clients>5</clients>
<sources>2</sources>
<queue-size>524288</queue-size>
<client-timeout>30</client-timeout>
<header-timeout>15</header-timeout>
<source-timeout>10</source-timeout>
<burst-on-connect>1</burst-on-connect>
<burst-size>65535</burst-size>
</limits>
<authentication>
<source-password>localradio_source</source-password>
<relay-password>localradio_relay</relay-password>
<admin-user>admin</admin-user>
<admin-password>localradio_admin</admin-password>
</authentication>
<hostname>localhost</hostname>
<listen-socket>
<port>8000</port>
<bind-address>127.0.0.1</bind-address>
</listen-socket>
<mount>
<mount-name>/stream</mount-name>
<fallback-mount>/fallback</fallback-mount>
<fallback-override>1</fallback-override>
<hidden>0</hidden>
<public>0</public>
</mount>
<fileserve>1</fileserve>
<paths>
<basedir>/usr/share/icecast2</basedir>
<logdir>/opt/localradio/logs</logdir>
<webroot>/usr/share/icecast2/web</webroot>
<adminroot>/usr/share/icecast2/admin</adminroot>
<alias source="/" destination="/status.xsl"/>
</paths>
<logging>
<accesslog>icecast_access.log</accesslog>
<errorlog>icecast_error.log</errorlog>
<loglevel>3</loglevel>
<logsize>10000</logsize>
</logging>
<security>
<chroot>0</chroot>
</security>
</icecast>
+10
View File
@@ -0,0 +1,10 @@
# Local Radio Environment Configuration
# Override defaults here. Loaded by systemd services.
LOCALRADIO_BASE=/opt/localradio
# RSS poll interval in seconds (default: 300 = 5 minutes)
POLL_INTERVAL=300
# Download timeout in seconds (default: 600)
DOWNLOAD_TIMEOUT=600
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/liquidsoap
# =============================================================================
# Local Radio Station — Liquidsoap Configuration
# =============================================================================
# Source priority (highest to lowest):
# 1. Podcast queue (from queue/ directory)
# 2. Scheduled music (time/day-based playlists)
# 3. Emergency fallback (static safe playlist)
# =============================================================================
settings.log.file.path := "/opt/localradio/logs/liquidsoap.log"
settings.log.level := 3
# --- Paths -------------------------------------------------------------------
music_base = "/opt/localradio/media/music"
queue_dir = "/opt/localradio/state/queue"
fallback_dir = "/opt/localradio/media/fallback"
# --- Icecast output settings -------------------------------------------------
icecast_host = "localhost"
icecast_port = 8000
icecast_password = "localradio_source"
icecast_mount = "/stream"
# --- Emergency Fallback ------------------------------------------------------
# A safe playlist that always has content. Put at least one audio file in
# /opt/localradio/media/fallback/ to guarantee the stream never dies.
fallback_source = playlist(
mode = "randomize",
reload_mode = "watch",
fallback_dir
)
# --- Scheduled Music Sources --------------------------------------------------
# Each playlist watches its directory for changes. If files are added/removed,
# the playlist updates automatically.
morning_music = playlist(
mode = "randomize",
reload_mode = "watch",
"#{music_base}/morning"
)
day_music = playlist(
mode = "randomize",
reload_mode = "watch",
"#{music_base}/day"
)
night_music = playlist(
mode = "randomize",
reload_mode = "watch",
"#{music_base}/night"
)
weekend_music = playlist(
mode = "randomize",
reload_mode = "watch",
"#{music_base}/weekend"
)
# --- Time-based music schedule ------------------------------------------------
# switch() picks the first matching source based on time predicates.
# Predicates use: { <time range> } and weekday functions.
#
# Weekday numbers: 1w=Monday ... 5w=Friday, 6w=Saturday, 7w=Sunday
scheduled_music = switch(
track_sensitive = true,
[
# Weekend: all day Saturday and Sunday
({ 6w or 7w }, weekend_music),
# Weekday morning: 06:00 - 11:59
({ 1w-5w and 6h-11h }, morning_music),
# Weekday afternoon: 12:00 - 17:59
({ 1w-5w and 12h-17h }, day_music),
# Weekday evening/night: 18:00 - 05:59
({ 1w-5w and (18h-23h or 0h-5h) }, night_music),
]
)
# Add fallback so scheduled_music never fails
music_safe = fallback(
track_sensitive = true,
[scheduled_music, fallback_source]
)
# --- Podcast Queue Source -----------------------------------------------------
# Watches the queue directory. When files appear, they play with priority.
# After a file finishes, it is removed from the queue directory.
#
# request.queue() is not used here because we want filesystem-based control
# from the Python poller. Instead, we use playlist() in "normal" mode on the
# queue directory, which plays files in alphabetical order (we prefix filenames
# with timestamps to ensure FIFO ordering).
podcast_queue = playlist(
mode = "normal",
reload_mode = "watch",
queue_dir
)
# --- Source Priority Merge ----------------------------------------------------
# fallback() plays the first available source.
# track_sensitive = true means it waits for the current music track to finish
# before switching to a podcast (and vice versa).
radio = fallback(
track_sensitive = true,
[podcast_queue, music_safe]
)
# --- Metadata Logging ---------------------------------------------------------
radio = on_metadata(
fun(m) -> begin
title = m["title"]
artist = m["artist"]
log("Now playing: #{artist} - #{title}")
end,
radio
)
# --- Make the source infallible -----------------------------------------------
# mksafe() ensures the source never fails. If all sources are unavailable,
# it outputs silence rather than crashing.
radio = mksafe(radio)
# --- Output to Icecast --------------------------------------------------------
# MP3 320kbps stereo output.
# To switch to Opus, replace %mp3 with %opus and adjust bitrate.
output.icecast(
%mp3(
bitrate = 320,
samplerate = 44100,
stereo = true
),
host = icecast_host,
port = icecast_port,
password = icecast_password,
mount = icecast_mount,
name = "Local Radio",
description = "Personal automated radio station",
genre = "Various",
url = "http://localhost:8000",
radio
)
+67
View File
@@ -0,0 +1,67 @@
# Local Radio Station Configuration
station:
name: "Local Radio"
description: "Personal automated radio station"
icecast:
host: "localhost"
port: 8000
source_password: "localradio_source"
mount: "/stream"
audio:
# Output format: "mp3" or "opus"
format: "mp3"
# Bitrate in kbps (for mp3: 128, 192, 256, 320; for opus: 64-256)
bitrate: 320
samplerate: 44100
channels: 2
music:
base_path: "/opt/localradio/media/music"
fallback_path: "/opt/localradio/media/fallback"
# File extensions to include
extensions:
- "*.mp3"
- "*.flac"
- "*.ogg"
- "*.opus"
- "*.m4a"
- "*.wav"
# Schedule blocks define which music folder plays at what times.
# Times are in 24-hour format. Each block specifies:
# days: list of weekday numbers (1=Monday ... 7=Sunday)
# start: HH:MM
# end: HH:MM
# folder: subfolder name under music.base_path
schedule:
- name: "Weekday Morning"
days: [1, 2, 3, 4, 5]
start: "06:00"
end: "12:00"
folder: "morning"
- name: "Weekday Afternoon"
days: [1, 2, 3, 4, 5]
start: "12:00"
end: "18:00"
folder: "day"
- name: "Weekday Evening & Night"
days: [1, 2, 3, 4, 5]
start: "18:00"
end: "06:00"
folder: "night"
- name: "Weekend"
days: [6, 7]
start: "00:00"
end: "23:59"
folder: "weekend"
podcasts:
queue_dir: "/opt/localradio/state/queue"
download_dir: "/opt/localradio/media/podcasts"
db_path: "/opt/localradio/state/radio.db"