radio: /srv/betoniarka paths, in-container Icecast, drop ffmpeg.pref

Per review of the first radio-container cut:

- radio_conjurer.liq: replace the cargo-culted /home/pi/Conjurer and
  /home/pi/MediaFolder paths with the container layout -
  /srv/betoniarka/data (playlists, script.params, persistence/radio logs),
  /srv/betoniarka/music (library + emergency track) and
  /srv/betoniarka/secrets/icecast_credentials.json;
  interactive.persistent now uses an absolute path;
  output.icecast targets host="localhost" (icecast lives in the container)
- Icecast2 now runs INSIDE the radio container: entrypoint renders
  /etc/icecast2/icecast.xml from docker/icecast.xml.tpl, filling
  source/admin/relay passwords from the secret provisioned at install time
  (same pattern as the bot's netrc); optional admin_password/relay_password
  fields default to password; icecast starts as its unprivileged user;
  port 8000 exposed for listeners; extra_hosts hack removed
- compose.radio.yaml: same-path mounts for /srv/betoniarka/{data,music,secrets}
- ffmpeg.pref removed from the repo (the pin only mattered against the
  RPi OS repo; bookworm ships the right FFmpeg 5.x natively) - verdict
  preserved in the docs and the legacy installer note updated
- docs: updated radio section (volumes, secret provisioning, wire-up)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Michal Tuszowski
2026-07-08 11:32:48 +02:00
committed by Michał Tuszowski
parent ebc73c16d4
commit fcb03e304c
8 changed files with 162 additions and 64 deletions
+14 -14
View File
@@ -1,16 +1,16 @@
# FILEPATH: /home/mtuszowski/conjurer/conjurer_musician/radio_conjurer.liq
# Radio Conjurer - Liquidsoap script (containerised paths: /srv/betoniarka/*)
# This script sets up a Liquidsoap radio stream with various features and configurations.
# Load icecast credentials from a JSON file
let json.parse credentials = file.contents("/home/pi/Conjurer/icecast_credentials.json")
let json.parse credentials = file.contents("/srv/betoniarka/secrets/icecast_credentials.json")
# Enable replaygain metadata processing
enable_replaygain_metadata()
# Set up a playlog for tracking played tracks
l = playlog(duration = 72000.0, persistency="/home/pi/Conjurer/persistence.log")
l = playlog(duration = 72000.0, persistency="/srv/betoniarka/data/persistence.log")
# Function to check if a track can be played based on its metadata
def check(r)
@@ -25,20 +25,20 @@ def check(r)
end
# Define playlists to be used in the stream
s1 = replaygain(playlist(reload_mode="watch", check_next=check, "/home/pi/Conjurer/all_playlist.playlist"))
s2 = replaygain(playlist(reload_mode="watch", check_next=check, "/home/pi/Conjurer/priority_queue.playlist"))
s3 = replaygain(playlist(reload_mode="watch", check_next=check, "/home/pi/Conjurer/hit.playlist"))
s1 = replaygain(playlist(reload_mode="watch", check_next=check, "/srv/betoniarka/data/all_playlist.playlist"))
s2 = replaygain(playlist(reload_mode="watch", check_next=check, "/srv/betoniarka/data/priority_queue.playlist"))
s3 = replaygain(playlist(reload_mode="watch", check_next=check, "/srv/betoniarka/data/hit.playlist"))
# Create a request queue for user-generated requests
requests_queue = request.queue()
# Function to process the request queue and add new requests
def queue_processing()
text=file.lines("/home/pi/Conjurer/request.playlist")
text=file.lines("/srv/betoniarka/data/request.playlist")
if text != [] then
list.iter(fun(item) -> requests_queue.push.uri(item), text)
file.remove("/home/pi/Conjurer/request.playlist")
f = file.open("/home/pi/Conjurer/request.playlist", create=true)
file.remove("/srv/betoniarka/data/request.playlist")
f = file.open("/srv/betoniarka/data/request.playlist", create=true)
f.close()
end
end
@@ -63,7 +63,7 @@ end
s4 = random(id="randomizer", weights=[2, 3, 5], [s1, s2, s3])
# Load jingles playlist
jingles = (playlist(reload_mode="watch", "/home/pi/Conjurer/jingles.playlist"))
jingles = (playlist(reload_mode="watch", "/srv/betoniarka/data/jingles.playlist"))
# Create the main stream with random playlist and jingles
s = rotate(id="randomizer", weights=[10, 1], [s4, jingles])
@@ -111,7 +111,7 @@ s=switch(track_sensitive=true,
# Configure logging settings
log_to_stdout = true
log_to_file = true
logpath = "/home/pi/Conjurer/radio_log.log"
logpath = "/srv/betoniarka/data/radio_log.log"
loglevel = 3
set("log.stdout", log_to_stdout)
set("log.level", loglevel)
@@ -120,7 +120,7 @@ set("log.level", loglevel)
set("log.file", log_to_file)
set("log.file.path", logpath)
# Set up emergency fallback track
emergency = single("/home/pi/MediaFolder/mp3/Youtube/Dr. Peacock - Trip to Ireland [GvrvQTUbUcA].mp3")
emergency = single("/srv/betoniarka/music/Youtube/Dr. Peacock - Trip to Ireland [GvrvQTUbUcA].mp3")
radio = fallback(id="switcher2", track_sensitive=false, [s, emergency])
# Set up an interactive control for skipping tracks
@@ -150,11 +150,11 @@ thread.run(every=15., check_skip)
# Enable persistent script parameters
interactive.persistent("script.params")
interactive.persistent("/srv/betoniarka/data/script.params")
# Configure output formats and destinations
output.icecast(%mp3, host="radio", port=8000, password=credentials.password, icy_metadata="true", mount="mp3-stream", radio)
output.icecast(%mp3, host="localhost", port=8000, password=credentials.password, icy_metadata="true", mount="mp3-stream", radio)
output.pulseaudio(radio)
#output.file.hls("/tmp/hls", [("mp3-low", %mp3(bitrate=96)), ("mp3-hi", %mp3(bitrate=160))], radio)
# Uncomment the following lines to enable additional output formats
+14 -8
View File
@@ -43,6 +43,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libavdevice-dev libswresample-dev libswscale-dev libpostproc-dev \
ffmpeg \
pulseaudio pulseaudio-utils \
icecast2 jq \
&& rm -rf /var/lib/apt/lists/*
# Group membership for PulseAudio/ALSA access. On the Pi this was done by
@@ -75,20 +76,25 @@ RUN liquidsoap --version
COPY docker/pulse-system.pa /etc/pulse/system.pa
# The script and its persistent params are seeded into the data volume on
# first run (never overwritten), so live edits survive image rebuilds.
# first run (never overwritten), so live edits survive image rebuilds. The
# icecast config is rendered from the template at startup with passwords
# taken from the secrets volume (never baked into the image).
WORKDIR /app
COPY conjurer_musician/radio_conjurer.liq ./radio_conjurer.liq
COPY conjurer_musician/script.params ./script.params
COPY docker/icecast.xml.tpl ./icecast.xml.tpl
COPY docker/entrypoint.radio.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Mount points mirror the paths hardcoded in radio_conjurer.liq, so the
# script runs unmodified: data at /home/pi/Conjurer, music at
# /home/pi/MediaFolder/mp3.
VOLUME ["/home/pi/Conjurer", "/home/pi/MediaFolder/mp3"]
# Volume layout matches the paths inside radio_conjurer.liq:
# /srv/betoniarka/data - playlists, script.params, persistence/radio logs
# /srv/betoniarka/music - the mp3 library
# /srv/betoniarka/secrets - icecast_credentials.json (provisioned at install)
VOLUME ["/srv/betoniarka/data", "/srv/betoniarka/music", "/srv/betoniarka/secrets"]
# 54321 = harbor /skip (the bot's RADIO_HARBOR), 9999 = interactive harbor.
EXPOSE 54321 9999
# 8000 = icecast (listeners), 54321 = harbor /skip (the bot's RADIO_HARBOR),
# 9999 = interactive harbor.
EXPOSE 8000 54321 9999
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["liquidsoap", "/home/pi/Conjurer/radio_conjurer.liq"]
CMD ["liquidsoap", "/srv/betoniarka/data/radio_conjurer.liq"]
+11 -9
View File
@@ -1,4 +1,4 @@
# Radio (Liquidsoap) VM. Run from the repository root:
# Radio (Liquidsoap + Icecast) VM. Run from the repository root:
# docker compose -f docker/compose.radio.yaml up -d --build
#
# Version pins are build args - override via environment, e.g.:
@@ -21,19 +21,21 @@ services:
# host = mount the host pulse socket below and set PULSE_SERVER
PULSE_MODE: ${PULSE_MODE:-internal}
# PULSE_SERVER: unix:/tmp/pulseaudio.socket
extra_hosts:
# radio_conjurer.liq streams to host="radio" - point it at icecast.
- "radio:${ICECAST_HOST_IP:-192.168.1.12}"
# Hostname icecast reports in its status/YP pages:
ICECAST_HOSTNAME: ${ICECAST_HOSTNAME:-localhost}
ports:
- "8000:8000" # icecast - listeners tune in here
- "54321:54321" # harbor /skip - the bot's CONJURER_RADIO_HARBOR
- "9999:9999" # interactive harbor (keep LAN-only!)
volumes:
# Playlists, logs, script.params, icecast_credentials.json, the script
# itself (seeded on first run) - paths mirror the ones hardcoded in
# radio_conjurer.liq so it runs unmodified.
- /srv/radio/data:/home/pi/Conjurer
# Playlists, logs, script.params and the script itself (seeded on first
# run) - same paths inside and outside the container.
- /srv/betoniarka/data:/srv/betoniarka/data
# The mp3 library. Writable because the entrypoint seeds a silent
# emergency-fallback mp3 when the hardcoded single() file is missing.
- /srv/radio/music:/home/pi/MediaFolder/mp3
- /srv/betoniarka/music:/srv/betoniarka/music
# icecast_credentials.json - provision at install like the other
# services' secrets (a CHANGE_ME placeholder is seeded if absent).
- /srv/betoniarka/secrets:/srv/betoniarka/secrets
# PULSE_MODE=host: uncomment and adjust
# - /tmp/pulseaudio.socket:/tmp/pulseaudio.socket
+34 -12
View File
@@ -1,12 +1,14 @@
#!/bin/sh
# Prepare the radio volumes so radio_conjurer.liq boots on a fresh VM, then
# start (or point at) PulseAudio and exec liquidsoap. Existing files are never
# overwritten - live-edited script/params/playlists always win.
# Prepare the radio volumes, start the in-container Icecast server, wire up
# PulseAudio and exec liquidsoap. Existing files are never overwritten -
# live-edited script/params/playlists always win.
set -e
DATA="${RADIO_DATA_DIR:-/home/pi/Conjurer}"
MUSIC="${RADIO_MUSIC_DIR:-/home/pi/MediaFolder/mp3}"
mkdir -p "$DATA" "$MUSIC"
DATA="${RADIO_DATA_DIR:-/srv/betoniarka/data}"
MUSIC="${RADIO_MUSIC_DIR:-/srv/betoniarka/music}"
SECRETS="${RADIO_SECRETS_DIR:-/srv/betoniarka/secrets}"
CREDS="$SECRETS/icecast_credentials.json"
mkdir -p "$DATA" "$MUSIC" "$SECRETS"
# Seed the script + persistent interactive params from the image on first run.
[ -e "$DATA/radio_conjurer.liq" ] || cp /app/radio_conjurer.liq "$DATA/"
@@ -21,14 +23,34 @@ for f in all_playlist.playlist priority_queue.playlist hit.playlist \
[ -e "$DATA/$f" ] || : > "$DATA/$f"
done
# Icecast credentials are a secret - never baked into the image. Seed a
# placeholder so the script can parse the JSON, but shout about it.
if [ ! -e "$DATA/icecast_credentials.json" ]; then
printf '{\n"password" : "CHANGE_ME"\n}\n' > "$DATA/icecast_credentials.json"
echo "WARNING: seeded placeholder icecast_credentials.json - the icecast" >&2
echo " output will be rejected until you set the real password!" >&2
# The icecast secret is provisioned at install time, like the other services'
# secrets (bot: /srv/conjurer/secrets/.netrc). A placeholder keeps the stack
# bootable, but both icecast and the stream stay locked until you fix it.
if [ ! -e "$CREDS" ]; then
printf '{\n"password" : "CHANGE_ME"\n}\n' > "$CREDS"
echo "WARNING: $CREDS was missing - seeded a CHANGE_ME placeholder." >&2
echo " Put the real password there (see docs) and restart." >&2
fi
# Render /etc/icecast2/icecast.xml from the template with passwords from the
# secret. Optional fields admin_password/relay_password default to password.
SOURCE_PW=$(jq -r '.password' "$CREDS")
ADMIN_PW=$(jq -r '.admin_password // .password' "$CREDS")
RELAY_PW=$(jq -r '.relay_password // .password' "$CREDS")
ICECAST_HOSTNAME="${ICECAST_HOSTNAME:-localhost}"
sed -e "s|__SOURCE_PASSWORD__|$SOURCE_PW|" \
-e "s|__ADMIN_PASSWORD__|$ADMIN_PW|" \
-e "s|__RELAY_PASSWORD__|$RELAY_PW|" \
-e "s|__HOSTNAME__|$ICECAST_HOSTNAME|" \
/app/icecast.xml.tpl > /etc/icecast2/icecast.xml
chown icecast2:icecast /etc/icecast2/icecast.xml 2>/dev/null || true
chmod 640 /etc/icecast2/icecast.xml
# Start Icecast in the background as its unprivileged user.
mkdir -p /var/log/icecast2 && chown -R icecast2:icecast /var/log/icecast2
su -s /bin/sh icecast2 -c "icecast2 -b -c /etc/icecast2/icecast.xml" \
|| echo "WARNING: icecast2 failed to start - the stream output will retry" >&2
# single() aborts the whole script when its file is missing; guarantee the
# emergency fallback exists (5s of silence beats a dead radio).
EMERGENCY="$MUSIC/Youtube/Dr. Peacock - Trip to Ireland [GvrvQTUbUcA].mp3"
+55
View File
@@ -0,0 +1,55 @@
<!-- Icecast2 config template for the radio container.
The entrypoint substitutes the __*_PASSWORD__ placeholders from
/srv/betoniarka/secrets/icecast_credentials.json and writes the result
to /etc/icecast2/icecast.xml. Never commit real passwords here. -->
<icecast>
<location>Wolne Ksiestwo Baluty</location>
<admin>admin@localhost</admin>
<limits>
<clients>64</clients>
<sources>4</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>__SOURCE_PASSWORD__</source-password>
<relay-password>__RELAY_PASSWORD__</relay-password>
<admin-user>admin</admin-user>
<admin-password>__ADMIN_PASSWORD__</admin-password>
</authentication>
<hostname>__HOSTNAME__</hostname>
<listen-socket>
<port>8000</port>
<bind-address>0.0.0.0</bind-address>
</listen-socket>
<http-headers>
<header name="Access-Control-Allow-Origin" value="*" />
</http-headers>
<fileserve>1</fileserve>
<paths>
<basedir>/usr/share/icecast2</basedir>
<logdir>/var/log/icecast2</logdir>
<webroot>/usr/share/icecast2/web</webroot>
<adminroot>/usr/share/icecast2/admin</adminroot>
<alias source="/" destination="/status.xsl"/>
</paths>
<logging>
<accesslog>access.log</accesslog>
<errorlog>error.log</errorlog>
<loglevel>3</loglevel>
<logsize>10000</logsize>
<logarchive>0</logarchive>
</logging>
</icecast>
+33 -17
View File
@@ -235,26 +235,42 @@ those two files into the same `/srv/musician/data` directory (or set
| `OPAM_VERSION` | `2.1.5` | static binary from GitHub releases |
| `LIQ_OPAM_PACKAGES` | `mad lame cry taglib pulseaudio samplerate inotify ffmpeg` | exactly the features the script uses (mp3 in/out, icecast, tags/replaygain, pulse mic/out, watch-reload) |
```bash
sudo mkdir -p /srv/radio/data /srv/radio/music
# put the real password in place (otherwise a CHANGE_ME placeholder is seeded):
echo '{ "password" : "..." }' | sudo tee /srv/radio/data/icecast_credentials.json
The container runs **both Liquidsoap and Icecast** — the stream is served
from this one container (`output.icecast(host="localhost", …)` in the
script). Volume layout (same paths inside and outside):
ICECAST_HOST_IP=192.168.1.12 docker compose -f docker/compose.radio.yaml up -d --build
| Host & container path | Holds |
|---|---|
| `/srv/betoniarka/data` | playlists, `script.params`, `persistence.log`, `radio_log.log`, the seeded `radio_conjurer.liq` |
| `/srv/betoniarka/music` | the mp3 library |
| `/srv/betoniarka/secrets` | `icecast_credentials.json`**provision at install**, like the other services' secrets |
```bash
sudo mkdir -p /srv/betoniarka/data /srv/betoniarka/music /srv/betoniarka/secrets
# provision the icecast secret (same pattern as the bot's netrc):
echo '{ "password" : "SOURCE_PW", "admin_password" : "ADMIN_PW" }' \
| sudo tee /srv/betoniarka/secrets/icecast_credentials.json
sudo chmod 600 /srv/betoniarka/secrets/icecast_credentials.json
docker compose -f docker/compose.radio.yaml up -d --build
docker logs -f conjurer-radio
```
The container mounts data at **`/home/pi/Conjurer`** and music at
**`/home/pi/MediaFolder/mp3`** — the exact paths hardcoded in the script — so
`radio_conjurer.liq` runs **unmodified**. The script + `script.params` are
seeded into the volume on first run and never overwritten (live edits
survive rebuilds). Missing playlists are created empty and a silent
emergency-fallback mp3 is generated if the `single()` file is absent, so the
script always boots. `host="radio"` (the icecast output) resolves via the
compose `extra_hosts` entry — set `ICECAST_HOST_IP`.
At startup the entrypoint renders `/etc/icecast2/icecast.xml` from
`docker/icecast.xml.tpl`, filling the source/admin/relay passwords from the
secret (`admin_password`/`relay_password` are optional and default to
`password`), and starts icecast as its unprivileged user. If the secret is
missing, a `CHANGE_ME` placeholder is seeded with a loud warning — the stack
boots but the stream stays locked until you fix it.
Point the bot at this VM: `CONJURER_RADIO_HARBOR=http://RADIO_VM_IP:54321`
(the harbor `/skip` endpoint lives in the script itself).
The script + `script.params` are seeded into the data volume on first run
and never overwritten (live edits survive rebuilds). Missing playlists are
created empty and a silent emergency-fallback mp3 is generated if the
`single()` file is absent, so the script always boots.
Wire-up: listeners tune to `http://RADIO_VM_IP:8000/mp3-stream`; the bot
points at `CONJURER_RADIO_HARBOR=http://RADIO_VM_IP:54321` (harbor `/skip`
lives in the script itself).
**PulseAudio** (`PULSE_MODE` env):
- `internal` (default) — a system-wide pulse daemon runs inside the container
@@ -272,8 +288,8 @@ Point the bot at this VM: `CONJURER_RADIO_HARBOR=http://RADIO_VM_IP:54321`
for 2.1.x are compiled against Debian bookworm's FFmpeg 5.x sonames, and
the pin forced those over conflicting Raspberry Pi OS repo builds (even as
a downgrade). In this single-repo container the same versions come
naturally, so **no pin is needed** — documented here so it doesn't get
cargo-culted forward.
naturally, so the pin is redundant — **the file has been removed from the
repo** (this note preserves the knowledge).
- adding root to `pulse-access`/`audio` groups: needed on the Pi for the
system-wide pulse socket and ALSA device access. The image bakes the same
memberships in (`usermod -aG audio,pulse-access root`) — harmless with the
-3
View File
@@ -1,3 +0,0 @@
Package: ffmpeg libavcodec-dev libavcodec59 libavdevice59 libavfilter8 libavformat-dev libavformat59 libavutil-dev libavutil57 libpostproc56 libswresample-dev libswresample4 libswscale-dev libswscale6 libavdevice-dev libavfilter-dev libpostproc-dev
Pin: origin deb.debian.org
Pin-Priority: 1001
+1 -1
View File
@@ -27,7 +27,7 @@ sudo usermod -aG audio root
echo "Add exception suppresion to signal handler in spotify __ini__.py"
echo "Add password to youtube opts in spotify_dl youtube.py"
echo "Downgrade ffmpeg by copying ffmpeg.pref from repository to /etc/apt/preferences.d"
echo "NOTE: the old ffmpeg.pref pin is obsolete (containerised radio uses Debian bookworm ffmpeg 5.x natively; file removed from the repo)"
echo "Install opam from installation link"
echo "Initialize opam"
echo "Install liquidsoap and its dependencies"