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.

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:

  • absentPOST /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.

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.mjs throws No pre-loaded point for a city that lacks one and filters it out of the list. If no city resolves, the display reports noData and 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-ws4kp names 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 than cp because -map 0:a:0 drops an embedded APIC cover — ffmpeg exposes that as an attached_pic video 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#

  1. PERMALINK_URL bypasses 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 without kiosk=true streams a frozen page with the settings toolbar in shot. The module appends it unconditionally; a hand-pasted permalink must carry it.
  2. WSQS_* only applies to a bare request. The entrypoint’s nginx rule is if ($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.
  3. CHANNEL_NUMBER does nothing. It is documented upstream but never read; tvg-chno is hardcoded to 275 in the playlist handler.
  4. 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.
  5. It wants about four cores, permanently. A headless Chromium plus a continuous libx264 -preset ultrafast encode saturates whatever cpus cap 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.
  6. US only. ws4kp reads NOAA/NWS, which serves the United States exclusively.
  7. --shm-size. Podman’s 64 MB default /dev/shm is the usual cause of silent Chromium screenshot failures and browser restarts; the module sets 256 MB.
  8. Upstream ships floating :latest tags. Both images are unpinned, in line with the rest of the container modules here.
  9. A restart only papers over drift. Restarting podman-ws4channels resets 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 is frameRate exceeding what the box can capture. Fix the setting, not the symptom.
  10. Registering a tuner by hand creates a duplicate. jellyfin-provision identifies tuners by URL, so a UI-added tuner pointing at the host’s Tailscale IP and the provisioned one pointing at 127.0.0.1 are 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.