shared playlist vibes
  • Shell 31.8%
  • Python 28%
  • Liquidsoap 23.2%
  • Nix 12.2%
  • Just 4.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
hbooth a064cb0700 Initial import: yt-dlp ingest, liquidsoap/icecast station, WiiM as the speaker
The queue is a directory of files under .state/queue, so a future UI can drive it
without a database or an API. liquidsoap pulls from it with request.dynamic gated on
a native file.ls check (the gate matters: fallback polls readiness every frame, and
an ungated null-returning callback forks a shell ~1000x/second), falls back to a
rotation directory, then to a digital-silence floor so the mount exists from boot
and the Amp always has something to dial. No loudness stage -- a volume knob on the
Amp is the better place; see README for the three attempts that proved it.

Also: nix dev shell, just task list, ingest scripts that keep the source codec, and
a stdlib-only UPnP/SSDP probe for the WiiM.
2026-10-02 00:26:14 -04:00
config Initial import: yt-dlp ingest, liquidsoap/icecast station, WiiM as the speaker 2026-10-02 00:26:14 -04:00
scripts Initial import: yt-dlp ingest, liquidsoap/icecast station, WiiM as the speaker 2026-10-02 00:26:14 -04:00
station Initial import: yt-dlp ingest, liquidsoap/icecast station, WiiM as the speaker 2026-10-02 00:26:14 -04:00
.envrc Initial import: yt-dlp ingest, liquidsoap/icecast station, WiiM as the speaker 2026-10-02 00:26:14 -04:00
.gitignore Initial import: yt-dlp ingest, liquidsoap/icecast station, WiiM as the speaker 2026-10-02 00:26:14 -04:00
flake.lock Initial import: yt-dlp ingest, liquidsoap/icecast station, WiiM as the speaker 2026-10-02 00:26:14 -04:00
flake.nix Initial import: yt-dlp ingest, liquidsoap/icecast station, WiiM as the speaker 2026-10-02 00:26:14 -04:00
justfile Initial import: yt-dlp ingest, liquidsoap/icecast station, WiiM as the speaker 2026-10-02 00:26:14 -04:00
README.md Initial import: yt-dlp ingest, liquidsoap/icecast station, WiiM as the speaker 2026-10-02 00:26:14 -04:00

333radio

yt-dlp in, a queue in the middle, sound out of the WiiM Amp in the living room.

Architecture

just enqueue URL ──> yt-dlp ──> media/ (tagged, original codec)
                                     │
                                     ▼
                        .state/queue/NNNNNN   one file per item
                                     │
                    liquidsoap ──────┘  request.dynamic asks scripts/next
                      │  crossfade 3 s — no loudness processing, see below
                      ▼
                   icecast  :8000/live.mp3   320k mp3, one mount
                      │
                      ▼
                   WiiM Amp  — dials the mount as a station and holds it

The Amp is handed one URL for the evening. Everything about that choice follows from it: a renderer cannot crossfade two things it was told about separately, so the mixing has to happen server-side, which means a stream, which means liquidsoap feeding icecast. In exchange the laptop never has to control the speaker at all — put the mount URL in WiiM Home as a custom station and the Amp will play the house radio without knowing or caring that this repo exists.

