clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / README.md
README.md 47.1K rendered
1# Airwaves23A desktop TV and program guide for free over-the-air broadcasts, built with Go and4[Wails v2](https://wails.io). It answers three questions for a US ZIP code:561. **What is on the air here?** Every licensed transmitter within a radius, from the FCC.72. **What do I actually receive?** The channels the tuner's scan found, and what it8 measured on each RF channel (lock, strength, quality, errors), over time and on demand,9 beside each transmitter's records and a radar map. Planning an antenna before there's10 a tuner is `otascan`'s job: terrain-aware estimates for indoor, attic and rooftop11 antennas.123. **What is on now?** A Gracenote-backed grid guide, live TV, and recordings.1314There is one setup: an HDHomeRun on the network as the tuner, a home server running15`airwavesd`, and the desktop app as the TV. The server owns the tuner, the lineup, the16guide, recording and the library; the app is the remote.1718It also maps which stations broadcast ATSC 3.0 (NextGen TV), which transmitter hosts each19service, and where the ATSC 1.0 simulcast of each channel lives.2021## Run2223Requirements: Go 1.26+ and the Wails CLI. On first launch the app asks for the server24address (for example `nas.local`).2526```sh27go install github.com/wailsapp/wails/v2/cmd/wails@v2.16.028wails build # produces build/bin/Airwaves.app29open build/bin/Airwaves.app30wails dev # live-reload development31```3233There is also a terminal report that needs only Go, for planning an antenna: the34reception it estimates from the terrain (see How reception is estimated):3536```sh37go run ./cmd/otascan -zip 80302 # rooftop antenna, receivable channels38go run ./cmd/otascan -zip 80302 -antenna indoor39go run ./cmd/otascan -zip 80302 -lat 39.74 -lon -104.99 -all -json > report.json40```4142## Server4344The server is two containers: `airwavesd` (the tuner, lineup, reception, guide,45recording, live and recorded HLS, the emulated HDHomeRun and custom channels) and the46WeatherStar display behind the weather channel.4748```sh49AIRWAVES_ZIP=80302 scripts/deploy.sh nas # cross-compiles airwavesd, syncs deploy/ to ~/airwaves, starts it50```5152`deploy.sh` writes `~/airwaves/.env` on first run with the host name, its Tailscale53address, the host's time zone (`TZ`) and the antenna's ZIP code (`AIRWAVES_ZIP`, which54sets the lineup and listings), and binds `airwavesd` (port 8089) to the Tailscale address55only. Other `.env` settings: `AIRWAVES_LAT` / `AIRWAVES_LON` for the56antenna's exact position (better than the ZIP centroid, since terrain decides57reception); `AIRWAVES_TOKEN` (requires the same token in the app);58`AIRWAVES_ADMIN_PASSWORD` (for the admin page and `/admin/mcp`); `AIRWAVES_HDHOMERUN`59(the tuner's address or device ID, when discovery can't find it); `RECORDINGS` (host60path for recordings, default `~/airwaves/recordings`); `MUSIC` and `CHANNELS` (host61folders for the custom channels); `TZ`. Location is deployment62configuration only; the app never shows or edits it.6364In the app, open More, the Server tab, and set **Address** to the server's name, `nas`65in these examples.6667**The TV in a browser**: `airwavesd` also serves the app itself at `/tv/`, for example68`http://nas:8089/tv/`, to any browser on the tailnet and to the Android TV app, which69shows that page. It is the same interface over the same API (`frontend/dist/web.js`70stands in for the desktop app's Go side), so `AIRWAVES_TOKEN` still applies: the page71asks for the token. Favorites, hidden channels and the other app settings are kept in72each browser.7374**The tuner** is an HDHomeRun (for example a FLEX DUO) with the antenna on its coax75input, and `airwavesd` is its only client. It uses the device's own HTTP API: its76channel scan and lineup, its tuner status, and its streams. `airwavesd` uses host77networking to find it by broadcast, on the LAN or on a spare Ethernet port with78link-local addressing that the firewall trusts; `AIRWAVES_HDHOMERUN` names it instead79(`192.168.1.30`, or a device ID such as `1050ABCD`).8081**One tuner per RF channel.** `airwavesd` reads each RF channel in use once, as a whole,82and hands each viewer, recording and HDHomeRun client its own program from it, so83watching 9.2 while 9.1 records takes one tuner. With every tuner taken, a recording takes84one from a viewer (the one tuned last), a viewer one from Measure now, and changing85channels always gets the viewer's own tuner back; otherwise tuning says who has the86tuners. A tuner stays on its RF channel for a few seconds after its last viewer, so87coming back to it is instant.8889**The lineup is the tuner's.** The app's antenna channels are exactly the channels the90HDHomeRun's scan found; the FCC's records, the Gracenote listings and the ATSC 3.0 list91only describe them (call sign, network, transmitter, listings), never add one. Without a92tuner there are no antenna channels. A channel that won't lock right now stays listed,93and tuning it says so: "No signal on RF 31 (KTVD 20.x) right now". The HDHomeRun scans94when it powers up; **Scan for channels** in Settings, Reception (`POST /api/tuner/scan`) scans95again, after moving the antenna, taking every tuner for the few minutes it runs.9697**Signal is measured, never estimated.** `airwavesd` records, per RF channel, readings98of the tuner: strength, signal-to-noise quality and symbol quality, as the HDHomeRun99reports them in percent, whether data arrived, and the transport and continuity errors100in it, counted as it arrives. It reads them every 10 seconds while a tuner is in use, and101on request (`POST /api/signal/measure`, Measure now), which briefly tunes each RF102channel worth measuring on a free tuner, at the lowest priority, so any viewer or103recording takes the tuner over and the sweep stops. Readings are kept in104`<data>/signal.json` and served at `GET /api/signal` and in the snapshot's `antenna`105section. Terrain-based estimates are for planning an antenna, in `otascan`, and aren't106served to the app.107108**Recording** is Airwaves' own. Rules are one airing, or a series by Gracenote series ID109on one channel, optionally new episodes only; series rules skip episodes already recorded110or already scheduled, and an airing skipped from a series is recorded at a repeat when111there is one. An airing records from a minute early to 3 minutes late (30 for sports)112into the recordings folder, a folder per show (`Nature/2026-10-04 20.00 Episode.ts`),113and its details, with the rules, are in `<data>/dvr.json`. Coming up shows each airing114to come with whether it will get a tuner: a conflict names the recordings that have115every tuner then. A recording under way can be watched from the start, given 30 more116minutes, or stopped, keeping what it recorded. Losing the signal or the tuner makes a117gap, not an end: it tries again until its time is up, and after a restart a recording118whose time isn't up carries on. A finished recording says what it's missing: "Began 12119minutes into the program", "About 3 minutes are missing in between".120121`dvr.json`, `signal.json` and `config.json` are written whole or not at all, each122keeping its last version as `.bak` and a copy a day in `<data>/backups` for a week. One123that won't read is set aside as `.corrupt-<time>` and the newest good copy takes its124place, so a crash or a bad write loses at most the last change.125126## Without an antenna127128`airwavesd` also runs on its own for the custom channels only (Jellyfin, YouTube,129folders of videos and the weather), with no antenna or tuner. That is130`deploy/standalone`, built from this repository on any Docker host, amd64 or arm64;131[docs/self-hosting.md](docs/self-hosting.md) walks a newcomer through it. `scripts/deploy.sh`132and `deploy/compose.yml` are unchanged by it: every setting below defaults to what they133had.134135| Setting | Does |136| --- | --- |137| `AIRWAVES_ANTENNA=off` | Custom channels only: no tuner, FCC or Gracenote lineup, terrain, reception reports or recording. `/api/info` says `"antenna": false` and the app hides Reception; what needs an antenna answers 501 with the reason. The location (`AIRWAVES_ZIP`, or `AIRWAVES_LAT` and `AIRWAVES_LON`) is only for the weather, and there's none unless given. |138| `AIRWAVES_HDHR` | `on` or `off`, the emulated HDHomeRun: on by default, off without an antenna. |139| `AIRWAVES_HDHR_DISCOVERY` | `on` or `off`, answering HDHomeRun discovery on the LAN (needs host networking): the same defaults. Without it, the device's URLs use the address clients were given (their `Host` header), since a container's port mapping changes its own. |140| `AIRWAVES_ADMIN_PASSWORD` | A password for `/admin/` and `/admin/mcp`: HTTP Basic auth with any user name, or `Authorization: Bearer <password>` from MCP clients. It's never logged, and the programs `airwavesd` runs don't inherit it. |141| `AIRWAVES_WEATHERSTAR_URL` | Where `airwavesd` reaches the WeatherStar display when that isn't 127.0.0.1 on `AIRWAVES_WEATHERSTAR_PORT` (`http://weatherstar:8080` for a container of its own). The weather channel is then on only while the display answers. |142143`GET /healthz` answers `ok`, token or not, for container health checks (`airwavesd144-healthcheck` asks it). At startup `airwavesd` logs which features are on.145146## Keys147148| Key | TV |149| --- | --- |150| Enter (a remote's OK) | On-screen controls: the banner, a timeline, and buttons for the guide (first, so Enter twice opens it), pause, skip, live, captions, audio, record, favorite and last channel, with the views above. Arrows move, Enter presses, Esc puts them away; they stay up while paused |151| Up / Down, PgUp / PgDn | Channel up / down |152| Digits then Enter | Tune a channel, e.g. `7` `.` `2` |153| L or Backspace | Previous channel |154| Space | Pause and resume (live TV keeps a 30 minute rewind window) |155| Left / Right | Back 10 s / forward 30 s (Shift: 60 s), with the controls up on the timeline |156| End | Jump back to live |157| R / Shift R / N | Record this show / every episode / new episodes |158| C | Closed captions on or off |159| V | Next audio track (second language, described video) |160| Shift F | Favorite this channel |161| I | Info banner; again for the measured signal, again to hide |162| M / F | Mute / fullscreen |163164| Key | Elsewhere |165| --- | --- |166| G | Guide: arrows move, and Up and Down wrap round the channels. The filters (Favorites, Sports...; also `[` `]`) are Down from the top bar, which is Left from the earliest program. Enter watches what's on, or shows a program to come's actions; hold Enter, or I, for any program's: watch, record (which opens to this one, every episode or new only), favorite, hide. Home jumps to now, R / Shift R / N record, Shift F favorite, H hide (U undoes) |167| D | Recordings: Left (or `[` `]`) for Library, Coming up, Series. Enter shows a recording's actions: resume, from the start, watched, delete (press twice); for one recording now, watch from the start, 30 more minutes, stop (keeping it) and delete; Shift Enter starts over, W marks watched, Delete twice removes; on a series K sets how many to keep, N new episodes only |168| P | Podcasts: video podcasts from YouTube, on demand. The rail has Latest (every show's newest), Continue and each show, and under them Refresh (also R), which looks for new episodes now; Enter shows an episode's actions: play or resume, from the start, watched. Shows are added in Settings, Podcasts, by search or a channel's or playlist's address |169| W | Weather: current conditions, alerts, the next 24 hours, 7-day, radar loop and satellite; Up and Down move through them, Left to watch the weather channel |170| `,` or S | More: Settings, a tab for each area in the rail (Display, Watching, Channels, Reception, Recordings, Server, About). Up and Down pick a tab, Enter goes in, Back returns to the tabs; Page Up and Down (a controller's LB and RB, also `[` `]`) change tab |171| A | Settings' Reception tab: what the tuners measured, by RF channel (the ones the scan found first), with the transmitters licensed on each. Up and Down pick one, Enter watches its first channel; above them the tuner, Measure now, which reads every RF channel on a free tuner and shows its progress, and Scan for channels (press twice) |172| Esc | Back to TV; from a recording, back to live |173174A recording that fails puts a count on Recordings in the top bar, and says so once,175until Recordings is opened.176177With only a remote's arrows, OK and Back (an Android TV remote), every view is178reached from the views above it: Up from a view's top, or Left from its left179edge, and on TV, Up through the on-screen controls. Whatever the arrows are on has180an amber ring.181182### Desktop, console, TV and touch183184The app is four experiences, kept apart in the code (`platform` in js/core.js, `pf-*`185classes in style.css):186187| | Where | In hand | Hints |188| --- | --- | --- | --- |189| Desktop | The app in a window on a Mac, Linux or Windows, or a browser | Mouse and keyboard; letters are shortcuts | Keys |190| Console | The Linux app under gamescope (SteamOS Game Mode) | A controller | Its buttons, from the start |191| TV | The Android TV app | A remote: arrows, OK, Back and media keys | The remote's keys; hold OK for more |192| Touch | A phone or a tablet (a browser whose main pointer is a finger), at `/tv/` or installed from it | Taps, swipes and holds | None |193194The app says which it is (`AIRWAVES_PLATFORM=desktop`, `console` or `tv` overrides the195desktop app's guess; `?platform=` does the same at `/tv/`), and Settings, Display, Layout196can change it on one device. Console and TV start at 125%, sized for across a room. A197controller or a keyboard used on any of them still brings its own hints, and a finger its198gestures.199200On touch, a tap on TV brings up the controls, and a tap anywhere off a button puts them201away, as a phone's video players do; a swipe left or right202skips back 10 seconds or ahead 30, up or down changes channel. In the guide a swipe moves203the hours shown, a tap picks a program and a second tap watches it, and holding one shows204its actions; in Recordings and Podcasts a second tap plays. A phone is used on its side,205with a compact banner, controls and guide; held upright it asks to be turned.206207The web app is installable (`manifest.webmanifest`, and `sw.js`, which keeps nothing208offline): from a secure address, such as a [Tailscale Service](https://tailscale.com/kb/1552/tailscale-services)209in front of `airwavesd` (`tailscale serve --service=svc:airwaves --https=443210http://<server>:8089`), Add to Home Screen on a phone or Install in a desktop browser211opens it full screen with its own icon. A plain `http://` address works in a browser but212can't be installed.213214## Data sources215216| Source | Used for | Cache |217| --- | --- | --- |218| [FCC TV Query](https://www.fcc.gov/media/television/tv-query) | Licensed transmitters: RF channel, ERP, height, location | 7 days |219| [AWS Terrain Tiles](https://registry.opendata.aws/terrain-tiles/) | Elevation along each path (SRTM, USGS NED and others) | permanent |220| [RabbitEars.Info](https://www.rabbitears.info/market.php?request=atsc3) | ATSC 3.0 hosts and services | 3 days |221| [Gracenote TV listings](https://tvlistings.gracenote.com/) | Lineup `USA-OTA<zip>` and schedules, without a Schedules Direct account | 4 hours |222| [Schedules Direct](https://www.schedulesdirect.org/) | Lineup `USA-OTA-<zip>` and two weeks of schedules and programs, with an account | Updated daily |223| [Zippopotam.us](https://zippopotam.us/) | ZIP centroid | 1 year |224225Caches live in `~/Library/Caches/airwaves` (on the server, `~/airwaves/data/cache`);226app settings in `~/Library/Application Support/airwaves/settings.json`.227228The Gracenote grid endpoint is the public JSON behind tvlistings.gracenote.com. It is229undocumented and meant for personal use, and it answers only browsers.230231**Schedules Direct.** With an account at [Schedules Direct](https://www.schedulesdirect.org)232(the licensed source of the same Gracenote data, for personal use), the listings come from233there instead: two weeks of them, with long descriptions, cast, original air dates, movie234runtimes and star ratings, and sports teams. Sign in on the admin page (Guide listings); the235server keeps the user name and the password's SHA-1, which is what the API takes, in236`<data>/schedulesdirect`, readable by it alone, and never in backups or the app. It adds the237ZIP code's antenna lineup to the account, updates about once a day at a minute of its own, asks only238for the station-days that changed, and stops asking altogether when the account is refused239(a wrong password, a locked or expired account) until it's saved again, as Schedules Direct240requires. The app's guide still shows its listing hours; recording rules look the two weeks241ahead, and the emulated HDHomeRun's XMLTV carries them all. Pictures come from there too: one242landscape picture per series or movie, picked without the title written on it where there's243one, downloaded once and kept in `<data>/schedulesdirect/images` (served at `/guide-art/`),244up to 500 an update, the shows on soonest first, pausing until midnight UTC when Schedules245Direct's daily cap is reached. Airwaves identifies itself as246`airwaves/<version>`; it is used under Schedules Direct's personal research terms, for one247household.248249## How reception is estimated250251For `otascan` only: the app shows what the tuner measured instead (see The lineup is the252tuner's). `internal/reception` computes free-space loss plus Deygout multiple knife-edge diffraction253over a terrain profile on a 4/3 effective earth, then compares the received power against254the ATSC 1.0 threshold (15.2 dB C/N over a 6 MHz noise floor, with extra man-made noise255on VHF). Each antenna preset adds its height, gain by band and wall or cable loss.256257It captures line of sight and ridge blockage, which decide most outcomes along the Front258Range. It does not model multipath, buildings, foliage or directional transmit patterns,259so treat margins as a ranking and rough headroom rather than a promise. For an260authoritative second opinion, compare with the FCC DTV Reception Maps or RabbitEars'261Signal Search Map.262263## Weather264265`airwavesd` builds the weather report from public sources for the server's location:266NWS (api.weather.gov) for observations, the forecast, hourly data and alerts; the NWS267radar loop for the local radar site; NOAA GOES GeoColor satellite imagery; and268Open-Meteo for air quality and sunrise and sunset. Everything is cached on the server and269images are served from `/wximg/`, so the app never handles coordinates.270271- **Weather channel**: 1.1 Airwaves Weather (WX), or whatever number and name the admin272 page gives it, is a WeatherStar 4000+ display ([ws4kp](https://github.com/netbymatt/ws4kp),273 MIT) running on the server with Airwaves branding (`deploy/weatherstar`).274 `/weatherstar` redirects to it with the server's location; the app shows it there, and275 HDHomeRun clients get it rendered to video (see Custom channels).276- **W page**: the weather report in the app.277- **Alert crawl**: NWS watches and warnings for the location crawl across live TV.278279## Podcasts280281The Podcasts app is video podcasts on demand, from YouTube, where they are: their public282RSS feeds carry only the sound. A show is a YouTube channel's videos, its past live streams283(its `/streams` tab, where full shows that air live end up) or a playlist, added by its284link, `@handle` or a search for channels. Videos shorter than the show's minimum (10285minutes unless it's added with another) are clips, not episodes; Shorts, and streams to286come, on now or not yet processed, are left out.287288`airwavesd` keeps each show's newest 30 episodes in `<data>/podcasts.json`, with how far289each was watched (and any older ones part watched), backed up like the recordings:290291- every 6 hours it lists the show's newest 40 videos with yt-dlp, and every 30 minutes reads292 its channel's feed for new uploads in between; Refresh, in the app, lists every show now;293- a video new to a show is looked up (its length, date, description and picture), a dozen294 an update, a few seconds apart, so YouTube isn't asked too much;295- the newest unwatched episode of each show has where it streams from found ahead, so it296 starts at once.297298Nothing is downloaded: an episode streams from YouTube while it's watched, H.264 up to2991080p with English sound, its picture copied rather than transcoded, and played like a300recording, from where it was left. One show failing to list says so on that show alone.301302Listing a show takes a few seconds. Adding one, or Refresh, does it at once; the background303listings and lookups take turns with the YouTube channels' jobs, one yt-dlp job at a time,304since listing a channel's whole catalog for its schedule can take minutes.305306| | |307| --- | --- |308| `GET /api/podcasts` | `{"shows": [{"id", "title", "url", "thumbnail", "minMinutes", "error", "episodes": [{"id", "showId", "title", "description", "published", "duration", "thumbnail", "position", "watched"}]}], "updating", "listed"}`, episodes newest first; `listed` is since when every show has been listed |309| `POST /api/podcasts/refresh` | Lists every show now, a few at once, and replies as `GET /api/podcasts`; new episodes are looked up after |310| `GET /api/podcasts/search?q=` | YouTube channels: `[{"id", "title", "url", "thumbnail", "description", "subscribers"}]` |311| `POST /api/podcasts/shows` | `{"url", "minMinutes"}`: adds a show, listed before the reply, which is the show; 400 for what isn't one |312| `DELETE /api/podcasts/shows/{id}` | Takes a show out |313| `POST /api/podcasts/play` | `{"client", "id", "from"}`: plays an episode, as `/api/dvr/play` plays a recording |314| `POST /api/podcasts/progress` | `{"id", "position", "duration", "watched"}`; within a minute of the end is watched |315316## HDHomeRun emulation317318`airwavesd` also appears on the home network as an HDHomeRun network tuner, so other319players can watch the lineup: model HDTC-2US, friendly name "Airwaves", a device ID320derived from the server name, and as many tuners as the real one has (2 until it is321found). Antenna channels are passed through as broadcast (each channel's own program of322the RF channel's MPEG-TS, MPEG-2 video and AC-3 audio), on tuners shared with the app323and recordings as above; custom channels are H.264 and AAC in MPEG-TS at 1280x720.324325It serves HTTP on port 5004 (`AIRWAVES_HDHR_LISTEN`, default `:5004`; empty or326`AIRWAVES_HDHR=off` disables it, and `AIRWAVES_HDHR_DISCOVERY=off` only its discovery):327328| Path | Serves |329| --- | --- |330| `/discover.json`, `/lineup.json`, `/lineup_status.json` | The HDHomeRun device API |331| `/auto/v<number>`, `/tuner0/v<number>` | A channel's stream, e.g. `/auto/v9.1` |332| `/channels.m3u` | Playlist, with `url-tvg` pointing at the guide |333| `/xmltv.xml` | The guide as far as the listings go (two days from Gracenote's grid, two weeks from Schedules Direct) and custom channel schedules; channel ids `airwaves.<number>` |334| `/channel-logos/<number>` | A custom channel's logo, also on the API port; the playlist and guide link to it |335336Discovery answers on UDP 65001, only to other machines: requests from the server itself337are ignored, so `airwavesd` never finds itself when it looks for the real tuner. Open TCP 5004 and UDP 65001338on the server's LAN interface only. With the server at 192.168.1.20:339340- **Channels DVR**: found automatically as an HDHomeRun under Settings > Sources;341 otherwise use Add Source there to add it by IP, `192.168.1.20:5004`.342- **Jellyfin, Emby**: in Live TV, add a tuner device of type HDHomeRun with URL343 `http://192.168.1.20:5004`, and an XMLTV guide provider with344 `http://192.168.1.20:5004/xmltv.xml`.345- **HDHomeRun app**: discovers it on the same network.346- **VLC**: open the network stream `http://192.168.1.20:5004/channels.m3u`, or a single347 channel such as `http://192.168.1.20:5004/auto/v1.1`.348349Plex is not a target.350351## Custom channels352353Custom channels have a guide schedule but no tuner behind them. `airwavesd` generates354them and decodes nothing unless someone is watching. They are in the HDHomeRun lineup355and guide, and in the app's guide, where pause and rewind work as for antenna channels;356they can't be recorded.357358**Channel details**: every custom channel, the weather channel included, has a number359(1.0 to 999.999, kept as written, so 104.0 stays 104.0; no two custom channels share360one, however written: 1.05 is 1.5), a name, a short call sign for guide361rows and banners ("WX", "TOON"), a category (Kids, Family, Movies, Sports, News, Music,362Pets, Gaming, Documentary, Weather or Other), a description, a logo, and whether it's on363the air. A folder channel's number and name are its folder's name; the rest is in364`channel.json` in the folder, and its logo is an image there (`logo.png`, `.svg`, `.jpg`365or `.webp`, found by name or named in `channel.json`):366367```json368{ "callSign": "TOON", "category": "Kids", "description": "Saturday morning cartoons.", "logo": "logo.png", "enabled": true }369```370371The weather channel has no folder, so its whole record, number and name included, is372`.weather.json` in the channels folder (defaults: 1.1 "Airwaves Weather", WX, Weather),373with its logo beside it as `.weather-logo.png`. Changes are picked up on the next374request, with no restart; a stream already playing carries on. Channels turned off375(`"enabled": false`) leave the HDHomeRun lineup and the app. A custom channel may take an376antenna channel's number, and takes its place in the HDHomeRun lineup; the admin page377warns when it does. The HDHomeRun playlist keeps custom channels in the "Airwaves" group,378apart from the antenna's, and gives their logos as `tvg-logo`; the XMLTV guide lists the379call sign among each channel's names, links the logo as its icon, and gives every380programme without a category of its own the channel's (none for Other).381382The app lists custom channels among the antenna channels by number (a custom channel383takes the place of an antenna channel with the same number there too), with their call384signs and logos in the guide and the banner, and their descriptions when nothing is385listed. A category counts as a genre of the channel's programs for the guide's filters,386and categories with no filter of their own (Kids, Music, Pets, Gaming, Documentary) get387one while a channel has them. Favorites and hidden channels follow a custom channel to a388new number or name.389390**1.1 Airwaves Weather** is the WeatherStar 4000+ display rendered by headless Chromium391inside the `airwavesd` container and encoded with ffmpeg, over looping background music.392Chromium only runs while someone is watching and is stopped afterwards (the compose393service sets `init: true` so its helper processes are reaped). The guide has an394hour-long "Local Forecast" block each hour; its subtitle is that hour's forecast (e.g.395"66° and Clear", prefixed with an active NWS alert) and its description is the NWS396forecast period text.397398By default the four royalty-free tracks bundled with ws4kp loop. MP3s in the music399folder (`MUSIC` in `~/airwaves/.env`, mounted at `/music`; `AIRWAVES_MUSIC`) are used400instead, looped in file name order.401402**Folder channels**: every subfolder of the channels folder is a channel (`CHANNELS` in403`~/airwaves/.env`, such as `/srv/airwaves/channels`, mounted at `/channels`;404`AIRWAVES_CHANNELS`). The folder name sets the number and name:405`1.2 Cat Calming` is channel 1.2 "Cat Calming"; folders without a leading number, or406whose number is the weather channel's, get the next free 1.x numbers. A folder with no407playable videos is left out of the lineup.408409Videos (mp4, mkv, webm, mov, m4v, ts) play in file name order and loop forever on a fixed410clock, so the guide is accurate and tuning in joins whatever is on part way through.411Changing a folder's contents reshuffles its schedule. Partial yt-dlp downloads (`.part`,412`.ytdl`, `.temp` and `.fNNN` intermediate files) are ignored, and new files are noticed413within about a minute, with no restart. Guide titles come from the yt-dlp `.info.json`414next to each video (the title, and the first paragraph of the description); without415one, the file name minus its `[video id]` suffix is used. Every video is scaled and416letterboxed to 1280x720 at 30 fps with stereo AAC; videos without audio get silence.417418To fill a channel with yt-dlp (H.264 at up to 1080p keeps decoding cheap):419420```sh421cd "/srv/airwaves/channels/1.2 Cat Calming"422yt-dlp -S "res:1080,vcodec:h264,acodec:m4a" --merge-output-format mp4 --write-info-json \423 -o "%(title)s [%(id)s].%(ext)s" "<video or playlist URL>"424```425426YouTube needs a current yt-dlp (older packaged copies get HTTP 403 on downloads), with427deno for YouTube's JavaScript challenges; `yt-dlp -U` updates the release builds.428429**Jellyfin channels**: a channel folder holding `jellyfin.json` plays from a Jellyfin430server instead of its own videos, the way ErsatzTV builds channels from a media library.431The series, movies, collections, playlists and libraries it names loop on a fixed clock432with a full guide (series and episode titles, season and episode numbers, descriptions,433artwork from the server), streamed from the server only while someone watches.434435```json436{437 "series": ["Futurama", "Batman: The Animated Series (1992)"],438 "movies": ["The Iron Giant", "The Thing (1982)"],439 "collections": ["Saturday Morning"],440 "playlists": [],441 "libraries": [],442 "order": "shuffle",443 "maxBitrate": 0444}445```446447Names match Jellyfin's, ignoring case; a trailing year picks between same-named titles.448Names that match nothing are logged. Only episodes and movies play, and episodes the449server has no file for are skipped. `order` is `shuffle` (the default: a fixed shuffle450per channel) or `aired` (each series in season and episode order, movies by release451date; for a one-show channel, its episodes in order). The list is refreshed about every45230 minutes, and the schedule only changes when the set of videos does: adding or453removing one reshuffles it.454455The account goes in `.jellyfin.json` in the channels folder, shared by every Jellyfin456channel, which sign in once between them:457458```json459{ "server": "https://jellyfin.example.com", "user": "airwaves", "password": "..." }460```461462`token` (an API key, or one from Quick Connect with its `userId` and `deviceId`) can463replace the user and password, and a channel's own `jellyfin.json` can name a different464`server`, `user` and `password` or `token`. These files live only on the server, in the465channels folder; the password and token stay out of logs, the guide and the app. Ask466your friend for a Jellyfin user just for Airwaves, with access to only what the channels467need. If the server refuses the password, Airwaves stops trying until one of the files468changes, since repeated failures lock a Jellyfin account.469470Bandwidth: with `maxBitrate` 0 the original files stream as they are (a 1080p movie is471often 5 to 20 Mb/s) and `airwavesd` decodes them; a `maxBitrate` in Mb/s (say 4) has the472Jellyfin server transcode to H.264 and AAC at that rate and at most 720p, on its CPU.473Either way it is one stream per viewer, and none while no one watches.474475**YouTube channels**: a channel folder holding `youtube.json` runs the uploads of YouTube476channels like a TV channel, with a real guide, and downloads nothing. For example477`1.8 Northernlion/youtube.json` in the channels folder:478479```json480{481 "channels": ["https://www.youtube.com/@Northernlion", "https://www.youtube.com/@TheLibraryofLetourneau"],482 "repeatDays": 30,483 "maxHeight": 720,484 "minMinutes": 3,485 "maxMinutes": 240,486 "rerunMix": "balanced",487 "lockHours": 2,488 "maxAgeDays": 0,489 "deadAir": false490}491```492493`channels` are channel URLs, `@handle`s or `UC...` channel IDs. No video airs again within494`repeatDays` while the channels have enough videos (then the one aired longest ago goes495next). With `deadAir` true none ever does: a YouTube channel with nothing left to air sits496out its turn, and when every video has aired, the channel shows "Please stand by" ("Off497air" in the guide) until a new upload arrives or a video's repeat window passes. `maxHeight` caps the picture streamed (H.264 and AAC). Videos shorter than498`minMinutes` or longer than `maxMinutes` are left out (`maxMinutes` 0 for no limit), as499are Shorts, live streams and upcoming premieres. `rerunMix` leans reruns towards recent500uploads: `recent` picks about 70% of them from the last six months, 20% from the last501two years and 10% from any time; `balanced` 40/30/30; `any` all videos alike. When none502from the last six months (or two years) is free of the repeat guard, the pick looks503further back. `lockHours` is how far ahead new uploads leave the schedule as it is.504`maxAgeDays` plays only videos uploaded within that many days (0 for any age), aging505them out as days pass; with none that recent, the channel leaves the lineup until a new506upload arrives. The values shown are the defaults.507508A YouTube channel can play a playlist instead, in its order, looping at the end:509510```json511{ "playlist": "https://www.youtube.com/playlist?list=PL8dPuuaLjXtNlUrzyH5r6jN9ulIgZBpdo", "maxHeight": 720 }512```513514`playlist` is the playlist's link, the link of a video played from it (anything with515`list=`), or its ID; a `youtube.json` naming both channels and a playlist is refused. It516runs like a folder channel, looping on a fixed clock or airing on a schedule (see517Schedules below), so there's no planner, first runs or repeat guard, no `.playout.json`,518and of the settings only `maxHeight` applies. Private, deleted and members-only videos519are left out; live streams and premieres join once they can play. The playlist is listed520when the folder appears, then daily and whenever `youtube.json` changes, so videos added521to it join where the playlist has them; `.catalog.json` keeps them in order, and a length522the listing lacks is looked up. Its videos stream exactly as uploads do, captions, sound523language and leveling included.524525`airwavesd` lists each channel's uploads with yt-dlp (titles, lengths and rough upload526dates, from YouTube's "3 years ago") when the folder appears and then daily;527Northernlion's 22,000 videos take about five minutes, and the channel joins the lineup528once every channel is listed. Every 15 minutes it reads the channels' RSS feeds for new529uploads and looks up their lengths and exact dates. The catalog is kept in the folder as530`.catalog.json`, and the schedule as `.playout.json`: two days ahead plus the history the531repeat guard reads, so the guide holds across restarts (delete it to start the schedule532afresh). Automatic updates only add to the schedule: what's on and the next `lockHours`533stay as they are, and new uploads go in as first runs (marked new in the guide) right534after, newest first, pushing the planned reruns later. Editing `youtube.json` instead535plans it all again from that moment, what's on included (a viewer already watching536finishes the current video first). Reruns alternate between the channels, so each gets537as many slots whatever its size; each picks at random by the rerun mix among its videos538that haven't aired within `repeatDays`, avoiding the game or series of the last few539airings (guessed from titles). Guide entries have the video's title, the channel's name540with the upload date ("Northernlion, Mar 4, 2014"; rough dates as "2014", or "March5412026" within the last year), the first paragraph of its description (looked up a few at542a time for what airs next) and its YouTube thumbnail.543544Only while someone watches, yt-dlp finds the video's stream and ffmpeg plays it from545where the schedule is, in real time, like any other custom channel: tuning in takes about546four seconds, and the next video is found a minute before it starts. A video that won't547play (removed, private, or refused by YouTube) gets a dark "Please stand by" slate for its548slot and the channel carries on with the next one on time; removed videos are dropped549from the rest of the schedule.550551YouTube changes often and older yt-dlp versions stop working, so `airwavesd` keeps its552own copy of the latest release at `/data/tools/yt-dlp`: downloaded on first use, checked553against the release's checksums, and replaced when a newer release is out (checked554daily), with no image rebuild. The image has python3 to run it and deno for YouTube's555JavaScript challenges. `AIRWAVES_YTDLP` (or `-ytdlp`) names a different yt-dlp instead,556which is then left as it is. `airwavesd` (uid 1000) needs write access to the channel557folder.558559**Schedules**: folder and Jellyfin channels, and YouTube channels playing a playlist,560play their videos in order, looping around the clock. A `schedule` in the channel's561`channel.json` airs them on a timetable instead, so a series can be watched as it comes:562four episodes of House of the Dragon a night from the first is563564```json565{ "schedule": { "start": "2026-10-04", "first": 1, "blocks": [{ "at": "20:00", "count": 4 }] } }566```567568`start` is the day it begins (or `"2026-10-04T20:00"` for a time that day), in the569server's time zone, and `first` the video it begins with, counting from 1 in the570channel's order (file name order for a folder; for a series on Jellyfin, `"order":571"aired"`). A block airs `count` videos back to back from `at`, or starts them until572`until` (`"23:30"`; earlier than `at` is the next day's) and lets the last one finish;573`days` (`["sat", "sun"]`) keeps it to those days, every day without. Each block picks up574where the last left off, and the list loops at the end. Between blocks the channel is575off the air but stays in the lineup: the guide says "Off air" and what's next ("Back at5768:00 PM with House of the Dragon, S1 E4."), and tuning in shows a dark slate saying when.577Without blocks it airs around the clock from `start`. Changes show in the guide at once,578and in a stream already playing at its next video (within a minute while it's off the579air). The admin page sets it all under Airs.580581**Loudness**: with `AIRWAVES_LOUDNESS` set to a level in LUFS (-24 is broadcast TV's,582ATSC A/85), custom channels' sound is evened out to it, so a loud YouTube upload doesn't583follow a quiet Jellyfin episode; it's `off` by default. Nothing extra is read for it: the584sound is metered as it streams, in the samples on their way to the encoder (ITU-R585BS.1770: K-weighted, gated loudness), and turned up or down toward the level, slowly (no586more than 3 dB a second, settling within about 15 seconds of a new video) and within -20587to +12 dB, with raised peaks held under -1 dBFS. What each video measured is remembered588(`.loudness.json` in a folder or Jellyfin channel's folder, the catalog for YouTube), so589its next airing starts at the right level; until then YouTube videos start 10 dB down590(YouTube's usual -14 LUFS) and the rest as they are. The weather channel's music is591leveled the same way.592593## Admin page594595`http://<server>:8089/admin/` (for example `http://nas:8089/admin/`) is for the596server's owner, not the app's viewers. Every custom channel's page starts with its597details: number and name (a folder channel's rename its folder), call sign, category,598description, on the air or not, and a logo (upload or drop an image, take the poster of599a Jellyfin channel's first pick or a YouTube channel's avatar, or remove it), with a600warning when the number is an antenna channel's too. Below that it edits Jellyfin601channels: sign in to Jellyfin (with a Quick Connect code from any signed-in Jellyfin app,602or a user and password), browse the server's series, movies, collections and playlists603as posters with search and genre filters, pick exactly what each channel plays, set its604order and bitrate cap, and see what airs next; and YouTube channels, found by search or605by link, with their length, repeat, age and quality settings, or a playlist by its link. Folder, Jellyfin and YouTube606playlist channels also say when they air: around the clock, or on a schedule (a start607date, the video to begin with, and daily blocks), summed up in a line as it's edited.608It writes the same `channel.json`, `.weather.json`, `jellyfin.json`, `youtube.json` and609`.jellyfin.json` files described above (account files are readable only by the server's610user), so hand edits and the page agree.611612**Backups**: so the channel setup never has to be made again by hand, `airwavesd` keeps613backups of it, under Backups below the account on the page. A backup is a small `.tar.gz`614holding every channel folder's name (folders of videos too, so they can be made again),615each one's `jellyfin.json`, `youtube.json`, `channel.json`, logo and YouTube schedule616(`.playout.json`), and the channels folder's `.jellyfin.json` (the Jellyfin account),617`.weather.json` and weather logo, with a `manifest.json` saying when, why and by which618`airwavesd` it was made and what's in it. Videos, YouTube catalogs (listed again) and619yt-dlp's `.info.json` files are never in one. A backup is made 30 seconds after changes620through the page or the agent tools (a burst of changes makes one), before every restore,621and daily (a minute after `airwavesd` starts, then every day) for changes made some other622way; an automatic one that would hold the same as the newest is skipped. The newest 30623automatic backups are kept, and the newest of each of the last 14 days; older ones are624removed. Those made with Back up now (with an optional note) or uploaded stay until625deleted. The page lists them with download, delete and restore.626627Restoring asks what to restore (everything or chosen channels, the Jellyfin account or628not, YouTube schedules or not) and says what happens to each channel before anything629changes. It goes folder by folder: a channel in the backup is the folder of the same630name; failing that, a Jellyfin or YouTube channel renamed since is the one folder playing631the same YouTube channels or Jellyfin picks, and gets its old number and name back;632failing that, its folder is made again (a folder channel's empty: put its videos back).633The folder gets the backup's settings, details and logo, and loses those added since;634videos, catalogs and anything else in it stay. A YouTube schedule comes back only where635the channel has none, so channels on the air keep their guide and remade ones pick up636where the backup left off. A channel whose number another channel has now is left out637until that one moves, and channels the backup doesn't have are left alone. The setup as638it was is backed up first, so restoring that undoes a restore. Uploaded backups are639checked first (no paths outside the channels folder, links or devices; only the files a640setup has; under 16 MB, and 64 MB unpacked), and older snapshots made by hand (a641`.tar.gz` of the channels folder's settings and logos, without a manifest) restore the642same: upload one on the page, or copy it into the backups folder.643644The backups are in `~/airwaves/data/backups` (`/data/backups` in the container), readable645only by the server's user. To keep copies off the server, download them from the page, or646copy the folder:647648```sh649rsync -a nas:airwaves/data/backups/ ~/airwaves-backups/650```651652Without `AIRWAVES_ADMIN_PASSWORD` the page has no login of its own: it is only as private653as the address `airwavesd` listens on, which `deploy.sh` makes the tailnet address,654never the LAN, so never expose it beyond a network you trust. With a password, the page, its API655and `/admin/mcp` ask for it (HTTP Basic auth, any user name).656657## Agents658659AI agents manage the custom channels with the page's tools, two ways. Both run the660page's own API, so its checks apply and hand edits, the page and agents agree:661`list_channels`, `get_channel`, `get_schedule`, `list_antenna_channels`,662`browse_jellyfin`, `search_youtube_channels`, `lookup_youtube_channel`,663`create_youtube_channel`, `update_youtube_channel`, `create_jellyfin_channel`,664`update_jellyfin_channel`, `set_channel_details`, `delete_channel` (which takes665`confirm: true`), `list_backups` and `create_backup` (restoring a backup is left to a666person, on the page). `airwavesd` logs each call with the tool and channel number only.667668**MCP**: `airwavesd` serves the tools at `http://<server>:8089/admin/mcp` (Streamable669HTTP, stateless, so clients carry on across restarts). For Claude Code:670671```sh672claude mcp add --transport http airwaves http://nas:8089/admin/mcp673```674675**WebMCP**: the page registers the same tools with WebMCP (`document.modelContext`), and676shows what an agent changes as it happens. An agent on the computer with the page open677reaches them through the WebMCP local relay, added as an MCP server:678679```sh680claude mcp add webmcp -- npx -y @mcp-b/webmcp-local-relay@5.1.0 --widget-origin http://localhost:8089681```682683WebMCP needs a secure context, so open the page through a tunnel684(`ssh -L 8089:<tailnet address>:8089 nas`, then `http://localhost:8089/admin/`) or over685HTTPS; over plain HTTP to the tailnet address the page leaves WebMCP off. The page only686connects to the relay on 127.0.0.1:9333, and without one it works as ever (the browser687logs a few refused connections, then stops trying).688689Like the page, the endpoint has no login unless `AIRWAVES_ADMIN_PASSWORD` is set (then add690`--header "Authorization: Bearer <password>"` to `claude mcp add`): without one, anyone691who can reach `airwavesd` can change channels with it, so keep it on the tailnet address. Other sites' browser requests are692refused, and the Jellyfin account isn't among the tools.693694## Playback695696Live channels are read from the HDHomeRun, and recordings from their files, as MPEG-TS697and transcoded by `airwavesd` to H.264 and AAC in HLS, which the app plays natively. Custom channels come698from `airwavesd` itself and play the same way. While a stream is open, playing or paused,699the app says so every 30 seconds (`POST /api/heartbeat`); `airwavesd` stops a stream700nobody has fetched from or vouched for in 2 minutes, which frees its tuner when an app701goes away without saying so. ATSC 1.0 (MPEG-2 video, AC-3 audio) plays702fully; ATSC 3.0 is out of scope (AC-4 audio and A3SA DRM).703704## Layout705706```707main.go, app.go Wails entry point; the app is a client of airwavesd708cmd/airwavesd the home server709cmd/otascan terminal report710internal/service the engine airwavesd runs711internal/api HTTP API, and a client with the same interface712internal/dvr recording rules, the recorder and the library713internal/fcc FCC TV Query client and parser714internal/terrain Terrarium elevation tiles and path profiles715internal/reception propagation model and antenna presets (otascan)716internal/atsc3 RabbitEars ATSC 3.0 list parser717internal/guide Gracenote lineup and listings718internal/sd Schedules Direct listings: account, daily updates of what changed719internal/lineup merges all of the above into a Report, and describes the tuner's channels from it720internal/signal what the tuners measured per RF channel, kept across restarts721internal/tuner shares the HDHomeRun's tuners: one per RF channel, by priority722internal/ts MPEG-TS: tables, a program out of an RF channel, errors723internal/stream ffmpeg to HLS, one session per client724internal/hdhr the HDHomeRun's HTTP API and discovery (hdhrtest fakes it); the emulated one725internal/vchan custom channels: Airwaves Weather, folder, Jellyfin and YouTube channels726internal/jellyfin Jellyfin client: sign-in, Quick Connect, browsing, stream URLs727internal/youtube YouTube as a source: yt-dlp kept up to date, listings, feeds, search, where to stream from728internal/podcast the Podcasts app's library: shows, their newest episodes, how far each was watched729deploy/, scripts/ Docker files for the server and the deploy script730deploy/standalone Docker Compose for custom channels only, built from source (docs/self-hosting.md)731frontend/dist the UI: plain HTML, CSS and JS, no build step; also served at /tv/732frontend/dist/js the app by area (core, player, input, guide, settings...), loaded in order733```734735`go test ./...` covers the parsers, the propagation model, the tuner sharing, recording,736the tuner lineup and signal measuring against a fake HDHomeRun (with a real one's Denver737lineup, and synthetic MPEG-TS), the API client and server, the emulated HDHomeRun's738discovery and HTTP API, and an end-to-end ffmpeg HLS stream.739740## License741742MIT, see `LICENSE`. Bundled libraries, fonts and icons keep their own licenses, listed in743`THIRD_PARTY_NOTICES.md`.