WeatherStar 4000 Channel#
A recreation of the 1990s Weather Channel local forecast, rendered from live
NOAA data and published as a 24/7 channel inside Jellyfin Live TV. It runs on
ereshkigal as two Podman containers, gated on
tsunaminoai.media.weatherChannel.enable.
- Module:
modules/nixos/media/weather-channel.nix - ws4kp web UI (admin, Tailscale): https://ereshkigal.armadillo-banfish.ts.net:12822
- Feed endpoints (loopback only):
http://127.0.0.1:9798/{playlist.m3u,guide.xml,health} - Upstream: netbymatt/ws4kp · rice9797/ws4channels
Family access is the Jellyfin channel itself — there is no Cloudflare origin for this service, matching the launcher policy in Family Media.
Architecture#
podman network: ws4kp_default
┌──────────────────────────────┐ ┌────────────────────────────────────┐
│ ws4kp (nginx) │ │ ws4channels (node) │
│ ghcr.io/netbymatt/ws4kp │◀─────│ headless Chromium → PNG @ 10 fps │
│ serves the WeatherStar app; │ HTTP │ ffmpeg: x264 ultrafast + looping │
│ all config is a query string │ │ jazz → HLS (2 s segments) │
│ WSQS_* env → query string │ │ express: /playlist.m3u /guide.xml │
└──────────────────────────────┘ └────────────────────────────────────┘
▲ :8080 │ :9798
│ 127.0.0.1:12821 (nginx vhost :12822) │ 127.0.0.1:9798
│ ▼
admin browser Jellyfin Live TV — M3U tuner + XMLTV guide
(registered by jellyfin-provision, step 4)
The browser inside ws4channels talks to api.weather.gov directly, so the whole thing is US-only and needs working host egress.
Enabling the module#
# hosts/x86_64-nixos/ereshkigal/default.nix
tsunaminoai.media.weatherChannel = {
enable = true;
location = {
query = "Lafayette, IN, USA";
latLon = {
lat = 40.4167;
lon = -86.8753;
};
zipCode = "47905";
};
# Everything below is optional — these are the defaults.
ws4kp.settings.viewMode = "wide"; # 1280x720 capture
ws4kp.displays.travel = false;
ws4channels.frameRate = 10;
};
Setting latLon is worth doing: with only query, ws4kp geocodes the string
through ArcGIS on every page load, which is one more thing that can fail while
nobody is watching.
Jellyfin Live TV#
weatherChannel.jellyfin.enable defaults to true whenever
media.server.video and media.server.jellyfin.provision.enable are set. It
contributes to the generic option pair on the Jellyfin module:
tsunaminoai.media.server.jellyfin.provision.liveTv = {
tuners = [
{
name = "WeatherStar 4000";
m3uUrl = "http://127.0.0.1:9798/playlist.m3u";
}
];
listings = [
{
name = "WeatherStar 4000";
xmltvUrl = "http://127.0.0.1:9798/guide.xml";
}
];
};
Those are plain lists, so any module can add its own tuner — nothing about them is weather-specific.
Step 4 of jellyfin-provision.sh reconciles them on the daily 05:00 timer. It
reads GET /System/Configuration/livetv, matches tuners on Url and listings
providers on Type + Path, and:
- absent →
POST /LiveTv/TunerHosts/POST /LiveTv/ListingProviders - present but drifted → the same POST carrying the existing
Id, updating in place - present and correct → left completely alone
The idempotency check is the live config, not a state marker, so a tuner deleted in the UI comes back on the next run. Skipping the no-op write matters: saving a listings provider re-queues Jellyfin’s Refresh Guide task and rewrites the provider’s channel mappings, so a blind daily POST would refresh forever and stomp any mapping made by hand.
The guide populates on Jellyfin’s schedule
SaveListingProvider queues the guide refresh itself, so a fresh
registration fills in within a few minutes. To force it:
Dashboard → Scheduled Tasks → Refresh Guide.
ws4kp permalink reference#
Every display and setting in ws4kp is addressed by one query-string key. The “Copy Permalink” button in the UI just serialises the current state into those keys, and the Nix module generates the same string from typed options.
Naming rules
| Query key | the display’s elemId, or the setting’s shortName |
| Legacy fallbacks | <display>-checkbox, settings-<setting>-<type> — still accepted |
| Env form | WSQS_<key>, with - replaced by _ (e.g. WSQS_current_weather) |
| Booleans | the literal strings true / false |
| Selects | parsed with parseFloat, falling back to the raw string |
Displays#
<key>=true|false, listed in rotation order. All are exposed as
ws4kp.displays.<key>.
| # | Key | WSQS_ variable |
Default | Screen |
|---|---|---|---|---|
| 0 | hazards |
WSQS_hazards |
true |
Hazards — active NWS watches/warnings |
| 1 | current-weather |
WSQS_current_weather |
true |
Current Conditions |
| 2 | latest-observations |
WSQS_latest_observations |
true |
Latest Observations |
| 3 | hourly |
WSQS_hourly |
false |
Hourly Forecast |
| 4 | hourly-graph |
WSQS_hourly_graph |
true |
Hourly Graph |
| 5 | travel |
WSQS_travel |
false |
Travel Forecast |
| 6 | regional-forecast |
WSQS_regional_forecast |
true |
Regional Forecast map |
| 7 | local-forecast |
WSQS_local_forecast |
true |
Local Forecast |
| 8 | extended-forecast |
WSQS_extended_forecast |
true |
Extended Forecast |
| 9 | almanac |
WSQS_almanac |
true |
Almanac |
| 10 | spc-outlook |
WSQS_spc_outlook |
true |
SPC Convective Outlook |
| 11 | radar |
WSQS_radar |
true |
Local Radar |
Settings#
| Key | Type | Values | Default | Notes |
|---|---|---|---|---|
viewMode |
select | standard wide wide-enhanced portrait-enhanced |
derived (standard) |
dropdown; also sets the capture size |
wide |
checkbox | true false |
false |
hidden — driven by viewMode |
portrait |
checkbox | true false |
false |
hidden — driven by viewMode |
enhanced |
checkbox | true false |
false |
hidden — driven by viewMode |
kiosk |
checkbox | true false |
false |
also aliased settings-kiosk-checkbox; starts playback |
stickyKiosk |
checkbox | true false |
false |
persists kiosk in localStorage |
speed |
select | 0.50 0.75 1.00 1.25 1.50 |
1.00 |
screen dwell multiplier |
scanLines |
checkbox | true false |
false |
CRT overlay; nav-bar toggle |
scanLineMode |
select | auto thin medium thick |
auto |
hidden until scan lines are on |
units |
select | us si |
us |
|
refreshTime |
select | 30000 300000 600000 900000 1800000 |
600000 |
milliseconds; hidden |
customTextEnable |
checkbox | true false |
false |
replaces the bottom scroll |
customText |
string | free text | "" |
|
mediaPlaying |
boolean | true false |
false |
hidden; ws4kp’s own music player |
mediaVolume |
select | 1 0.75 0.50 0.25 |
0.75 |
hidden slider |
The module exposes viewMode, units, speed, scanLines, scanLineMode,
refreshTime, stickyKiosk, customTextEnable and customText under
ws4kp.settings. wide / portrait / enhanced are omitted because
viewMode sets them; kiosk is omitted because the module controls it (see the
gotchas); mediaPlaying / mediaVolume are omitted because ws4channels supplies
its own audio track and never captures browser audio. Anything missing can still
be passed through ws4kp.extraQuery.
Two places upstream’s README disagrees with the code
speed — the value multiplies the dwell time, so 0.50 is labelled
“Very Fast” and 1.50 “Very Slow”. The README says the opposite.
units — the value is si, not metric; “Metric” is only the dropdown
label.
Location#
| Key | Type | Notes |
|---|---|---|
latLonQuery |
string | display text in the location box; geocoded through ArcGIS at page load if latLon is absent |
latLon |
JSON {"lat":n,"lon":n} |
used directly, no network geocode — prefer this for a headless capture |
Travel-forecast cities#
ws4kp.travelLocations is the one option under ws4kp that is not a
permalink parameter. ws4kp has no query-string key for the travel cities, so
setting them takes a different mechanism entirely.
ws4kp.displays.travel = true; # the screen is off by default
ws4kp.travelLocations = [
{ name = "Indianapolis"; lat = 39.7684; lon = -86.1581; }
{ name = "Chicago"; lat = 41.8781; lon = -87.6298; }
];
Upstream’s answer to “customise that screen”
(netbymatt/ws4kp#146)
is to edit datagenerators/travelcities-raw.json in a checkout and run
npm run build:travelcities, which resolves every city to an NWS gridpoint and
writes datagenerators/output/travelcities.json. We consume the prebuilt image,
so the module runs the same job from the other end:
ws4kp.travelLocations (Nix)
└─ writeText travelcities-raw.json [{Name, Latitude, Longitude}]
└─ podman-ws4kp.preStart
GET api.weather.gov/points/{lat},{lon}
→ point = {x: gridX, y: gridY, wfo: gridId}
└─ /var/lib/ws4kp/travelcities.json (0444, StateDirectory)
└─ bind-mounted :ro over
/usr/share/nginx/html/data/travelcities.json
└─ loadData('travelcities', …) in resources/index.min.js
Two consequences worth knowing before you touch this:
- The gridpoint is not optional.
travelforecast.mjsthrowsNo pre-loaded pointfor a city that lacks one and filters it out of the list. If no city resolves, the display reportsnoDataand is dropped from the rotation — the screen goes missing silently rather than rendering an error. - api.weather.gov covers the US and its territories only. Coordinates
anywhere else have no gridpoint.
journalctl -u podman-ws4kpnames every city that failed to resolve and prints the running total.
The city name never leaves the flake — it is a display label, not a lookup
key. That makes the travel screen the only place a location can be renamed
without patching ws4kp.
The preStart re-resolves on every start. When the NWS is unreachable it reuses the gridpoint from the last good run rather than losing the city, and a total failure still leaves a valid array on disk, because a missing bind-mount source would make podman create a directory there and shadow the image’s copy with something nginx cannot serve.
The two configuration paths#
They are not interchangeable, and the module drives both from the same options.
WSQS_* on ws4kp |
PERMALINK_URL on ws4channels |
|
|---|---|---|
| Applies to | the browser-facing UI on :12821 |
the headless capture only |
| Mechanism | entrypoint writes redirect.html + an nginx if ($args = '') rule |
Puppeteer page.goto(url) |
| Fires when | the request has no query string | always |
| Kiosk | off unless ws4kp.kioskUi = true |
always forced on |
Set ws4channels.usePermalink = false to fall back to upstream’s default
behaviour, where ws4channels types location.zipCode into the UI and clicks the
kiosk / view-mode controls itself.
ws4channels environment#
Generated by the module; listed here because the container logs refer to them.
| Variable | Default | Meaning |
|---|---|---|
ZIP_CODE |
90210 |
typed into ws4kp’s location box (non-permalink path only) |
WS4KP_HOST |
localhost |
set to the ws4kp podman network alias |
WS4KP_PORT |
8080 |
|
STREAM_PORT |
9798 |
listen port inside the container |
PERMALINK_URL |
unset | full ws4kp URL; bypasses all UI interaction |
FRAME_RATE |
10 |
screenshots per second — see Sizing |
VIEW_MODE |
wide |
standard 640x480 · wide/wide-enhanced 1280x720 · portrait-enhanced 720x1280 |
SHUFFLE_MUSIC |
false |
randomise the bundled tracks at start |
Custom music: point ws4channels.musicDir at a directory of .mp3 files; it is
bind-mounted read-only over /app/music. Or let a query build that directory for
you — see The music bed below.
The music bed#
The channel loops a music bed under the forecast. Upstream ships seven tracks; this module can instead materialize the bed from a library query, which is how ereshkigal runs it:
tsunaminoai.media.weatherChannel.ws4channels.music = {
source = "beets"; # query engine — see docs/services/beets.md
query = "genre:jazz"; # beets query syntax
limit = 40;
normalize = true; # -> 44.1 kHz stereo mp3
refresh = null; # OnCalendar expression, or null for manual only
};
Setting music points musicDir at /var/lib/ws4channels/music and takes
ownership of that directory; setting both is an eval error.
Why a systemd oneshot and not a derivation#
Nix cannot build this directory. A derivation is sandboxed and pure: it can read
neither /mnt/voile nor the beets catalogue at build time, and 13,914 mp3s have
no business in the store. The library is mutable runtime state, so the flake
declares the recipe and ws4channels-music.service does the I/O against live
data, ordered Before=podman-ws4channels.service.
The five constraints it satisfies#
All five are hardcoded in the container’s /app/index.js with no env knob:
| Constraint | Source | Consequence | How the materializer satisfies it |
|---|---|---|---|
| Flat directory | fs.readdirSync(AUDIO_DIR) — not recursive |
an Artist/Album/ tree matches nothing |
one flat file per track, named <12-hex of sha256(source path)>-<slug>.mp3; the hash prefix is what keeps the dozen 01 - Intro.mp3s in the library from colliding, and being deterministic is what lets a re-run recognise its own output |
.mp3 only |
.filter(f => f.toLowerCase().endsWith('.mp3')) |
the library’s 2,701 .m4a files are invisible |
everything is transcoded (or remuxed) to mp3 |
| Real files, not symlinks | podman binds only the staging dir | a symlink into /mnt/voile/… dangles inside the container |
real files on local disk under /var/lib |
| Uniform stream params | ffmpeg concat demuxer + -stream_loop -1 |
mixed sample rates / channel layouts glitch at track boundaries | normalize = true → 44.1 kHz stereo, 192 kbit/s |
| Read once at startup | fs.writeFileSync('audio_list.txt') |
changing the set needs a container restart | a refresh that changed the selection issues systemctl --no-block try-restart podman-ws4channels; an unchanged selection leaves the stream alone |
Per track the unit does one of three things, decided by ffprobe:
- already 44.1 kHz stereo mp3 → remux,
-c:a copy, no re-encode. It still goes through ffmpeg rather thancpbecause-map 0:a:0drops an embedded APIC cover — ffmpeg exposes that as anattached_picvideo stream, which the concat demuxer would meet at every track boundary. - anything else → transcode to 44.1 kHz stereo mp3.
- unchanged since the last run (target exists, source no newer) → skipped.
Then it prunes everything in the directory that is not in the current selection,
including .part files from a run that died mid-transcode. A single unreadable
or undecodable file logs a warning and is skipped; it does not abort the run.
Failure is loud rather than silent by construction: a query that matches nothing
fails the unit without emptying the directory, and if the directory ends up
with no .mp3 at all, podman-ws4channels’ preStart guard refuses to start the
container rather than streaming a playlist of files that do not exist.
First run#
Both units are manual. Order matters — the materializer queries the catalogue, so the catalogue has to exist:
# 1. Build the beets catalogue. Hours, over CIFS; see docs/services/beets.md.
systemctl start beets-index.service
beet stats
beet ls -p 'genre:jazz' | wc -l # sanity-check the query first
# 2. Materialize the bed.
systemctl start ws4channels-music.service
journalctl -u ws4channels-music.service -n 50
ls -la /var/lib/ws4channels/music
# 3. Pick it up. Step 2 does this itself when the selection changed, but the
# container only re-reads audio_list.txt at startup, so do it by hand if the
# log said "set unchanged" and you still want a bounce.
systemctl restart podman-ws4channels.service
refresh stays null until that first run has been measured. Set it to an
OnCalendar expression ("weekly") to add a timer; the timer is Persistent
with a 30 min jitter, and a tick that changes nothing costs no stream
interruption.
Sizing: frame rate must match the hardware#
This is the one setting that can break the channel outright, and it fails silently.
ws4channels hands ffmpeg -framerate <FRAME_RATE> on an image2pipe input, so
every captured frame is stamped as 1/FRAME_RATE of a second regardless of
when it actually arrived. If the host cannot screenshot that fast, the HLS
timeline advances slower than wall clock — permanently, with no catch-up
mechanism. The playlist keeps only two 2-second segments, so a player drains the
window in four seconds and then starves waiting for the next one. Jellyfin shows
a channel that will not tune.
/health will not tell you
ready: true only means ffmpeg has produced segments. On the first
deployment here the endpoint reported ready: true for 16 hours straight
while the capture ran at 1.8 fps against a declared 10 — by the end the
stream was 13.3 hours behind wall clock. The tell is
framesSkippedOverlap climbing far faster than framesWritten (it was
463,783 vs 105,000, i.e. 81% of ticks dropped).
Measured on ereshkigal (dual Xeon E5-2650 v2 @ 2.60 GHz, 32 threads):
viewMode |
Resolution | cpus |
Capture rate | Safe frameRate |
|---|---|---|---|---|
wide |
1280x720 | 1.0 |
1.8 fps | 1 |
wide |
1280x720 | 4.0 |
3.4 fps | 2 |
wide |
1280x720 | 8.0 |
3.3 fps | 2 |
standard |
640x480 | 4.0 |
5.1 fps | 4 |
Two things fall out of that table. Upstream’s suggested --cpus 1.0 is a
throughput cliff, not a safe floor — Chromium’s rasteriser, its PNG encoder,
ffmpeg’s PNG decode and libx264 all contend for that single core, and lifting the
cap to 4 nearly doubles the rate. Past ~4 cores nothing more is available,
because each screenshot is serialised behind the previous one by ws4channels’
own isCapturing guard, so the ceiling is per-screenshot latency. Resolution is
the other lever and a stronger one: 640x480 is 3.4x fewer pixels and buys 50%
more frames.
Measuring your own ceiling#
framesWritten is monotonic within a container lifetime, so two samples give the
true capture rate:
a=$(curl -s 127.0.0.1:9798/health | jq -r .framesWritten); sleep 60
b=$(curl -s 127.0.0.1:9798/health | jq -r .framesWritten)
echo "$(( (b - a) / 60 )) fps (target: whatever frameRate is set to)"
Set frameRate to roughly 70-80% of what you measure. And to see how far behind
the stream already is, compare the HLS media sequence with the container’s
uptime — each sequence number is two seconds of video:
seq=$(curl -s 127.0.0.1:9798/stream/stream.m3u8 | grep -oP 'MEDIA-SEQUENCE:\K[0-9]+')
echo "video produced: $(( seq * 2 / 3600 )) h"
systemctl show podman-ws4channels -p ActiveEnterTimestamp --value # vs. now
The ws4channels-drift timer does exactly this every 10 minutes and fails its
unit when the ratio drops below 0.7. That failure alerts at warning tier
(ntfy only, no page): the unit is listed in
tsunaminoai.telemetry.server.nonCriticalUnits on the telemetry server, because
drift is media-only and a later healthy sample clears the failure by itself:
systemctl status ws4channels-drift
journalctl -u ws4channels-drift -n 20
Operations#
# is the stream alive? 503 until ffmpeg has produced segments
curl -s 127.0.0.1:9798/health | jq
# what Jellyfin will see
curl -s 127.0.0.1:9798/playlist.m3u
curl -s 127.0.0.1:9798/guide.xml | head
# containers
journalctl -u podman-ws4channels -f # capture/ffmpeg/browser-restart logs
journalctl -u podman-ws4kp -f
systemctl restart podman-compose-ws4kp-root.target
# force a Live TV reconcile now instead of waiting for 05:00
systemctl start jellyfin-provision && journalctl -u jellyfin-provision -n 40
# what Jellyfin currently has registered
curl -s -H "X-Emby-Token: $TOK" 127.0.0.1:8096/System/Configuration/livetv \
| jq '{TunerHosts, ListingProviders}'
A Gatus check named weather-channel polls /health every 5 minutes and asserts
[BODY].ready == true, because a stalled stream is otherwise completely silent —
Jellyfin just shows a channel that will not tune.
Gotchas#
PERMALINK_URLbypasses every UI interaction. No kiosk click, no view-mode select, no ZIP typing (index.js:182-253). Kiosk mode is also what starts playback at all (ws4kp index.mjs:157), so a permalink withoutkiosk=truestreams a frozen page with the settings toolbar in shot. The module appends it unconditionally; a hand-pasted permalink must carry it.WSQS_*only applies to a bare request. The entrypoint’s nginx rule isif ($args = ''). Any URL that already has a query string ignores the environment entirely — which is exactly why the permalink path has to be self-contained.CHANNEL_NUMBERdoes nothing. It is documented upstream but never read;tvg-chnois hardcoded to275in the playlist handler.- The channel IDs do not match, and that is fine. The M3U advertises
tvg-id="weatherStar4000"while the XMLTV guide declares<channel id="WS4000">. Jellyfin tries ID, then tuner-channel ID, then number, and finally the space/hyphen-stripped name — both sides say “WeatherStar 4000”, so the guide binds on that last fallback. If it ever comes up empty, map it by hand in Dashboard → Live TV → the provider’s Map Channels. - It wants about four cores, permanently. A headless Chromium plus a
continuous
libx264 -preset ultrafastencode saturates whatevercpuscap it is given and settles at ~850 MB, 24/7. Giving it upstream’s suggested 1.0 core is what caused the drift described in Sizing. On ereshkigal that runs alongside Jellyfin transcodes and Tdarr, on a btrfs root sitting near 90% full — and the image pull alone is roughly 1 GB. See ereshkigal storage and health. - US only. ws4kp reads NOAA/NWS, which serves the United States exclusively.
--shm-size. Podman’s 64 MB default/dev/shmis the usual cause of silent Chromium screenshot failures and browser restarts; the module sets 256 MB.- Upstream ships floating
:latesttags. Both images are unpinned, in line with the rest of the container modules here. - A restart only papers over drift. Restarting
podman-ws4channelsresets the HLS timeline to now, so a stream that had fallen behind looks fixed — and then falls behind again at exactly the same rate. Upstream files this as “stream gets 30+ minutes behind”, but on under-provisioned hardware it is not a bug, it isframeRateexceeding what the box can capture. Fix the setting, not the symptom. - Registering a tuner by hand creates a duplicate.
jellyfin-provisionidentifies tuners by URL, so a UI-added tuner pointing at the host’s Tailscale IP and the provisioned one pointing at127.0.0.1are two different tuners — you get the channel twice in the guide. Delete the manual one; deleting the provisioned one just brings it back at 05:00.
Related#
- beets — the catalogue behind
ws4channels.music - Media — the rest of the media server stack
- Family Media — how family members reach Jellyfin
- Containers — the Podman/compose2nix pattern
- ereshkigal