diff --git a/conjurer_musician/radio_conjurer.liq b/conjurer_musician/radio_conjurer.liq index b28c947..9e46ae7 100644 --- a/conjurer_musician/radio_conjurer.liq +++ b/conjurer_musician/radio_conjurer.liq @@ -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 diff --git a/docker/Dockerfile.radio b/docker/Dockerfile.radio index 3d0250a..a57c114 100644 --- a/docker/Dockerfile.radio +++ b/docker/Dockerfile.radio @@ -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"] diff --git a/docker/compose.radio.yaml b/docker/compose.radio.yaml index e2f2345..1313cc0 100644 --- a/docker/compose.radio.yaml +++ b/docker/compose.radio.yaml @@ -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 diff --git a/docker/entrypoint.radio.sh b/docker/entrypoint.radio.sh index f2c45f1..b4b8de9 100755 --- a/docker/entrypoint.radio.sh +++ b/docker/entrypoint.radio.sh @@ -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" diff --git a/docker/icecast.xml.tpl b/docker/icecast.xml.tpl new file mode 100644 index 0000000..ed2e0ff --- /dev/null +++ b/docker/icecast.xml.tpl @@ -0,0 +1,55 @@ + + + Wolne Ksiestwo Baluty + admin@localhost + + + 64 + 4 + 524288 + 30 + 15 + 10 + 1 + 65535 + + + + __SOURCE_PASSWORD__ + __RELAY_PASSWORD__ + admin + __ADMIN_PASSWORD__ + + + __HOSTNAME__ + + + 8000 + 0.0.0.0 + + + +
+ + + 1 + + + /usr/share/icecast2 + /var/log/icecast2 + /usr/share/icecast2/web + /usr/share/icecast2/admin + + + + + access.log + error.log + 3 + 10000 + 0 + + diff --git a/docs/deployment/DOCKER_PROXMOX.md b/docs/deployment/DOCKER_PROXMOX.md index a8c25ca..e87b771 100644 --- a/docs/deployment/DOCKER_PROXMOX.md +++ b/docs/deployment/DOCKER_PROXMOX.md @@ -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 diff --git a/ffmpeg.pref b/ffmpeg.pref deleted file mode 100644 index eb72eeb..0000000 --- a/ffmpeg.pref +++ /dev/null @@ -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 diff --git a/install_music_bot.sh b/install_music_bot.sh index f1a984c..9b9d28a 100644 --- a/install_music_bot.sh +++ b/install_music_bot.sh @@ -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"