Why not push one file per track over UPnP? Better sound (the Amp's own DAC, no re-encode) and fewer daemons, but every track costs a fetch-and-buffer gap and there is no way to overlap them. That is the right design for "play me this song", not for "keep the living room quietly full".

What is verified

Ran end to end on 2026-10-01 with generated tones:

  • just station brings up icecast + liquidsoap; mount answers 200 audio/mpeg, icy-br:320, decoded by ffprobe as mp3 / 44.1 kHz / stereo / 320k.
  • Two queued tracks both reached air, in order — the queue advances.
  • File tags reach the speaker: now-playing reported Bravo - Two, from artist/title written by yt-dlp.
  • icy-metaint appears only when the client sends Icy-MetaData: 1. Players do send it; curl does not, which is a fun half hour if you don't know it.
  • The station is dialable from the first second even with an empty queue and an empty rotation: <sources>1</sources> and /live.mp3 → 200. That needed the silence floor — output.icecast does not open its SOURCE connection until its input is ready, so a station booting with nothing to play never created the mount and the Amp had nothing to dial. The idle stream is 320k mp3 at -91 dB (digital silence, verified — not a tone).
  • Rotation: junk files in fallback/ are moved to .state/junk at startup with a warning, then the rotation plays and announces its track. Queuing a request while a rotation track is in progress does not cut it short — the request takes over at the next track boundary (~16 s with an 8 s sample), which is what track_sensitive = true is for.
  • Idle station, mounted with nothing to play: 0 log lines in 10 s, no request-leak warnings, no latency warnings. Tracks queued afterwards still reach air, so the available gate opens again correctly.
  • With the queue drained after connecting, icecast still reports <sources>1</sources>: the mount stays dialled rather than dropping.

Gotchas learned the expensive way, already commented in the source

  • yt-dlp 403 on the video stream. Without -f, the default format spec is bestvideo*+bestaudio, so --extract-audio downloads a whole video to peel the audio off it — and the video URL is where YouTube throws 403s first (unable to download video data while the audio you wanted is fine). Use -f bestaudio/best.
  • request.dynamic spins at 10 forks/second when the queue is empty. Its retry_delay defaults to 0.1 s, and each poll is a forked shell script. Left idling overnight that is ~800k processes. retry_delay = 2.0.
  • playlist reads every file in its directory, including fallback/.gitkeep, and logs a decoder error for each one it cannot decode. The obvious fix — check_next — is worse than the disease: when it returns false the playlist re-asks, fallback polls every frame, and you get the request-leak storm. So the filter lives in scripts/station, which moves non-audio files into .state/junk before liquidsoap starts. The filter has to be outside the operator that would reject.
  • Icecast adminroot must point at the XSL files the package ships. With an empty one, /admin/ answers 500 with nothing in the log — and because HTML error pages render from adminroot too (error-html.xsl lives there), every browser request turns any error into 500 - Recursive error, since icecast cannot render the page explaining the error. Browsers are the common case: they send Accept: text/html, curl does not, which is why it reproduces in a browser and not in a shell.
  • ...and do not probe for admin.xsl to verify that path. Icecast 2.4 renamed the entry point to dashboard.xsl, so that check fails on any modern version and silently falls back to the empty directory — the guard caused the bug it was written to prevent. Test for "any .xsl here" instead. The admin UI is HTTP basic auth (WWW-Authenticate: Basic), so a browser pops a login prompt: admin / hackme.
  • ffprobe reads Ogg tags at the stream level, not the format level (-show_format / -show_entries format_tags come back empty for .opus, while mp3/ID3 shows up there). Checking tags in the wrong place made me write a 150-line tagger for a problem yt-dlp did not have — it tags opus correctly. Read tags with mutagen, or ffprobe -show_entries stream_tags.
  • liquidsoap: if c then radio = crossfade(...) end binds radio locally and the crossfade is silently discarded. Reassign through the if instead. liquidsoap --check catches it; nothing else will.
  • An endless source is an endless track, and a track-sensitive fallback sits on a track until it ends. Put a permanent silence source in the same list as the queue and the station latches onto silence forever — observed: one track played, then nothing. The fix is two fallbacks: inner one track-sensitive so a song is never cut short, outer one not, so it leaves silence the moment there is something to play. sine() defaults to amplitude=1.0, so sine(0.) alone is a full-scale tone — the amplitude = 0. is load-bearing.
  • sine() defaults to amplitude=1.0: sine(0.) is a full-scale tone, not silence.
  • Icecast segfaults on a malformed config rather than reporting it, and <alias> takes source=/dest=, not alias= twice.
  • Icecast serves the mount verbatim: /live is not /live.mp3. The mount default carries the extension, and the flake's shellHook agrees with it.
  • get_process_output is deprecated in favour of process.read (same signature), and the deprecation warning is logged once per call.
  • fallback polls readiness every frame, and request.dynamic answers that poll by fetching. A callback that returns null on an empty queue therefore becomes a fork bomb: ~1150 shell scripts a second, a "possible request leak" warning every 100 of them, and Latency is too high: we must catchup 3.8 seconds as the audio clock starves. retry_delay does not help — it governs the retry path, not the readiness poll. available is the designed off-switch; feeding it a native file.ls check means the forked script runs only when there is something to play.
  • Which is how the silence floor nearly sank the ship. Adding the floor is what started the polling. The version without it was quiet and I had measured that ("13 log lines in 12 s") — then changed the graph and never re-ran the same measurement. Idle log volume was the health signal, and I stopped watching it the moment I changed the thing it was watching.
  • Two variables for one fact. scripts/station set RADIO_PORT for icecast's listener while station.liq dialled RADIO_ICECAST_PORT. Equal at the default, so every day-one run worked; RADIO_PORT=8001 moved icecast while liquidsoap kept streaming into whatever owned 8000, and the symptom was only a mount that never appeared. Tests that pass by accident are worse than tests that fail.

Volume

Turn it up on the Amp. There is deliberately no loudness stage in the stream: a volume knob is a constant offset, applied after the DAC at the level you are actually listening at, which is a better place for "too quiet" than anything running on the laptop. Verified transparent — a file measuring −25.1 LUFS loud / −45.5 quiet comes out of the mount at −25.1, unchanged.

What a knob cannot do is match one track to the next. If yt-dlp's wildly varying sources ever make that a real complaint, do not add a live processor; measure each file once in scripts/grab and write ReplayGain tags into it, so the gain is a constant per track, computed once and cached in the file. Check the WiiM app's own volume leveling first — it may already do this for free.

Three tenants occupied this slot before it was emptied, and all three cost more than they paid for: normalize(lufs = true) is an automatic gain control that rode gain on a sliding window and audibly pumped quiet verses against loud choruses; any loudness measure placed after the silence floor swells at the top of every song, because silence wants unlimited gain until a track starts; and a track-based replacement measured nothing at all, which is the worst outcome — dead code that reads like a feature.

Tuning

just station reads RADIO_PORT (moves icecast and liquidsoap together), RADIO_MOUNT (default live.mp3), RADIO_SOURCE_PASSWORD, RADIO_STATE, RADIO_XFADE (seconds, default 3), RADIO_BITRATE (default 320), and RADIO_NEXT_SCRIPT / RADIO_FALLBACK_DIR to point the auto-DJ somewhere other than scripts/next and fallback/ — which is also how you run a throwaway second instance for testing without disturbing the one that is playing music.

Day one

direnv allow
just doctor                      # toolchain, encoders, LAN address, what answers
just station                     # icecast + liquidsoap, Ctrl-C stops both
just enqueue https://youtu.be/…  # pulls it and queues it
just queue
just listen                      # hear it locally before blaming the Amp

Then in WiiM Home: Music Services → add a custom station → http://<the address just doctor prints>:8000/live.mp3.

Not done

  • No web UI yet — enqueue is a CLI, and pnpm dev has nothing to run.
  • Icecast's browser admin UI is not worth fighting. The 500 was mine (empty adminroot) and is fixed; what remains is that /admin/stats.xsl and friends 404 in 2.5 even though the files exist and auth is correct. The JSON API is healthy — just info, or /admin/stats?mode=json — and the frontend this repo is about to grow should render queue and now-playing itself rather than adopt icecast's stylesheet stack.
  • The Amp is assumed to reach icecast. Same wifi/VLAN, and never 127.0.0.1.
  • UPnP control is unproven on this LAN: no MediaRenderer answered M-SEARCH and nothing had TCP 1400 open, with the Amp asleep. It is only needed for volume and power, so it is a nice-to-have rather than a dependency.
  • Nothing watches for icecast or liquidsoap dying. That is the NixOS module's job.