Airwaves had outgrown Tvheadend: it owned the lineup, guide, recording rules and signal history, and used Tvheadend only to tune the HDHomeRun as a dumb pipe and write files, through a second source of truth kept in step every minute. Tvheadend's over-the-air guide grabber also held a tuner for minutes at a time for listings nothing used. airwavesd now uses the device natively. The lineup is the HDHomeRun's own scan (lineup.json with frequencies and programs), and Scan for channels in Reception scans again. A tuner manager (internal/tuner) is the device's only client: each RF channel in use is streamed once and every viewer, recording and HDHomeRun client gets its own program from it, so watching 9.2 while 9.1 records takes one tuner. With every tuner taken, a recording takes one from a viewer, a viewer one from Measure now, and a viewer changing channels gets their own tuner back; a tuner lingers on its RF channel for a moment so coming back is instant. Signal readings are the device's strength, quality and symbol quality, with errors counted in what actually arrived. Recording is Airwaves' own: files in the recordings folder, a folder per show, described in dvr.json with the rules. Coming up says which airings won't get a tuner and why. A recording under way can be watched from the start, extended, or stopped keeping what it has; losing the tuner or signal makes a gap rather than an end, a restart carries on, and a finished recording says what it's missing. Playback reads the files directly, so it seeks. A corrupt dvr.json is set aside instead of stopping the server. Gone with Tvheadend: its container, the demo channels, the USB tuner overlay and dB/BER readings no tuner here had, and the app's "needs another tuner" notes for ATSC 3.0.
- commit
- e13385562eec65051385ecf4d432559ee507c389
- parent
- e3bf70d34ac5793b1e1c7a20632b70f6203d73d5
- author
- FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
- kind
- feat risk score 3.0 of 3, Jev's estimate
@@ -12,8 +12,8 @@ A desktop TV and program guide for free over-the-air broadcasts, built with Go a
1212 3. **What is on now?** A Gracenote-backed grid guide, live TV, and recordings.1313 1414 There is one setup: an HDHomeRun on the network as the tuner, a home server running15-`airwavesd` and Tvheadend, and the desktop app as the TV. The server owns the lineup,16-guide, recording rules and library; the app is the remote.15+`airwavesd`, and the desktop app as the TV. The server owns the tuner, the lineup, the16+guide, recording and the library; the app is the remote.1717 1818 It also maps which stations broadcast ATSC 3.0 (NextGen TV), which transmitter hosts each1919 service, and where the ATSC 1.0 simulcast of each channel lives.@@ -41,10 +41,9 @@ go run ./cmd/otascan -zip 80302 -lat 39.74 -lon -104.99 -all -json > report.json
4141 4242 ## Server4343 44-The server is three containers: Tvheadend (tuners, scanning, writing recordings),45-`airwavesd` (lineup, reception, guide, recording rules, live and recorded HLS, the46-emulated HDHomeRun and custom channels) and the WeatherStar display behind the weather47-channel.44+The server is two containers: `airwavesd` (the tuner, lineup, reception, guide,45+recording, live and recorded HLS, the emulated HDHomeRun and custom channels) and the46+WeatherStar display behind the weather channel.4847 4948 ```sh5049 AIRWAVES_ZIP=80302 scripts/deploy.sh nas # cross-compiles airwavesd, syncs deploy/ to ~/airwaves, starts it@@ -53,12 +52,11 @@ AIRWAVES_ZIP=80302 scripts/deploy.sh nas # cross-compiles airwavesd, syncs dep
5352 `deploy.sh` writes `~/airwaves/.env` on first run with the host name, its Tailscale5453 address, the host's time zone (`TZ`) and the antenna's ZIP code (`AIRWAVES_ZIP`, which5554 sets the lineup and listings), and binds `airwavesd` (port 8089) to the Tailscale address56-only. Tvheadend listens on 127.0.0.1 only; reach its admin UI with57-`ssh -L 9981:127.0.0.1:9981 nas`, then http://localhost:9981. Other `.env` settings:58-`AIRWAVES_LAT` / `AIRWAVES_LON` for the55+only. Other `.env` settings: `AIRWAVES_LAT` / `AIRWAVES_LON` for the5956 antenna's exact position (better than the ZIP centroid, since terrain decides6057 reception); `AIRWAVES_TOKEN` (requires the same token in the app);61-`AIRWAVES_ADMIN_PASSWORD` (for the admin page and `/admin/mcp`); `RECORDINGS` (host58+`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` (host6260 path for recordings, default `~/airwaves/recordings`); `MUSIC` and `CHANNELS` (host6361 folders for the custom channels); `TZ`. Location is deployment6462 configuration only; the app never shows or edits it.@@ -74,42 +72,56 @@ asks for the token. Favorites, hidden channels and the other app settings are ke
7472 each browser.7573 7674 **The tuner** is an HDHomeRun (for example a FLEX DUO) with the antenna on its coax77-input. Both containers use host networking so Tvheadend can find it by broadcast, on78-the LAN or on a spare Ethernet port with link-local addressing that the firewall trusts.79-For development without one, `AIRWAVES_DEMO=true` gives Tvheadend generated80-test-pattern channels until a tuner is found.81-82-When the HDHomeRun appears, `airwavesd` creates an "Airwaves antenna" network from the83-US ATSC channel list, attaches its tuners, and lets Tvheadend scan. Channels are mapped as84-they are found, and any demo channels are removed for good. The scan takes a few minutes;85-if channels show up under the wrong numbers, check them in Tvheadend's Configuration >86-Channel / EPG.87-88-**The lineup is the tuner's.** The app's antenna channels are exactly the channels89-Tvheadend's scan found; the FCC's records, the Gracenote listings and the ATSC 3.0 list75+input, and `airwavesd` is its only client. It uses the device's own HTTP API: its76+channel scan and lineup, its tuner status, and its streams. `airwavesd` uses host77+networking to find it by broadcast, on the LAN or on a spare Ethernet port with78+link-local addressing that the firewall trusts; `AIRWAVES_HDHOMERUN` names it instead79+(`192.168.1.30`, or a device ID such as `1050ABCD`).80+81+**One tuner per RF channel.** `airwavesd` reads each RF channel in use once, as a whole,82+and hands each viewer, recording and HDHomeRun client its own program from it, so83+watching 9.2 while 9.1 records takes one tuner. With every tuner taken, a recording takes84+one from a viewer (the one tuned last), a viewer one from Measure now, and changing85+channels always gets the viewer's own tuner back; otherwise tuning says who has the86+tuners. A tuner stays on its RF channel for a few seconds after its last viewer, so87+coming back to it is instant.88+89+**The lineup is the tuner's.** The app's antenna channels are exactly the channels the90+HDHomeRun's scan found; the FCC's records, the Gracenote listings and the ATSC 3.0 list9091 only describe them (call sign, network, transmitter, listings), never add one. Without a9192 tuner there are no antenna channels. A channel that won't lock right now stays listed,92-and tuning it says so: "No signal on RF 31 (KTVD 20.x) right now".93-94-**Signal is measured, never estimated.** `airwavesd` records, per RF channel, Tvheadend's95-scan result and readings of the tuner (strength and quality as the HDHomeRun reports them,96-in percent; SNR in dB, BER and uncorrected blocks from tuners that report them): every97-10 seconds while a tuner is in use, and on request (`POST /api/signal/measure`, Measure98-now), which briefly tunes each RF channel worth measuring on an idle tuner, at the lowest99-weight a viewer's can have, so any viewer or recording takes the tuner over and the100-sweep stops. Readings are kept in `<data>/signal.json` and served at `GET /api/signal`101-and in the snapshot's `antenna` section. Terrain-based estimates are for planning an102-antenna, in `otascan`, and aren't served to the app.103-104-Recording rules are Airwaves': one airing, or a series by Gracenote series ID on one105-channel, optionally new episodes only. Series rules skip episodes already recorded or106-already scheduled. Every minute `airwavesd` turns the rules into timed Tvheadend entries107-(1 minute early, 3 late, 30 late for sports) and removes entries for deleted rules.93+and tuning it says so: "No signal on RF 31 (KTVD 20.x) right now". The HDHomeRun scans94+when it powers up; **Scan for channels** in Reception (`POST /api/tuner/scan`) scans95+again, after moving the antenna, taking every tuner for the few minutes it runs.96+97+**Signal is measured, never estimated.** `airwavesd` records, per RF channel, readings98+of the tuner: strength, signal-to-noise quality and symbol quality, as the HDHomeRun99+reports them in percent, whether data arrived, and the transport and continuity errors100+in it, counted as it arrives. It reads them every 10 seconds while a tuner is in use, and101+on request (`POST /api/signal/measure`, Measure now), which briefly tunes each RF102+channel worth measuring on a free tuner, at the lowest priority, so any viewer or103+recording 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`105+section. Terrain-based estimates are for planning an antenna, in `otascan`, and aren't106+served to the app.107+108+**Recording** is Airwaves' own. Rules are one airing, or a series by Gracenote series ID109+on one channel, optionally new episodes only; series rules skip episodes already recorded110+or already scheduled, and an airing skipped from a series is recorded at a repeat when111+there is one. An airing records from a minute early to 3 minutes late (30 for sports)112+into the recordings folder, a folder per show (`Nature/2026-10-04 20.00 Episode.ts`),113+and its details, with the rules, are in `<data>/dvr.json`. Coming up shows each airing114+to come with whether it will get a tuner: a conflict names the recordings that have115+every tuner then. A recording under way can be watched from the start, given 30 more116+minutes, or stopped, keeping what it recorded. Losing the signal or the tuner makes a117+gap, not an end: it tries again until its time is up, and after a restart a recording118+whose time isn't up carries on. A finished recording says what it's missing: "Began 12119+minutes into the program", "About 3 minutes are missing in between".108120 109121 ## Without an antenna110122 111123 `airwavesd` also runs on its own for the custom channels only (Jellyfin, YouTube,112-folders of videos and the weather), with no antenna, tuner or Tvheadend. That is124+folders of videos and the weather), with no antenna or tuner. That is113125 `deploy/standalone`, built from this repository on any Docker host, amd64 or arm64;114126 [docs/self-hosting.md](docs/self-hosting.md) walks a newcomer through it. `scripts/deploy.sh`115127 and `deploy/compose.yml` are unchanged by it: every setting below defaults to what they@@ -117,7 +129,7 @@ had.
117129 118130 | Setting | Does |119131 | --- | --- |120-| `AIRWAVES_ANTENNA=off` | Custom channels only: no Tvheadend, demo channels, 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. |132+| `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. |121133 | `AIRWAVES_HDHR` | `on` or `off`, the emulated HDHomeRun: on by default, off without an antenna. |122134 | `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. |123135 | `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. |@@ -147,8 +159,8 @@ had.
147159 | Key | Elsewhere |148160 | --- | --- |149161 | 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, series, new episodes only, favorite, hide. Home jumps to now, R / Shift R / N record, f favorite, H hide (U undoes) |150-| D | Recordings: Left (or `[` `]`) for Library, Coming up, Series. Enter shows a recording's actions: resume, from the start, watched, delete (press twice); Shift Enter starts over, W marks watched, Delete twice removes; on a series K sets how many to keep, N new episodes only |151-| A | Reception: 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; Left for the tuner and Measure now, which reads every RF channel on a free tuner and shows its progress |162+| 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 |163+| A | Reception: 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; Left for the tuner, Measure now, which reads every RF channel on a free tuner and shows its progress, and Scan for channels (press twice) |152164 | 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 |153165 | `,` or S | Settings: server, guide hours, deleting watched recordings, hidden channels, the tuner |154166 | Esc | Back to TV; from a recording, back to live |@@ -209,10 +221,10 @@ images are served from `/wximg/`, so the app never handles coordinates.
209221 210222 `airwavesd` also appears on the home network as an HDHomeRun network tuner, so other211223 players can watch the lineup: model HDTC-2US, friendly name "Airwaves", a device ID212-derived from the server name, and as many tuners as Tvheadend213-reports (2 until the real tuner is found). Antenna channels are passed through from214-Tvheadend untouched (the original MPEG-TS, MPEG-2 video and AC-3 audio); custom channels215-are H.264 and AAC in MPEG-TS at 1280x720.224+derived from the server name, and as many tuners as the real one has (2 until it is225+found). Antenna channels are passed through as broadcast (each channel's own program of226+the RF channel's MPEG-TS, MPEG-2 video and AC-3 audio), on tuners shared with the app227+and recordings as above; custom channels are H.264 and AAC in MPEG-TS at 1280x720.216228 217229 It serves HTTP on port 5004 (`AIRWAVES_HDHR_LISTEN`, default `:5004`; empty or218230 `AIRWAVES_HDHR=off` disables it, and `AIRWAVES_HDHR_DISCOVERY=off` only its discovery):@@ -226,7 +238,7 @@ It serves HTTP on port 5004 (`AIRWAVES_HDHR_LISTEN`, default `:5004`; empty or
226238 | `/channel-logos/<number>` | A custom channel's logo, also on the API port; the playlist and guide link to it |227239 228240 Discovery answers on UDP 65001, only to other machines: requests from the server itself229-are ignored so Tvheadend never adopts Airwaves as a tuner. Open TCP 5004 and UDP 65001241+are ignored, so `airwavesd` never finds itself when it looks for the real tuner. Open TCP 5004 and UDP 65001230242 on the server's LAN interface only. With the server at 192.168.1.20:231243 232244 - **Channels DVR**: found automatically as an HDHomeRun under Settings > Sources;@@ -585,8 +597,8 @@ refused, and the Jellyfin account isn't among the tools.
585597 586598 ## Playback587599 588-Live channels and recordings are read from Tvheadend as MPEG-TS and transcoded by589-`airwavesd` to H.264 and AAC in HLS, which the app plays natively. Custom channels come600+Live channels are read from the HDHomeRun, and recordings from their files, as MPEG-TS601+and transcoded by `airwavesd` to H.264 and AAC in HLS, which the app plays natively. Custom channels come590602 from `airwavesd` itself and play the same way. ATSC 1.0 (MPEG-2 video, AC-3 audio) plays591603 fully; ATSC 3.0 is out of scope (AC-4 audio and A3SA DRM).592604 @@ -598,8 +610,7 @@ cmd/airwavesd the home server
598610 cmd/otascan terminal report599611 internal/service the engine airwavesd runs600612 internal/api HTTP API, and a client with the same interface601-internal/dvr recording rules and reconciliation with Tvheadend602-internal/tvh Tvheadend API: channels, DVR entries, network setup, input status; tvhtest fakes it613+internal/dvr recording rules, the recorder and the library603614 internal/fcc FCC TV Query client and parser604615 internal/terrain Terrarium elevation tiles and path profiles605616 internal/reception propagation model and antenna presets (otascan)@@ -607,9 +618,10 @@ internal/atsc3 RabbitEars ATSC 3.0 list parser
607618 internal/guide Gracenote lineup and listings608619 internal/lineup merges all of the above into a Report, and describes the tuner's channels from it609620 internal/signal what the tuners measured per RF channel, kept across restarts610-internal/tuner Tvheadend input, demo test patterns621+internal/tuner shares the HDHomeRun's tuners: one per RF channel, by priority622+internal/ts MPEG-TS: tables, a program out of an RF channel, errors611623 internal/stream ffmpeg to HLS, one session per client612-internal/hdhr emulated HDHomeRun: discovery, lineup, streams, XMLTV624+internal/hdhr the HDHomeRun's HTTP API and discovery (hdhrtest fakes it); the emulated one613625 internal/vchan custom channels: Airwaves Weather, folder, Jellyfin and YouTube channels614626 internal/jellyfin Jellyfin client: sign-in, Quick Connect, browsing, stream URLs615627 deploy/, scripts/ Docker files for the server and the deploy script@@ -617,10 +629,10 @@ deploy/standalone Docker Compose for custom channels only, built from so
617629 frontend/dist the UI: plain HTML, CSS and JS, no build step; also served at /tv/618630 ```619631 620-`go test ./...` covers the parsers, the propagation model, recording rules, the tuner621-lineup, signal measuring and demo cleanup against a fake Tvheadend (answering with a real622-one's JSON), the API client and server, the emulated HDHomeRun's discovery and HTTP623-API, and an end-to-end ffmpeg HLS stream.632+`go test ./...` covers the parsers, the propagation model, the tuner sharing, recording,633+the tuner lineup and signal measuring against a fake HDHomeRun (with a real one's Denver634+lineup, and synthetic MPEG-TS), the API client and server, the emulated HDHomeRun's635+discovery and HTTP API, and an end-to-end ffmpeg HLS stream.624636 625637 ## License626638 @@ -17,8 +17,7 @@ its own license, kept beside it in the tree.
1717 Go modules are listed in `go.mod` and keep their own licenses.1818 1919 The deployment builds or runs these from their upstream images and releases20-rather than shipping them: [Tvheadend](https://tvheadend.org) (GPL-3.0),21-[WeatherStar 4000+](https://github.com/netbymatt/ws4kp) (MIT, with Airwaves20+rather than shipping them: [WeatherStar 4000+](https://github.com/netbymatt/ws4kp) (MIT, with Airwaves2221 branding swapped in by `deploy/weatherstar/Dockerfile`),2322 [yt-dlp](https://github.com/yt-dlp/yt-dlp) (Unlicense), [Deno](https://deno.com)2423 (MIT), [FFmpeg](https://ffmpeg.org) and Chromium.@@ -24,7 +24,7 @@ const appName = "airwaves"
2424 var errNoServer = errors.New("no Airwaves server set")2525 2626 // App is the desktop remote for an airwavesd server, which owns the tuner27-// (an HDHomeRun through Tvheadend), the lineup, the guide and recordings.27+// (an HDHomeRun), the lineup, the guide and recordings.2828 type App struct {2929 ctx context.Context3030 @@ -206,6 +206,15 @@ func (a *App) Measure() (*service.SweepStatus, error) {
206206 return c.Measure(a.ctx)207207 }208208 209+// ScanChannels has the server's tuner scan for channels again.210+func (a *App) ScanChannels() (service.TunerInfo, error) {211+ c, _, err := a.server()212+ if err != nil {213+ return service.TunerInfo{}, err214+ }215+ return c.ScanChannels(a.ctx)216+}217+209218 // Weather returns the local weather report from the server.210219 func (a *App) Weather() (*weather.Report, error) {211220 c, _, err := a.server()@@ -283,6 +292,25 @@ func (a *App) DeleteRecording(id string) error {
283292 return c.DeleteRecording(a.ctx, id)284293 }285294 295+// StopRecording ends a recording in progress early, keeping it.296+func (a *App) StopRecording(id string) error {297+ c, _, err := a.server()298+ if err != nil {299+ return err300+ }301+ return c.StopRecording(a.ctx, id)302+}303+304+// ExtendRecording makes a recording in progress run minutes longer, and305+// says until when.306+func (a *App) ExtendRecording(id string, minutes int) (time.Time, error) {307+ c, _, err := a.server()308+ if err != nil {309+ return time.Time{}, err310+ }311+ return c.ExtendRecording(a.ctx, id, minutes)312+}313+286314 // PlayRecording streams a recording from about from seconds in.287315 func (a *App) PlayRecording(id string, from float64) (*stream.Playback, error) {288316 c, s, err := a.server()@@ -1,8 +1,8 @@
1-// Command airwavesd runs the Airwaves engine on a home server: lineup,2-// reception and guide, live TV and recordings through Tvheadend, served to3-// the desktop app over HTTP. With AIRWAVES_ANTENNA=off it serves the custom1+// Command airwavesd runs the Airwaves engine on a home server: the lineup,2+// reception and guide, live TV and recordings from an HDHomeRun, served to3+// the TV app over HTTP. With AIRWAVES_ANTENNA=off it serves the custom44 // channels only (Jellyfin, YouTube, folders of videos and the weather), with5-// no antenna or Tvheadend; see docs/self-hosting.md.5+// no antenna or tuner; see docs/self-hosting.md.66 package main77 88 import (@@ -28,7 +28,6 @@ import (
2828 "airwaves/internal/api"2929 "airwaves/internal/hdhr"3030 "airwaves/internal/service"31- "airwaves/internal/tvh"3231 "airwaves/internal/vchan"3332 "airwaves/internal/web"3433 )@@ -50,16 +49,15 @@ func run() error {
5049 host, _ := os.Hostname()5150 listen := flag.String("listen", env("AIRWAVES_LISTEN", ":"+strconv.Itoa(api.DefaultPort)), "HTTP listen address")5251 data := flag.String("data", env("AIRWAVES_DATA", "/data"), "directory for config, caches and DVR rules")53- antenna := flag.String("antenna", os.Getenv("AIRWAVES_ANTENNA"), "on (the default): the over-the-air lineup, guide, tuners and recording through Tvheadend; off: custom channels only")54- tvhURL := flag.String("tvheadend", env("AIRWAVES_TVHEADEND", "http://tvheadend:9981"), "Tvheadend base URL; empty disables tuners and recording")52+ antenna := flag.String("antenna", os.Getenv("AIRWAVES_ANTENNA"), "on (the default): the over-the-air lineup, guide, HDHomeRun and recording; off: custom channels only")53+ tunerAddr := flag.String("hdhomerun", os.Getenv("AIRWAVES_HDHOMERUN"), "the HDHomeRun's address or device ID; empty finds it on the network")54+ recordings := flag.String("recordings", os.Getenv("AIRWAVES_RECORDINGS"), "folder for recordings (default <data>/recordings)")5555 // Location is deployment configuration; clients never see or change it.5656 zip := flag.String("zip", os.Getenv("AIRWAVES_ZIP"), "US ZIP code of the antenna, for its lineup and listings (required with an antenna); without an antenna, of the weather (default none)")5757 lat := flag.Float64("lat", envFloat("AIRWAVES_LAT"), "antenna latitude; with -lon, overrides the ZIP centroid")5858 lon := flag.Float64("lon", envFloat("AIRWAVES_LON"), "antenna longitude")5959 name := flag.String("name", env("AIRWAVES_NAME", host), "server name shown in the app")6060 token := flag.String("token", env("AIRWAVES_TOKEN", ""), "optional bearer token for the API")61- demo := flag.Bool("demo", env("AIRWAVES_DEMO", "false") == "true", "for development: give Tvheadend generated test-pattern channels until a tuner is attached (never once one has been)")62- demoFFmpeg := flag.String("demo-ffmpeg", env("AIRWAVES_DEMO_FFMPEG", "/usr/bin/ffmpeg"), "ffmpeg path inside the Tvheadend container")6361 weatherStar := flag.Int("weatherstar-port", int(envFloat("AIRWAVES_WEATHERSTAR_PORT")), "host port of a WeatherStar 4000+ container; 0 disables the WX channel")6462 weatherStarURL := flag.String("weatherstar-url", os.Getenv("AIRWAVES_WEATHERSTAR_URL"), "where airwavesd reaches the WeatherStar display, when not at 127.0.0.1 on -weatherstar-port (http://weatherstar:8080 for a container of its own)")6563 chromium := flag.String("chromium", env("AIRWAVES_CHROMIUM", findChromium()), "Chromium for rendering the weather channel; empty disables it")@@ -95,6 +93,14 @@ func run() error {
9593 if err := writable(*data); err != nil {9694 return err9795 }96+ if *recordings == "" {97+ *recordings = filepath.Join(*data, "recordings")98+ }99+ if feat.antenna {100+ if err := writable(*recordings); err != nil {101+ return err102+ }103+ }98104 if *channels != "" {99105 if err := writable(*channels); err != nil {100106 log.Printf("channels: %v; the admin page can't save channels", err)@@ -115,9 +121,10 @@ func run() error {
115121 ConfigPath: filepath.Join(*data, "config.json"),116122 Config: service.Config{ZIP: *zip, GuideHours: 48},117123 DVRPath: filepath.Join(*data, "dvr.json"),124+ RecordingsDir: *recordings,118125 SignalPath: filepath.Join(*data, "signal.json"),119- Demo: *demo,120- DemoFFmpeg: *demoFFmpeg,126+ HDHomeRun: *tunerAddr,127+ SkipDeviceID: hdhr.DeviceIDFor(*name),121128 NoAntenna: !feat.antenna,122129 WeatherStarPort: *weatherStar,123130 WeatherStarURL: *weatherStarURL,@@ -128,9 +135,6 @@ func run() error {
128135 Loudness: loudness,129136 Progress: func(s string) { log.Print(s) },130137 }131- if feat.antenna && *tvhURL != "" {132- opt.Tvheadend = tvh.New(*tvhURL, web.NewClient())133- }134138 svc, err := service.New(opt)135139 if err != nil {136140 return err@@ -166,10 +170,11 @@ func run() error {
166170 }()167171 log.Printf("airwavesd %s listening on %s", service.Version, *listen)168172 if feat.antenna {169- log.Printf("antenna: on, Tvheadend %q; signal measurements in %s", *tvhURL, opt.SignalPath)170- if *demo {171- log.Print("demo channels: on until a tuner is found (AIRWAVES_DEMO=true)")173+ where := "found on the network"174+ if *tunerAddr != "" {175+ where = "at " + *tunerAddr172176 }177+ log.Printf("antenna: on, the HDHomeRun %s; recordings in %s; signal measurements in %s", where, *recordings, opt.SignalPath)173178 } else {174179 log.Print("antenna: off, custom channels only")175180 }@@ -296,7 +301,7 @@ func startHDHR(ctx context.Context, svc *service.Service, addr, name string, dis
296301 297302 // features are what this server does.298303 type features struct {299- antenna bool // the over-the-air lineup, guide and Tvheadend304+ antenna bool // the over-the-air lineup, guide, tuner and recording300305 hdhr bool // the emulated HDHomeRun301306 discovery bool // answering HDHomeRun discovery on the LAN302307 }@@ -1,24 +1,7 @@
1-# Airwaves server: Tvheadend owns the tuners and recordings, airwavesd2-# serves the guide, live TV and DVR to the desktop app.1+# Airwaves server: airwavesd drives the HDHomeRun, records, and serves the2+# guide, live TV and recordings to the TV app.33 name: airwaves44 services:5- tvheadend:6- image: lscr.io/linuxserver/tvheadend:latest7- container_name: airwaves-tvheadend8- restart: unless-stopped9- # Host networking lets Tvheadend find HDHomeRun tuners by LAN broadcast.10- network_mode: host11- environment:12- PUID: "1000"13- PGID: "100"14- TZ: ${TZ:?set TZ in .env, like America/Chicago}15- # Access control is off, so listen on 127.0.0.1 only: reachable from16- # this host (admin UI via `ssh -L 9981:127.0.0.1:9981`) and airwavesd.17- RUN_OPTS: --noacl --bindaddr 127.0.0.118- volumes:19- - ./tvheadend/config:/config20- - ${RECORDINGS:-./recordings}:/recordings21-225 airwavesd:236 build: ./airwavesd247 image: airwavesd:local@@ -27,12 +10,14 @@ services:
2710 # An init process reaps Chromium's helper processes after the weather2811 # channel stops rendering.2912 init: true13+ # Host networking finds the HDHomeRun by broadcast, on the LAN or on a14+ # spare Ethernet port with link-local addressing.3015 network_mode: host31- depends_on:32- - tvheadend3316 environment:3417 TZ: ${TZ:?set TZ in .env, like America/Chicago}35- AIRWAVES_TVHEADEND: http://127.0.0.1:998118+ # The HDHomeRun's address or device ID, when discovery can't find it.19+ AIRWAVES_HDHOMERUN: ${AIRWAVES_HDHOMERUN:-}20+ AIRWAVES_RECORDINGS: /recordings3621 # Listen on the tailnet address only (set in .env by deploy.sh).3722 AIRWAVES_LISTEN: ${AIRWAVES_BIND:-127.0.0.1}:80893823 AIRWAVES_NAME: ${AIRWAVES_NAME:-airwaves}@@ -57,6 +42,7 @@ services:
5742 AIRWAVES_LOUDNESS: ${AIRWAVES_LOUDNESS:-off}5843 volumes:5944 - ./data:/data45+ - ${RECORDINGS:-./recordings}:/recordings6046 - ${MUSIC:-./music}:/music:ro6147 # Writable: the admin page saves channel settings here, and YouTube6248 # channels keep their schedules here.@@ -1,7 +1,7 @@
11 # Airwaves without an antenna: the custom channels only (Jellyfin, YouTube,22 # folders of videos and the weather), for the Airwaves app and, if you like,3-# Jellyfin, Channels DVR and other HDHomeRun players. No tuner, Tvheadend or4-# antenna needed. Settings are in .env (copy .env.example); the steps are in3+# Jellyfin, Channels DVR and other HDHomeRun players. No tuner or antenna4+# needed. Settings are in .env (copy .env.example); the steps are in55 # docs/self-hosting.md.66 # docker compose up -d --build77 name: airwaves-standalone@@ -1,13 +1,8 @@
11 2-# Start or update the Airwaves server. Run again after plugging in a tuner.2+# Start or update the Airwaves server.33 set -eu44 cd "$(dirname "$0")"55 # shellcheck disable=SC109166 [ -f .env ] && . ./.env7-mkdir -p data tvheadend/config "${RECORDINGS:-recordings}" "${MUSIC:-music}" "${CHANNELS:-channels}"8-files="-f compose.yml"9-if [ -d /dev/dvb ]; then10- files="$files -f compose.dvb.yml"11- echo "tuner found: $(ls /dev/dvb | tr '\n' ' ')"12-fi13-docker compose $files up -d --build --remove-orphans7+mkdir -p data "${RECORDINGS:-recordings}" "${MUSIC:-music}" "${CHANNELS:-channels}"8+docker compose up -d --build --remove-orphans@@ -1,7 +1,6 @@
11 # Running Airwaves without an antenna22 3-This sets up an Airwaves server for its own channels only, with no antenna, tuner or4-Tvheadend:3+This sets up an Airwaves server for its own channels only, with no antenna or tuner:54 65 - **Jellyfin channels**: series, movies and collections from a Jellyfin server, on a76 schedule, with a guide.@@ -24,9 +24,10 @@ function log(level, msg) {
2424 // /api/signal): per RF channel, the latest reading and the latest stretch2525 // of readings (recent: the last Measure now, or the last while something2626 // watched it), with lows and means. The stretch is what's shown: a single27-// reading of a marginal signal jumps around. An HDHomeRun reports strength28-// and quality (its signal-to-noise quality) as percentages; other tuners29-// may report SNR in dB.27+// reading of a marginal signal jumps around. The HDHomeRun reports28+// strength, quality (its signal-to-noise quality) and symbol quality (the29+// share of data arriving whole or corrected) as percentages; the errors in30+// what arrived the server counts itself.3031 const SIG_COLOR = { lock: 'var(--amber)', part: 'var(--amber-2)', off: 'var(--red)', none: 'var(--faint)' };3132 3233 // sigOf sums up a measurement ({ signal, recent }, of a channel or an RF@@ -43,22 +44,18 @@ function sigOf(m) {
4344 const val = (key) => (r ? (r[key] ? r[key].avg : null) : sig[key]);4445 const src = r || sig;4546 const q = val('qualityPct');46- const snr = val('snrDb');4747 const strengthPct = val('strengthPct');48- const strengthDbm = val('strengthDbm');48+ const symbolPct = val('symbolPct');4949 let bars = 0;50- if (state !== 'off') {51- if (q != null) bars = q >= 70 ? 5 : q >= 55 ? 4 : q >= 40 ? 3 : q >= 25 ? 2 : q > 0 ? 1 : 0;52- else if (snr != null) bars = snr >= 30 ? 5 : snr >= 25 ? 4 : snr >= 20 ? 3 : snr >= 17 ? 2 : snr > 0 ? 1 : 0;53- else bars = 3;54- }55- const quality = q != null ? `${Math.round(q)}%` : snr != null ? `${snr.toFixed(1)} dB` : '';56- const strength = strengthPct != null ? `${Math.round(strengthPct)}%` : strengthDbm != null ? `${strengthDbm.toFixed(1)} dBm` : '';50+ if (state !== 'off') bars = q == null ? 3 : q >= 70 ? 5 : q >= 55 ? 4 : q >= 40 ? 3 : q >= 25 ? 2 : q > 0 ? 1 : 0;51+ const quality = q != null ? `${Math.round(q)}%` : '';52+ const strength = strengthPct != null ? `${Math.round(strengthPct)}%` : '';53+ const symbol = symbolPct != null ? `${Math.round(symbolPct)}%` : '';5754 const errors = sig && sig.errorsPerSec != null ? sig.errorsPerSec : null;5855 const label = state === 'off' ? 'No lock'5956 : state === 'part' ? `Locked ${Math.round(lockedPct)}% of the time`6057 : quality ? `Quality ${quality}` : 'Locked';61- return { state, bars, label, color: SIG_COLOR[state], quality, strength, errors, at: Date.parse(src.to || src.at), source: src.source, lockedPct };58+ return { state, bars, label, color: SIG_COLOR[state], quality, strength, symbol, errors, at: Date.parse(src.to || src.at), source: src.source, lockedPct };6259 }6360 6461 // meter draws a measurement's bars.@@ -94,6 +91,7 @@ function sigDetail(s) {
9491 const parts = [];9592 if (s.state !== 'lock' && s.quality && s.state !== 'off') parts.push(`quality ${s.quality}`);9693 if (s.strength) parts.push(`strength ${s.strength}`);94+ if (s.symbol && s.state !== 'off' && s.symbol !== '100%') parts.push(`symbol quality ${s.symbol}`);9795 if (s.errors != null && s.state !== 'off') parts.push(s.errors >= 1 ? `${Math.round(s.errors)} errors a second` : 'no errors');9896 parts.push(`${ago(s.at)}${s.source === 'active' ? ', while tuned' : ''}`);9997 return parts.join(', ');@@ -338,8 +336,8 @@ const nextAfter = (ch, t) => programsFor(ch).find((p) => p._s > t);
338336 const stationFor = (ch) => ch && (state.bySite.get(`${ch.facilityId}:${ch.rf}`) || state.byFacility.get(ch.facilityId));339337 // sigFor is what was measured of a channel's RF channel.340338 const sigFor = (ch) => (ch && !ch.own ? { signal: ch.signal, recent: ch.recent } : null);341-// nextGen reports whether the tuners take in ATSC 3.0: through Tvheadend342-// they never do, so NextGen viewing (its guide filter) stays out of sight.339+// nextGen reports whether the tuners take in ATSC 3.0; without, NextGen340+// viewing (its guide filter) stays out of sight.343341 const nextGen = () => !!(state.info && state.info.tuner && state.info.tuner.atsc3);344342 // displayCall is a channel's short label: a station's call sign, or an own345343 // channel's call sign, "Airwaves" when it has none.@@ -795,7 +793,7 @@ function renderBanner() {
795793 }796794 lines.push(esc(sigDetail(sig)));797795 f(b, 'tx').innerHTML = lines.join('<br>');798- f(b, 'atsc3').textContent = ch.atsc3 ? `Also in ATSC 3.0 on ${ch.atsc3.hostCall} RF ${ch.atsc3.rf}${nextGen() ? '' : ', which needs a NextGen TV tuner'}` : '';796+ f(b, 'atsc3').textContent = ch.atsc3 ? `Also in ATSC 3.0 on ${ch.atsc3.hostCall} RF ${ch.atsc3.rf}` : '';799797 }800798 801799 // What the server's own channels play, for the banner's details.@@ -2088,7 +2086,7 @@ function renderGuide() {
20882086 const w = Math.max(0, right - left - 3);20892087 const isSel = r === state.gRow && sel === p;20902088 const rec = state.dvrKeys.get(airingKey(ch, p));2091- const recCls = rec ? (rec.status === 'unavailable' ? 'rec rec-x' : rec.status === 'recording' ? 'rec rec-on' : 'rec') : '';2089+ const recCls = rec ? (rec.status === 'unavailable' || rec.status === 'conflict' ? 'rec rec-x' : rec.status === 'recording' ? 'rec rec-on' : 'rec') : '';20922090 const c = ['g-cell', p._e <= now ? 'past' : '', p._s <= now && now < p._e ? 'now' : '', isSel ? 'sel' : '', recCls].join(' ');20932091 const tag = (p.flags || []).includes('Live') ? '<span class="tag">LIVE </span>' : (p.flags || []).includes('New') ? '<span class="tag">NEW </span>' : '';20942092 const sub = p.episodeTitle || `${clock(p._s)} to ${clock(p._e)}`;@@ -2154,9 +2152,9 @@ function renderGuideActions(ch, p) {
21542152 const it = state.dvrKeys.get(airingKey(ch, p));21552153 const rule = seriesRuleFor(ch, p);21562154 if (it) {2157- const label = it.status === 'recording' ? 'Recording now' : it.status === 'unavailable' ? 'Channel not tuned on server' : 'Recording scheduled';2158- parts.push(`<span class="chip ${it.status === 'unavailable' ? '' : 'live'}">${label}</span>`);2159- if (it.id || !rule) parts.push(act('once', 'Cancel'));2155+ const label = { recording: 'Recording now', unavailable: 'Channel not available', conflict: 'Conflict: every tuner records then' }[it.status] || 'Recording scheduled';2156+ parts.push(`<span class="chip ${it.status === 'unavailable' || it.status === 'conflict' ? 'warn' : 'live'}">${label}</span>`);2157+ parts.push(act('once', it.status === 'recording' ? 'Stop recording' : 'Cancel'));21602158 } else {21612159 parts.push(act('once', '<i></i>Record', 'rec'));21622160 }@@ -2196,9 +2194,11 @@ async function recordAiring(ch, p, kind, newOnly) {
21962194 const it = state.dvrKeys.get(airingKey(ch, p));21972195 const rule = seriesRuleFor(ch, p);21982196 try {2199- if (kind === 'once' && it) {2200- if (it.id) await api().DeleteRecording(it.id);2201- else if (!rule && it.ruleId) await api().DeleteRule(it.ruleId);2197+ if (kind === 'once' && it && it.status === 'recording') {2198+ await api().StopRecording(it.id);2199+ toast(`Stopped recording ${p.title}; what was recorded is in the library`);2200+ } else if (kind === 'once' && it) {2201+ await api().DeleteRecording(it.id);22022202 toast(`Won't record ${p.title}`);22032203 } else if (kind === 'series' && rule) {22042204 await api().DeleteRule(rule.id);@@ -2207,9 +2207,11 @@ async function recordAiring(ch, p, kind, newOnly) {
22072207 return toast(`${p.title} has no series information. Press R to record this airing.`);22082208 } else {22092209 await api().Record({ kind, channel: ch.number, callSign: ch.callSign, start: p.start, newOnly: !!newOnly });2210+ const aired = Math.round((Date.now() - p._s) / MIN);22102211 toast(kind === 'series'22112212 ? `Recording ${newOnly ? 'new episodes' : 'every episode'} of ${p.title} on ${ch.number}`2212- : `Recording ${p.title} at ${clock(p._s)} on ${ch.number}`);2213+ : aired >= 1 ? `Recording ${p.title} from now; the first ${aired} minute${aired > 1 ? 's' : ''} already aired`2214+ : `Recording ${p.title} at ${clock(p._s)} on ${ch.number}`);22132215 }22142216 } catch (e) {22152217 toast(String(e && e.message ? e.message : e), 6000);@@ -2258,6 +2260,7 @@ function renderGuideDetail(ch, p) {
22582260 // section, or /api/signal.22592261 function applySignal(sig) {22602262 const was = state.antenna && state.antenna.sweep;2263+ const wasScanning = !!(state.antenna && state.antenna.tuner.scanning);22612264 state.antenna = { tuner: sig.tuner || {}, muxes: sig.muxes || [], sweep: sig.sweep || {} };22622265 state.byRF = new Map(state.antenna.muxes.map((m) => [m.rf, m]));22632266 const by = new Map((sig.channels || []).map((c) => [c.number, c]));@@ -2270,6 +2273,13 @@ function applySignal(sig) {
22702273 if (was && was.running && !sw.running && state.view === 'antenna') {22712274 toast(sw.note ? `Measure now ${sw.note}` : `Measured ${sw.done} RF channels`, 4000);22722275 }2276+ // A channel scan just finished: its channels are the lineup now.2277+ if (wasScanning && !state.antenna.tuner.scanning) {2278+ scan(false).then(() => {2279+ renderAll();2280+ toast(`The scan found ${state.antennaChans.length} channels`, 4000);2281+ }).catch((e) => log('warn', e));2282+ }22732283 }22742284 22752285 let signalAt = 0;@@ -2294,11 +2304,11 @@ async function loadSignal() {
22942304 }22952305 22962306 // signalTick reads the signal again while it's on screen: every 2 s while2297-// Measure now runs, every 10 s in Reception or with an antenna channel's2298-// banner up, every 30 s in the guide.2307+// Measure now or a channel scan runs, every 10 s in Reception or with an2308+// antenna channel's banner up, every 30 s in the guide.22992309 function signalTick() {23002310 if (!state.info || state.info.antenna === false || document.hidden) return;2301- const running = !!(state.antenna && state.antenna.sweep.running);2311+ const running = !!(state.antenna && (state.antenna.sweep.running || state.antenna.tuner.scanning));23022312 const banner = state.view === 'tv' && state.current && !state.current.own && $('#banner').classList.contains('show');23032313 const every = running ? 2000 : state.view === 'antenna' || banner ? 10_000 : state.view === 'guide' ? 30_000 : 0;23042314 if (every && Date.now() - signalAt >= every - 100) loadSignal();@@ -2319,9 +2329,48 @@ async function measureNow() {
23192329 return undefined;23202330 }23212331 2332+// scanChannels has the tuner scan for channels again, on a second press2333+// within a few seconds: it takes every tuner for a few minutes.2334+let scanArmed = 0;2335+async function scanChannels() {2336+ const t = (state.antenna && state.antenna.tuner) || {};2337+ if (t.scanning) return toast('The tuner is scanning for channels', 2000);2338+ if (Date.now() - scanArmed > 4000) {2339+ scanArmed = Date.now();2340+ renderScan();2341+ setTimeout(renderScan, 4100);2342+ return undefined;2343+ }2344+ scanArmed = 0;2345+ try {2346+ const ti = await api().ScanChannels();2347+ if (state.antenna) state.antenna.tuner = ti;2348+ signalAt = 0;2349+ renderTunerCard();2350+ renderScan();2351+ } catch (e) {2352+ toast(String(e && e.message ? e.message : e), 6000);2353+ }2354+ return undefined;2355+}2356+2357+// renderScan shows the channel scan's button: what it does, and that it2358+// asks twice.2359+function renderScan() {2360+ const a = $('#antenna');2361+ const t = (state.antenna && state.antenna.tuner) || {};2362+ const btn = f(a, 'scan');2363+ btn.classList.toggle('busy', !!t.scanning);2364+ btn.textContent = t.scanning ? 'Scanning' : 'Scan for channels';2365+ f(a, 'snote').textContent = t.scanning ? 'The tuner is looking for channels; watching and measuring wait until it is done'2366+ : Date.now() - scanArmed < 4000 ? 'Press again to scan: it takes every tuner for a few minutes'2367+ : 'After moving the antenna, or when channels go missing';2368+}2369+23222370 function wireAntenna() {23232371 const a = $('#antenna');23242372 f(a, 'measure').addEventListener('click', () => measureNow());2373+ f(a, 'scan').addEventListener('click', () => scanChannels());23252374 $('.a-table tbody').addEventListener('click', (e) => {23262375 const tr = e.target.closest('tr[data-rf]');23272376 if (tr) pickRF(Number(tr.dataset.rf));@@ -2378,6 +2427,7 @@ function antennaKey(e) {
23782427 case 'ArrowLeft': openDock(); break;23792428 case 'Enter':23802429 if (el && el.dataset.act === 'measure') measureNow();2430+ else if (el && el.dataset.act === 'scan') scanChannels();23812431 else if (i < 0) antZone('rail');23822432 break;23832433 default: return;@@ -2420,16 +2470,12 @@ function antMuxes() {
24202470 const rest = all.filter((m) => !muxFound(m) && m.stations.length);24212471 return [...found, ...rest];24222472 }2423-const muxFound = (m) => m.channels.length > 0 || !!(m.scan && m.scan.lock);2473+const muxFound = (m) => m.channels.length > 0;24242474 const antRows = () => antMuxes().map((m) => m.rf);24252475 const muxSig = (m) => sigOf(m && { signal: m.signal, recent: m.history && m.history.windows.length ? m.history.windows[m.history.windows.length - 1] : null });24262476 2427-// tunerName names the tuner from its model as Tvheadend reports it.2428-function tunerName(t) {2429- if (!t || !t.model) return 'Tuner';2430- if (/^hdhomerun/i.test(t.model)) return 'HDHomeRun';2431- return t.model;2432-}2477+// tunerName names the tuner: "HDHomeRun FLEX DUO".2478+const tunerName = (t) => (t && t.name) || 'Tuner';24332479 24342480 function renderAntenna() {24352481 if (!state.report || !state.info || state.info.antenna === false) return;@@ -2438,6 +2484,7 @@ function renderAntenna() {
24382484 if (!muxes.some((m) => m.rf === state.aRF)) state.aRF = muxes.length ? muxes[0].rf : 0;24392485 renderTunerCard();24402486 renderMeasure();2487+ renderScan();24412488 const found = muxes.filter(muxFound);24422489 const locked = found.filter((m) => muxSig(m).state === 'lock').length;24432490 const chans = state.lineup.filter((c) => !c.own).length;@@ -2462,7 +2509,6 @@ function renderTunerCard() {
24622509 ${use ? `<div class="at-line">${esc(use)}</div>` : ''}24632510 ${t.scanning ? '<div class="at-line at-warn">Scanning for channels</div>' : ''}24642511 ${t.reason ? `<div class="at-line at-warn">${esc(t.reason)}</div>` : ''}2465- ${n && !t.atsc3 ? '<div class="at-note">NextGen TV (ATSC 3.0) needs another tuner</div>' : ''}24662512 ${t.model ? `<div class="at-model">${esc(t.model)}</div>` : ''}`;24672513 }24682514 @@ -2544,10 +2590,10 @@ function renderRFDetail() {
25442590 return;25452591 }25462592 const s = muxSig(m);2547- const scan = m.scan ? `${m.scan.lock ? 'Found by the scan' : 'Not found by the scan'}, ${new Date(m.scan.at).toLocaleDateString([], { month: 'short', day: 'numeric' })}` : 'Not scanned yet';2593+ const scan = muxFound(m) ? "In the tuner's lineup" : 'Not found by the scan';25482594 const wins = (m.history && m.history.windows) || [];25492595 const bars = wins.map((w) => {2550- const q = w.qualityPct ? w.qualityPct.avg : w.snrDb ? Math.min(100, w.snrDb.avg * 3) : 0;2596+ const q = w.qualityPct ? w.qualityPct.avg : 0;25512597 const lo = w.qualityPct ? w.qualityPct.min : 0;25522598 const st = w.lockedPct >= 100 ? 'lock' : w.lockedPct > 0 ? 'part' : 'off';25532599 const tip = `${new Date(w.from).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}: ${w.samples} reading${w.samples > 1 ? 's' : ''}${w.source === 'active' ? ' while watched' : ''}, locked ${Math.round(w.lockedPct)}%${w.qualityPct ? `, quality ${w.qualityPct.min} to ${w.qualityPct.max}%` : ''}`;@@ -2847,7 +2893,7 @@ function settingsRows() {
28472893 if (info.antenna !== false && info.tuner) {28482894 const t = info.tuner;28492895 const what = t.tuners ? `${t.tuners} tuner${t.tuners > 1 ? 's' : ''}, ${(t.standards || []).join(', ')}` : 'No tuner yet';2850- add('About', { id: 'tuner', kind: 'info', label: 'Tuner', value: t.tuners ? tunerName(t) : what, sub: [t.tuners && what, t.model, t.tuners && !t.atsc3 && 'NextGen TV (ATSC 3.0) needs another tuner'].filter(Boolean).join(', ') });2896+ add('About', { id: 'tuner', kind: 'info', label: 'Tuner', value: t.tuners ? tunerName(t) : what, sub: [t.tuners && what, t.model].filter(Boolean).join(', ') });28512897 }28522898 add('About', { id: 'controls', kind: 'open', label: SETTINGS_LISTS.controls, open: 'controls' });28532899 if (state.report && state.report.sources.length) add('About', { id: 'sources', kind: 'open', label: SETTINGS_LISTS.sources, value: `${state.report.sources.length}`, open: 'sources' });@@ -3284,8 +3330,9 @@ function recLists() {
32843330 // The view's parts take the focus in turn (data-zone): the list, the menu32853331 // of lists in the rail (Left from the list's left edge), and the selected32863332 // item's actions (OK): for a recording Play (or Resume), From the start,3287-// Watched and Delete, above the grid; for one to come, Don't record; for a3288-// series, how many to keep, new episodes only, and Stop.3333+// Watched and Delete, above the grid; for one to come, Don't record; for3334+// one under way, Watch from the start, 30 more minutes, Stop recording and3335+// Delete; for a series, how many to keep, new episodes only, and Stop.32893336 const recUI = { zone: 'list', act: '' };32903337 32913338 function openRecordings() {@@ -3316,7 +3363,29 @@ function recRefocus() {
33163363 // armed is whether a delete-like action waits for its second press; ARMED33173364 // says what the second does.33183365 const armed = (act, it) => pendingDelete === `${act}:${it && it.id}`;3319-const ARMED = { delete: 'delete', skip: 'skip it', stop: 'stop it' };3366+const ARMED = { delete: 'delete', skip: 'skip it', stop: 'stop it', end: 'stop recording', discard: 'delete it' };3367+3368+// upcomingChips says how an airing to come, or a recording under way,3369+// stands: recording until when, a conflict or a channel the tuner doesn't3370+// have (and why), part of a series.3371+function upcomingChips(it) {3372+ const series = it.ruleId && (state.dvr.rules || []).some((r) => r.id === it.ruleId && r.kind === 'series');3373+ const chips = [];3374+ if (it.status === 'recording') chips.push(`<span class="chip live">Recording until ${clock(Date.parse(it.until))}</span>`);3375+ if (it.status === 'conflict') chips.push('<span class="chip warn">Conflict</span>');3376+ if (it.status === 'unavailable') chips.push('<span class="chip">Channel not available</span>');3377+ if (series) chips.push('<span class="chip">Part of a series</span>');3378+ const why = it.status === 'conflict' || it.status === 'unavailable' || (it.status === 'recording' && it.detail) ? `<div class="r-why">${esc(it.detail || '')}</div>` : '';3379+ return chips.join('') + why;3380+}3381+3382+// upcomingActs are the actions of the selected airing to come: skip it; or3383+// of a recording under way: watch it from the start, record half an hour3384+// more, stop it (keeping what's recorded), or delete it.3385+function upcomingActs(it, act) {3386+ if (it.status !== 'recording') return act('skip', "Don't record", it);3387+ return act('play', 'Watch from the start', it) + act('extend', '30 more minutes', it) + act('end', 'Stop recording', it) + act('discard', 'Delete', it);3388+}33203389 33213390 function renderRecordings() {33223391 const v = $('#recordings');@@ -3338,6 +3407,7 @@ function renderRecordings() {
33383407 <div class="rd-title">${esc(cur.title)}</div>33393408 <div class="rd-ep">${esc(cur.subtitle || '')}</div>33403409 <p class="rd-desc">${esc(cur.status === 'failed' ? cur.detail || 'This recording failed' : cur.description || '')}</p>3410+ ${cur.status !== 'failed' && cur.detail ? `<p class="rd-note">${esc(cur.detail)}</p>` : ''}33413411 <div class="rd-actions">${cur.status === 'failed' ? '' : act('play', resumable(cur) ? `Resume at ${mmss(cur.position)}` : 'Play', cur)}${resumable(cur) ? act('restart', 'From the start', cur) : ''}${cur.status === 'failed' ? '' : act('watched', cur.watched ? 'Mark unwatched' : 'Mark watched', cur)}${act('delete', 'Delete', cur)}</div>33423412 <div class="rd-hint">${recHint(cur)}</div>33433413 </div>@@ -3359,8 +3429,8 @@ function renderRecordings() {
33593429 <li class="r-item ${it.status} ${i === sel.upcoming ? 'sel' : ''}" data-i="${i}">33603430 <div class="r-time">${dayLabel(it.start)}<b>${clock(it.start)}</b></div>33613431 <div class="r-what"><div class="r-name">${esc(it.title)}</div><div class="r-sub">${esc(it.channel)} ${esc(it.callSign ? it.callSign.replace(/(DT|LD|CD|LP|CA|D)\d*$/, '') : '')}${it.subtitle ? ' | ' + esc(it.subtitle) : ''}</div>3362- ${it.status === 'recording' ? '<span class="chip live">Recording now</span>' : it.status === 'unavailable' ? '<span class="chip">Channel not available</span>' : ''}${!it.id ? '<span class="chip">Part of a series</span>' : ''}</div>3363- <div class="r-acts">${i === sel.upcoming && it.id ? act('skip', "Don't record", it) : ''}</div>3432+ ${upcomingChips(it)}</div>3433+ <div class="r-acts">${i === sel.upcoming ? upcomingActs(it, act) : ''}</div>33643434 </li>`).join('') : '<li class="r-empty">Nothing scheduled.</li>';33653435 33663436 f(v, 'rules').innerHTML = lists.series.length ? lists.series.map((r, i) => `@@ -3415,7 +3485,11 @@ function recAct(act) {
34153485 if (!it) return null;34163486 const fail = (err) => toast(String(err && err.message ? err.message : err), 6000);34173487 switch (act) {3418- case 'play': return playRecording(it, resumable(it) ? it.position : 0);3488+ case 'play': return playRecording(it, it.status === 'recording' ? 0 : resumable(it) ? it.position : 0);3489+ case 'extend': return api().ExtendRecording(it.id, 30).then((until) => {3490+ toast(`Recording ${it.title} until ${clock(Date.parse(until))}`);3491+ return loadDVR();3492+ }).catch(fail);34193493 case 'restart': return playRecording(it, 0);34203494 case 'watched': return api().MarkWatched(it.id, !it.watched).then(loadDVR).catch(fail);34213495 case 'keep': case 'newonly': {@@ -3423,21 +3497,21 @@ function recAct(act) {
34233497 const u = act === 'keep' ? { keep: steps[(steps.indexOf(it.keep || 0) + 1) % steps.length], newOnly: !!it.newOnly } : { keep: it.keep || 0, newOnly: !it.newOnly };34243498 return api().UpdateRule(it.id, u).then(loadDVR).catch(fail);34253499 }3426- case 'delete': case 'skip': case 'stop': {3500+ case 'delete': case 'skip': case 'stop': case 'end': case 'discard': {34273501 if (!it.id) return null;34283502 if (!armed(act, it)) {34293503 pendingDelete = `${act}:${it.id}`;34303504 clearTimeout(pendingTimer);34313505 pendingTimer = setTimeout(() => { pendingDelete = ''; if (state.view === 'recordings') renderRecordings(); }, 4000);3432- const what = act === 'delete' ? `delete ${it.title}` : act === 'stop' ? `stop recording ${it.title}` : `skip ${it.title}`;3506+ const what = act === 'delete' || act === 'discard' ? `delete ${it.title}` : act === 'stop' ? `stop recording ${it.title}` : act === 'end' ? `stop recording ${it.title}, keeping what's recorded` : `skip ${it.title}`;34333507 if (recUI.zone !== 'actions') toast(`Press Delete again to ${what}`);34343508 return renderRecordings();34353509 }34363510 pendingDelete = '';34373511 clearTimeout(pendingTimer);3438- const done = act === 'stop' ? api().DeleteRule(it.id) : api().DeleteRecording(it.id);3512+ const done = act === 'stop' ? api().DeleteRule(it.id) : act === 'end' ? api().StopRecording(it.id) : api().DeleteRecording(it.id);34393513 return done.then(() => {3440- toast(act === 'delete' ? 'Deleted' : act === 'stop' ? 'Series recording stopped' : 'Removed from the schedule');3514+ toast(act === 'delete' || act === 'discard' ? 'Deleted' : act === 'stop' ? 'Series recording stopped' : act === 'end' ? `Stopped. ${it.title} is in the library` : 'Removed from the schedule');34413515 recZone('list');34423516 return loadDVR();34433517 }).catch(fail);@@ -98,6 +98,10 @@
9898 <div class="a-mprog" data-f="mprog" hidden><i data-f="mbar"></i></div>9999 <div class="a-mnote" data-f="mnote"></div>100100 </div>101+ <div class="a-measure">102+ <button type="button" class="btn fx" data-act="scan" data-f="scan">Scan for channels</button>103+ <div class="a-mnote" data-f="snote"></div>104+ </div>101105 <dl class="rail-stats" data-f="stats"></dl>102106 </nav>103107 <div class="main a-body">@@ -546,7 +546,10 @@ body:not(.mode-guide) .g-cell.rec-on::after { animation: none; } /* hidden, so s
546546 .r-item .r-name { font-size: 16px; }547547 .r-item .r-time { font: 400 11px var(--f-mono); color: var(--muted); text-align: right; min-width: 64px; }548548 .r-item .r-time b { display: block; font: 600 18px var(--f-display); color: var(--paper); letter-spacing: .02em; }549-.r-item.unavailable .r-name { color: var(--muted); }549+.r-item.unavailable .r-name, .r-item.conflict .r-name { color: var(--muted); }550+.r-item .r-why { color: var(--muted); font-size: 0.85em; margin-top: 4px; }551+.r-detail .rd-note { margin: 6px 0 0; color: var(--amber-2); max-width: 70ch; }552+.chip.warn { background: var(--amber-2); color: #000; box-shadow: none; }550553 .r-item .chip { display: inline-block; margin-top: 5px; }551554 .r-list ul .r-item, .r-list .r-item:not(:has(.r-time)) { grid-template-columns: minmax(0, 1fr) auto; }552555 .x { border: 0; background: none; color: var(--faint); cursor: pointer; font-size: 14px; padding: 6px; border-radius: 3px; }@@ -212,6 +212,7 @@
212212 // What the tuners measured, and Measure now.213213 Signal: () => call('GET', '/api/signal', undefined, { ms: 15_000 }),214214 Measure: () => call('POST', '/api/signal/measure', {}, { ms: 15_000 }),215+ ScanChannels: () => call('POST', '/api/tuner/scan', {}, { ms: 15_000 }),215216 Weather: () => call('GET', '/api/weather'),216217 217218 async CopyText(text) {@@ -252,6 +253,8 @@
252253 Record: (req) => call('POST', '/api/dvr/record', req),253254 DeleteRule: (id) => call('DELETE', `/api/dvr/rules/${enc(id)}`).then(() => undefined),254255 DeleteRecording: (id) => call('DELETE', `/api/dvr/recordings/${enc(id)}`).then(() => undefined),256+ StopRecording: (id) => call('POST', `/api/dvr/recordings/${enc(id)}/stop`, {}).then(() => undefined),257+ ExtendRecording: (id, minutes) => call('POST', `/api/dvr/recordings/${enc(id)}/extend`, { minutes }).then((r) => r.until),255258 async PlayRecording(id, from) {256259 if (!id) throw 'no recording selected';257260 return playback(await call('POST', '/api/dvr/play', { client: load().clientId, id, from: from || 0 }));@@ -21,6 +21,7 @@ import (
2121 "airwaves/internal/phase"2222 "airwaves/internal/service"2323 "airwaves/internal/stream"24+ "airwaves/internal/tuner"2425 "airwaves/internal/weather"2526 )2627 @@ -34,6 +35,8 @@ type Backend interface {
3435 Signal(ctx context.Context) (*service.SignalReport, error)3536 // Measure starts measuring every RF channel on an idle tuner.3637 Measure(ctx context.Context) (*service.SweepStatus, error)38+ // ScanChannels has the tuner scan for channels again.39+ ScanChannels(ctx context.Context) (service.TunerInfo, error)3740 Weather(ctx context.Context) (*weather.Report, error)3841 Tune(ctx context.Context, client, number string) (*stream.Playback, error)3942 Stop(ctx context.Context, client string) error@@ -41,6 +44,10 @@ type Backend interface {
4144 Record(ctx context.Context, req dvr.Request) (*dvr.Rule, error)4245 DeleteRule(ctx context.Context, id string) error4346 DeleteRecording(ctx context.Context, id string) error47+ // StopRecording ends a recording in progress, keeping it;48+ // ExtendRecording makes one run longer.49+ StopRecording(ctx context.Context, id string) error50+ ExtendRecording(ctx context.Context, id string, minutes int) (time.Time, error)4451 PlayRecording(ctx context.Context, client, id string, from float64) (*stream.Playback, error)4552 SaveProgress(ctx context.Context, id string, position, duration float64) error4653 MarkWatched(ctx context.Context, id string, watched bool) error@@ -105,6 +112,12 @@ type progressReq struct {
105112 Watched *bool `json:"watched,omitempty"`106113 }107114 115+// extendReq extends a recording by Minutes; the reply says Until when.116+type extendReq struct {117+ Minutes int `json:"minutes,omitempty"`118+ Until time.Time `json:"until,omitzero"`119+}120+108121 // Handler routes requests.109122 func (s *Server) Handler() http.Handler {110123 b := s.Backend@@ -131,6 +144,9 @@ func (s *Server) Handler() http.Handler {
131144 mux.HandleFunc("POST /api/signal/measure", func(w http.ResponseWriter, r *http.Request) {132145 reply(w)(b.Measure(r.Context()))133146 })147+ mux.HandleFunc("POST /api/tuner/scan", func(w http.ResponseWriter, r *http.Request) {148+ reply(w)(b.ScanChannels(r.Context()))149+ })134150 mux.HandleFunc("GET /api/weather", func(w http.ResponseWriter, r *http.Request) {135151 reply(w)(b.Weather(r.Context()))136152 })@@ -177,6 +193,17 @@ func (s *Server) Handler() http.Handler {
177193 mux.HandleFunc("DELETE /api/dvr/recordings/{id}", func(w http.ResponseWriter, r *http.Request) {178194 reply(w)(struct{}{}, b.DeleteRecording(r.Context(), r.PathValue("id")))179195 })196+ mux.HandleFunc("POST /api/dvr/recordings/{id}/stop", func(w http.ResponseWriter, r *http.Request) {197+ reply(w)(struct{}{}, b.StopRecording(r.Context(), r.PathValue("id")))198+ })199+ mux.HandleFunc("POST /api/dvr/recordings/{id}/extend", func(w http.ResponseWriter, r *http.Request) {200+ var req extendReq201+ if !decode(w, r, &req) {202+ return203+ }204+ until, err := b.ExtendRecording(r.Context(), r.PathValue("id"), req.Minutes)205+ reply(w)(extendReq{Until: until}, err)206+ })180207 mux.HandleFunc("POST /api/dvr/play", func(w http.ResponseWriter, r *http.Request) {181208 var req clientReq182209 if !decode(w, r, &req) {@@ -306,13 +333,15 @@ func reply(w http.ResponseWriter) func(any, error) {
306333 307334 // errStatus is the HTTP status for a backend error: 501 for what this308335 // server doesn't have (an antenna, a tuner, recording, a location for the309-// weather), 503 for a channel with no signal right now, else 500.336+// weather), 503 for a channel with no signal or no free tuner right now,337+// else 500.310338 func errStatus(err error) int {311339 var noSignal *service.NoSignalError340+ var busy *tuner.BusyError312341 switch {313342 case errors.Is(err, service.ErrNoAntenna), errors.Is(err, service.ErrNoTuner), errors.Is(err, service.ErrNoDVR), errors.Is(err, service.ErrNoLocation):314343 return http.StatusNotImplemented315- case errors.As(err, &noSignal):344+ case errors.As(err, &noSignal), errors.As(err, &busy):316345 return http.StatusServiceUnavailable317346 }318347 return http.StatusInternalServerError@@ -9,6 +9,7 @@ import (
99 "net/http/httptest"1010 "strings"1111 "testing"12+ "time"1213 1314 "airwaves/internal/dvr"1415 "airwaves/internal/guide"@@ -50,6 +51,16 @@ func (f *fake) Signal(context.Context) (*service.SignalReport, error) {
5051 }, nil5152 }5253 54+func (f *fake) ScanChannels(context.Context) (service.TunerInfo, error) {55+ return service.TunerInfo{Ready: true, Tuners: 2, Scanning: true}, nil56+}57+58+func (f *fake) StopRecording(context.Context, string) error { return nil }59+60+func (f *fake) ExtendRecording(_ context.Context, _ string, minutes int) (time.Time, error) {61+ return time.Date(2026, 10, 4, 20, 3+minutes, 0, 0, time.UTC), nil62+}63+5364 func (f *fake) Measure(context.Context) (*service.SweepStatus, error) {5465 f.measured++5566 if f.measured > 1 {@@ -151,6 +162,15 @@ func TestClientServerRoundTrip(t *testing.T) {
151162 !sig.Channels[0].Signal.Lock || *sig.Channels[0].Signal.StrengthPct != 85 || sig.Channels[0].Signal.QualityPct != nil {152163 t.Errorf("signal: %+v, %v", sig, err)153164 }165+ if until, err := c.ExtendRecording(ctx, "rec1", 30); err != nil || !until.Equal(time.Date(2026, 10, 4, 20, 33, 0, 0, time.UTC)) {166+ t.Errorf("extend: %s, %v", until, err)167+ }168+ if err := c.StopRecording(ctx, "rec1"); err != nil {169+ t.Errorf("stop recording: %v", err)170+ }171+ if ti, err := c.ScanChannels(ctx); err != nil || !ti.Scanning {172+ t.Errorf("scan: %+v, %v", ti, err)173+ }154174 if st, err := c.Measure(ctx); err != nil || !st.Running || st.Total != 12 {155175 t.Errorf("measure: %+v, %v", st, err)156176 }@@ -127,6 +127,24 @@ func (c *Client) DeleteRecording(ctx context.Context, id string) error {
127127 return c.call(ctx, http.MethodDelete, "/api/dvr/recordings/"+url.PathEscape(id), nil, nil)128128 }129129 130+// StopRecording implements Backend.131+func (c *Client) StopRecording(ctx context.Context, id string) error {132+ return c.call(ctx, http.MethodPost, "/api/dvr/recordings/"+url.PathEscape(id)+"/stop", struct{}{}, nil)133+}134+135+// ExtendRecording implements Backend.136+func (c *Client) ExtendRecording(ctx context.Context, id string, minutes int) (time.Time, error) {137+ var out extendReq138+ err := c.call(ctx, http.MethodPost, "/api/dvr/recordings/"+url.PathEscape(id)+"/extend", extendReq{Minutes: minutes}, &out)139+ return out.Until, err140+}141+142+// ScanChannels implements Backend.143+func (c *Client) ScanChannels(ctx context.Context) (service.TunerInfo, error) {144+ var out service.TunerInfo145+ return out, c.call(ctx, http.MethodPost, "/api/tuner/scan", struct{}{}, &out)146+}147+130148 // PlayRecording implements Backend.131149 func (c *Client) PlayRecording(ctx context.Context, client, id string, from float64) (*stream.Playback, error) {132150 var pb stream.Playback@@ -1,8 +1,8 @@
1-// Package dvr turns recording rules into timed Tvheadend recordings.2-//3-// Airwaves owns the rules and the guide (Gracenote, with series IDs and4-// "new episode" flags); Tvheadend owns the tuners and writes the files.5-// Reconcile keeps Tvheadend's schedule matching the rules.1+// Package dvr records over-the-air TV. The user's rules (one airing, or a2+// series by Gracenote series ID on one channel) pick airings from the3+// guide; Airwaves records them itself, on the tuners it shares with4+// viewers, where a recording comes first. Recordings are files in the5+// recordings folder, described in the same state file as the rules.66 package dvr77 88 import (@@ -13,7 +13,7 @@ import (
1313 "encoding/json"1414 "errors"1515 "fmt"16- "maps"16+ "log"1717 "os"1818 "path/filepath"1919 "slices"@@ -24,7 +24,7 @@ import (
2424 2525 "airwaves/internal/guide"2626 "airwaves/internal/lineup"27- "airwaves/internal/tvh"27+ "airwaves/internal/tuner"2828 )2929 3030 // Kind is the type of rule.@@ -36,6 +36,28 @@ const (
3636 Series Kind = "series"3737 )3838 39+// Statuses of a recording, as Item shows them.40+const (41+ // Scheduled is an airing a rule wants that hasn't started.42+ Scheduled = "scheduled"43+ // Conflict is one that won't get a tuner: others record from every44+ // tuner then.45+ Conflict = "conflict"46+ // Unavailable is one on a channel the tuner doesn't have.47+ Unavailable = "unavailable"48+ Recording = "recording"49+ Completed = "completed"50+ Failed = "failed"51+)52+53+// Padding around an airing: a minute early, three late, and half an hour54+// late for sports, which overrun.55+const (56+ early = time.Minute57+ late = 3 * time.Minute58+ lateSports = 30 * time.Minute59+)60+3961 // Request asks to record an airing picked in the guide.4062 type Request struct {4163 Kind Kind `json:"kind"`@@ -74,9 +96,42 @@ type Rule struct {
7496 Keep int `json:"keep,omitempty"`7597 }7698 99+// Rec is a recording Airwaves made or is making: an airing, and the file100+// it went to.101+type Rec struct {102+ ID string `json:"id"`103+ Key string `json:"key"` // the airing's, "7.1@1791058239"104+ RuleID string `json:"ruleId,omitempty"`105+ Title string `json:"title"`106+ Subtitle string `json:"subtitle,omitempty"`107+ Description string `json:"description,omitempty"`108+ Image string `json:"image,omitempty"`109+ ProgramID string `json:"programId,omitempty"`110+ Channel string `json:"channel"`111+ CallSign string `json:"callSign,omitempty"`112+ // Start and End are the airing's; From and Until what is recorded,113+ // with padding. Until moves when the recording is extended or stopped.114+ Start time.Time `json:"start"`115+ End time.Time `json:"end"`116+ From time.Time `json:"from"`117+ Until time.Time `json:"until"`118+ // File is where it is, in the recordings folder.119+ File string `json:"file"`120+ Status string `json:"status"` // Recording, Completed or Failed121+ // Detail says what's missing or what went wrong.122+ Detail string `json:"detail,omitempty"`123+ // Began and Ended are when data first and last arrived; Covered how124+ // much of the time between them was recorded.125+ Began time.Time `json:"began,omitzero"`126+ Ended time.Time `json:"ended,omitzero"`127+ Covered time.Duration `json:"covered,omitempty"`128+ Size int64 `json:"size,omitempty"`129+}130+77131 // Item is a recording as shown to the user.78132 type Item struct {79- ID string `json:"id"` // Tvheadend entry UUID; empty if not scheduled133+ // ID is a recording's own; for an airing not started, its key.134+ ID string `json:"id"`80135 Key string `json:"key"`81136 RuleID string `json:"ruleId,omitempty"`82137 Title string `json:"title"`@@ -87,12 +142,16 @@ type Item struct {
87142 CallSign string `json:"callSign,omitempty"`88143 Start time.Time `json:"start"`89144 End time.Time `json:"end"`90- // Status is scheduled, recording, completed, failed or unavailable.91- Status string `json:"status"`92- Detail string `json:"detail,omitempty"`93- SizeBytes int64 `json:"sizeBytes,omitempty"`94- // Duration of the recorded file, Position the resume point and Watched95- // whether it has been seen, all for finished recordings.145+ // Status is Scheduled, Conflict, Unavailable, Recording, Completed146+ // or Failed.147+ Status string `json:"status"`148+ Detail string `json:"detail,omitempty"`149+ // Until is when a recording in progress stops: the airing's end with150+ // its padding, later when extended.151+ Until time.Time `json:"until,omitzero"`152+ SizeBytes int64 `json:"sizeBytes,omitempty"`153+ // Duration of what's recorded so far, Position the resume point and154+ // Watched whether it has been seen.96155 Duration float64 `json:"duration,omitempty"`97156 Position float64 `json:"position,omitempty"`98157 Watched bool `json:"watched,omitempty"`@@ -122,53 +181,58 @@ type State struct {
122181 Prefs Prefs `json:"prefs"`123182 }124183 125-// meta remembers guide details of an airing Airwaves scheduled, for126-// artwork and episode de-duplication after the guide has moved on.127-type meta struct {128- RuleID string `json:"ruleId"`129- Channel string `json:"channel"`130- CallSign string `json:"callSign"`131- Image string `json:"image,omitempty"`132- ProgramID string `json:"programId,omitempty"`133-}134-135184 type fileState struct {136- Rules []Rule `json:"rules"`137- Meta map[string]meta `json:"meta"`138- Progress map[string]Progress `json:"progress"` // by Tvheadend entry UUID139- Prefs Prefs `json:"prefs"`185+ Rules []Rule `json:"rules"`186+ Recordings []Rec `json:"recordings"`187+ Progress map[string]Progress `json:"progress"` // by recording ID188+ Prefs Prefs `json:"prefs"`140189 }141190 142-// ChannelResolver maps a virtual channel to a Tvheadend channel UUID.143-type ChannelResolver interface {144- ChannelUUID(ctx context.Context, number string) (string, error)191+// Tuners is where recordings get their tuner.192+type Tuners interface {193+ Open(ctx context.Context, r tuner.Request) (*tuner.Sub, error)194+ Count() int145195 }146196 147-// Manager owns the rules.148-type Manager struct {149- tvh *tvh.Client150- channels ChannelResolver151- path string197+// Locate finds where a channel is broadcast: its RF channel and program.198+type Locate func(ctx context.Context, number string) (freqHz int64, program int, err error)152199 153- mu sync.Mutex154- st fileState155-}200+// Listings supplies the guide and the tuner's channels, for the rules.201+type Listings func(ctx context.Context) (*guide.Guide, []lineup.Channel, error)156202 157-// Open loads rules from path (created on first save).158-func Open(path string, c *tvh.Client, channels ChannelResolver) (*Manager, error) {159- m := &Manager{tvh: c, channels: channels, path: path, st: fileState{Meta: map[string]meta{}, Progress: map[string]Progress{}}}203+// Manager owns the rules and the recordings.204+type Manager struct {205+ path string206+ dir string207+ tuners Tuners208+ locate Locate209+210+ ctx context.Context // until Close, for the recordings211+ cancel context.CancelCauseFunc212+213+ mu sync.Mutex214+ st fileState215+ jobs map[string]*job // recordings in progress, by ID216+}217+218+// Open loads the rules and recordings kept at path (created on first219+// save), recording into dir. A file that can't be read is set aside, as220+// path.bad, and the DVR starts empty rather than not at all.221+func Open(path, dir string, tuners Tuners, locate Locate) (*Manager, error) {222+ m := &Manager{path: path, dir: dir, tuners: tuners, locate: locate, jobs: map[string]*job{},223+ st: fileState{Progress: map[string]Progress{}}}224+ m.ctx, m.cancel = context.WithCancelCause(context.Background())160225 raw, err := os.ReadFile(path)161- if errors.Is(err, os.ErrNotExist) {162- return m, nil163- }164- if err != nil {226+ switch {227+ case errors.Is(err, os.ErrNotExist):228+ case err != nil:165229 return nil, err166- }167- if err := json.Unmarshal(raw, &m.st); err != nil {168- return nil, fmt.Errorf("parse %s: %w", path, err)169- }170- if m.st.Meta == nil {171- m.st.Meta = map[string]meta{}230+ default:231+ if err := json.Unmarshal(raw, &m.st); err != nil {232+ log.Printf("recordings: %s can't be read (%v); set aside as %s.bad, starting empty", path, err, path)233+ _ = os.Rename(path, path+".bad")234+ m.st = fileState{}235+ }172236 }173237 if m.st.Progress == nil {174238 m.st.Progress = map[string]Progress{}@@ -176,6 +240,7 @@ func Open(path string, c *tvh.Client, channels ChannelResolver) (*Manager, error
176240 return m, nil177241 }178242 243+// save writes the state. Called with m.mu held.179244 func (m *Manager) save() error {180245 raw, err := json.MarshalIndent(m.st, "", " ")181246 if err != nil {@@ -184,14 +249,35 @@ func (m *Manager) save() error {
184249 if err := os.MkdirAll(filepath.Dir(m.path), 0o755); err != nil {185250 return err186251 }187- tmp := m.path + ".tmp"188- if err := os.WriteFile(tmp, raw, 0o644); err != nil {252+ f, err := os.CreateTemp(filepath.Dir(m.path), filepath.Base(m.path)+".*")253+ if err != nil {189254 return err190255 }191- return os.Rename(tmp, m.path)256+ _, err = f.Write(raw)257+ if err == nil {258+ err = f.Sync()259+ }260+ if cerr := f.Close(); err == nil {261+ err = cerr262+ }263+ if err == nil {264+ err = os.Rename(f.Name(), m.path)265+ }266+ if err != nil {267+ os.Remove(f.Name())268+ }269+ return err192270 }193271 194-// Add creates a rule from a guide selection and schedules it.272+// saveLogged saves, logging a failure. Called with m.mu held.273+func (m *Manager) saveLogged() {274+ if err := m.save(); err != nil {275+ log.Printf("recordings: %v", err)276+ }277+}278+279+// Add creates a rule from a guide selection. An airing that's on now280+// starts recording at once.195281 func (m *Manager) Add(ctx context.Context, req Request, g *guide.Guide, chans []lineup.Channel) (*Rule, error) {196282 ch := findChannel(chans, req.Channel, req.CallSign)197283 if ch == nil || ch.GuideID == "" || g == nil {@@ -238,11 +324,12 @@ func (m *Manager) Add(ctx context.Context, req Request, g *guide.Guide, chans []
238324 if err != nil {239325 return nil, err240326 }241- return &r, m.Reconcile(ctx, g, chans)327+ m.Start(g, chans, time.Now())328+ return &r, nil242329 }243330 244-// DeleteRule removes a rule and any of its recordings that have not started.245-func (m *Manager) DeleteRule(ctx context.Context, id string) error {331+// DeleteRule removes a rule. Its recordings in progress carry on.332+func (m *Manager) DeleteRule(_ context.Context, id string) error {246333 m.mu.Lock()247334 defer m.mu.Unlock()248335 i := slices.IndexFunc(m.st.Rules, func(r Rule) bool { return r.ID == id })@@ -250,53 +337,64 @@ func (m *Manager) DeleteRule(ctx context.Context, id string) error {
250337 return fmt.Errorf("no rule %s", id)251338 }252339 m.st.Rules = slices.Delete(m.st.Rules, i, i+1)253- if err := m.save(); err != nil {254- return err340+ return m.save()341+}342+343+// DeleteEntry removes one recording, by its ID or, for an airing not344+// started, its key: an airing to come is skipped (a once rule for it345+// goes), one recording now is stopped and discarded, and a finished one346+// is deleted with its file. A series never records an airing deleted347+// from it again.348+func (m *Manager) DeleteEntry(_ context.Context, id string) error {349+ m.mu.Lock()350+ i := slices.IndexFunc(m.st.Recordings, func(r Rec) bool { return r.ID == id })351+ if i < 0 {352+ // An airing to come.353+ defer m.mu.Unlock()354+ m.skip(id)355+ return m.save()255356 }256- upcoming, err := m.tvh.Upcoming(ctx)257- if err != nil {258- return err357+ rec := m.st.Recordings[i]358+ j := m.jobs[id]359+ m.mu.Unlock()360+ if j != nil {361+ j.cancel(errDiscard)362+ <-j.done259363 }260- for _, e := range upcoming {261- if rule, _, ok := parseComment(e.Comment); ok && rule == id && e.SchedStatus == "scheduled" {262- if err := m.tvh.DeleteScheduled(ctx, e.UUID); err != nil {263- return err264- }364+ m.mu.Lock()365+ defer m.mu.Unlock()366+ m.skip(rec.Key)367+ m.remove(rec.ID)368+ return m.save()369+}370+371+// skip keeps the airing key from being recorded: a once rule for it goes,372+// and series rules on its channel skip it. Called with m.mu held.373+func (m *Manager) skip(key string) {374+ channel, _, _ := strings.Cut(key, "@")375+ m.st.Rules = slices.DeleteFunc(m.st.Rules, func(r Rule) bool { return r.Kind == Once && airingKey(r.Channel, r.Start) == key })376+ for i := range m.st.Rules {377+ if r := &m.st.Rules[i]; r.Kind == Series && r.Channel == channel && !slices.Contains(r.Skip, key) {378+ r.Skip = append(r.Skip, key)265379 }266380 }267- return nil268381 }269382 270-// DeleteEntry removes one recording: a scheduled airing is skipped (and271-// stays skipped for series rules), an active one is cancelled, a finished272-// one is deleted with its file.273-func (m *Manager) DeleteEntry(ctx context.Context, uuid string) error {274- m.mu.Lock()275- defer m.mu.Unlock()276- upcoming, err := m.tvh.Upcoming(ctx)277- if err != nil {278- return err383+// remove deletes a finished recording and its file. Called with m.mu held.384+func (m *Manager) remove(id string) {385+ i := slices.IndexFunc(m.st.Recordings, func(r Rec) bool { return r.ID == id })386+ if i < 0 {387+ return279388 }280- for _, e := range upcoming {281- if e.UUID != uuid {282- continue389+ if f := m.st.Recordings[i].File; f != "" {390+ path := filepath.Join(m.dir, f)391+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {392+ log.Printf("recordings: %v", err)283393 }284- if rule, key, ok := parseComment(e.Comment); ok {285- for i := range m.st.Rules {286- if m.st.Rules[i].ID == rule {287- m.st.Rules[i].Skip = append(m.st.Rules[i].Skip, key)288- }289- }290- if err := m.save(); err != nil {291- return err292- }293- }294- if e.SchedStatus == "recording" {295- return m.tvh.Cancel(ctx, uuid)296- }297- return m.tvh.DeleteScheduled(ctx, uuid)394+ _ = os.Remove(filepath.Dir(path)) // the show's folder, once empty298395 }299- return m.tvh.Remove(ctx, uuid)396+ m.st.Recordings = slices.Delete(m.st.Recordings, i, i+1)397+ delete(m.st.Progress, id)300398 }301399 302400 type airing struct {@@ -306,75 +404,19 @@ type airing struct {
306404 ch lineup.Channel307405 }308406 309-// Reconcile schedules what the rules want and unschedules what they no310-// longer want. Safe to call often.311-func (m *Manager) Reconcile(ctx context.Context, g *guide.Guide, chans []lineup.Channel) error {312- m.mu.Lock()313- defer m.mu.Unlock()314- now := time.Now()315-316- upcoming, err := m.tvh.Upcoming(ctx)317- if err != nil {318- return err319- }320- finished, err := m.tvh.Finished(ctx)321- if err != nil {322- return err323- }324- scheduled := make(map[string]tvh.Entry)325- for _, e := range upcoming {326- if _, key, ok := parseComment(e.Comment); ok {327- scheduled[key] = e328- }329- }330- recordedPrograms := make(map[string]bool)331- for _, e := range finished {332- if _, key, ok := parseComment(e.Comment); ok && e.ErrorCode == 0 {333- if id := m.st.Meta[key].ProgramID; episodeID(id) {334- recordedPrograms[id] = true335- }336- }337- }338-339- // Once rules whose airing is over are done; Tvheadend keeps the file.340- m.st.Rules = slices.DeleteFunc(m.st.Rules, func(r Rule) bool { return r.Kind == Once && r.End.Before(now) })341-342- want := m.desired(g, chans, now, recordedPrograms)343- wantKeys := make(map[string]bool, len(want))344- for _, a := range want {345- wantKeys[a.key] = true346- if _, ok := scheduled[a.key]; ok {347- continue348- }349- uuid, err := m.channels.ChannelUUID(ctx, a.ch.Number)350- if err != nil {351- continue // shown as unavailable in State352- }353- stopExtra := 3354- if slices.Contains(a.p.Genres, "sports") {355- stopExtra = 30 // live sports overrun356- }357- _, err = m.tvh.CreateEntry(ctx, tvh.NewEntry{358- Channel: uuid, Start: a.p.Start.Unix(), Stop: a.p.End.Unix(),359- Title: a.p.Title, Subtitle: a.p.EpisodeTitle, Description: a.p.Description,360- Comment: comment(a.rule.ID, a.key), StartExtra: 1, StopExtra: stopExtra,361- })362- if err != nil {363- return err364- }365- m.st.Meta[a.key] = meta{RuleID: a.rule.ID, Channel: a.ch.Number, CallSign: a.ch.CallSign, Image: a.p.Image, ProgramID: a.p.ProgramID}366- }367- for key, e := range scheduled {368- if !wantKeys[key] && e.SchedStatus == "scheduled" {369- if err := m.tvh.DeleteScheduled(ctx, e.UUID); err != nil {370- return err371- }372- }407+// window is what's recorded of the airing: from a minute before it to a408+// few minutes after, half an hour for sports.409+func (a airing) window() (from, until time.Time) {410+ after := late411+ if slices.Contains(a.p.Genres, "sports") {412+ after = lateSports373413 }374- return m.save()414+ return a.p.Start.Add(-early), a.p.End.Add(after)375415 }376416 377-// desired lists the airings the rules call for, earliest first.417+// desired lists the airings the rules call for that haven't ended,418+// earliest first. recorded holds the episodes already recorded.419+// Called with m.mu held.378420 func (m *Manager) desired(g *guide.Guide, chans []lineup.Channel, now time.Time, recorded map[string]bool) []airing {379421 var out []airing380422 seen := make(map[string]bool) // episode IDs already claimed@@ -389,9 +431,9 @@ func (m *Manager) desired(g *guide.Guide, chans []lineup.Channel, now time.Time,
389431 if r.Sports {390432 p.Genres = []string{"sports"}391433 }392- ch := lineup.Channel{Number: r.Channel, CallSign: r.CallSign, GuideID: r.GuideID}393- if key := airingKey(r.Channel, r.Start); !slices.Contains(r.Skip, key) {394- out = append(out, airing{key: key, rule: r, p: p, ch: ch})434+ a := airing{key: airingKey(r.Channel, r.Start), rule: r, p: p, ch: lineup.Channel{Number: r.Channel, CallSign: r.CallSign, GuideID: r.GuideID}}435+ if _, until := a.window(); until.After(now) && !slices.Contains(r.Skip, a.key) {436+ out = append(out, a)395437 }396438 case Series:397439 if g == nil {@@ -420,129 +462,184 @@ func (m *Manager) desired(g *guide.Guide, chans []lineup.Channel, now time.Time,
420462 }421463 }422464 }423- slices.SortFunc(out, func(a, b airing) int { return a.p.Start.Compare(b.p.Start) })465+ slices.SortStableFunc(out, func(a, b airing) int { return a.p.Start.Compare(b.p.Start) })424466 return out425467 }426468 469+// recorded lists the episodes recorded, or being recorded, by program ID.470+// Called with m.mu held.471+func (m *Manager) recorded() map[string]bool {472+ out := map[string]bool{}473+ for _, r := range m.st.Recordings {474+ if r.Status != Failed && episodeID(r.ProgramID) {475+ out[r.ProgramID] = true476+ }477+ }478+ return out479+}480+481+// has reports whether the airing key has a recording, made or being made.482+// Called with m.mu held.483+func (m *Manager) has(key string) bool {484+ return slices.ContainsFunc(m.st.Recordings, func(r Rec) bool { return r.Key == key })485+}486+427487 // State describes rules, schedule and library for the UI.428488 func (m *Manager) State(ctx context.Context, g *guide.Guide, chans []lineup.Channel) (*State, error) {429- if _, err := m.tvh.ServerInfo(ctx); err != nil {430- return &State{Reason: "Tvheadend is not reachable: " + err.Error()}, nil431- }432- upcoming, err := m.tvh.Upcoming(ctx)433- if err != nil {434- return nil, err435- }436- finished, err := m.tvh.Finished(ctx)437- if err != nil {438- return nil, err489+ now := time.Now()490+ m.mu.Lock()491+ st := &State{Available: true, Rules: slices.Clone(m.st.Rules), Prefs: m.st.Prefs, Upcoming: []Item{}, Recorded: []Item{}}492+ want := m.desired(g, chans, now, m.recorded())493+ var active []Rec494+ for _, r := range m.st.Recordings {495+ if r.Status == Recording {496+ active = append(active, r)497+ st.Upcoming = append(st.Upcoming, m.item(r, now))498+ } else {499+ st.Recorded = append(st.Recorded, m.item(r, now))500+ }439501 }440- failed, err := m.tvh.Failed(ctx)441- if err != nil {442- return nil, err502+ var todo []airing503+ for _, a := range want {504+ if !m.has(a.key) {505+ todo = append(todo, a)506+ }443507 }508+ m.mu.Unlock()444509 445- m.mu.Lock()446- defer m.mu.Unlock()447- st := &State{Available: true, Rules: slices.Clone(m.st.Rules), Prefs: m.st.Prefs}448- scheduledKeys := make(map[string]bool)449- for _, e := range upcoming {450- it := m.item(e)451- scheduledKeys[it.Key] = true452- st.Upcoming = append(st.Upcoming, it)453- }454- // Airings the rules want but Tvheadend cannot tune yet.455- for _, a := range m.desired(g, chans, time.Now(), nil) {456- if scheduledKeys[a.key] {457- continue458- }510+ for _, p := range m.plan(ctx, active, todo) {511+ a := p.airing459512 st.Upcoming = append(st.Upcoming, Item{460- Key: a.key, RuleID: a.rule.ID, Title: a.p.Title, Subtitle: a.p.EpisodeTitle,513+ ID: a.key, Key: a.key, RuleID: a.rule.ID, Title: a.p.Title, Subtitle: a.p.EpisodeTitle,461514 Description: a.p.Description, Image: a.p.Image, Channel: a.ch.Number, CallSign: a.ch.CallSign,462- Start: a.p.Start, End: a.p.End, Status: "unavailable",463- Detail: "Channel " + a.ch.Number + " is not tuned on the server",515+ Start: a.p.Start, End: a.p.End, Status: p.status, Detail: p.detail,464516 })465517 }466- for _, e := range finished {467- st.Recorded = append(st.Recorded, m.item(e))468- }469- for _, e := range failed {470- it := m.item(e)471- it.Status = "failed"472- st.Recorded = append(st.Recorded, it)473- }474- slices.SortFunc(st.Upcoming, func(a, b Item) int { return a.Start.Compare(b.Start) })475- slices.SortFunc(st.Recorded, func(a, b Item) int { return b.Start.Compare(a.Start) })518+ slices.SortStableFunc(st.Upcoming, func(a, b Item) int { return a.Start.Compare(b.Start) })519+ slices.SortStableFunc(st.Recorded, func(a, b Item) int { return b.Start.Compare(a.Start) })476520 return st, nil477521 }478522 479-func (m *Manager) item(e tvh.Entry) Item {480- it := Item{481- ID: e.UUID, Title: e.Title, Subtitle: e.Subtitle, Description: e.Description,482- Channel: e.ChannelName, Start: time.Unix(e.Start, 0), End: time.Unix(e.Stop, 0),483- Status: e.SchedStatus, Detail: e.Status, SizeBytes: e.FileSize, Duration: fileDuration(e),484- }485- if p, ok := m.st.Progress[e.UUID]; ok {486- it.Position, it.Watched = p.Position, p.Watched523+// planned is an airing to come and whether it will get a tuner.524+type planned struct {525+ airing526+ status, detail string527+}528+529+// plan says which airings to come will get a tuner: each RF channel530+// takes one, whatever is recorded from it, and earlier airings (then531+// older rules) come first. Recordings in progress hold theirs.532+func (m *Manager) plan(ctx context.Context, active []Rec, todo []airing) []planned {533+ type slot struct {534+ from, until time.Time535+ freq int64536+ title string487537 }488- if strings.HasPrefix(it.Status, "completed") {489- it.Status = "completed"538+ tuners := m.tuners.Count()539+ var taken []slot540+ for _, r := range active {541+ if freq, _, err := m.locate(ctx, r.Channel); err == nil {542+ taken = append(taken, slot{r.From, r.Until, freq, r.Title})543+ }490544 }491- if rule, key, ok := parseComment(e.Comment); ok {492- md := m.st.Meta[key]493- it.Key, it.RuleID, it.Image = key, rule, md.Image494- if md.Channel != "" {495- it.Channel, it.CallSign = md.Channel, md.CallSign545+ out := make([]planned, 0, len(todo))546+ for _, a := range todo {547+ p := planned{airing: a, status: Scheduled}548+ freq, _, err := m.locate(ctx, a.ch.Number)549+ if err != nil {550+ p.status, p.detail = Unavailable, err.Error()551+ out = append(out, p)552+ continue553+ }554+ from, until := a.window()555+ // The moments the tuners in use could change within the window.556+ moments := []time.Time{from}557+ for _, s := range taken {558+ if s.from.After(from) && s.from.Before(until) {559+ moments = append(moments, s.from)560+ }561+ }562+ for _, t := range moments {563+ freqs := map[int64]bool{}564+ var titles []string565+ for _, s := range taken {566+ if !s.from.After(t) && s.until.After(t) {567+ freqs[s.freq] = true568+ titles = append(titles, s.title)569+ }570+ }571+ if !freqs[freq] && len(freqs) >= tuners {572+ p.status = Conflict573+ slices.Sort(titles)574+ p.detail = "Every tuner is recording then: " + strings.Join(slices.Compact(titles), ", ")575+ break576+ }577+ }578+ if p.status == Scheduled {579+ taken = append(taken, slot{from, until, freq, a.p.Title})496580 }581+ out = append(out, p)497582 }498- return it583+ return out499584 }500585 501-// Prune drops metadata for recordings that no longer exist.502-func (m *Manager) Prune(ctx context.Context) error {503- upcoming, err := m.tvh.Upcoming(ctx)504- if err != nil {505- return err506- }507- finished, err := m.tvh.Finished(ctx)508- if err != nil {509- return err586+// item shows a recording. Called with m.mu held.587+func (m *Manager) item(r Rec, now time.Time) Item {588+ it := Item{589+ ID: r.ID, Key: r.Key, RuleID: r.RuleID, Title: r.Title, Subtitle: r.Subtitle, Description: r.Description,590+ Image: r.Image, Channel: r.Channel, CallSign: r.CallSign, Start: r.Start, End: r.End,591+ Status: r.Status, Detail: r.Detail, SizeBytes: r.Size, Duration: r.Duration(now).Seconds(),592+ }593+ if r.Status == Recording {594+ it.Until = r.Until595+ if j := m.jobs[r.ID]; j != nil {596+ it.SizeBytes, it.Detail = j.size(), cmp.Or(j.problem(), r.Detail)597+ }510598 }511- failed, err := m.tvh.Failed(ctx)512- if err != nil {513- return err599+ if p, ok := m.st.Progress[r.ID]; ok {600+ it.Position, it.Watched = p.Position, p.Watched514601 }515- live := make(map[string]bool)516- liveIDs := make(map[string]bool)517- for _, e := range slices.Concat(upcoming, finished, failed) {518- liveIDs[e.UUID] = true519- if _, key, ok := parseComment(e.Comment); ok {520- live[key] = true521- }602+ return it603+}604+605+// Duration is how long the recording runs: from its first data to its606+// last, or to now while it records.607+func (r Rec) Duration(now time.Time) time.Duration {608+ switch {609+ case r.Began.IsZero():610+ return 0611+ case r.Status == Recording:612+ return now.Sub(r.Began)613+ case r.Ended.After(r.Began):614+ return r.Ended.Sub(r.Began)522615 }616+ return 0617+}618+619+// Get returns a recording and the path of its file.620+func (m *Manager) Get(id string) (Rec, string, bool) {523621 m.mu.Lock()524622 defer m.mu.Unlock()525- for key := range m.st.Meta {526- if !live[key] {527- delete(m.st.Meta, key)623+ for _, r := range m.st.Recordings {624+ if r.ID == id {625+ return r, filepath.Join(m.dir, r.File), true528626 }529627 }628+ return Rec{}, "", false629+}630+631+// Prune drops watch progress of recordings that no longer exist.632+func (m *Manager) Prune(context.Context) error {633+ m.mu.Lock()634+ defer m.mu.Unlock()530635 for id := range m.st.Progress {531- if !liveIDs[id] {636+ if !slices.ContainsFunc(m.st.Recordings, func(r Rec) bool { return r.ID == id }) {532637 delete(m.st.Progress, id)533638 }534639 }535640 return m.save()536641 }537642 538-// fileDuration is how long the recorded file runs, padding included.539-func fileDuration(e tvh.Entry) float64 {540- if e.StopReal > e.StartReal && e.StartReal > 0 {541- return float64(e.StopReal - e.StartReal)542- }543- return float64(e.Stop - e.Start)544-}545-546643 // SaveProgress records how far a recording has been watched. Reaching the547644 // last two minutes (or 95%) marks it watched.548645 func (m *Manager) SaveProgress(id string, position, duration float64) error {@@ -603,12 +700,9 @@ func (m *Manager) SetPrefs(p Prefs) (Prefs, error) {
603700 604701 // Retain deletes recordings beyond a series' Keep count (oldest first) and605702 // watched recordings older than the library's limit.606-func (m *Manager) Retain(ctx context.Context) error {607- finished, err := m.tvh.Finished(ctx)608- if err != nil {609- return err610- }703+func (m *Manager) Retain(context.Context) error {611704 m.mu.Lock()705+ defer m.mu.Unlock()612706 keep := make(map[string]int)613707 for _, r := range m.st.Rules {614708 if r.Kind == Series && r.Keep > 0 {@@ -616,43 +710,28 @@ func (m *Manager) Retain(ctx context.Context) error {
616710 }617711 }618712 days := m.st.Prefs.DeleteWatchedAfterDays619- progress := maps.Clone(m.st.Progress)620- m.mu.Unlock()621-622- slices.SortFunc(finished, func(a, b tvh.Entry) int { return cmp.Compare(b.Start, a.Start) })713+ done := slices.DeleteFunc(slices.Clone(m.st.Recordings), func(r Rec) bool { return r.Status == Recording })714+ slices.SortFunc(done, func(a, b Rec) int { return b.Start.Compare(a.Start) })623715 var remove []string624716 seen := make(map[string]int)625- for _, e := range finished {626- if rule, _, ok := parseComment(e.Comment); ok && keep[rule] > 0 {627- seen[rule]++628- if seen[rule] > keep[rule] {629- remove = append(remove, e.UUID)717+ for _, r := range done {718+ if keep[r.RuleID] > 0 {719+ if seen[r.RuleID]++; seen[r.RuleID] > keep[r.RuleID] {720+ remove = append(remove, r.ID)630721 continue631722 }632723 }633- if p := progress[e.UUID]; days > 0 && p.Watched && time.Since(time.Unix(e.Stop, 0)) > time.Duration(days)*24*time.Hour {634- remove = append(remove, e.UUID)724+ if p := m.st.Progress[r.ID]; days > 0 && p.Watched && time.Since(r.End) > time.Duration(days)*24*time.Hour {725+ remove = append(remove, r.ID)635726 }636727 }637- for _, id := range remove {638- if err := m.tvh.Remove(ctx, id); err != nil {639- return err640- }728+ if len(remove) == 0 {729+ return nil641730 }642- return nil643-}644-645-const commentPrefix = "airwaves|"646-647-func comment(ruleID, key string) string { return commentPrefix + ruleID + "|" + key }648-649-func parseComment(c string) (rule, key string, ok bool) {650- rest, ok := strings.CutPrefix(c, commentPrefix)651- if !ok {652- return "", "", false731+ for _, id := range remove {732+ m.remove(id)653733 }654- rule, key, ok = strings.Cut(rest, "|")655- return rule, key, ok734+ return m.save()656735 }657736 658737 // AiringKey identifies an airing: "7.1@1791058239".@@ -2,252 +2,412 @@ package dvr
22 33 import (44 "context"5- "encoding/json"65 "fmt"7- "net/http"8- "net/http/httptest"6+ "os"97 "path/filepath"108 "strings"11- "sync"129 "testing"1310 "time"1411 1512 "airwaves/internal/guide"13+ "airwaves/internal/hdhr"14+ "airwaves/internal/hdhr/hdhrtest"1615 "airwaves/internal/lineup"17- "airwaves/internal/tvh"16+ "airwaves/internal/ts"17+ "airwaves/internal/ts/tstest"18+ "airwaves/internal/tuner"1819 )1920 20-// fakeTVH is just enough of Tvheadend's DVR API.21-type fakeTVH struct {22- mu sync.Mutex23- next int24- upcoming map[string]tvh.Entry25- finished map[string]tvh.Entry21+// Where the test's channels are: 9.1 and 9.2 on one RF channel, 4.1 and22+// 2.1 on others; 7.1 isn't in the tuner's lineup.23+var where = map[string][2]int64{24+ "9.1": {189_000_000, 3},25+ "9.2": {189_000_000, 4},26+ "4.1": {557_000_000, 1},27+ "2.1": {605_000_000, 6},2628 }2729 28-func (f *fakeTVH) ServeHTTP(w http.ResponseWriter, r *http.Request) {29- f.mu.Lock()30- defer f.mu.Unlock()31- _ = r.ParseForm()32- list := func(m map[string]tvh.Entry) {33- var out []tvh.Entry34- for _, e := range m {35- out = append(out, e)36- }37- _ = json.NewEncoder(w).Encode(map[string]any{"entries": out})38- }39- switch r.URL.Path {40- case "/api/serverinfo":41- fmt.Fprint(w, `{"sw_version":"test"}`)42- case "/api/dvr/entry/grid_upcoming":43- list(f.upcoming)44- case "/api/dvr/entry/grid_finished":45- list(f.finished)46- case "/api/dvr/entry/grid_failed":47- list(nil)48- case "/api/dvr/entry/create":49- var conf struct {50- Channel string `json:"channel"`51- Start int64 `json:"start"`52- Stop int64 `json:"stop"`53- Title map[string]string `json:"title"`54- Comment string `json:"comment"`55- }56- _ = json.Unmarshal([]byte(r.Form.Get("conf")), &conf)57- f.next++58- id := fmt.Sprintf("e%d", f.next)59- f.upcoming[id] = tvh.Entry{UUID: id, Title: conf.Title["eng"], Channel: conf.Channel, Start: conf.Start, Stop: conf.Stop, Comment: conf.Comment, SchedStatus: "scheduled"}60- fmt.Fprintf(w, `{"uuid":%q}`, id)61- case "/api/idnode/delete", "/api/dvr/entry/cancel":62- delete(f.upcoming, r.Form.Get("uuid"))63- fmt.Fprint(w, `{}`)64- case "/api/dvr/entry/remove":65- delete(f.finished, r.Form.Get("uuid"))66- fmt.Fprint(w, `{}`)67- default:68- http.NotFound(w, r)30+func mux(prog ...int) []byte {31+ var ps []tstest.Program32+ for i, n := range prog {33+ pid := 0x30 + i*0x1034+ ps = append(ps, tstest.Program{Number: n, PMTPID: pid, VideoPID: pid + 1, Audio: []tstest.Audio{{PID: pid + 4, Lang: "eng"}}})6935 }36+ return tstest.Mux{TSID: prog[0], Programs: ps}.Packets(400)7037 }7138 72-func (f *fakeTVH) titles() []string {73- f.mu.Lock()74- defer f.mu.Unlock()75- var out []string76- for _, e := range f.upcoming {77- out = append(out, fmt.Sprintf("%s@%d", e.Title, e.Start))39+func locate(_ context.Context, number string) (int64, int, error) {40+ if w, ok := where[number]; ok {41+ return w[0], int(w[1]), nil7842 }79- return out43+ return 0, 0, fmt.Errorf("channel %s isn't in the tuner's lineup", number)8044 }8145 82-type channels map[string]string83-84-func (c channels) ChannelUUID(_ context.Context, n string) (string, error) {85- if id, ok := c[n]; ok {86- return id, nil87- }88- return "", fmt.Errorf("channel %s not tuned", n)46+type env struct {47+ m *Manager48+ srv *hdhrtest.Server49+ g *guide.Guide50+ chans []lineup.Channel51+ base time.Time8952 }9053 91-func setup(t *testing.T) (*Manager, *fakeTVH, *guide.Guide, []lineup.Channel, time.Time) {54+func setup(t *testing.T) *env {9255 t.Helper()93- fake := &fakeTVH{upcoming: map[string]tvh.Entry{}, finished: map[string]tvh.Entry{}}94- srv := httptest.NewServer(fake)95- t.Cleanup(srv.Close)96- m, err := Open(filepath.Join(t.TempDir(), "dvr.json"), tvh.New(srv.URL, srv.Client()), channels{"9.1": "ch-kusa"})56+ muxes := map[int64][]byte{189_000_000: mux(3, 4), 557_000_000: mux(1), 605_000_000: mux(6)}57+ srv := hdhrtest.New(t, hdhrtest.Device{Mux: func(f int64) []byte { return muxes[f] }})58+ dev, err := hdhr.Open(context.Background(), srv.URL(), nil)9759 if err != nil {9860 t.Fatal(err)9961 }62+ tuners := tuner.NewManager()63+ tuners.Linger = 064+ tuners.SetDevice(dev, dev.Tuners)65+ dir := t.TempDir()66+ m, err := Open(filepath.Join(dir, "dvr.json"), filepath.Join(dir, "recordings"), tuners, locate)67+ if err != nil {68+ t.Fatal(err)69+ }70+ t.Cleanup(m.Close)10071 base := time.Now().Truncate(time.Hour).Add(2 * time.Hour)10172 ep := func(h int, id string, flags ...string) guide.Program {10273 s := base.Add(time.Duration(h) * time.Hour)10374 return guide.Program{Start: s, End: s.Add(time.Hour), Title: "Nature", SeriesID: "SH1", ProgramID: id, Flags: flags}10475 }10576 g := &guide.Guide{Programs: map[string][]guide.Program{106- "g9": {ep(0, "EP1", "New"), ep(1, "EP1"), ep(2, "EP2"), {Start: base.Add(3 * time.Hour), End: base.Add(4 * time.Hour), Title: "News", SeriesID: "SH2", ProgramID: "EP9"}},107- "g4": {{Start: base, End: base.Add(time.Hour), Title: "Golf", SeriesID: "SH3", ProgramID: "SP1"}},77+ "g9": {ep(0, "EP1", "New"), ep(1, "EP1"), ep(2, "EP2"), {Start: base.Add(3 * time.Hour), End: base.Add(4 * time.Hour), Title: "News", SeriesID: "SH2", ProgramID: "EP9"}},78+ "g92": {{Start: base, End: base.Add(time.Hour), Title: "Western", SeriesID: "SH4", ProgramID: "MV1"}},79+ "g4": {{Start: base, End: base.Add(time.Hour), Title: "Golf", SeriesID: "SH3", ProgramID: "SP1", Genres: []string{"sports"}}},80+ "g2": {{Start: base, End: base.Add(time.Hour), Title: "Cartoons", SeriesID: "SH5", ProgramID: "EP5"}},81+ "g7": {{Start: base, End: base.Add(time.Hour), Title: "Quiz", SeriesID: "SH6", ProgramID: "EP6"}},10882 }}109- chans := []lineup.Channel{{Number: "9.1", CallSign: "KUSADT", GuideID: "g9"}, {Number: "4.1", CallSign: "KCNCDT", GuideID: "g4"}}110- return m, fake, g, chans, base83+ chans := []lineup.Channel{84+ {Number: "9.1", CallSign: "KUSADT", GuideID: "g9"}, {Number: "9.2", CallSign: "KUSADT2", GuideID: "g92"},85+ {Number: "4.1", CallSign: "KCNCDT", GuideID: "g4"}, {Number: "2.1", CallSign: "KWGNDT", GuideID: "g2"},86+ {Number: "7.1", CallSign: "KMGHDT", GuideID: "g7"},87+ }88+ return &env{m: m, srv: srv, g: g, chans: chans, base: base}11189 }11290 113-func TestSeriesSkipsRepeatsAndOtherShows(t *testing.T) {114- m, fake, g, chans, base := setup(t)115- ctx := context.Background()116- if _, err := m.Add(ctx, Request{Kind: Series, Channel: "9.1", CallSign: "KUSADT", Start: base}, g, chans); err != nil {117- t.Fatal(err)91+func (e *env) add(t *testing.T, kind Kind, channel string, start time.Time) *Rule {92+ t.Helper()93+ call := ""94+ for _, c := range e.chans {95+ if c.Number == channel {96+ call = c.CallSign97+ }11898 }119- got := strings.Join(fake.titles(), " ")120- // EP1 airs twice; only the first airing is recorded. News is not part of the series.121- if len(fake.titles()) != 2 || strings.Contains(got, "News") {122- t.Fatalf("scheduled %v, want the first EP1 airing and EP2", fake.titles())99+ r, err := e.m.Add(context.Background(), Request{Kind: kind, Channel: channel, CallSign: call, Start: start}, e.g, e.chans)100+ if err != nil {101+ t.Fatal(err)123102 }103+ return r104+}124105 125- // Reconciling again is a no-op.126- if err := m.Reconcile(ctx, g, chans); err != nil {106+func (e *env) upcoming(t *testing.T) []Item {107+ t.Helper()108+ st, err := e.m.State(context.Background(), e.g, e.chans)109+ if err != nil {127110 t.Fatal(err)128111 }129- if len(fake.titles()) != 2 {130- t.Fatalf("reconcile duplicated entries: %v", fake.titles())112+ return st.Upcoming113+}114+115+func titles(items []Item) string {116+ var out []string117+ for _, it := range items {118+ out = append(out, fmt.Sprintf("%s@%s:%s", it.Title, it.Start.Sub(it.Start.Truncate(24*time.Hour)), it.Status))119+ }120+ return strings.Join(out, " ")121+}122+123+func TestSeriesSkipsRepeatsAndOtherShows(t *testing.T) {124+ e := setup(t)125+ e.add(t, Series, "9.1", e.base)126+ up := e.upcoming(t)127+ // EP1 airs twice; only the first airing is recorded. News is not part of the series.128+ if len(up) != 2 || up[0].Start != e.base || up[1].Start != e.base.Add(2*time.Hour) || strings.Contains(titles(up), "News") {129+ t.Fatalf("upcoming %s, want the first EP1 airing and EP2", titles(up))131130 }132131 }133132 134133 func TestNewOnly(t *testing.T) {135- m, fake, g, chans, base := setup(t)136- if _, err := m.Add(context.Background(), Request{Kind: Series, Channel: "9.1", CallSign: "KUSADT", Start: base, NewOnly: true}, g, chans); err != nil {134+ e := setup(t)135+ if _, err := e.m.Add(context.Background(), Request{Kind: Series, Channel: "9.1", CallSign: "KUSADT", Start: e.base, NewOnly: true}, e.g, e.chans); err != nil {137136 t.Fatal(err)138137 }139- if n := len(fake.titles()); n != 1 {140- t.Fatalf("scheduled %d airings, want only the new one", n)138+ if up := e.upcoming(t); len(up) != 1 {139+ t.Fatalf("upcoming %s, want only the new one", titles(up))141140 }142141 }143142 143+// TestSkipAndDeleteRule: an airing removed from a series stays removed144+// (its episode is recorded at a repeat), and a deleted rule records145+// nothing more.144146 func TestSkipAndDeleteRule(t *testing.T) {145- m, fake, g, chans, base := setup(t)146- ctx := context.Background()147- rule, err := m.Add(ctx, Request{Kind: Series, Channel: "9.1", CallSign: "KUSADT", Start: base}, g, chans)148- if err != nil {147+ e := setup(t)148+ rule := e.add(t, Series, "9.1", e.base)149+ up := e.upcoming(t)150+ if up[0].ID != up[0].Key || up[0].Status != Scheduled {151+ t.Fatalf("an airing to come is %+v, want its key as its ID", up[0])152+ }153+ if err := e.m.DeleteEntry(context.Background(), up[0].ID); err != nil {149154 t.Fatal(err)150155 }151- var first string152- for id, e := range fake.upcoming {153- if e.Start == base.Unix() {154- first = id155- }156+ // The episode is recorded at its repeat instead.157+ if up := e.upcoming(t); len(up) != 2 || !up[0].Start.Equal(e.base.Add(time.Hour)) {158+ t.Fatalf("after skipping the first: %s", titles(up))156159 }157- if err := m.DeleteEntry(ctx, first); err != nil {160+ if err := e.m.DeleteRule(context.Background(), rule.ID); err != nil {158161 t.Fatal(err)159162 }160- if err := m.Reconcile(ctx, g, chans); err != nil {161- t.Fatal(err)163+ if up := e.upcoming(t); len(up) != 0 {164+ t.Fatalf("deleting the rule left %s", titles(up))162165 }163- for _, e := range fake.upcoming {164- if e.Start == base.Unix() {165- t.Fatal("skipped airing was scheduled again")166+}167+168+func TestUntunedChannelIsUnavailable(t *testing.T) {169+ e := setup(t)170+ e.add(t, Once, "7.1", e.base)171+ up := e.upcoming(t)172+ if len(up) != 1 || up[0].Status != Unavailable || up[0].Title != "Quiz" || !strings.Contains(up[0].Detail, "lineup") {173+ t.Fatalf("upcoming = %+v, want Quiz marked unavailable", up)174+ }175+}176+177+// TestConflicts: two tuners record two RF channels at once; a third RF178+// channel then conflicts, while another program of an RF channel being179+// recorded shares its tuner.180+func TestConflicts(t *testing.T) {181+ e := setup(t)182+ e.add(t, Once, "9.1", e.base)183+ e.add(t, Once, "4.1", e.base)184+ e.add(t, Once, "9.2", e.base)185+ e.add(t, Once, "2.1", e.base)186+ got := map[string]Item{}187+ for _, it := range e.upcoming(t) {188+ got[it.Title] = it189+ }190+ for title, want := range map[string]string{"Nature": Scheduled, "Golf": Scheduled, "Western": Scheduled, "Cartoons": Conflict} {191+ if got[title].Status != want {192+ t.Errorf("%s is %s (%s), want %s", title, got[title].Status, got[title].Detail, want)166193 }167194 }168- if err := m.DeleteRule(ctx, rule.ID); err != nil {195+ if d := got["Cartoons"].Detail; !strings.Contains(d, "Golf") || !strings.Contains(d, "Nature") {196+ t.Errorf("conflict detail %q doesn't say what records then", d)197+ }198+}199+200+// recordNow records an airing on now: 9.2's, which began 10 minutes ago.201+func recordNow(t *testing.T, e *env) Item {202+ t.Helper()203+ now := time.Now()204+ e.g.Programs["g92"] = []guide.Program{{Start: now.Add(-10 * time.Minute), End: now.Add(50 * time.Minute), Title: "Western", ProgramID: "MV1"}}205+ e.add(t, Once, "9.2", now.Add(-10*time.Minute))206+ var it Item207+ for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(20 * time.Millisecond) {208+ up := e.upcoming(t)209+ if len(up) == 1 && up[0].Status == Recording && up[0].SizeBytes > 0 {210+ it = up[0]211+ break212+ }213+ if time.Now().After(deadline) {214+ t.Fatalf("not recording: %s", titles(up))215+ }216+ }217+ return it218+}219+220+// TestRecordStopKeeps: an airing on now records at once, into its own221+// file holding its program alone; stopping it keeps what it recorded,222+// and says what's missing.223+func TestRecordStopKeeps(t *testing.T) {224+ e := setup(t)225+ it := recordNow(t, e)226+ if until := it.Until; until.Before(time.Now().Add(50 * time.Minute)) {227+ t.Fatalf("records until %s, want the end with padding", until)228+ }229+ if err := e.m.Stop(it.ID); err != nil {169230 t.Fatal(err)170231 }171- if n := len(fake.titles()); n != 0 {172- t.Fatalf("deleting the rule left %d scheduled", n)232+ st, _ := e.m.State(context.Background(), e.g, e.chans)233+ if len(st.Recorded) != 1 || len(st.Upcoming) != 0 {234+ t.Fatalf("after stopping: recorded %s, upcoming %s", titles(st.Recorded), titles(st.Upcoming))235+ }236+ done := st.Recorded[0]237+ if done.Status != Completed || !strings.Contains(done.Detail, "Began 10 minutes into the program") || !strings.Contains(done.Detail, "Stopped 50 minutes before the end") {238+ t.Fatalf("recorded = %+v", done)239+ }240+ r, path, ok := e.m.Get(done.ID)241+ if !ok || !strings.HasPrefix(r.File, "Western/") {242+ t.Fatalf("file %q", r.File)243+ }244+ raw, err := os.ReadFile(path)245+ if err != nil || int64(len(raw)) != done.SizeBytes {246+ t.Fatalf("file has %d bytes (%v), the recording says %d", len(raw), err, done.SizeBytes)247+ }248+ d := ts.NewDemux()249+ var split ts.Splitter250+ split.Write(raw, d.Packet)251+ if ps := d.Programs(); len(ps) != 1 || ps[0].Number != 4 {252+ t.Fatalf("the file carries %+v, want program 4 alone", ps)173253 }174254 }175255 176-func TestUntunedChannelIsUnavailable(t *testing.T) {177- m, fake, g, chans, base := setup(t)178- ctx := context.Background()179- if _, err := m.Add(ctx, Request{Kind: Once, Channel: "4.1", CallSign: "KCNCDT", Start: base}, g, chans); err != nil {256+// TestExtend moves a recording's end while it records.257+func TestExtend(t *testing.T) {258+ e := setup(t)259+ it := recordNow(t, e)260+ until, err := e.m.Extend(it.ID, 30*time.Minute)261+ if err != nil || !until.Equal(it.Until.Add(30*time.Minute)) {262+ t.Fatalf("extended to %s (%v), want %s", until, err, it.Until.Add(30*time.Minute))263+ }264+ if up := e.upcoming(t); !up[0].Until.Equal(until) {265+ t.Fatalf("shown until %s", up[0].Until)266+ }267+}268+269+// TestDeleteWhileRecording discards the recording, and never records270+// that airing again.271+func TestDeleteWhileRecording(t *testing.T) {272+ e := setup(t)273+ it := recordNow(t, e)274+ _, path, _ := e.m.Get(it.ID)275+ if err := e.m.DeleteEntry(context.Background(), it.ID); err != nil {276+ t.Fatal(err)277+ }278+ if _, err := os.Stat(path); !os.IsNotExist(err) {279+ t.Fatalf("the file is still there: %v", err)280+ }281+ e.m.Start(e.g, e.chans, time.Now())282+ st, _ := e.m.State(context.Background(), e.g, e.chans)283+ if len(st.Recorded)+len(st.Upcoming) != 0 {284+ t.Fatalf("after deleting: recorded %s, upcoming %s", titles(st.Recorded), titles(st.Upcoming))285+ }286+}287+288+// TestResume: a recording the server was making when it stopped carries289+// on after a restart if its time isn't up, and is finished otherwise.290+func TestResume(t *testing.T) {291+ e := setup(t)292+ it := recordNow(t, e)293+ e.m.Close()294+ path := e.m.path295+ r, file, _ := e.m.Get(it.ID)296+ if r.Status != Recording {297+ t.Fatalf("after a shutdown the recording is %s", r.Status)298+ }299+ size := r.Size300+301+ again, err := Open(path, e.m.dir, e.m.tuners, locate)302+ if err != nil {180303 t.Fatal(err)181304 }182- if n := len(fake.titles()); n != 0 {183- t.Fatalf("scheduled %d on an untuned channel", n)305+ t.Cleanup(again.Close)306+ again.resume(time.Now())307+ for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(20 * time.Millisecond) {308+ if fi, err := os.Stat(file); err == nil && fi.Size() > size {309+ break310+ }311+ if time.Now().After(deadline) {312+ t.Fatal("the recording didn't carry on")313+ }184314 }185- st, err := m.State(ctx, g, chans)315+ again.Close()316+317+ later, err := Open(path, e.m.dir, e.m.tuners, locate)186318 if err != nil {187319 t.Fatal(err)188320 }189- if len(st.Upcoming) != 1 || st.Upcoming[0].Status != "unavailable" || st.Upcoming[0].Title != "Golf" {190- t.Fatalf("upcoming = %+v, want Golf marked unavailable", st.Upcoming)321+ later.resume(time.Now().Add(2 * time.Hour))322+ if r, _, _ := later.Get(it.ID); r.Status != Completed || !strings.Contains(r.Detail, "the server stopped") {323+ t.Fatalf("a recording whose time ran out while the server was down is %s: %q", r.Status, r.Detail)191324 }192325 }193326 194327 func TestPersistence(t *testing.T) {195- m, _, g, chans, base := setup(t)196- if _, err := m.Add(context.Background(), Request{Kind: Series, Channel: "9.1", CallSign: "KUSADT", Start: base}, g, chans); err != nil {197- t.Fatal(err)198- }199- again, err := Open(m.path, m.tvh, m.channels)328+ e := setup(t)329+ e.add(t, Series, "9.1", e.base)330+ again, err := Open(e.m.path, e.m.dir, e.m.tuners, locate)200331 if err != nil {201332 t.Fatal(err)202333 }203- if len(again.st.Rules) != 1 || again.st.Rules[0].SeriesID != "SH1" || len(again.st.Meta) != 2 {204- t.Fatalf("reloaded rules %+v meta %d", again.st.Rules, len(again.st.Meta))334+ if len(again.st.Rules) != 1 || again.st.Rules[0].SeriesID != "SH1" {335+ t.Fatalf("reloaded rules %+v", again.st.Rules)205336 }206337 }207338 208-func TestRetainKeepsNewestPerSeries(t *testing.T) {209- m, fake, g, chans, base := setup(t)210- ctx := context.Background()211- rule, err := m.Add(ctx, Request{Kind: Series, Channel: "9.1", CallSign: "KUSADT", Start: base}, g, chans)339+// TestCorruptFile: a state file that can't be read is set aside, and the340+// DVR starts empty instead of not at all.341+func TestCorruptFile(t *testing.T) {342+ dir := t.TempDir()343+ path := filepath.Join(dir, "dvr.json")344+ if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {345+ t.Fatal(err)346+ }347+ m, err := Open(path, dir, tuner.NewManager(), locate)212348 if err != nil {213349 t.Fatal(err)214350 }351+ defer m.Close()352+ if len(m.st.Rules) != 0 {353+ t.Fatal("rules from a corrupt file")354+ }355+ if raw, err := os.ReadFile(path + ".bad"); err != nil || string(raw) != "{not json" {356+ t.Fatalf("the corrupt file wasn't kept aside: %v", err)357+ }358+}359+360+func TestRetainKeepsNewestPerSeries(t *testing.T) {361+ e := setup(t)362+ rule := e.add(t, Series, "9.1", e.base)215363 old := time.Now().Add(-30 * 24 * time.Hour)216- fake.mu.Lock()364+ e.m.mu.Lock()217365 for i, id := range []string{"f1", "f2", "f3"} {218- s := old.Add(time.Duration(i) * 24 * time.Hour).Unix()219- fake.finished[id] = tvh.Entry{UUID: id, Title: "Nature", Start: s, Stop: s + 3600, SchedStatus: "completed", Comment: comment(rule.ID, fmt.Sprintf("9.1@%d", s))}366+ s := old.Add(time.Duration(i) * 24 * time.Hour)367+ e.m.st.Recordings = append(e.m.st.Recordings, Rec{ID: id, RuleID: rule.ID, Title: "Nature", Start: s, End: s.Add(time.Hour), Status: Completed, File: "Nature/" + id + ".ts"})368+ }369+ e.m.st.Recordings = append(e.m.st.Recordings, Rec{ID: "other", Title: "Movie", Start: old, End: old.Add(2 * time.Hour), Status: Completed, File: "Movie/other.ts"})370+ e.m.mu.Unlock()371+ for _, f := range []string{"Nature/f1.ts", "Nature/f2.ts", "Nature/f3.ts", "Movie/other.ts"} {372+ p := filepath.Join(e.m.dir, f)373+ _ = os.MkdirAll(filepath.Dir(p), 0o755)374+ _ = os.WriteFile(p, []byte("x"), 0o644)220375 }221- fake.finished["other"] = tvh.Entry{UUID: "other", Title: "Movie", Start: old.Unix(), Stop: old.Unix() + 7200, SchedStatus: "completed"}222- fake.mu.Unlock()223376 224- if _, err := m.UpdateRule(rule.ID, RuleUpdate{Keep: 2}); err != nil {377+ if _, err := e.m.UpdateRule(rule.ID, RuleUpdate{Keep: 2}); err != nil {225378 t.Fatal(err)226379 }227- if err := m.Retain(ctx); err != nil {380+ if err := e.m.Retain(context.Background()); err != nil {228381 t.Fatal(err)229382 }230- if _, ok := fake.finished["f1"]; ok || len(fake.finished) != 3 {231- t.Fatalf("after keep=2: %v, want f1 (oldest) removed and the movie kept", keys(fake.finished))383+ if _, _, ok := e.m.Get("f1"); ok || len(e.m.st.Recordings) != 3 {384+ t.Fatalf("after keep=2: %d left, want f1 (oldest) removed and the movie kept", len(e.m.st.Recordings))385+ }386+ if _, err := os.Stat(filepath.Join(e.m.dir, "Nature/f1.ts")); !os.IsNotExist(err) {387+ t.Fatal("f1's file is still there")232388 }233389 234390 // Watched recordings go after the configured number of days.235- if err := m.MarkWatched("other", true); err != nil {391+ if err := e.m.MarkWatched("other", true); err != nil {236392 t.Fatal(err)237393 }238- if _, err := m.SetPrefs(Prefs{DeleteWatchedAfterDays: 7}); err != nil {394+ if _, err := e.m.SetPrefs(Prefs{DeleteWatchedAfterDays: 7}); err != nil {239395 t.Fatal(err)240396 }241- if err := m.Retain(ctx); err != nil {397+ if err := e.m.Retain(context.Background()); err != nil {242398 t.Fatal(err)243399 }244- if _, ok := fake.finished["other"]; ok {400+ if _, _, ok := e.m.Get("other"); ok {245401 t.Fatal("watched recording older than 7 days was kept")246402 }403+ if _, err := os.Stat(filepath.Join(e.m.dir, "Movie")); !os.IsNotExist(err) {404+ t.Fatal("the show's emptied folder is still there")405+ }247406 }248407 249408 func TestProgressMarksWatchedNearTheEnd(t *testing.T) {250- m, _, _, _, _ := setup(t)409+ e := setup(t)410+ m := e.m251411 if err := m.SaveProgress("e1", 600, 3600); err != nil {252412 t.Fatal(err)253413 }@@ -268,10 +428,15 @@ func TestProgressMarksWatchedNearTheEnd(t *testing.T) {
268428 }269429 }270430 271-func keys(m map[string]tvh.Entry) []string {272- var out []string273- for k := range m {274- out = append(out, k)431+func TestFileNames(t *testing.T) {432+ for in, want := range map[string]string{433+ "AC/DC: Live": "AC-DC: Live",434+ "..hidden": "hidden",435+ "tab\there": "tabhere",436+ strings.Repeat("é", 60): strings.Repeat("é", 40),437+ } {438+ if got := clean(in); got != want {439+ t.Errorf("clean(%q) = %q, want %q", in, got, want)440+ }275441 }276- return out277442 }@@ -0,0 +1,460 @@
1+package dvr2+3+import (4+ "cmp"5+ "context"6+ "errors"7+ "fmt"8+ "log"9+ "os"10+ "path/filepath"11+ "slices"12+ "strings"13+ "sync"14+ "time"15+ "unicode/utf8"16+17+ "airwaves/internal/guide"18+ "airwaves/internal/lineup"19+ "airwaves/internal/tuner"20+)21+22+// Why a recording ended before its time.23+var (24+ errStopped = errors.New("stopped")25+ errDiscard = errors.New("discarded")26+ errShutdown = errors.New("the server stopped")27+)28+29+// retry is how long a recording waits after losing its tuner or signal30+// before trying again.31+var retry = 5 * time.Second32+33+// job is a recording in progress.34+type job struct {35+ id string36+ ctx context.Context37+ cancel context.CancelCauseFunc38+ extend chan struct{}39+ done chan struct{}40+41+ mu sync.Mutex42+ written int6443+ began time.Time // first data44+ last time.Time // latest data45+ // covered is how much time the stretches so far recorded, and cur the46+ // stretch now.47+ covered, cur time.Duration48+ trouble string // the latest problem49+}50+51+func (j *job) size() int64 {52+ j.mu.Lock()53+ defer j.mu.Unlock()54+ return j.written55+}56+57+func (j *job) problem() string {58+ j.mu.Lock()59+ defer j.mu.Unlock()60+ return j.trouble61+}62+63+func (j *job) note(err error) {64+ j.mu.Lock()65+ j.trouble = err.Error()66+ j.mu.Unlock()67+}68+69+// Run starts recordings as their airings begin, until ctx ends. It first70+// picks up recordings the server was making when it last stopped.71+func (m *Manager) Run(ctx context.Context, listings Listings) {72+ m.resume(time.Now())73+ tick := time.NewTicker(10 * time.Second)74+ defer tick.Stop()75+ for {76+ if g, chans, err := listings(ctx); err == nil {77+ m.Start(g, chans, time.Now())78+ }79+ select {80+ case <-ctx.Done():81+ return82+ case <-tick.C:83+ }84+ }85+}86+87+// Close stops the recordings in progress, keeping them to pick up again.88+func (m *Manager) Close() {89+ m.cancel(errShutdown)90+ m.mu.Lock()91+ var jobs []*job92+ for _, j := range m.jobs {93+ jobs = append(jobs, j)94+ }95+ m.mu.Unlock()96+ for _, j := range jobs {97+ <-j.done98+ }99+}100+101+// Start begins recording every airing the rules want that's on at now.102+func (m *Manager) Start(g *guide.Guide, chans []lineup.Channel, now time.Time) {103+ m.mu.Lock()104+ defer m.mu.Unlock()105+ if m.ctx.Err() != nil {106+ return107+ }108+ n := len(m.st.Rules)109+ // Once rules whose airing is over are done.110+ m.st.Rules = slices.DeleteFunc(m.st.Rules, func(r Rule) bool { return r.Kind == Once && !r.End.Add(lateSports).After(now) })111+ changed := len(m.st.Rules) != n112+ for _, a := range m.desired(g, chans, now, m.recorded()) {113+ from, until := a.window()114+ if now.Before(from) || m.has(a.key) {115+ continue116+ }117+ r := Rec{118+ ID: newID(), Key: a.key, RuleID: a.rule.ID, Title: a.p.Title, Subtitle: a.p.EpisodeTitle,119+ Description: a.p.Description, Image: cmp.Or(a.p.Image, a.rule.Image), ProgramID: a.p.ProgramID,120+ Channel: a.ch.Number, CallSign: a.ch.CallSign, Start: a.p.Start, End: a.p.End, From: from, Until: until,121+ Status: Recording,122+ }123+ r.File = m.fileName(r)124+ m.st.Recordings = append(m.st.Recordings, r)125+ m.run(r)126+ changed = true127+ log.Printf("recording %s on %s until %s", r.Title, r.Channel, until.Format(time.Kitchen))128+ }129+ if changed {130+ m.saveLogged()131+ }132+}133+134+// resume picks up the recordings that were in progress when the server135+// stopped: those whose time isn't up carry on, appending to their files;136+// the rest are finished as they are.137+func (m *Manager) resume(now time.Time) {138+ m.mu.Lock()139+ defer m.mu.Unlock()140+ changed := false141+ for i := range m.st.Recordings {142+ r := &m.st.Recordings[i]143+ if r.Status != Recording || m.jobs[r.ID] != nil {144+ continue145+ }146+ changed = true147+ fi, err := os.Stat(filepath.Join(m.dir, r.File))148+ if err == nil {149+ r.Size = fi.Size()150+ }151+ if now.Before(r.Until) {152+ log.Printf("recording %s again, after the server stopped", r.Title)153+ m.run(*r)154+ continue155+ }156+ if r.Size == 0 {157+ r.Status, r.Detail = Failed, "The server stopped before it recorded anything"158+ continue159+ }160+ r.Status, r.Ended = Completed, fi.ModTime()161+ r.Detail = describe(*r, "the server stopped", false)162+ }163+ if changed {164+ m.saveLogged()165+ }166+}167+168+// run starts recording r. Called with m.mu held.169+func (m *Manager) run(r Rec) {170+ ctx, cancel := context.WithCancelCause(m.ctx)171+ j := &job{id: r.ID, ctx: ctx, cancel: cancel, extend: make(chan struct{}, 1), done: make(chan struct{}),172+ written: r.Size, began: r.Began, last: r.Ended, covered: r.Covered}173+ m.jobs[r.ID] = j174+ go m.record(j, r)175+}176+177+// until is when the recording id stops.178+func (m *Manager) until(id string) time.Time {179+ m.mu.Lock()180+ defer m.mu.Unlock()181+ for _, r := range m.st.Recordings {182+ if r.ID == id {183+ return r.Until184+ }185+ }186+ return time.Time{}187+}188+189+// record records r until its time is up or it's stopped. Losing the tuner190+// or the signal makes a gap, not an end: it tries again until its time is191+// up.192+func (m *Manager) record(j *job, r Rec) {193+ defer close(j.done)194+ path := filepath.Join(m.dir, r.File)195+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {196+ j.note(err)197+ } else {198+ for j.ctx.Err() == nil {199+ left := time.Until(m.until(j.id))200+ if left <= 0 {201+ break202+ }203+ if err := m.stretch(j, r, path); err != nil && j.ctx.Err() == nil {204+ j.note(err)205+ log.Printf("recording %s: %v", r.Title, err)206+ select {207+ case <-j.ctx.Done():208+ case <-j.extend:209+ case <-time.After(min(retry, left)):210+ }211+ }212+ }213+ }214+ m.finish(j)215+}216+217+// stretch records r on one tuner for as long as it can: to its end, or218+// until the tuner or the signal is lost. Extending the recording moves219+// the end while it records.220+func (m *Manager) stretch(j *job, r Rec, path string) error {221+ ctx := j.ctx222+ freq, program, err := m.locate(ctx, r.Channel)223+ if err != nil {224+ return err225+ }226+ sub, err := m.tuners.Open(ctx, tuner.Request{FrequencyHz: freq, Program: program, Priority: tuner.Recording, Who: r.Title})227+ if err != nil {228+ return err229+ }230+ defer sub.Close()231+ wait, cancel := context.WithTimeout(ctx, 15*time.Second)232+ _, err = sub.Wait(wait)233+ cancel()234+ if errors.Is(err, context.DeadlineExceeded) {235+ return fmt.Errorf("no signal on %s", r.Channel)236+ }237+ if err != nil {238+ return err239+ }240+ f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)241+ if err != nil {242+ return err243+ }244+ w := &stretchWriter{f: f, j: j, began: func(at time.Time) { m.began(j.id, at) }}245+ defer func() {246+ w.end()247+ f.Close()248+ }()249+ for {250+ until := m.until(j.id)251+ part, stop := context.WithDeadline(ctx, until)252+ go func() {253+ select {254+ case <-j.extend:255+ stop()256+ case <-part.Done():257+ }258+ }()259+ err := sub.Copy(part, w)260+ stop()261+ switch {262+ case ctx.Err() != nil:263+ return nil // stopped264+ case !time.Now().Before(m.until(j.id)):265+ return nil // done266+ case errors.Is(err, context.Canceled) && sub.Err() == nil:267+ continue // extended268+ }269+ if err == nil {270+ err = errors.New("the tuner stopped")271+ }272+ return err273+ }274+}275+276+// began notes when a recording's first data arrived.277+func (m *Manager) began(id string, at time.Time) {278+ m.mu.Lock()279+ defer m.mu.Unlock()280+ for i := range m.st.Recordings {281+ if r := &m.st.Recordings[i]; r.ID == id && r.Began.IsZero() {282+ r.Began = at283+ m.saveLogged()284+ }285+ }286+}287+288+// stretchWriter writes a recording's file and counts what it wrote.289+type stretchWriter struct {290+ f *os.File291+ j *job292+ first time.Time293+ began func(time.Time)294+}295+296+func (w *stretchWriter) Write(p []byte) (int, error) {297+ n, err := w.f.Write(p)298+ now := time.Now()299+ w.j.mu.Lock()300+ first := w.j.began.IsZero()301+ if first {302+ w.j.began = now303+ }304+ if w.first.IsZero() {305+ w.first = now306+ }307+ w.j.written += int64(n)308+ w.j.last, w.j.cur = now, now.Sub(w.first)309+ w.j.mu.Unlock()310+ if first {311+ w.began(now)312+ }313+ return n, err314+}315+316+// end adds the stretch to what's covered.317+func (w *stretchWriter) end() {318+ w.j.mu.Lock()319+ w.j.covered += w.j.cur320+ w.j.cur = 0321+ w.j.mu.Unlock()322+}323+324+// finish records how a recording went, once it has ended.325+func (m *Manager) finish(j *job) {326+ cause := context.Cause(j.ctx)327+ m.mu.Lock()328+ defer m.mu.Unlock()329+ delete(m.jobs, j.id)330+ i := slices.IndexFunc(m.st.Recordings, func(r Rec) bool { return r.ID == j.id })331+ if i < 0 || errors.Is(cause, errDiscard) {332+ return // DeleteEntry removes it333+ }334+ r := &m.st.Recordings[i]335+ j.mu.Lock()336+ r.Size, r.Began, r.Ended, r.Covered = j.written, j.began, j.last, j.covered+j.cur337+ trouble := j.trouble338+ j.mu.Unlock()339+ switch {340+ case errors.Is(cause, errShutdown):341+ // Still recording, as far as the next start is concerned.342+ case r.Size == 0:343+ r.Status, r.Detail = Failed, "Nothing was recorded"344+ if trouble != "" {345+ r.Detail += ": " + trouble346+ }347+ log.Printf("recording %s failed: %s", r.Title, r.Detail)348+ default:349+ r.Status = Completed350+ r.Detail = describe(*r, trouble, errors.Is(cause, errStopped))351+ log.Printf("recorded %s (%d MB)%s", r.Title, r.Size>>20, cmp.Or(": "+r.Detail, ""))352+ }353+ m.saveLogged()354+}355+356+// describe says what a finished recording is missing, if anything: a late357+// start, an early stop, gaps. trouble is the latest problem it had.358+func describe(r Rec, trouble string, stopped bool) string {359+ mins := func(d time.Duration) string {360+ n := int(d.Round(time.Minute).Minutes())361+ if n == 1 {362+ return "1 minute"363+ }364+ return fmt.Sprintf("%d minutes", n)365+ }366+ var out []string367+ if d := r.Began.Sub(r.Start); !r.Began.IsZero() && d >= time.Minute {368+ out = append(out, "Began "+mins(d)+" into the program")369+ }370+ if d := r.End.Sub(r.Ended); !r.Ended.IsZero() && d >= time.Minute {371+ if stopped {372+ out = append(out, "Stopped "+mins(d)+" before the end")373+ } else {374+ out = append(out, "Ends "+mins(d)+" before the program did")375+ }376+ }377+ if gap := r.Ended.Sub(r.Began) - r.Covered; r.Covered > 0 && gap >= time.Minute {378+ out = append(out, "About "+mins(gap)+" are missing in between")379+ }380+ if len(out) > 0 && trouble != "" && !stopped {381+ out[len(out)-1] += ": " + trouble382+ }383+ return strings.Join(out, ". ")384+}385+386+// Stop ends a recording in progress early, keeping what it recorded.387+func (m *Manager) Stop(id string) error {388+ m.mu.Lock()389+ j := m.jobs[id]390+ m.mu.Unlock()391+ if j == nil {392+ return fmt.Errorf("%s isn't recording", id)393+ }394+ j.cancel(errStopped)395+ <-j.done396+ return nil397+}398+399+// Extend makes a recording in progress run d longer.400+func (m *Manager) Extend(id string, d time.Duration) (time.Time, error) {401+ if d <= 0 || d > 6*time.Hour {402+ return time.Time{}, fmt.Errorf("can't extend a recording by %s", d)403+ }404+ m.mu.Lock()405+ defer m.mu.Unlock()406+ j := m.jobs[id]407+ i := slices.IndexFunc(m.st.Recordings, func(r Rec) bool { return r.ID == id })408+ if j == nil || i < 0 {409+ return time.Time{}, fmt.Errorf("%s isn't recording", id)410+ }411+ r := &m.st.Recordings[i]412+ r.Until = r.Until.Add(d)413+ select {414+ case j.extend <- struct{}{}:415+ default:416+ }417+ return r.Until, m.save()418+}419+420+// fileName picks r's file in the recordings folder: a folder per show,421+// named by date and episode, and never one already there. Called with422+// m.mu held.423+func (m *Manager) fileName(r Rec) string {424+ name := r.Start.Local().Format("2006-01-02 15.04")425+ if s := clean(r.Subtitle); s != "" {426+ name += " " + s427+ }428+ base := filepath.Join(cmp.Or(clean(r.Title), "Recording"), name)429+ for n := 1; ; n++ {430+ f := base + ".ts"431+ if n > 1 {432+ f = fmt.Sprintf("%s (%d).ts", base, n)433+ }434+ _, err := os.Stat(filepath.Join(m.dir, f))435+ taken := slices.ContainsFunc(m.st.Recordings, func(x Rec) bool { return x.File == f })436+ if errors.Is(err, os.ErrNotExist) && !taken {437+ return f438+ }439+ }440+}441+442+// clean makes s fit for a file name: no separators or control characters,443+// not hidden, and not too long.444+func clean(s string) string {445+ s = strings.Map(func(r rune) rune {446+ switch {447+ case r == '/' || r == '\\':448+ return '-'449+ case r < 0x20 || r == 0x7f:450+ return -1451+ }452+ return r453+ }, s)454+ s = strings.TrimLeft(strings.TrimSpace(s), ".")455+ for len(s) > 80 {456+ _, n := utf8.DecodeLastRuneInString(s)457+ s = s[:len(s)-n]458+ }459+ return strings.TrimSpace(s)460+}@@ -1,7 +1,7 @@
1-// Package lineuptest holds a report and listings for downtown Denver, to2-// match against the tuner lineup in internal/tvh/tvhtest: the stations on3-// Lookout Mountain whose RF channels that tuner's scan found, and some it4-// didn't (KMGH on VHF RF 7, KUSA's main channel, KWGN's ATSC 3.0 signal).1+// Package lineuptest holds a report, listings and an HDHomeRun's lineup2+// for downtown Denver: the stations on Lookout Mountain whose RF channels3+// the tuner's scan found, and some it didn't (KMGH on VHF RF 7, KUSA's4+// main channel, KWGN's ATSC 3.0 signal).55 package lineuptest66 77 import (@@ -0,0 +1,80 @@
1+package lineuptest2+3+import (4+ "airwaves/internal/hdhr"5+ "airwaves/internal/ts/tstest"6+)7+8+// Lineup is what an HDHomeRun's scan found around downtown Denver: 389+// channels on 8 RF channels, among them KWGN's 2.1 on KDVR's, and KUSA's10+// 9.4 on KTVD's.11+func Lineup() []hdhr.Channel {12+ return []hdhr.Channel{13+ {Number: "53.1", Name: "KETD-HD", FrequencyHz: 479_000_000, Program: 1, TSID: 447, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},14+ {Number: "53.2", Name: "E-NEWS", FrequencyHz: 479_000_000, Program: 2, TSID: 447, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},15+ {Number: "53.3", Name: "Confess", FrequencyHz: 479_000_000, Program: 3, TSID: 447, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},16+ {Number: "53.4", Name: "JTV", FrequencyHz: 479_000_000, Program: 4, TSID: 447, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},17+ {Number: "53.5", Name: "BUZZR", FrequencyHz: 479_000_000, Program: 5, TSID: 447, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},18+ {Number: "50.1", Name: "UniMas", FrequencyHz: 557_000_000, Program: 1, TSID: 471, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},19+ {Number: "50.2", Name: "LATV", FrequencyHz: 557_000_000, Program: 2, TSID: 471, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},20+ {Number: "25.1", Name: "KDEN-DT", FrequencyHz: 563_000_000, Program: 3, TSID: 491, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},21+ {Number: "25.2", Name: "Exitos", FrequencyHz: 563_000_000, Program: 4, TSID: 491, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},22+ {Number: "25.3", Name: "COZI", FrequencyHz: 563_000_000, Program: 5, TSID: 491, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},23+ {Number: "25.4", Name: "CRIMES", FrequencyHz: 563_000_000, Program: 6, TSID: 491, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},24+ {Number: "25.5", Name: "Oxygen", FrequencyHz: 563_000_000, Program: 7, TSID: 491, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},25+ {Number: "25.6", Name: "COZI 2", FrequencyHz: 563_000_000, Program: 8, TSID: 491, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},26+ {Number: "20.1", Name: "KTVD-HD", FrequencyHz: 575_000_000, Program: 3, TSID: 465, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},27+ {Number: "20.2", Name: "H & I", FrequencyHz: 575_000_000, Program: 4, TSID: 465, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},28+ {Number: "9.4", Name: "KUSA-HD", FrequencyHz: 575_000_000, Program: 5, TSID: 465, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},29+ {Number: "9.7", Name: "TBD", FrequencyHz: 575_000_000, Program: 6, TSID: 465, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},30+ {Number: "20.3", Name: "ShopLC", FrequencyHz: 575_000_000, Program: 7, TSID: 465, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},31+ {Number: "20.4", Name: "OUTLAW", FrequencyHz: 575_000_000, Program: 8, TSID: 465, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},32+ {Number: "14.1", Name: "Univision Denver", FrequencyHz: 581_000_000, Program: 1, TSID: 443, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},33+ {Number: "14.2", Name: "MovieSphere Gold by Lionsgate", FrequencyHz: 581_000_000, Program: 2, TSID: 443, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},34+ {Number: "14.3", Name: "GREAT", FrequencyHz: 581_000_000, Program: 3, TSID: 443, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},35+ {Number: "14.4", Name: "Better Tomorrows, Today", FrequencyHz: 581_000_000, Program: 4, TSID: 443, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},36+ {Number: "14.5", Name: "ShopLC", FrequencyHz: 581_000_000, Program: 5, TSID: 443, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},37+ {Number: "6.1", Name: "KRMADT1", FrequencyHz: 587_000_000, Program: 1, TSID: 459, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},38+ {Number: "6.2", Name: "PBSKIDS", FrequencyHz: 587_000_000, Program: 2, TSID: 459, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},39+ {Number: "6.3", Name: "Create", FrequencyHz: 587_000_000, Program: 3, TSID: 459, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},40+ {Number: "6.4", Name: "World", FrequencyHz: 587_000_000, Program: 4, TSID: 459, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},41+ {Number: "4.1", Name: "KCNC-TV", FrequencyHz: 599_000_000, Program: 1, TSID: 457, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},42+ {Number: "4.2", Name: "StartTV", FrequencyHz: 599_000_000, Program: 2, TSID: 457, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},43+ {Number: "4.3", Name: "Dabl", FrequencyHz: 599_000_000, Program: 3, TSID: 457, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},44+ {Number: "4.4", Name: "MeTV", FrequencyHz: 599_000_000, Program: 4, TSID: 457, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},45+ {Number: "4.5", Name: "Catchy", FrequencyHz: 599_000_000, Program: 5, TSID: 457, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},46+ {Number: "4.6", Name: "Story", FrequencyHz: 599_000_000, Program: 6, TSID: 457, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},47+ {Number: "31.1", Name: "KDVR-DT", FrequencyHz: 605_000_000, Program: 3, TSID: 467, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},48+ {Number: "31.2", Name: "Antenna", FrequencyHz: 605_000_000, Program: 4, TSID: 467, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},49+ {Number: "31.3", Name: "ROAR-TV", FrequencyHz: 605_000_000, Program: 5, TSID: 467, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},50+ {Number: "2.1", Name: "KWGN-DT", FrequencyHz: 605_000_000, Program: 6, TSID: 467, Modulation: "8vsb", VideoCodec: "MPEG2", AudioCodec: "AC3"},51+ }52+}53+54+// Muxes is what each RF channel of the lineup carries, for a fake55+// HDHomeRun: its programs, each with an English audio track (and a56+// Spanish one on 50.1, and a described video track on 4.1).57+func Muxes() map[int64][]byte {58+ byRF := map[int64]*tstest.Mux{}59+ for _, c := range Lineup() {60+ m := byRF[c.FrequencyHz]61+ if m == nil {62+ m = &tstest.Mux{TSID: c.TSID}63+ byRF[c.FrequencyHz] = m64+ }65+ pid := 0x30 + 0x10*len(m.Programs)66+ p := tstest.Program{Number: c.Program, PMTPID: pid, VideoPID: pid + 1, Audio: []tstest.Audio{{PID: pid + 4, Lang: "eng"}}, Name: c.Name}67+ switch c.Number {68+ case "50.1":69+ p.Audio = append(p.Audio, tstest.Audio{PID: pid + 5, Lang: "spa"})70+ case "4.1":71+ p.Audio = append(p.Audio, tstest.Audio{PID: pid + 5, Lang: "eng", Described: true})72+ }73+ m.Programs = append(m.Programs, p)74+ }75+ out := map[int64][]byte{}76+ for f, m := range byRF {77+ out[f] = m.Packets(60 * len(m.Programs))78+ }79+ return out80+}@@ -15,7 +15,7 @@ import (
1515 type Scanned struct {1616 Number string // "20.1"1717 // Name is the broadcast's own name for it (its PSIP short name, as18- // Tvheadend names the service): "KTVD-HD", "H & I".18+ // the HDHomeRun's lineup gives it): "KTVD-HD", "H & I".1919 Name string2020 // RF is the RF channel of the multiplex it came in on; 0 when unknown.2121 RF int@@ -13,15 +13,15 @@ import (
1313 1414 "airwaves/internal/fcc"1515 "airwaves/internal/guide"16+ "airwaves/internal/hdhr"1617 "airwaves/internal/lineup"1718 "airwaves/internal/signal"1819 "airwaves/internal/tuner"19- "airwaves/internal/tvh"2020 )2121 22-// ErrNoTuner is returned for an antenna channel by a server that has no23-// tuner to tune it with.24-var ErrNoTuner = errors.New("no tuner: the server's Tvheadend has found no ATSC tuner")22+// ErrNoTuner is returned for an antenna channel by a server that has found23+// no HDHomeRun to tune it with.24+var ErrNoTuner = tuner.ErrNoDevice2525 2626 // Antenna is what the app shows of the antenna: the tuner, the channels it2727 // receives and what was measured on each RF channel. Every channel is one@@ -36,26 +36,23 @@ type Antenna struct {
3636 3737 // TunerInfo describes the server's tuners.3838 type TunerInfo struct {39- // Ready is set when there is a tuner with its antenna network.39+ // Ready is set when there is a tuner.4040 Ready bool `json:"ready"`4141 // Reason says why there are no antenna channels, when there aren't.4242 Reason string `json:"reason,omitempty"`43- // Model is the tuner's model as Tvheadend reports it44- // ("hdhomerun_dvr_atsc").43+ // Name and Model are the tuner's, "HDHomeRun FLEX DUO" and "HDFX-2US".44+ Name string `json:"name,omitempty"`4545 Model string `json:"model,omitempty"`4646 Tuners int `json:"tuners"`47- // InUse counts tuners tuned now: to a viewer, a recording, a48- // measurement or Tvheadend's own guide scan.47+ // InUse counts tuners tuned now: to a viewer, a recording or a48+ // measurement.4949 InUse int `json:"inUse"`5050 // Standards lists what the tuners receive; ATSC3 is whether that takes51- // in ATSC 3.0 (NextGen TV). Tvheadend's ATSC tuners are ATSC 1.0 only.51+ // in ATSC 3.0 (NextGen TV).5252 Standards []string `json:"standards"`5353 ATSC3 bool `json:"atsc3"`54- // Scanning is set while Tvheadend scans RF channels for services.54+ // Scanning is set while the tuner scans for channels.5555 Scanning bool `json:"scanning"`56- // Demo is set while the channels are generated test patterns (demo57- // mode, before a tuner).58- Demo bool `json:"demo,omitempty"`5956 }6057 6158 // AntennaChannel is a channel the tuner receives.@@ -67,24 +64,17 @@ type AntennaChannel struct {
6764 // marginal signal; null when it has never been measured.6865 Signal *signal.Reading `json:"signal"`6966 Recent *signal.Period `json:"recent"`70- // Demo marks a generated test pattern (demo mode).71- Demo bool `json:"demo,omitempty"`7267 }7368 74-// MuxSignal is one RF channel: what Tvheadend's scan found there, what75-// the FCC licenses on it nearby, and what the tuners measured on it.69+// MuxSignal is one RF channel: the tuner's channels on it, what the FCC70+// licenses on it nearby, and what the tuners measured on it.7671 type MuxSignal struct {7772 RF int `json:"rf"`7873 FrequencyMHz float64 `json:"frequencyMhz"`7974 Band string `json:"band"`80- // Name is Tvheadend's for the multiplex: "605MHz".81- Name string `json:"name"`82- // Scan is how Tvheadend's last scan went; null before it scanned.83- Scan *signal.Scan `json:"scan"`84- Scanning bool `json:"scanning"`8575 // Tuned is set while a tuner is on it.8676 Tuned bool `json:"tuned"`87- // Channels are the tuner's channels on it.77+ // Channels are the tuner's channels on it: what its scan found there.8878 Channels []string `json:"channels"`8979 // Stations are the licensed transmitters on this RF channel within9080 // the search radius, nearest first.@@ -131,94 +121,240 @@ type SignalReport struct {
131121 Sweep SweepStatus `json:"sweep"`132122 }133123 134-// frontendsTTL is how long a hardware lookup stands.135-const frontendsTTL = time.Minute124+// device is the HDHomeRun, nil until one is found.125+func (s *Service) device() *hdhr.Device {126+ s.mu.Lock()127+ defer s.mu.Unlock()128+ return s.dev129+}130+131+// findTuner looks for the HDHomeRun until one is found, then keeps its132+// details current, looking again when it stops answering.133+func (s *Service) findTuner(ctx context.Context) {134+ if s.tuners == nil {135+ return136+ }137+ if dev := s.device(); dev != nil {138+ fresh := *dev139+ if err := fresh.Refresh(ctx); err == nil {140+ s.setDevice(&fresh)141+ return142+ }143+ log.Printf("tuner: %s stopped answering; looking for it again", dev.FriendlyName)144+ }145+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)146+ defer cancel()147+ dev, err := hdhr.Find(ctx, s.opt.HDHomeRun, s.opt.SkipDeviceID, s.http)148+ if err != nil {149+ s.mu.Lock()150+ changed := s.devErr != err.Error()151+ s.devErr = err.Error()152+ s.mu.Unlock()153+ if changed {154+ log.Printf("tuner: %v", err)155+ }156+ s.setDevice(nil)157+ return158+ }159+ if s.device() == nil {160+ log.Printf("tuner: %s (%s, %d tuners, firmware %s) at %s", dev.FriendlyName, dev.Model, dev.Tuners, dev.Version, dev.BaseURL)161+ }162+ s.setDevice(dev)163+}164+165+// setDevice makes dev the tuner to use; nil for none.166+func (s *Service) setDevice(dev *hdhr.Device) {167+ s.mu.Lock()168+ same := dev != nil && s.dev != nil && dev.ID == s.dev.ID && dev.BaseURL == s.dev.BaseURL && dev.Tuners == s.dev.Tuners169+ if dev == nil && s.dev == nil || same {170+ if same {171+ s.dev = dev172+ }173+ s.mu.Unlock()174+ return175+ }176+ s.dev, s.devErr = dev, ""177+ s.lineupAt = time.Time{}178+ s.mu.Unlock()179+ if dev == nil {180+ s.tuners.SetDevice(nil, 0)181+ return182+ }183+ s.tuners.SetDevice(dev, dev.Tuners)184+}136185 137-// frontends returns the ATSC tuners Tvheadend has, looked up at most once138-// a minute (or now, with fresh). A failed lookup keeps the last answer.139-func (s *Service) frontends(ctx context.Context, fresh bool) ([]tvh.Frontend, error) {186+// lineupTTL is how long the tuner's lineup is used before asking again.187+const lineupTTL = time.Minute188+189+// tunerLineup is the channels the tuner's scan found that it can show:190+// not ATSC 3.0 ones, whose audio can't be decoded, nor encrypted ones, by191+// number (of two with one number, the first stays). A failure keeps the192+// last lineup.193+func (s *Service) tunerLineup(ctx context.Context) ([]hdhr.Channel, error) {194+ dev := s.device()195+ if dev == nil {196+ return nil, ErrNoTuner197+ }140198 s.mu.Lock()141- fes, at, known := s.fes, s.fesAt, s.fesKnown199+ l, at := s.lineup, s.lineupAt142200 s.mu.Unlock()143- if known && !fresh && time.Since(at) < frontendsTTL {144- return fes, nil201+ if !at.IsZero() && time.Since(at) < lineupTTL {202+ return l, nil145203 }146- got, err := s.opt.Tvheadend.Frontends(ctx)204+ got, err := dev.Lineup(ctx)147205 if err != nil {148- if known {149- return fes, nil206+ if !at.IsZero() {207+ return l, nil150208 }151209 return nil, err152210 }211+ seen := map[string]bool{}212+ l = got[:0]213+ for _, c := range got {214+ if c.ATSC3 || c.DRM || c.FrequencyHz <= 0 || c.Program <= 0 || seen[c.Number] {215+ continue216+ }217+ seen[c.Number] = true218+ l = append(l, c)219+ }220+ slices.SortStableFunc(l, func(a, b hdhr.Channel) int { return guide.CompareNumbers(a.Number, b.Number) })153221 s.mu.Lock()154- s.fes, s.fesAt, s.fesKnown = got, time.Now(), true222+ s.lineup, s.lineupAt = l, time.Now()155223 s.mu.Unlock()156- return got, nil224+ return l, nil157225 }158226 159-// offeredNetworks are the Tvheadend networks whose channels the app,160-// the emulated HDHomeRun and recording use: the antenna network while161-// there's a tuner, else the demo network in demo mode, until the antenna162-// network exists. Demo channels never come back once it does.163-func (s *Service) offeredNetworks() []string {164- ctx, cancel := context.WithTimeout(s.ctx, 10*time.Second)165- defer cancel()166- fes, err := s.frontends(ctx, false)227+// tuning finds where a channel is broadcast: its RF channel and program.228+func (s *Service) tuning(ctx context.Context, number string) (int64, int, error) {229+ l, err := s.tunerLineup(ctx)167230 if err != nil {168- return nil231+ return 0, 0, err169232 }170- if len(fes) > 0 {171- return []string{tvh.ATSCNetwork}233+ for _, c := range l {234+ if c.Number == number {235+ return c.FrequencyHz, c.Program, nil236+ }172237 }173- if !s.opt.Demo {174- return nil238+ return 0, 0, fmt.Errorf("channel %s isn't in the tuner's lineup", number)239+}240+241+// scanStatus is the tuner's channel scan, read at most every few seconds.242+func (s *Service) scanStatus(ctx context.Context) hdhr.ScanStatus {243+ dev := s.device()244+ if dev == nil {245+ return hdhr.ScanStatus{}175246 }176- if l, err := s.tvhTuner.Lineup(ctx); err != nil || l.HasNetwork(tvh.ATSCNetwork) {177- return nil247+ s.mu.Lock()248+ st, at := s.scan, s.scanAt249+ s.mu.Unlock()250+ if !at.IsZero() && time.Since(at) < 5*time.Second {251+ return st178252 }179- return []string{tvh.DemoNetwork}253+ st, err := dev.ScanStatus(ctx)254+ if err != nil {255+ return hdhr.ScanStatus{}256+ }257+ s.mu.Lock()258+ if s.scan.InProgress && !st.InProgress {259+ s.lineupAt = time.Time{} // a scan just finished: read its lineup260+ }261+ s.scan, s.scanAt = st, time.Now()262+ s.mu.Unlock()263+ return st180264 }181265 182-// tunerInfo describes the tuners: fes, and what's tuned now.183-func (s *Service) tunerInfo(ctx context.Context, l *tvh.Lineup) TunerInfo {266+// ScanChannels has the tuner scan for channels again, as after moving the267+// antenna. Every tuner is taken for the few minutes it takes, ending268+// whatever was watched or measured; recordings in progress make it wait.269+func (s *Service) ScanChannels(ctx context.Context) (TunerInfo, error) {184270 if s.opt.NoAntenna {185- return TunerInfo{Reason: ErrNoAntenna.Error(), Standards: []string{}}271+ return TunerInfo{}, ErrNoAntenna272+ }273+ dev := s.device()274+ if dev == nil {275+ return TunerInfo{}, ErrNoTuner186276 }187- if s.opt.Tvheadend == nil {188- return TunerInfo{Reason: "no tuner: the server has no Tvheadend", Standards: []string{}}277+ if busy := s.recordingNow(ctx); busy != "" {278+ return TunerInfo{}, fmt.Errorf("not while recording %s: a scan takes every tuner", busy)189279 }190- ti := TunerInfo{Standards: []string{}}191- fes, err := s.frontends(ctx, false)280+ s.tuners.SetDevice(nil, 0) // let go of every tuner281+ err := dev.StartScan(ctx)282+ s.tuners.SetDevice(dev, dev.Tuners)192283 if err != nil {193- ti.Reason = "Tvheadend is not reachable: " + err.Error()194- return ti284+ return TunerInfo{}, err195285 }196- ti.Tuners = len(fes)197- for _, fe := range fes {198- ti.Model = cmp.Or(ti.Model, fe.Model)286+ log.Print("tuner: scanning for channels")287+ s.mu.Lock()288+ s.scanAt = time.Time{}289+ s.mu.Unlock()290+ return s.tunerInfo(ctx), nil291+}292+293+// recordingNow names a recording in progress, "" when there's none.294+func (s *Service) recordingNow(ctx context.Context) string {295+ tuners, err := s.tuners.Tuners(ctx)296+ if err != nil {297+ return ""199298 }200- if len(fes) > 0 {201- ti.Standards = []string{"ATSC 1.0"}299+ for _, t := range tuners {300+ for _, u := range t.Users {301+ if u.Priority == tuner.Recording {302+ return u.Who303+ }304+ }202305 }203- if st, ok := s.statusNow(); ok {204- ti.InUse = st.busy()306+ return ""307+}308+309+// tunerDevice describes the tuner for Info.310+func (s *Service) tunerDevice() tuner.Device {311+ if s.opt.NoAntenna {312+ return tuner.Device{ID: "none", Name: "No antenna", Kind: "none", Detail: "custom channels only"}313+ }314+ dev := s.device()315+ if dev == nil {316+ return tuner.Device{ID: "none", Name: "No tuner", Kind: "none", Detail: "no HDHomeRun found on the network"}317+ }318+ return tuner.Device{319+ ID: dev.ID, Name: dev.FriendlyName, Kind: "hdhomerun", Model: dev.Model, Tuners: dev.Tuners,320+ Detail: "firmware " + dev.Version, Standards: standards(dev), ATSC3: dev.ATSC3(),321+ }322+}323+324+// standards lists what dev's tuners receive.325+func standards(dev *hdhr.Device) []string {326+ if dev.ATSC3() {327+ return []string{"ATSC 1.0", "ATSC 3.0"}328+ }329+ return []string{"ATSC 1.0"}330+}331+332+// tunerInfo describes the tuners and what's tuned now.333+func (s *Service) tunerInfo(ctx context.Context) TunerInfo {334+ if s.opt.NoAntenna {335+ return TunerInfo{Reason: ErrNoAntenna.Error(), Standards: []string{}}205336 }206- switch {207- case l == nil:208- case len(fes) > 0 && l.HasNetwork(tvh.ATSCNetwork):209- ti.Ready = true210- for _, m := range l.NetworkMuxes(tvh.ATSCNetwork) {211- ti.Scanning = ti.Scanning || bool(m.Enabled) && m.Scanning()337+ dev := s.device()338+ if dev == nil {339+ reason := ErrNoTuner.Error()340+ s.mu.Lock()341+ if s.devErr != "" && s.opt.HDHomeRun != "" {342+ reason = "no tuner: " + s.devErr343+ }344+ s.mu.Unlock()345+ return TunerInfo{Reason: reason, Standards: []string{}}346+ }347+ ti := TunerInfo{Ready: true, Name: dev.FriendlyName, Model: dev.Model, Tuners: dev.Tuners, Standards: standards(dev), ATSC3: dev.ATSC3()}348+ if tuners, err := s.tuners.Tuners(ctx); err == nil {349+ for _, t := range tuners {350+ if t.FrequencyHz > 0 {351+ ti.InUse++352+ }212353 }213- case len(fes) > 0:214- ti.Reason = "the tuner was just found: its antenna network is being set up"215- case s.opt.Demo && slices.Contains(s.offeredNetworks(), tvh.DemoNetwork):216- ti.Demo = true217- ti.Reason = "demo mode: the channels are test patterns until a tuner is found"218- case l.HasNetwork(tvh.ATSCNetwork):219- ti.Reason = "Tvheadend sees no tuner right now"220- default:221- ti.Reason = ErrNoTuner.Error()354+ }355+ ti.Scanning = s.scanStatus(ctx).InProgress356+ if ti.Scanning {357+ ti.Reason = "the tuner is scanning for channels"222358 }223359 return ti224360 }@@ -226,103 +362,101 @@ func (s *Service) tunerInfo(ctx context.Context, l *tvh.Lineup) TunerInfo {
226362 // antenna is the tuner's lineup described from rep and g, with the227363 // measurements; rep and g may be nil.228364 func (s *Service) antenna(ctx context.Context, rep *lineup.Report, g *guide.Guide) *Antenna {229- a := &Antenna{Channels: []AntennaChannel{}, Muxes: []MuxSignal{}, Sweep: s.sweepStatus()}230- if s.opt.NoAntenna || s.opt.Tvheadend == nil {231- a.Tuner = s.tunerInfo(ctx, nil)232- return a233- }234- l, err := s.tvhTuner.Lineup(ctx)235- if err != nil {236- a.Tuner = s.tunerInfo(ctx, nil)237- a.Tuner.Reason = "Tvheadend is not reachable: " + err.Error()238- a.Muxes = s.muxSignals(nil, rep, nil)365+ a := &Antenna{Channels: []AntennaChannel{}, Muxes: []MuxSignal{}, Sweep: s.sweepStatus(), Tuner: s.tunerInfo(ctx)}366+ if s.opt.NoAntenna {239367 return a240368 }241- a.Tuner = s.tunerInfo(ctx, l)242- s.noteScans(l)243- chans, tuned, err := s.matched(ctx, rep, g)369+ chans, found, err := s.matched(ctx, rep, g)244370 if err != nil {245371 log.Printf("antenna channels: %v", err)372+ if a.Tuner.Reason == "" {373+ a.Tuner.Reason = "the tuner's lineup can't be read: " + err.Error()374+ }246375 }247- onMux := map[string][]string{} // channel numbers by multiplex UUID376+ onRF := map[int64][]string{} // channel numbers by frequency248377 for i, c := range chans {249- ac := AntennaChannel{TunerChannel: c, Demo: tuned[i].Mux.Network == tvh.DemoNetwork}250- if m, ok := s.signals.Get(tuned[i].Mux.FrequencyHz); ok {378+ ac := AntennaChannel{TunerChannel: c}379+ if m, ok := s.signals.Get(found[i].FrequencyHz); ok {251380 ac.Signal, ac.Recent = m.Latest, m.Recent()252381 }253382 a.Channels = append(a.Channels, ac)254- onMux[tuned[i].Mux.UUID] = append(onMux[tuned[i].Mux.UUID], c.Number)383+ onRF[found[i].FrequencyHz] = append(onRF[found[i].FrequencyHz], c.Number)255384 }256- a.Muxes = s.muxSignals(l, rep, onMux)385+ a.Muxes = s.muxSignals(ctx, rep, onRF)257386 return a258387 }259388 260-// matched is the offered channels described from rep and g, with the261-// Tvheadend channel each one is, in the same order.262-func (s *Service) matched(ctx context.Context, rep *lineup.Report, g *guide.Guide) ([]lineup.TunerChannel, []tuner.Channel, error) {263- if s.tvhTuner == nil {389+// matched is the tuner's channels described from rep and g, with the390+// tuner's own entry for each, in the same order.391+func (s *Service) matched(ctx context.Context, rep *lineup.Report, g *guide.Guide) ([]lineup.TunerChannel, []hdhr.Channel, error) {392+ if s.tuners == nil {264393 return nil, nil, nil265394 }266- tuned, err := s.tvhTuner.Channels(ctx)395+ l, err := s.tunerLineup(ctx)396+ if errors.Is(err, ErrNoTuner) {397+ return nil, nil, nil // no tuner, no channels398+ }267399 if err != nil {268400 return nil, nil, err269401 }270- found := make([]lineup.Scanned, len(tuned))271- for i, c := range tuned {272- found[i] = lineup.Scanned{Number: c.Number, Name: cmp.Or(c.Service.Name, c.Name), RF: signal.RF(c.Mux.FrequencyHz)}402+ found := make([]lineup.Scanned, len(l))403+ for i, c := range l {404+ found[i] = lineup.Scanned{Number: c.Number, Name: c.Name, RF: signal.RF(c.FrequencyHz)}273405 }274406 if rep == nil {275407 rep = &lineup.Report{}276408 }277409 chans := lineup.Match(rep, g, found)278- byNumber := make(map[string]tuner.Channel, len(tuned))279- for _, c := range tuned {410+ byNumber := make(map[string]hdhr.Channel, len(l))411+ for _, c := range l {280412 byNumber[c.Number] = c281413 }282- same := make([]tuner.Channel, len(chans))414+ same := make([]hdhr.Channel, len(chans))283415 for i, c := range chans {284416 same[i] = byNumber[c.Number]285417 }286418 return chans, same, nil287419 }288420 289-// muxSignals lists the antenna network's multiplexes (or, without290-// Tvheadend's lineup, those measured before) with what's known of each.291-func (s *Service) muxSignals(l *tvh.Lineup, rep *lineup.Report, chans map[string][]string) []MuxSignal {292- st, _ := s.statusNow()293- tunedTo := map[string]bool{}294- for _, in := range st.inputs {295- if mux, net, ok := in.Mux(); ok && net == tvh.ATSCNetwork && in.InUse() {296- tunedTo[mux] = true421+// muxSignals lists the RF channels worth showing: those the tuner's scan422+// found channels on (onRF, by frequency), those a licensed station nearby423+// uses, and any measured before.424+func (s *Service) muxSignals(ctx context.Context, rep *lineup.Report, onRF map[int64][]string) []MuxSignal {425+ tuned := map[int64]bool{}426+ if tuners, err := s.tuners.Tuners(ctx); err == nil {427+ for _, t := range tuners {428+ tuned[t.FrequencyHz] = t.FrequencyHz > 0297429 }298430 }299- var out []MuxSignal300- add := func(freq int64, name string) *MuxSignal {301- rf := signal.RF(freq)302- ms := MuxSignal{RF: rf, FrequencyMHz: float64(freq) / 1e6, Band: fcc.Band(rf), Name: name,303- Channels: []string{}, Stations: muxStations(rep, rf), Tuned: tunedTo[name]}304- if m, ok := s.signals.Get(freq); ok {305- ms.Scan, ms.Signal, ms.History = m.Scan, m.Latest, m.History()306- }307- out = append(out, ms)308- return &out[len(out)-1]431+ freqs := map[int64]bool{}432+ for f := range onRF {433+ freqs[f] = true309434 }310- if l != nil {311- for _, m := range l.NetworkMuxes(tvh.ATSCNetwork) {312- if m.FrequencyHz <= 0 || !bool(m.Enabled) {313- continue314- }315- ms := add(m.FrequencyHz, m.Name)316- ms.Scanning = m.Scanning()317- if c := chans[m.UUID]; c != nil {318- slices.SortFunc(c, guide.CompareNumbers)319- ms.Channels = c435+ if rep != nil {436+ for _, st := range rep.Stations {437+ if f := signal.Frequency(st.RFChannel); f > 0 {438+ freqs[f] = true320439 }321440 }322- } else {323- for _, m := range s.signals.All() {324- add(m.FrequencyHz, fmt.Sprintf("%dMHz", m.FrequencyHz/1_000_000))441+ }442+ for _, m := range s.signals.All() {443+ freqs[m.FrequencyHz] = true444+ }445+ out := make([]MuxSignal, 0, len(freqs))446+ for f := range freqs {447+ rf := signal.RF(f)448+ if rf == 0 {449+ continue450+ }451+ ms := MuxSignal{RF: rf, FrequencyMHz: float64(f) / 1e6, Band: fcc.Band(rf), Tuned: tuned[f],452+ Channels: []string{}, Stations: muxStations(rep, rf)}453+ if c := onRF[f]; c != nil {454+ ms.Channels = slices.SortedFunc(slices.Values(c), guide.CompareNumbers)325455 }456+ if m, ok := s.signals.Get(f); ok {457+ ms.Signal, ms.History = m.Latest, m.History()458+ }459+ out = append(out, ms)326460 }327461 slices.SortFunc(out, func(a, b MuxSignal) int { return cmp.Compare(a.FrequencyMHz, b.FrequencyMHz) })328462 return out@@ -1,6 +1,7 @@
11 package service22 33 import (4+ "bytes"45 "context"56 "encoding/json"67 "encoding/xml"@@ -11,36 +12,74 @@ import (
1112 "path/filepath"1213 "slices"1314 "strings"15+ "sync"1416 "testing"1517 "time"1618 1719 "airwaves/internal/guide"1820 "airwaves/internal/hdhr"21+ "airwaves/internal/hdhr/hdhrtest"1922 "airwaves/internal/lineup/lineuptest"2023 "airwaves/internal/signal"24+ "airwaves/internal/ts"25+ "airwaves/internal/ts/tstest"2126 "airwaves/internal/tuner"22- "airwaves/internal/tvh"23- "airwaves/internal/tvh/tvhtest"2427 )2528 2629 // fastTiming measures in milliseconds rather than seconds.2730 var fastTiming = measureTiming{28- sample: 20 * time.Millisecond,29- appear: 2 * time.Second, acquire: 300 * time.Millisecond, settle: 20 * time.Millisecond, interval: 20 * time.Millisecond, readings: 3,31+ sample: 20 * time.Millisecond,32+ acquire: 300 * time.Millisecond, settle: 20 * time.Millisecond, interval: 20 * time.Millisecond, readings: 3,3033 idleWait: 300 * time.Millisecond,31- poll: 20 * time.Millisecond, noLock: 200 * time.Millisecond,34+ poll: 20 * time.Millisecond, noLock: 300 * time.Millisecond,3235 }3336 34-// newTunerService is a server with the fake Tvheadend (an HDHomeRun whose35-// scan found 38 channels around Denver), and Denver's records and36-// listings already loaded.37-func newTunerService(t *testing.T, change ...func(*Options)) (*Service, *tvhtest.Server) {37+// fakeTuner is an HDHomeRun whose scan found 38 channels around Denver,38+// with RF channels that can be made to lose their signal.39+type fakeTuner struct {40+ *hdhrtest.Server41+ mu sync.Mutex42+ muxes map[int64][]byte43+ signal map[int64][3]int44+}45+46+func (f *fakeTuner) mux(freq int64) []byte {47+ f.mu.Lock()48+ defer f.mu.Unlock()49+ return f.muxes[freq]50+}51+52+func (f *fakeTuner) sig(freq int64) (int, int, int) {53+ f.mu.Lock()54+ defer f.mu.Unlock()55+ if v, ok := f.signal[freq]; ok {56+ return v[0], v[1], v[2]57+ }58+ if f.muxes[freq] != nil {59+ return 90, 95, 10060+ }61+ return 40, 0, 062+}63+64+// set makes the RF channel at freq carry mux (nil: nothing to lock), with65+// the given signal.66+func (f *fakeTuner) set(freq int64, mux []byte, strength, quality, symbol int) {67+ f.mu.Lock()68+ defer f.mu.Unlock()69+ f.muxes[freq] = mux70+ f.signal[freq] = [3]int{strength, quality, symbol}71+}72+73+// newTunerService is a server with the fake tuner, and Denver's records74+// and listings already loaded.75+func newTunerService(t *testing.T, change ...func(*Options)) (*Service, *fakeTuner) {3876 t.Helper()39- fake := tvhtest.New(t)77+ fake := &fakeTuner{muxes: lineuptest.Muxes(), signal: map[int64][3]int{}}78+ fake.Server = hdhrtest.New(t, hdhrtest.Device{Lineup: lineuptest.Lineup(), Mux: fake.mux, Signal: fake.sig})4079 dir := t.TempDir()4180 opt := Options{42- Name: "nas", Mode: "server", CacheDir: filepath.Join(dir, "cache"), Config: Config{ZIP: "80302"},43- Tvheadend: tvh.New(fake.URL, fake.Client()), DVRPath: filepath.Join(dir, "dvr.json"), SignalPath: filepath.Join(dir, "signal.json"),81+ Name: "nas", Mode: "server", CacheDir: filepath.Join(dir, "cache"), Config: Config{ZIP: "80302"}, HDHomeRun: fake.URL(),82+ DVRPath: filepath.Join(dir, "dvr.json"), RecordingsDir: filepath.Join(dir, "recordings"), SignalPath: filepath.Join(dir, "signal.json"),4483 }4584 for _, f := range change {4685 f(&opt)@@ -51,26 +90,28 @@ func newTunerService(t *testing.T, change ...func(*Options)) (*Service, *tvhtest
5190 }5291 t.Cleanup(s.Close)5392 s.timing = fastTiming93+ if s.tuners != nil {94+ s.tuners.Linger, s.tuners.StatusAge = 0, 095+ }96+ s.findTuner(t.Context())5497 s.mu.Lock()5598 s.report, s.guide, s.built = lineuptest.Report(), lineuptest.Guide(), time.Now()5699 s.mu.Unlock()57100 return s, fake58101 }59102 60-// tvhNumbers lists the fake's channel numbers, in order.61-func tvhNumbers(fake *tvhtest.Server) []string {103+func lineupNumbers() []string {62104 var out []string63- for _, c := range fake.Entries("channel") {64- out = append(out, c["number"].(string))105+ for _, c := range lineuptest.Lineup() {106+ out = append(out, c.Number)65107 }66- slices.SortFunc(out, guide.CompareNumbers)67108 return out68109 }69110 70111 // TestTunerLineup: the app's antenna channels are exactly the tuner's, the71112 // records and listings only describing them; nothing estimated is served.72113 func TestTunerLineup(t *testing.T) {73- s, fake := newTunerService(t)114+ s, _ := newTunerService(t)74115 ctx := t.Context()75116 snap, err := s.Snapshot(ctx, false)76117 if err != nil {@@ -83,7 +124,9 @@ func TestTunerLineup(t *testing.T) {
83124 numbers = append(numbers, c.Number)84125 by[c.Number] = c85126 }86- if want := tvhNumbers(fake); len(want) != 38 || !slices.Equal(numbers, want) {127+ want := lineupNumbers()128+ slices.SortFunc(want, guide.CompareNumbers)129+ if len(want) != 38 || !slices.Equal(numbers, want) {87130 t.Fatalf("antenna channels %v, want the tuner's %v", numbers, want)88131 }89132 // Listed and licensed, but not found by the scan.@@ -92,36 +135,37 @@ func TestTunerLineup(t *testing.T) {
92135 t.Errorf("%s listed without the tuner having it", n)93136 }94137 }95- if c := by["20.1"]; c.CallSign != "KTVD" || c.GuideCallSign != "KTVDDT" || c.Transmitter != "KTVD" || c.RF != 31 || c.Signal != nil || c.Name != "KTVD-HD" || c.Demo {138+ if c := by["20.1"]; c.CallSign != "KTVD" || c.GuideCallSign != "KTVDDT" || c.Transmitter != "KTVD" || c.RF != 31 || c.Signal != nil || c.Name != "KTVD-HD" {96139 t.Errorf("20.1 = %+v", c)97140 }98141 if c := by["2.1"]; c.Via != "KDVR" || c.NextGen == nil || c.NextGen.HostCall != "KWGN-TV" {99142 t.Errorf("2.1 = %+v", c)100143 }101144 ti := a.Tuner102- if !ti.Ready || ti.Tuners != 2 || ti.Model != "hdhomerun_dvr_atsc" || !slices.Equal(ti.Standards, []string{"ATSC 1.0"}) || ti.ATSC3 ||103- ti.InUse != 0 || ti.Reason != "" || ti.Demo || ti.Scanning {145+ if !ti.Ready || ti.Tuners != 2 || ti.Name != "HDHomeRun FLEX DUO" || ti.Model != "HDFX-2US" || !slices.Equal(ti.Standards, []string{"ATSC 1.0"}) || ti.ATSC3 ||146+ ti.InUse != 0 || ti.Reason != "" || ti.Scanning {104147 t.Errorf("tuner = %+v", ti)105148 }106149 107- // Every RF channel Tvheadend scans, with its scan and what's licensed108- // on it.109- if len(a.Muxes) != 36 {110- t.Fatalf("%d multiplexes", len(a.Muxes))111- }150+ // The RF channels the scan found channels on, and those licensed151+ // nearby, with what's licensed on each.112152 mux := map[int]MuxSignal{}153+ var rfs []int113154 for _, m := range a.Muxes {114155 mux[m.RF] = m156+ rfs = append(rfs, m.RF)115157 }116- if m := mux[31]; m.Name != "575MHz" || m.FrequencyMHz != 575 || m.Band != "UHF" || m.Scan == nil || !m.Scan.Lock || m.Scan.At.Unix() != 1791129355 ||117- !slices.Equal(m.Channels, []string{"9.4", "9.7", "20.1", "20.2", "20.3", "20.4"}) || len(m.Stations) != 2 ||118- m.Stations[0].CallSign != "K31AB-D" || m.Stations[1].CallSign != "KTVD" || m.Signal != nil || m.History != nil || m.Tuned {158+ if !slices.Equal(rfs, []int{7, 15, 16, 28, 29, 31, 32, 33, 34, 35, 36}) {159+ t.Fatalf("RF channels %v", rfs)160+ }161+ if m := mux[31]; m.FrequencyMHz != 575 || m.Band != "UHF" || !slices.Equal(m.Channels, []string{"9.4", "9.7", "20.1", "20.2", "20.3", "20.4"}) ||162+ len(m.Stations) != 2 || m.Stations[0].CallSign != "K31AB-D" || m.Stations[1].CallSign != "KTVD" || m.Signal != nil || m.History != nil || m.Tuned {119163 t.Errorf("RF 31 = %+v", m)120164 }121- if m := mux[7]; m.Scan == nil || m.Scan.Lock || len(m.Channels) != 0 || len(m.Stations) != 1 || m.Stations[0].CallSign != "KMGH-TV" || m.Band != "VHF-Hi" {165+ if m := mux[7]; len(m.Channels) != 0 || len(m.Stations) != 1 || m.Stations[0].CallSign != "KMGH-TV" || m.Band != "VHF-Hi" {122166 t.Errorf("RF 7 = %+v", m)123167 }124- if m := mux[34]; len(m.Stations) != 1 || !m.Stations[0].ATSC3 || m.Scan.Lock {168+ if m := mux[34]; len(m.Stations) != 1 || !m.Stations[0].ATSC3 || len(m.Channels) != 0 {125169 t.Errorf("RF 34 = %+v", m)126170 }127171 @@ -141,7 +185,7 @@ func TestTunerLineup(t *testing.T) {
141185 t.Error("the server's own listings were trimmed")142186 }143187 raw, _ := json.Marshal(snap)144- for _, bad := range []string{"noiseMarginDb", `"presets"`, `"strong"`, `"fair"`, `"tier"`, `"signal":{}`} {188+ for _, bad := range []string{"noiseMarginDb", `"presets"`, `"strong"`, `"fair"`, `"tier"`, `"signal":{}`, `"scan"`, "169.254"} {145189 if strings.Contains(string(raw), bad) {146190 t.Errorf("snapshot has %s", bad)147191 }@@ -153,8 +197,9 @@ func TestTunerLineup(t *testing.T) {
153197 }154198 155199 info, _ := s.Info(ctx)156- if info.Tuner.Tuners != 2 || info.Tuner.Model != "hdhomerun_dvr_atsc" || !slices.Equal(info.Tuner.Standards, []string{"ATSC 1.0"}) || info.Tuner.ATSC3 {157- t.Errorf("info tuner = %+v", info.Tuner)200+ if info.Tuner.Kind != "hdhomerun" || info.Tuner.Tuners != 2 || info.Tuner.Model != "HDFX-2US" || info.Tuner.Name != "HDHomeRun FLEX DUO" ||201+ !slices.Equal(info.Tuner.Standards, []string{"ATSC 1.0"}) || info.Tuner.ATSC3 || !info.DVR {202+ t.Errorf("info = %+v", info)158203 }159204 if got := s.AntennaChannels(ctx); len(got) != 38 || got["20.1"] != "KTVD" || got["9.7"] != "KUSA" || got["7.1"] != "" {160205 t.Errorf("antenna channels = %v", got)@@ -164,17 +209,16 @@ func TestTunerLineup(t *testing.T) {
164209 // TestNoTuner: no tuner, no antenna channels; nothing made up in their165210 // place, and tuning one says why.166211 func TestNoTuner(t *testing.T) {167- s, fake := newTunerService(t)168- fake.SetTuner(false)212+ s, _ := newTunerService(t, func(o *Options) { o.HDHomeRun = "http://127.0.0.1:1" })169213 ctx := t.Context()170214 snap, err := s.Snapshot(ctx, false)171215 if err != nil {172216 t.Fatal(err)173217 }174- if a := snap.Antenna; len(a.Channels) != 0 || a.Tuner.Ready || a.Tuner.Reason != "Tvheadend sees no tuner right now" || len(snap.Report.Channels) != 0 {218+ if a := snap.Antenna; len(a.Channels) != 0 || a.Tuner.Ready || !strings.HasPrefix(a.Tuner.Reason, "no tuner: ") || len(snap.Report.Channels) != 0 || len(snap.Guide.Channels) != 0 {175219 t.Errorf("antenna = %+v", a)176220 }177- if !slices.Contains(snap.Report.Warnings, "No antenna channels: Tvheadend sees no tuner right now") {221+ if !slices.ContainsFunc(snap.Report.Warnings, func(w string) bool { return strings.HasPrefix(w, "No antenna channels: no tuner") }) {178222 t.Errorf("warnings = %q", snap.Report.Warnings)179223 }180224 if err := s.tuneAntenna(ctx, "tv", "20.1", nil); !errors.Is(err, ErrNoTuner) {@@ -186,114 +230,114 @@ func TestNoTuner(t *testing.T) {
186230 if l, err := s.HDHR().Lineup(ctx); err != nil || len(l) != 0 {187231 t.Errorf("HDHomeRun lineup = %v, %v", l, err)188232 }189-190- // No Tvheadend at all.191- bare, err := New(Options{Mode: "server", CacheDir: t.TempDir(), Config: Config{ZIP: "80302"}})192- if err != nil {193- t.Fatal(err)194- }195- defer bare.Close()196- bare.report, bare.guide, bare.built = lineuptest.Report(), lineuptest.Guide(), time.Now()197- snap, err = bare.Snapshot(ctx, false)198- if err != nil {199- t.Fatal(err)200- }201- if a := snap.Antenna; len(a.Channels) != 0 || a.Tuner.Reason != "no tuner: the server has no Tvheadend" || len(snap.Report.Channels) != 0 || len(snap.Guide.Channels) != 0 {202- t.Errorf("no Tvheadend: %+v, %d report channels", a, len(snap.Report.Channels))203- }204- if _, err := bare.tuner.Input(ctx, "20.1"); !errors.Is(err, ErrNoTuner) {205- t.Errorf("tune without Tvheadend: %v", err)206- }207- if info, _ := bare.Info(ctx); info.Tuner.Kind != "none" {233+ if info, _ := s.Info(ctx); info.Tuner.Kind != "none" || info.Tuner.Tuners != 0 {208234 t.Errorf("info = %+v", info.Tuner)209235 }236+ if s.TunerCount() != 2 {237+ t.Errorf("emulated tuners %d before the real ones are found", s.TunerCount())238+ }239+}240+241+// readProgram reads in's stream for a moment and lists its programs.242+func readProgram(t *testing.T, in tuner.Input) []ts.Program {243+ t.Helper()244+ var buf bytes.Buffer245+ ctx, cancel := context.WithTimeout(t.Context(), 300*time.Millisecond)246+ defer cancel()247+ _ = in.Source(ctx, &buf)248+ d := ts.NewDemux()249+ var split ts.Splitter250+ split.Write(buf.Bytes(), d.Packet)251+ return d.Programs()210252 }211253 212-// TestDemo: demo channels stand in for the antenna only in demo mode213-// before a tuner; once a tuner's network exists they're removed, and they214-// don't come back while the tuner is away.215-func TestDemo(t *testing.T) {216- s, fake := newTunerService(t, func(o *Options) { o.Demo = true })254+// TestTune: a channel starts with its program alone and its audio tracks255+// described; one whose RF channel won't lock fails with what the tuner256+// measured, not ffmpeg's complaint; with every tuner busy, it says who257+// has them.258+func TestTune(t *testing.T) {259+ s, fake := newTunerService(t)217260 ctx := t.Context()218- // Before the tuner: a Tvheadend with the demo network only.219- fake.SetTuner(false)220- for _, n := range fake.Entries("network") {221- if err := s.opt.Tvheadend.RemoveNetwork(ctx, n["networkname"].(string)); err != nil {222- t.Fatal(err)223- }261+ var got tuner.Input262+ start := func(_ context.Context, in tuner.Input) error {263+ got = in264+ return nil224265 }225- fake.Add("network", map[string]any{"uuid": "demo-net", "networkname": tvh.DemoNetwork})226- fake.Add("mux", map[string]any{"uuid": "demo-mux", "name": "Demo 7.1 KMGH", "network": tvh.DemoNetwork, "network_uuid": "demo-net", "enabled": true, "iptv_url": "pipe://ffmpeg"})227- fake.Add("service", map[string]any{"uuid": "demo-svc", "svcname": "KMGH", "network": tvh.DemoNetwork, "multiplex": "Demo 7.1 KMGH", "multiplex_uuid": "demo-mux", "enabled": true, "channel": []any{"demo-ch"}})228- fake.Add("channel", map[string]any{"uuid": "demo-ch", "name": "KMGH", "number": "7.1", "enabled": true, "services": []any{"demo-svc"}})229- s.tvhTuner.Invalidate()230-231- snap, err := s.Snapshot(ctx, false)232- if err != nil {266+ if err := s.tuneAntenna(ctx, "den", "4.1", start); err != nil {233267 t.Fatal(err)234268 }235- if a := snap.Antenna; len(a.Channels) != 1 || a.Channels[0].Number != "7.1" || !a.Channels[0].Demo || !a.Tuner.Demo || a.Tuner.Ready {236- t.Fatalf("demo antenna = %+v", a)269+ if len(got.Audio) != 2 || !got.Audio[1].Described || got.Audio[0].Lang != "eng" || !got.Broadcast || got.Args[len(got.Args)-1] != "pipe:0" {270+ t.Errorf("input = %+v", got)237271 }238- if err := s.setupTuners(ctx); err != nil {239- t.Fatal(err)272+ if ps := readProgram(t, got); len(ps) != 1 || ps[0].Number != 1 {273+ t.Errorf("4.1 carries %+v, want its program alone", ps)240274 }241275 242- // The tuner arrives: its network is made, and the demo channels go.243- fake.SetTuner(true)244- s.mu.Lock()245- s.fesKnown = false246- s.mu.Unlock()247- s.maintain(ctx, false)248- var nets []string249- for _, n := range fake.Entries("network") {250- nets = append(nets, n["networkname"].(string))276+ // No lock on KTVD's RF channel.277+ fake.set(575_000_000, nil, 41, 0, 0)278+ err := s.tuneAntenna(ctx, "den", "20.1", start)279+ var ns *NoSignalError280+ if !errors.As(err, &ns) || ns.RF != 31 || ns.Label != "KTVD 20.x" ||281+ err.Error() != "No signal on RF 31 (KTVD 20.x) right now: the tuner measured strength 41%, quality 0% and couldn't lock" {282+ t.Fatalf("tune 20.1: %v", err)251283 }252- if !slices.Equal(nets, []string{tvh.ATSCNetwork}) || len(fake.Entries("mux")) != 0 {253- t.Errorf("networks %v, muxes %v", nets, fake.Entries("mux"))284+ if m, ok := s.signals.Get(575_000_000); !ok || m.Latest.Lock || *m.Latest.StrengthPct != 41 || m.Latest.Source != signal.Active {285+ t.Errorf("RF 31 = %+v", m)254286 }255- for _, c := range fake.Entries("channel") {256- if c["uuid"] == "demo-ch" {257- t.Errorf("demo channel left: %v", c)258- }287+ if err := s.tuneAntenna(ctx, "den", "9.4", start); err == nil || !strings.HasPrefix(err.Error(), "No signal on RF 31 (KUSA 9.x on KTVD) right now") {288+ t.Errorf("tune 9.4: %v", err)259289 }260-261- // The tuner goes away: no demo channels made, none offered.262- fake.SetTuner(false)263- before := len(fake.Requests())264- s.mu.Lock()265- s.fesKnown = false266- s.mu.Unlock()267- if err := s.setupTuners(ctx); err != nil {268- t.Fatal(err)290+ // Not a channel the tuner found.291+ if err := s.tuneAntenna(ctx, "den", "7.1", start); err == nil || err.Error() != "channel 7.1 isn't in the tuner's lineup" {292+ t.Errorf("tune 7.1: %v", err)269293 }270- for _, r := range fake.Requests()[before:] {271- if strings.Contains(r, "network/create") || strings.Contains(r, "mux_create") {272- t.Errorf("demo channels made again: %s", r)294+295+ // Both tuners recording: no one can watch something else, but the296+ // den can still watch what's recording.297+ for _, n := range []string{"6.1", "50.1"} {298+ freq, prog, _ := s.tuning(ctx, n)299+ r, err := s.tuners.Open(ctx, tuner.Request{FrequencyHz: freq, Program: prog, Priority: tuner.Recording, Who: "news on " + n})300+ if err != nil {301+ t.Fatal(err)273302 }303+ defer r.Close()274304 }275- s.tvhTuner.Invalidate()276- if got := s.offeredNetworks(); got != nil {277- t.Errorf("offered %v", got)305+ if err := s.tuneAntenna(ctx, "den", "31.1", start); err == nil || !strings.HasPrefix(err.Error(), "every tuner is in use right now (recording news on 50.1; recording news on 6.1)") {306+ t.Errorf("tune with both tuners recording: %v", err)307+ }308+ if err := s.tuneAntenna(ctx, "den", "6.2", start); err != nil {309+ t.Errorf("tune what shares a recording's RF channel: %v", err)278310 }279311 }280312 281-// TestSampleInputs: while a tuner is in use, its readings are recorded for282-// its RF channel, once it has been on it a while, and saved.283-func TestSampleInputs(t *testing.T) {313+// TestSampleTuners: while a tuner is in use, its readings are recorded314+// for its RF channel, once it has been on it a while, and saved.315+func TestSampleTuners(t *testing.T) {284316 s, fake := newTunerService(t)285317 ctx := t.Context()286- fake.SetSignal("575MHz", tvhtest.Signal{Lock: true, Strength: 85, Quality: 83, Errors: 4})287- fake.Busy(tvhtest.Tuner0, "575MHz", 150)288- s.sampleInputs(ctx)318+ // KTVD's RF channel arrives with errors.319+ damaged := fake.mux(575_000_000)320+ for i := 50; i < len(damaged)/188; i += 50 {321+ tstest.SetTEI(damaged, i)322+ }323+ fake.set(575_000_000, damaged, 85, 83, 97)324+ sub, err := s.tuners.Open(ctx, tuner.Request{FrequencyHz: 575_000_000, Program: 3, Priority: tuner.Watching, Who: "20.1"})325+ if err != nil {326+ t.Fatal(err)327+ }328+ defer sub.Close()329+ if _, err := sub.Wait(ctx); err != nil {330+ t.Fatal(err)331+ }332+ s.sampleTuners(ctx)289333 if _, ok := s.signals.Get(575_000_000); ok {290334 t.Fatal("read a tuner just tuned")291335 }292336 time.Sleep(50 * time.Millisecond)293- s.sampleInputs(ctx)337+ s.sampleTuners(ctx)294338 m, ok := s.signals.Get(575_000_000)295- if !ok || m.RF != 31 || m.Latest == nil || !m.Latest.Lock || *m.Latest.StrengthPct != 85 || *m.Latest.QualityPct != 83 || m.Latest.Source != signal.Active ||296- m.Latest.ErrorsPerSec == nil || *m.Latest.ErrorsPerSec <= 0 {339+ if !ok || m.RF != 31 || m.Latest == nil || !m.Latest.Lock || *m.Latest.StrengthPct != 85 || *m.Latest.QualityPct != 83 || *m.Latest.SymbolPct != 97 ||340+ m.Latest.Source != signal.Active || m.Latest.ErrorsPerSec == nil || *m.Latest.ErrorsPerSec <= 0 {297341 t.Fatalf("RF 31 = %+v, %+v", m, m.Latest)298342 }299343 @@ -308,9 +352,6 @@ func TestSampleInputs(t *testing.T) {
308352 if (c.RF == 31) != (c.Signal != nil) || (c.RF == 31) != (c.Recent != nil) {309353 t.Errorf("%s (RF %d) signal %+v, recent %+v", c.Number, c.RF, c.Signal, c.Recent)310354 }311- if c.Recent != nil && (c.Recent.Samples != 1 || c.Recent.Source != signal.Active || c.Recent.QualityPct.Avg != 83) {312- t.Errorf("%s recent %+v", c.Number, c.Recent)313- }314355 }315356 for _, m := range sig.Muxes {316357 if m.RF == 31 && (!m.Tuned || m.History == nil || m.History.Samples != 1 || m.History.LockedPct != 100) {@@ -328,7 +369,7 @@ func TestSampleInputs(t *testing.T) {
328369 if err != nil {329370 t.Fatal(err)330371 }331- if m, ok := again.Get(575_000_000); !ok || m.Latest == nil || m.Scan == nil || !m.Scan.Lock {372+ if m, ok := again.Get(575_000_000); !ok || m.Latest == nil || !m.Latest.Lock {332373 t.Errorf("saved RF 31 = %+v", m)333374 }334375 }@@ -349,190 +390,75 @@ func waitSweep(t *testing.T, s *Service) SweepStatus {
349390 }350391 }351392 352-// TestMeasure: Measure now reads every RF channel with channels or a353-// licensed station, one at a time on an idle tuner, at the lowest weight.393+// TestMeasure: Measure now reads every RF channel with channels, then394+// those with a licensed station, one at a time on a free tuner.354395 func TestMeasure(t *testing.T) {355396 s, fake := newTunerService(t)356397 ctx := t.Context()357- // KMGH's VHF signal arrives strong, but the tuner can't lock to it;358- // KRMA's locks, with errors.359- fake.SetSignal("177MHz", tvhtest.Signal{Strength: 81})360- fake.SetSignal("587MHz", tvhtest.Signal{Lock: true, Strength: 79, Quality: 51, Errors: 2})361- st, err := s.Measure(ctx)362- if err != nil || !st.Running || st.StartedAt == nil {363- t.Fatalf("measure: %+v, %v", st, err)364- }365- if again, _ := s.Measure(ctx); !again.Running || !again.StartedAt.Equal(*st.StartedAt) {366- t.Errorf("a second sweep started: %+v", again)367- }368- done := waitSweep(t, s)369- // The 8 multiplexes with channels, and RF 7 (KMGH), 16 (KUSA) and 34370- // (KWGN's ATSC 3.0), licensed but not found.371- if done.Total != 11 || done.Found != 8 || done.Done != 11 || done.Note != "" || done.FinishedAt == nil {372- t.Errorf("sweep = %+v", done)373- }374- // The found ones first, by RF; then the others.375- opened, most := fake.Opened()376- var muxes []string377- for _, o := range opened {378- muxes = append(muxes, o[strings.LastIndex(o, " ")+1:])379- }380- want := []string{"479MHz", "557MHz", "563MHz", "575MHz", "581MHz", "587MHz", "599MHz", "605MHz", "177MHz", "485MHz", "593MHz"}381- if !slices.Equal(muxes, want) || most != 1 {382- t.Errorf("opened %v, %d at once", muxes, most)383- }384- for _, r := range fake.Requests() {385- if strings.HasPrefix(r, "GET /stream/") && !strings.HasSuffix(r, "?pids=0&weight=10") {386- t.Errorf("subscribed as %s", r)387- }388- }389- for rf, freq := range map[int]int64{31: 575_000_000, 36: 605_000_000, 7: 177_000_000, 34: 593_000_000} {390- m, ok := s.signals.Get(freq)391- // Three readings with a lock; without one, the one after giving392- // the tuner its time to lock.393- locked := rf == 31 || rf == 36394- if want := map[bool]int{true: 3, false: 1}[locked]; !ok || m.Latest == nil || m.Latest.Source != signal.Sweep || m.History().Samples != want || m.Latest.Lock != locked {395- t.Errorf("RF %d = %+v", rf, m)396- }398+ // KMGH's VHF signal arrives strong, but the tuner can't lock to it.399+ fake.set(177_000_000, nil, 81, 0, 0)400+ if _, err := s.Measure(ctx); err != nil {401+ t.Fatal(err)397402 }398- if _, ok := s.signals.Get(473_000_000); ok {399- t.Error("measured RF 14, which nothing uses")403+ st := waitSweep(t, s)404+ if st.Total != 11 || st.Found != 8 || st.Done != 11 || st.Note != "" || st.FinishedAt == nil {405+ t.Fatalf("sweep = %+v", st)400406 }401- // No lock is a reading too, with the strength the tuner measured.402- if m, _ := s.signals.Get(177_000_000); m.Latest.Lock || m.Latest.StrengthPct == nil || *m.Latest.StrengthPct != 81 || *m.Latest.QualityPct != 0 {403- t.Errorf("RF 7 = %+v", m.Latest)407+ if m, ok := s.signals.Get(587_000_000); !ok || len(m.Windows) != 1 || m.Windows[0].Samples != 3 || !m.Latest.Lock || m.Latest.Source != signal.Sweep {408+ t.Errorf("RF 33 = %+v", m)404409 }405- // A lock, read once the tuner has a quality to give.406- if m, _ := s.signals.Get(575_000_000); !m.Latest.Lock || m.History().QualityPct == nil || m.History().QualityPct.Min != 83 ||407- m.Latest.ErrorsPerSec == nil || *m.Latest.ErrorsPerSec != 0 {408- t.Errorf("RF 31 = %+v, %+v", m.History(), m.Latest)410+ if m, ok := s.signals.Get(177_000_000); !ok || m.Latest.Lock || *m.Latest.StrengthPct != 81 {411+ t.Errorf("RF 7 = %+v", m)409412 }410- if m, _ := s.signals.Get(587_000_000); !m.Latest.Lock || m.Latest.ErrorsPerSec == nil || *m.Latest.ErrorsPerSec <= 0 {411- t.Errorf("RF 33 = %+v", m.Latest)413+ if n := fake.Streams(); n != 0 {414+ t.Errorf("%d streams left open", n)412415 }413416 }414417 415-// TestMeasureLeavesTunersAlone: the sweep reads a viewer's tuner rather416-// than tune another to the same RF channel, waits for a free tuner and417-// stops when there's none, and stops when a viewer takes its tuner.418+// TestMeasureLeavesTunersAlone: measuring never takes a tuner someone is419+// using, and stops when someone needs the one it has.418420 func TestMeasureLeavesTunersAlone(t *testing.T) {419- s, fake := newTunerService(t)421+ s, _ := newTunerService(t)420422 ctx := t.Context()421-422- // Someone watches 31.1 on tuner 0: the sweep uses tuner 1 only, and423- // reads RF 36 from tuner 0.424- fake.Busy(tvhtest.Tuner0, "605MHz", 150)425- if _, err := s.Measure(ctx); err != nil {426- t.Fatal(err)427- }428- if st := waitSweep(t, s); st.Done != 11 || st.Note != "" {429- t.Errorf("sweep = %+v", st)430- }431- opened, _ := fake.Opened()432- for _, o := range opened {433- if !strings.HasPrefix(o, tvhtest.Tuner1) || strings.HasSuffix(o, "605MHz") {434- t.Errorf("opened %s", o)423+ open := func(n string) *tuner.Sub {424+ freq, prog, _ := s.tuning(ctx, n)425+ sub, err := s.tuners.Open(ctx, tuner.Request{FrequencyHz: freq, Program: prog, Priority: tuner.Watching, Who: n})426+ if err != nil {427+ t.Fatal(err)435428 }429+ t.Cleanup(sub.Close)430+ return sub436431 }437- if m, _ := s.signals.Get(605_000_000); m.Latest == nil || !m.Latest.Lock || m.Latest.Source != signal.Sweep {438- t.Errorf("RF 36 = %+v", m)439- }440-441- // Both tuners in use: nothing tuned, and the sweep says why.442- fake.Busy(tvhtest.Tuner1, "587MHz", 300)432+ a, b := open("4.1"), open("6.1")443433 if _, err := s.Measure(ctx); err != nil {444434 t.Fatal(err)445435 }446- if st := waitSweep(t, s); st.Done != 0 || !strings.Contains(st.Note, "no tuner was free") {447- t.Errorf("busy sweep = %+v", st)436+ if st := waitSweep(t, s); st.Note != "stopped at RF 15: no tuner was free (they're left to viewers and recordings)" {437+ t.Errorf("sweep with both tuners watched = %+v", st)448438 }449- if now, _ := fake.Opened(); len(now) != len(opened) {450- t.Errorf("tuned a busy tuner: %v", now[len(opened):])439+ if a.Err() != nil || b.Err() != nil {440+ t.Fatalf("measuring ended a viewer: %v, %v", a.Err(), b.Err())451441 }452442 453- // A viewer takes the sweep's tuner: it stops there.454- fake.Free(tvhtest.Tuner0)455- fake.Free(tvhtest.Tuner1)456- fake.OnStream = func(mux string) {457- if mux == "575MHz" {458- go func() {459- time.Sleep(30 * time.Millisecond)460- fake.TakeOver("587MHz")461- }()462- }463- }443+ // One free: measuring takes it, until a viewer wants it.444+ b.Close()445+ s.timing.settle = 200 * time.Millisecond464446 if _, err := s.Measure(ctx); err != nil {465447 t.Fatal(err)466448 }467- if st := waitSweep(t, s); st.Done >= st.Total || !strings.Contains(st.Note, "stopped at RF 31: a viewer or a recording needed the tuner") {468- t.Errorf("taken over: %+v", st)469- }470-}471-472-// TestTuneNoSignal: a channel whose RF channel won't lock fails with what473-// the tuner measured, not ffmpeg's complaint; one that locks starts and474-// leaves a reading.475-func TestTuneNoSignal(t *testing.T) {476- s, fake := newTunerService(t)477- ctx := t.Context()478- fake.SetSignal("575MHz", tvhtest.Signal{Strength: 41, Quality: 0})479- // start reads the channel's stream as ffmpeg would, until data comes480- // or the start is called off.481- start := func(ctx context.Context, in tuner.Input) error {482- url := in.Args[len(in.Args)-1]483- req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)484- resp, err := http.DefaultClient.Do(req)485- if err != nil {486- return err487- }488- defer resp.Body.Close()489- if resp.StatusCode != http.StatusOK {490- return errors.New("ffmpeg exited: Server returned 503 Service Unavailable")491- }492- buf := make([]byte, 188)493- if _, err := io.ReadFull(resp.Body, buf); err != nil {494- return errors.New("stream did not start in time: ")449+ for deadline := time.Now().Add(5 * time.Second); s.sweepStatus().Done < 9; time.Sleep(5 * time.Millisecond) {450+ if time.Now().After(deadline) {451+ t.Fatalf("sweep = %+v", s.sweepStatus())495452 }496- return nil497- }498- err := s.tuneAntenna(ctx, "tv", "20.1", start)499- var ns *NoSignalError500- if !errors.As(err, &ns) || ns.RF != 31 || ns.Label != "KTVD 20.x" ||501- err.Error() != "No signal on RF 31 (KTVD 20.x) right now: the tuner measured strength 41%, quality 0% and couldn't lock" {502- t.Fatalf("tune 20.1: %v", err)503453 }504- if m, ok := s.signals.Get(575_000_000); !ok || m.Latest.Lock || *m.Latest.StrengthPct != 41 || m.Latest.Source != signal.Active {505- t.Errorf("RF 31 = %+v", m)506- }507- if err := s.tuneAntenna(ctx, "tv", "9.4", start); err == nil || !strings.HasPrefix(err.Error(), "No signal on RF 31 (KUSA 9.x on KTVD) right now") {508- t.Errorf("tune 9.4: %v", err)509- }510-511- // Locked: it starts.512- if err := s.tuneAntenna(ctx, "tv", "31.1", start); err != nil {513- t.Errorf("tune 31.1: %v", err)514- }515- for deadline := time.Now().Add(2 * time.Second); len(fake.Streams()) > 0 && time.Now().Before(deadline); {516- time.Sleep(10 * time.Millisecond)517- }518- // Not a channel the tuner found.519- if err := s.tuneAntenna(ctx, "tv", "7.1", start); !errors.Is(err, tuner.ErrNotFound) || err.Error() != "channel 7.1: not a channel the tuner found" {520- t.Errorf("tune 7.1: %v", err)521- }522- // Both tuners busy.523- fake.Busy(tvhtest.Tuner0, "587MHz", 300)524- fake.Busy(tvhtest.Tuner1, "599MHz", 300)525- slow := func(ctx context.Context, in tuner.Input) error {526- time.Sleep(5 * fastTiming.poll)527- return start(ctx, in)528- }529- if err := s.tuneAntenna(ctx, "tv", "31.1", slow); err == nil || !strings.HasPrefix(err.Error(), "every tuner is in use right now") {530- t.Errorf("tune with both tuners busy: %v", err)454+ open("50.1")455+ if st := waitSweep(t, s); !strings.Contains(st.Note, "a viewer or a recording needed the tuner") && !strings.Contains(st.Note, "no tuner was free") {456+ t.Errorf("sweep = %+v", st)531457 }532458 }533459 534460 // TestHDHomeRunAntenna: the emulated HDHomeRun lists the tuner's channels,535-// named and guided as the app has them, and nothing else.461+// named and guided as the app has them, and streams them as broadcast.536462 func TestHDHomeRunAntenna(t *testing.T) {537463 s, fake := newTunerService(t)538464 dev := &hdhr.Server{Backend: s.HDHR(), FriendlyName: "Airwaves", Tuners: s.TunerCount}@@ -555,7 +481,9 @@ func TestHDHomeRunAntenna(t *testing.T) {
555481 numbers = append(numbers, n)556482 names[n] = e["GuideName"].(string)557483 }558- if !slices.Equal(numbers, tvhNumbers(fake)) || names["20.1"] != "KTVDDT" || names["20.2"] != "H & I" || names["9.7"] != "TBD" {484+ want := lineupNumbers()485+ slices.SortFunc(want, guide.CompareNumbers)486+ if !slices.Equal(numbers, want) || names["20.1"] != "KTVDDT" || names["20.2"] != "H & I" || names["9.7"] != "TBD" {559487 t.Errorf("lineup %v, names %v", numbers, names)560488 }561489 if s.TunerCount() != 2 {@@ -579,42 +507,60 @@ func TestHDHomeRunAntenna(t *testing.T) {
579507 if len(doc.Channels) != 38 || progs["airwaves.20.1"] != 2 || progs["airwaves.7.1"] != 0 || len(progs) != 7 {580508 t.Errorf("%d channels, programmes %v", len(doc.Channels), progs)581509 }582-}583510 584-// TestMeasureFallbacks: a Tvheadend that won't stream whole multiplexes585-// is measured through a channel on each, and the RF channels with none586-// are skipped, saying so; tuners busy with Tvheadend's own scans are left587-// to them, saying that.588-func TestMeasureFallbacks(t *testing.T) {589- s, fake := newTunerService(t)590- ctx := t.Context()591- fake.NoMuxStreams = true592- if _, err := s.Measure(ctx); err != nil {511+ // A stream is the channel's program alone, and frees its tuner after.512+ ctx, cancel := context.WithTimeout(t.Context(), 300*time.Millisecond)513+ defer cancel()514+ req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/auto/v20.2", nil)515+ resp, err = http.DefaultClient.Do(req)516+ if err != nil {593517 t.Fatal(err)594518 }595- st := waitSweep(t, s)596- if st.Done != 11 || st.Note != "3 RF channels skipped: Tvheadend won't stream them" {597- t.Errorf("sweep = %+v", st)519+ body, _ := io.ReadAll(resp.Body)520+ resp.Body.Close()521+ d := ts.NewDemux()522+ var split ts.Splitter523+ split.Write(body, d.Packet)524+ if ps := d.Programs(); len(ps) != 1 || ps[0].Number != 4 {525+ t.Errorf("20.2's stream carries %+v", ps)598526 }599- channels := 0600- for _, r := range fake.Requests() {601- if strings.HasPrefix(r, "GET /stream/channel/") {602- channels++603- if !strings.HasSuffix(r, "?profile=pass&weight=10") {604- t.Errorf("subscribed as %s", r)605- }527+ for deadline := time.Now().Add(2 * time.Second); fake.Streams() > 0; time.Sleep(10 * time.Millisecond) {528+ if time.Now().After(deadline) {529+ t.Fatal("the tuner wasn't freed")606530 }607531 }608- if m, _ := s.signals.Get(575_000_000); channels != 8 || m.Latest == nil || !m.Latest.Lock {609- t.Errorf("%d channel streams; RF 31 = %+v", channels, m)610- }532+}611533 612- fake.Busy(tvhtest.Tuner0, "587MHz", 4)613- fake.Busy(tvhtest.Tuner1, "605MHz", 4)614- if _, err := s.Measure(ctx); err != nil {534+// TestScanChannels: a scan lets go of the tuners, and its result becomes535+// the lineup.536+func TestScanChannels(t *testing.T) {537+ fake := &fakeTuner{muxes: lineuptest.Muxes(), signal: map[int64][3]int{}}538+ rescan := append(lineuptest.Lineup(), hdhr.Channel{Number: "9.1", Name: "KUSA-HD", FrequencyHz: 189_000_000, Program: 3})539+ fake.Server = hdhrtest.New(t, hdhrtest.Device{Lineup: lineuptest.Lineup(), Rescan: rescan, ScanTime: 200 * time.Millisecond, Mux: fake.mux, Signal: fake.sig})540+ s, _ := newTunerService(t, func(o *Options) { o.HDHomeRun = fake.URL() })541+ ctx := t.Context()542+ sub, err := s.tuners.Open(ctx, tuner.Request{FrequencyHz: 575_000_000, Program: 3, Priority: tuner.Watching, Who: "20.1"})543+ if err != nil {615544 t.Fatal(err)616545 }617- if st := waitSweep(t, s); !strings.Contains(st.Note, "busy with Tvheadend's own guide or channel scan") {618- t.Errorf("sweep = %+v", st)546+ defer sub.Close()547+ ti, err := s.ScanChannels(ctx)548+ if err != nil || !ti.Scanning {549+ t.Fatalf("scan: %+v, %v", ti, err)550+ }551+ <-sub.Done()552+ for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(20 * time.Millisecond) {553+ s.mu.Lock()554+ s.scanAt = time.Time{}555+ s.mu.Unlock()556+ if !s.tunerInfo(ctx).Scanning {557+ break558+ }559+ if time.Now().After(deadline) {560+ t.Fatal("still scanning")561+ }562+ }563+ if got := s.AntennaChannels(ctx); got["9.1"] == "" || len(got) != 39 {564+ t.Errorf("after the scan: %d channels, 9.1 %q", len(got), got["9.1"])619565 }620566 }@@ -6,7 +6,7 @@ import (
66 "encoding/xml"77 "fmt"88 "io"9- "net/http"9+ "log"1010 "net/url"1111 "slices"1212 "strings"@@ -17,6 +17,7 @@ import (
1717 "airwaves/internal/guide"1818 "airwaves/internal/hdhr"1919 "airwaves/internal/lineup"20+ "airwaves/internal/tuner"2021 "airwaves/internal/vchan"2122 )2223 @@ -25,11 +26,12 @@ import (
2526 func (s *Service) HDHR() hdhr.Backend { return hdhrBackend{s} }2627 2728 // TunerCount is what the emulated HDHomeRun reports: the server's real28-// tuners (two until any are found). Custom channels need no tuner.29+// tuners (two until they're found). Custom channels need no tuner.2930 func (s *Service) TunerCount() int {30- s.mu.Lock()31- defer s.mu.Unlock()32- return max(len(s.fes), 2)31+ if s.tuners == nil {32+ return 233+ }34+ return cmp.Or(s.tuners.Count(), 2)3335 }3436 3537 type hdhrBackend struct{ s *Service }@@ -78,32 +80,33 @@ func (b hdhrBackend) Lineup(ctx context.Context) ([]hdhr.Entry, error) {
7880 return out, nil7981 }8082 83+// Stream implements hdhr.Backend: a custom channel, or an antenna channel84+// passed through as broadcast, on a tuner shared like the app's.8185 func (b hdhrBackend) Stream(ctx context.Context, number string, w io.Writer) error {8286 if v := b.s.customChannel(number); v != nil {8387 return v.Stream(ctx, w)8488 }85- if b.s.tvhTuner == nil {86- return fmt.Errorf("no tuner")89+ if b.s.tuners == nil {90+ return fmt.Errorf("no channel %s: %w", number, ErrNoAntenna)8791 }88- uuid, err := b.s.tvhTuner.ChannelUUID(ctx, number)92+ freq, program, err := b.s.tuning(ctx, number)8993 if err != nil {9094 return err9195 }92- req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.s.opt.Tvheadend.StreamURL(uuid), nil)96+ sub, err := b.s.tuners.Open(ctx, tuner.Request{FrequencyHz: freq, Program: program, Priority: tuner.Watching, Who: number + " for an HDHomeRun app"})9397 if err != nil {9498 return err9599 }96- // No client timeout: a stream runs until the viewer stops.97- resp, err := http.DefaultClient.Do(req)100+ defer sub.Close()101+ wait, cancel := context.WithTimeout(ctx, b.s.timing.noLock)102+ _, err = sub.Wait(wait)103+ cancel()98104 if err != nil {99105 return err100106 }101- defer resp.Body.Close()102- if resp.StatusCode != http.StatusOK {103- return fmt.Errorf("tvheadend stream: HTTP %d", resp.StatusCode)104- }105- _, err = io.Copy(w, resp.Body)106- return err107+ log.Printf("hdhr: streaming %s", number)108+ defer log.Printf("hdhr: stopped streaming %s", number)109+ return sub.Copy(ctx, w)107110 }108111 109112 type xmltvDoc struct {@@ -7,39 +7,31 @@ import (
77 "fmt"88 "io"99 "log"10- "net/http"1110 "slices"1211 "strings"13- "sync"1412 "time"1513 1614 "airwaves/internal/fcc"15+ "airwaves/internal/hdhr"1716 "airwaves/internal/lineup"1817 "airwaves/internal/signal"1918 "airwaves/internal/tuner"20- "airwaves/internal/tvh"2119 )2220 23-// sweepWeight is the weight of the sweep's subscriptions: the lowest a24-// user's can be in Tvheadend, so any viewer or recording takes the tuner25-// over, while Tvheadend's own scans (weights 1 to 7) don't.26-const sweepWeight = 1027-2821 // measureTiming holds the measuring's pauses; tests shorten them.2922 type measureTiming struct {30- // sample is how often the inputs are read while tuners are in use.23+ // sample is how often the tuners are read while in use.3124 sample time.Duration32- // appear is how long the sweep waits for its tuner to show the33- // multiplex, acquire how long it then gives the tuner to lock, settle34- // how long it waits after a lock before reading, and interval the time25+ // acquire is how long the sweep gives a tuner to lock, settle how26+ // long it waits after a lock before reading, and interval the time3527 // between its readings, readings of them (one without a lock).36- appear, acquire, settle, interval time.Duration37- readings int38- // idleWait is how long the sweep waits for an idle tuner before it28+ acquire, settle, interval time.Duration29+ readings int30+ // idleWait is how long the sweep waits for a free tuner before it3931 // gives up.4032 idleWait time.Duration41- // poll is how often the tuner is read while a channel starts, and42- // noLock how long it may go without a lock before the start fails.33+ // poll is how often the tuner is read while a channel starts or the34+ // sweep waits, and noLock how long a channel may take to start.4335 poll, noLock time.Duration4436 }4537 @@ -49,7 +41,7 @@ var defaultTiming = measureTiming{
4941 // one in three or four; its quality reads 0 for its first second on a5042 // multiplex. So about 5 s for an RF channel with a lock, 7 s for one5143 // without.52- appear: 5 * time.Second, acquire: 6 * time.Second, settle: time.Second, interval: time.Second, readings: 3,44+ acquire: 6 * time.Second, settle: time.Second, interval: time.Second, readings: 3,5345 idleWait: 30 * time.Second,5446 poll: 500 * time.Millisecond, noLock: 10 * time.Second,5547 }@@ -60,8 +52,9 @@ type SweepStatus struct {
6052 StartedAt *time.Time `json:"startedAt"`6153 FinishedAt *time.Time `json:"finishedAt"`6254 // Total is how many RF channels it measures, Done how many it has.63- // The first Found of them are those Tvheadend's scan found, measured64- // first; the rest are those a licensed station nearby uses.55+ // The first Found of them are those the tuner's scan found channels56+ // on, measured first; the rest are those a licensed station nearby57+ // uses.6558 Total int `json:"total"`6659 Found int `json:"found"`6760 Done int `json:"done"`@@ -71,177 +64,44 @@ type SweepStatus struct {
7164 Note string `json:"note,omitempty"`7265 }7366 74-// ---------- inputs ----------75-76-// tunerStatus is what Tvheadend says of its inputs at one moment: every77-// input, tuned or idle, and the subscriptions using them.78-type tunerStatus struct {79- inputs []tvh.InputStatus80- subs []tvh.Subscription81-}82-83-// busy counts the inputs something subscribes to.84-func (t tunerStatus) busy() int {85- seen := map[string]bool{}86- for _, in := range t.inputs {87- if in.InUse() {88- seen[in.Input] = true89- }90- }91- return len(seen)92-}93-94-// on finds an input in use on the antenna multiplex named mux.95-func (t tunerStatus) on(mux string) *tvh.InputStatus {96- for i := range t.inputs {97- if name, net, ok := t.inputs[i].Mux(); ok && name == mux && net == tvh.ATSCNetwork && t.inputs[i].InUse() {98- return &t.inputs[i]99- }100- }101- return nil102-}103-104-// input finds an input by name.105-func (t tunerStatus) input(name string) *tvh.InputStatus {106- for i := range t.inputs {107- if t.inputs[i].Input == name {108- return &t.inputs[i]109- }110- }111- return nil112-}113-114-// reading is what in measures now.115-func (t tunerStatus) reading(in tvh.InputStatus, at time.Time, source string) signal.Reading {116- return signal.FromInput(in, t.subs, at, source)117-}118-119-// ownScansOnly reports whether everything using the inputs is Tvheadend's120-// own scanning (weights below a user's).121-func (t tunerStatus) ownScansOnly() bool {122- own := false123- for _, in := range t.inputs {124- if in.InUse() {125- if in.Weight >= sweepWeight {126- return false127- }128- own = true129- }130- }131- return own132-}133-134-// statusNow returns the inputs and subscriptions as read in the last few135-// seconds; ok is false when Tvheadend can't be read.136-func (s *Service) statusNow() (tunerStatus, bool) {137- if s.opt.Tvheadend == nil {138- return tunerStatus{}, false139- }140- s.mu.Lock()141- st, at := s.status, s.statusAt142- s.mu.Unlock()143- if !at.IsZero() && time.Since(at) < 5*time.Second {144- return st, true145- }146- ctx, cancel := context.WithTimeout(s.ctx, 5*time.Second)147- defer cancel()148- st, err := s.readStatus(ctx)149- return st, err == nil150-}151-152-// readStatus reads the inputs and subscriptions from Tvheadend, and keeps153-// them for statusNow.154-func (s *Service) readStatus(ctx context.Context) (tunerStatus, error) {155- ins, err := s.opt.Tvheadend.Inputs(ctx)156- if err != nil {157- return tunerStatus{}, err158- }159- subs, err := s.opt.Tvheadend.Subscriptions(ctx)160- if err != nil {161- return tunerStatus{}, err162- }163- st := tunerStatus{inputs: ins, subs: subs}164- s.mu.Lock()165- s.status, s.statusAt = st, time.Now()166- s.mu.Unlock()167- return st, nil168-}169-170-// antennaMuxes maps the antenna network's multiplexes by name.171-func (s *Service) antennaMuxes(ctx context.Context) (map[string]tvh.Mux, error) {172- l, err := s.tvhTuner.Lineup(ctx)173- if err != nil {174- return nil, err175- }176- out := map[string]tvh.Mux{}177- for _, m := range l.NetworkMuxes(tvh.ATSCNetwork) {178- out[m.Name] = m179- }180- return out, nil181-}67+// ---------- tuners in use ----------18268 183-// sampleInputs records a reading of every antenna multiplex a tuner has184-// been on since the last call, whoever tuned it: a viewer, a recording or185-// Tvheadend's guide scan. An input just tuned isn't read yet, as it may186-// not have locked; the sweep reads its own.187-func (s *Service) sampleInputs(ctx context.Context) {188- st, err := s.readStatus(ctx)189- if err != nil {190- return191- }192- muxes, err := s.antennaMuxes(ctx)69+// sampleTuners records a reading of every RF channel a tuner has been on70+// since the last call, for whoever tuned it, with the errors in what71+// arrived meanwhile. A tuning just started isn't read yet, as it may not72+// have locked; the sweep reads its own.73+func (s *Service) sampleTuners(ctx context.Context) {74+ tuners, err := s.tuners.Tuners(ctx)19375 if err != nil {19476 return19577 }19678 s.mu.Lock()19779 prev, prevAt := s.lastSample, s.lastSampleAt198- sweeping := s.sweepMux80+ sweeping := s.sweepFreq19981 s.mu.Unlock()20082 now := time.Now()201- seen := map[string]tvh.InputStatus{}202- for _, in := range st.inputs {203- name, net, ok := in.Mux()204- if !ok || net != tvh.ATSCNetwork || !in.InUse() {83+ seen := map[int]tuner.State{}84+ for _, t := range tuners {85+ if t.FrequencyHz == 0 || t.Elsewhere {20586 continue20687 }207- seen[in.Input] = in208- m, ok := muxes[name]209- was, stable := prev[in.Input]210- if !ok || !stable || was.Stream != in.Stream || name == sweeping {88+ seen[t.Number] = t89+ was, stable := prev[t.Number]90+ if !stable || was.FrequencyHz != t.FrequencyHz || !was.Since.Equal(t.Since) || t.FrequencyHz == sweeping {21191 continue21292 }213- r := st.reading(in, now, signal.Active)214- r.SetErrors(was, in, now.Sub(prevAt).Seconds())215- s.signals.Record(m.FrequencyHz, signal.RF(m.FrequencyHz), r)93+ r := signal.FromTuner(t, now, signal.Active)94+ r.SetErrors(was, t, now.Sub(prevAt).Seconds())95+ s.signals.Record(t.FrequencyHz, signal.RF(t.FrequencyHz), r)21696 }21797 s.mu.Lock()21898 s.lastSample, s.lastSampleAt = seen, now21999 s.mu.Unlock()220100 }221101 222-// syncScans notes Tvheadend's scan results of the antenna multiplexes.223-func (s *Service) syncScans(ctx context.Context) {224- if l, err := s.tvhTuner.Lineup(ctx); err == nil {225- s.noteScans(l)226- }227-}228-229-// noteScans notes the scan results in l.230-func (s *Service) noteScans(l *tvh.Lineup) {231- now := time.Now()232- for _, m := range l.NetworkMuxes(tvh.ATSCNetwork) {233- if sc, ok := signal.ScanOf(m, now); ok && m.FrequencyHz > 0 {234- s.signals.SetScan(m.FrequencyHz, signal.RF(m.FrequencyHz), sc)235- }236- }237-}238-239-// sample reads the inputs every timing.sample until ctx ends, saving the102+// sample reads the tuners every timing.sample until ctx ends, saving the240103 // measurements every minute.241104 func (s *Service) sample(ctx context.Context) {242- if s.opt.Tvheadend == nil {243- return244- }245105 tick := time.NewTicker(s.timing.sample)246106 defer tick.Stop()247107 for {@@ -253,7 +113,7 @@ func (s *Service) sample(ctx context.Context) {
253113 return254114 case <-tick.C:255115 }256- s.sampleInputs(ctx)116+ s.sampleTuners(ctx)257117 if err := s.signals.SaveEvery(time.Minute); err != nil {258118 log.Printf("signal measurements: %v", err)259119 }@@ -262,13 +122,11 @@ func (s *Service) sample(ctx context.Context) {
262122 263123 // ---------- Measure now ----------264124 265-var (266- errTakenOver = errors.New("a viewer or a recording needed the tuner")267- errNotTuned = errors.New("Tvheadend didn't tune it")268-)125+// errTakenOver ends a measurement whose tuner someone needed.126+var errTakenOver = errors.New("a viewer or a recording needed the tuner")269127 270128 // Measure starts a sweep that measures every RF channel worth measuring,271-// one at a time, on an idle tuner: those the scan found channels on, and129+// one at a time, on a free tuner: those the scan found channels on, and272130 // those a licensed station nearby uses. It never takes a tuner that's in273131 // use, and stops as soon as a viewer or a recording needs the one it274132 // uses. It returns at once; Signal reports progress and the readings.@@ -276,14 +134,7 @@ func (s *Service) Measure(ctx context.Context) (*SweepStatus, error) {
276134 if s.opt.NoAntenna {277135 return nil, ErrNoAntenna278136 }279- if s.opt.Tvheadend == nil {280- return nil, errors.New("no tuner: the server has no Tvheadend")281- }282- fes, err := s.frontends(ctx, false)283- if err != nil {284- return nil, fmt.Errorf("Tvheadend is not reachable: %w", err)285- }286- if len(fes) == 0 {137+ if s.device() == nil {287138 return nil, ErrNoTuner288139 }289140 s.mu.Lock()@@ -295,7 +146,7 @@ func (s *Service) Measure(ctx context.Context) (*SweepStatus, error) {
295146 now := time.Now().UTC().Truncate(time.Second)296147 s.sweep = SweepStatus{Running: true, StartedAt: &now}297148 st := s.sweep298- go s.runSweep(s.ctx, len(fes))149+ go s.runSweep(s.ctx)299150 return &st, nil300151 }301152 @@ -311,110 +162,84 @@ func (s *Service) setSweep(f func(*SweepStatus)) {
311162 s.mu.Unlock()312163 }313164 314-// sweepTargets lists the antenna multiplexes worth measuring: first those315-// Tvheadend's scan found (by RF), then those a licensed station within the316-// report's radius broadcasts on (nearest station first); found is how317-// many of them are of the first kind. It also names a channel on each318-// found one, for servers that won't stream a whole multiplex.319-func (s *Service) sweepTargets(ctx context.Context) (muxes []tvh.Mux, found int, channelOn map[string]string, err error) {320- s.tvhTuner.Invalidate()321- l, err := s.tvhTuner.Lineup(ctx)322- if err != nil {323- return nil, 0, nil, err324- }165+// sweepTargets lists the RF channels worth measuring, by frequency: first166+// those the tuner's scan found channels on, then those a licensed station167+// within the report's radius broadcasts on (nearest station first); found168+// is how many are of the first kind.169+func (s *Service) sweepTargets(ctx context.Context) (freqs []int64, found int, err error) {325170 s.mu.Lock()171+ s.lineupAt = time.Time{}326172 rep := s.report327173 s.mu.Unlock()328- nearest := map[int]float64{} // km to the nearest licensed station, by RF174+ l, err := s.tunerLineup(ctx)175+ if err != nil {176+ return nil, 0, err177+ }178+ have := map[int64]bool{}179+ for _, c := range l {180+ if !have[c.FrequencyHz] {181+ have[c.FrequencyHz] = true182+ freqs = append(freqs, c.FrequencyHz)183+ }184+ }185+ slices.Sort(freqs)186+ found = len(freqs)187+ nearest := map[int64]float64{} // km to the nearest licensed station, by frequency329188 if rep != nil {330189 for _, st := range rep.Stations {331- if d, ok := nearest[st.RFChannel]; !ok || st.DistanceKm < d {332- nearest[st.RFChannel] = st.DistanceKm190+ f := signal.Frequency(st.RFChannel)191+ if d, ok := nearest[f]; f > 0 && !have[f] && (!ok || st.DistanceKm < d) {192+ nearest[f] = st.DistanceKm333193 }334194 }335195 }336- channelOn = map[string]string{}337- for _, c := range l.Channels {338- if _, m, ok := l.Source(c); ok && c.Enabled && channelOn[m.UUID] == "" {339- channelOn[m.UUID] = c.UUID340- }341- }342- var scanned, licensed []tvh.Mux343- for _, m := range l.NetworkMuxes(tvh.ATSCNetwork) {344- if !bool(m.Enabled) || m.FrequencyHz <= 0 {345- continue346- }347- _, near := nearest[signal.RF(m.FrequencyHz)]348- switch {349- case m.NumSvc > 0 || m.ScanResult == tvh.ScanOK || m.ScanResult == tvh.ScanPartial:350- scanned = append(scanned, m)351- case near:352- licensed = append(licensed, m)196+ licensed := slices.Collect(func(yield func(int64) bool) {197+ for f := range nearest {198+ if !yield(f) {199+ return200+ }353201 }354- }355- slices.SortFunc(scanned, func(a, b tvh.Mux) int { return cmp.Compare(a.FrequencyHz, b.FrequencyHz) })356- slices.SortFunc(licensed, func(a, b tvh.Mux) int {357- return cmp.Or(cmp.Compare(nearest[signal.RF(a.FrequencyHz)], nearest[signal.RF(b.FrequencyHz)]), cmp.Compare(a.FrequencyHz, b.FrequencyHz))358202 })359- return append(scanned, licensed...), len(scanned), channelOn, nil203+ slices.SortFunc(licensed, func(a, b int64) int { return cmp.Or(cmp.Compare(nearest[a], nearest[b]), cmp.Compare(a, b)) })204+ return append(freqs, licensed...), found, nil360205 }361206 362-func (s *Service) runSweep(ctx context.Context, tuners int) {207+func (s *Service) runSweep(ctx context.Context) {363208 note := ""364- skipped := 0365209 defer func() {366- if skipped > 0 {367- note = strings.TrimPrefix(note+fmt.Sprintf("; %d RF channels skipped: Tvheadend won't stream them", skipped), "; ")368- }369210 if err := s.signals.Save(); err != nil {370211 log.Printf("signal measurements: %v", err)371212 }372213 now := time.Now().UTC().Truncate(time.Second)373214 s.mu.Lock()374- s.sweepMux = ""215+ s.sweepFreq = 0375216 s.sweep.Running, s.sweep.FinishedAt, s.sweep.RF, s.sweep.Note = false, &now, 0, note376217 s.mu.Unlock()377218 log.Printf("measuring done: %+v", s.sweepStatus())378219 }()379- muxes, found, channelOn, err := s.sweepTargets(ctx)220+ freqs, found, err := s.sweepTargets(ctx)380221 if err != nil {381- note = "Tvheadend is not reachable: " + err.Error()222+ note = "the tuner's lineup can't be read: " + err.Error()382223 return383224 }384- s.setSweep(func(st *SweepStatus) { st.Total, st.Found = len(muxes), found })385- log.Printf("measuring %d RF channels, the %d found first", len(muxes), found)386- for _, m := range muxes {225+ s.setSweep(func(st *SweepStatus) { st.Total, st.Found = len(freqs), found })226+ log.Printf("measuring %d RF channels, the %d found first", len(freqs), found)227+ for _, f := range freqs {387228 if ctx.Err() != nil {388229 note = "stopped: the server is shutting down"389230 return390231 }391- rf := signal.RF(m.FrequencyHz)232+ rf := signal.RF(f)392233 s.setSweep(func(st *SweepStatus) { st.RF = rf })393- st, err := s.readStatus(ctx)394- if on := st.on(m.Name); err == nil && on != nil {395- // A tuner is on it already (a viewer, a recording): read that,396- // once it has locked, rather than take another.397- if r := st.reading(*on, time.Now(), signal.Sweep); r.Lock {398- s.signals.Record(m.FrequencyHz, rf, r)399- }400- s.setSweep(func(st *SweepStatus) { st.Done++ })401- continue402- }403- if st, ok := s.waitIdle(ctx, tuners); !ok {404- note = fmt.Sprintf("stopped at RF %d: %s", rf, noTunerFree(st))405- return406- }407- err = s.measureMux(ctx, m, channelOn[m.UUID])408- var se *tvh.StreamError234+ err := s.measureRF(ctx, f)235+ var busy *tuner.BusyError409236 switch {237+ case errors.As(err, &busy):238+ note = fmt.Sprintf("stopped at RF %d: no tuner was free (they're left to viewers and recordings)", rf)239+ return410240 case errors.Is(err, errTakenOver):411241 note = fmt.Sprintf("stopped at RF %d: %v", rf, err)412242 return413- case errors.As(err, &se) && se.Status == http.StatusServiceUnavailable:414- note = fmt.Sprintf("stopped at RF %d: %s", rf, noTunerFree(tunerStatus{}))415- return416- case errors.As(err, &se):417- skipped++418243 case err != nil:419244 log.Printf("measure RF %d: %v", rf, err)420245 }@@ -422,249 +247,129 @@ func (s *Service) runSweep(ctx context.Context, tuners int) {
422247 }423248 }424249 425-// waitIdle waits up to timing.idleWait for a tuner nobody subscribes to,426-// and returns the status then; ok is false when none came free.427-func (s *Service) waitIdle(ctx context.Context, tuners int) (tunerStatus, bool) {250+// measureRF records timing.readings readings of the RF channel at freq:251+// with a lock, or without one, with whatever strength the tuner measures.252+// A tuner already on it is read where it is; otherwise it waits up to253+// timing.idleWait for a free one, which any viewer or recording takes254+// back.255+func (s *Service) measureRF(ctx context.Context, freq int64) error {256+ rf := signal.RF(freq)257+ if t, ok := s.tunerOn(ctx, freq); ok {258+ if r := signal.FromTuner(t, time.Now(), signal.Sweep); r.Lock {259+ s.signals.Record(freq, rf, r)260+ }261+ return nil262+ }263+ var sub *tuner.Sub428264 deadline := time.Now().Add(s.timing.idleWait)429265 for {430- st, err := s.readStatus(ctx)431- if err == nil && st.busy() < tuners {432- return st, true266+ var err error267+ sub, err = s.tuners.Open(ctx, tuner.Request{FrequencyHz: freq, Priority: tuner.Measuring, Who: fmt.Sprintf("RF %d", rf)})268+ var busy *tuner.BusyError269+ if err == nil || !errors.As(err, &busy) || time.Now().After(deadline) {270+ if err != nil {271+ return err272+ }273+ break433274 }434- if time.Now().After(deadline) || sleep(ctx, s.timing.poll) != nil {435- return st, false275+ if err := sleep(ctx, s.timing.poll); err != nil {276+ return err436277 }437278 }438-}439-440-// noTunerFree says why the sweep found no tuner: in use by viewers or441-// recordings, which it leaves alone, or by Tvheadend's own guide or442-// channel scans (weights below a user's), which end on their own.443-func noTunerFree(st tunerStatus) string {444- if st.ownScansOnly() {445- return "every tuner is busy with Tvheadend's own guide or channel scan; try again in a few minutes"446- }447- return "no tuner was free (they're left to viewers and recordings)"448-}449-450-func sleep(ctx context.Context, d time.Duration) error {451- t := time.NewTimer(d)452- defer t.Stop()453- select {454- case <-ctx.Done():455- return ctx.Err()456- case <-t.C:457- return nil458- }459-}460-461-// measureMux subscribes to a multiplex at the sweep's weight, waits for a462-// tuner to take it, and records timing.readings readings of it: with a463-// lock, or without one, with whatever strength the tuner measures. A464-// subscription Tvheadend refuses (a server that won't stream whole465-// multiplexes) is retried on channelUUID, a channel of the multiplex,466-// when it has one.467-func (s *Service) measureMux(ctx context.Context, m tvh.Mux, channelUUID string) error {468- ctx, cancel := context.WithCancel(ctx)469- defer cancel()279+ defer sub.Close()470280 s.mu.Lock()471- s.sweepMux = m.Name281+ s.sweepFreq = freq472282 s.mu.Unlock()473- rf := signal.RF(m.FrequencyHz)474-475- // Tvheadend answers a multiplex it can't lock with no headers at all,476- // so this waits on its own while the tuner is read; what comes is477- // drained, so the stream keeps flowing.478- opened := make(chan error, 1)479- go func() {480- body, err := s.opt.Tvheadend.OpenMux(ctx, m.UUID, sweepWeight)481- var se *tvh.StreamError482- if errors.As(err, &se) && se.Status != http.StatusServiceUnavailable && channelUUID != "" {483- body, err = s.opt.Tvheadend.OpenChannel(ctx, channelUUID, sweepWeight)484- }485- opened <- err486- if err != nil {487- return488- }489- defer body.Close()490- _, _ = io.Copy(io.Discard, body)491- }()492283 493- // Wait for a tuner to show the multiplex.494- var input string495- deadline := time.Now().Add(s.timing.appear)496- for input == "" {284+ // Give the tuner time to lock: a weak signal can take seconds. What it285+ // measures meanwhile, past its first second on the RF channel, is the286+ // reading when it doesn't lock.287+ read := func() (tuner.State, error) {497288 select {498- case err := <-opened:499- if err != nil {500- return err501- }289+ case <-sub.Done():290+ return tuner.State{}, errTakenOver502291 default:503292 }504- if st, err := s.readStatus(ctx); err == nil {505- if on := st.on(m.Name); on != nil {506- input = on.Input507- break508- }509- }510- if time.Now().After(deadline) {511- return fmt.Errorf("RF %d: %w", rf, errNotTuned)512- }513- if err := sleep(ctx, s.timing.poll); err != nil {514- return err293+ tuners, err := s.tuners.Tuners(ctx)294+ if err != nil {295+ return tuner.State{}, err515296 }297+ return tuners[sub.Tuner()], nil516298 }517- // Give the tuner time to lock: a weak signal can take seconds. What it518- // measures meanwhile, past its first second on the multiplex (which519- // reads what it measured before, or nothing), is the reading when it520- // doesn't lock.521- locked := false522299 var tried *signal.Reading523300 start := time.Now()524- for !locked && time.Since(start) < s.timing.acquire {301+ for !sub.Locked() && time.Since(start) < s.timing.acquire {525302 if err := sleep(ctx, s.timing.poll); err != nil {526303 return err527304 }528- st, err := s.readStatus(ctx)305+ t, err := read()529306 if err != nil {530- continue531- }532- on := st.on(m.Name)533- if on == nil {534- if in := st.input(input); in != nil && in.InUse() {535- return errTakenOver536- }537- break // Tvheadend gave up on it307+ return err538308 }539- input = on.Input540- r := st.reading(*on, time.Now(), signal.Sweep)541- locked = r.Lock542- if !locked && time.Since(start) >= s.timing.settle {309+ if r := signal.FromTuner(t, time.Now(), signal.Sweep); time.Since(start) >= s.timing.settle {543310 tried = &r544311 }545312 }546- if !locked {313+ if !sub.Locked() {547314 if tried == nil {548- return fmt.Errorf("RF %d: the tuner left it before a reading", rf)315+ return fmt.Errorf("RF %d: no reading", rf)549316 }550- s.signals.Record(m.FrequencyHz, rf, *tried)317+ s.signals.Record(freq, rf, *tried)551318 return nil552319 }553320 if err := sleep(ctx, s.timing.settle); err != nil {554321 return err555322 }556- readings := s.timing.readings557- var prev *tvh.InputStatus323+ var prev *tuner.State558324 var prevAt time.Time559- for i := range readings {325+ for i := range s.timing.readings {560326 if i > 0 {561327 if err := sleep(ctx, s.timing.interval); err != nil {562328 return err563329 }564330 }565- st, err := s.readStatus(ctx)331+ t, err := read()566332 if err != nil {567333 return err568334 }569- on := st.on(m.Name)570- if on == nil {571- if in := st.input(input); in != nil && in.InUse() {572- // Its tuner is on another multiplex now: someone else's573- // subscription took it over.574- return errTakenOver575- }576- // Tvheadend let the multiplex go (or is between577- // subscriptions): there's nothing to read, and nothing is578- // made up.579- return fmt.Errorf("RF %d: the tuner left it after %d readings", rf, i)580- }581- // Tvheadend may move a subscription that can't lock to another582- // tuner; follow it there.583- input = on.Input584335 now := time.Now()585- r := st.reading(*on, now, signal.Sweep)336+ r := signal.FromTuner(t, now, signal.Sweep)586337 if prev != nil {587- r.SetErrors(*prev, *on, now.Sub(prevAt).Seconds())338+ r.SetErrors(*prev, t, now.Sub(prevAt).Seconds())588339 }589- prev, prevAt = on, now590- s.signals.Record(m.FrequencyHz, rf, r)340+ prev, prevAt = &t, now341+ s.signals.Record(freq, rf, r)591342 }592343 return nil593344 }594345 595-// ---------- tuning ----------596-597-// tuneWatch follows the tuner while a channel starts.598-type tuneWatch struct {599- done chan struct{}600-601- mu sync.Mutex602- // reading is the latest of the channel's multiplex, taken at last; a603- // tuner has been on it since since.604- reading *signal.Reading605- since, last time.Time606- // busy is set when every tuner was in use by others while it waited.607- busy bool608-}609-610-// errNoLock cancels a start that has had no lock for timing.noLock.611-var errNoLock = errors.New("no lock")612-613-// watchTune reads the tuner every timing.poll while ctx lasts, and614-// cancels it when the tuner has been on mux without a lock for615-// timing.noLock.616-func (s *Service) watchTune(ctx context.Context, cancel context.CancelCauseFunc, mux tvh.Mux, tuners int) *tuneWatch {617- w := &tuneWatch{done: make(chan struct{})}618- go func() {619- defer close(w.done)620- var unlocked time.Time621- for {622- if sleep(ctx, s.timing.poll) != nil {623- return624- }625- st, err := s.readStatus(ctx)626- if err != nil {627- continue628- }629- on := st.on(mux.Name)630- w.mu.Lock()631- if on == nil {632- w.busy = w.busy || tuners > 0 && st.busy() >= tuners633- w.mu.Unlock()634- continue635- }636- now := time.Now()637- if w.since.IsZero() {638- w.since = now639- }640- r := st.reading(*on, now, signal.Active)641- w.reading, w.last = &r, now642- w.mu.Unlock()643- switch {644- case r.Lock:645- unlocked = time.Time{}646- case unlocked.IsZero():647- unlocked = now648- case now.Sub(unlocked) >= s.timing.noLock:649- cancel(errNoLock)650- return651- }346+// tunerOn finds a tuner of ours already on the RF channel at freq.347+func (s *Service) tunerOn(ctx context.Context, freq int64) (tuner.State, bool) {348+ tuners, err := s.tuners.Tuners(ctx)349+ if err != nil {350+ return tuner.State{}, false351+ }352+ for _, t := range tuners {353+ if t.FrequencyHz == freq && !t.Elsewhere {354+ return t, true652355 }653- }()654- return w356+ }357+ return tuner.State{}, false655358 }656359 657-// result waits for the watch to end and returns what it saw.658-func (w *tuneWatch) result() (r *signal.Reading, on time.Duration, busy bool) {659- <-w.done660- w.mu.Lock()661- defer w.mu.Unlock()662- if w.reading != nil {663- on = w.last.Sub(w.since)664- }665- return w.reading, on, w.busy360+func sleep(ctx context.Context, d time.Duration) error {361+ t := time.NewTimer(d)362+ defer t.Stop()363+ select {364+ case <-ctx.Done():365+ return ctx.Err()366+ case <-t.C:367+ return nil368+ }666369 }667370 371+// ---------- tuning ----------372+668373 // NoSignalError is a channel that didn't start because its RF channel had669374 // no lock.670375 type NoSignalError struct {@@ -685,15 +390,9 @@ func (e *NoSignalError) Error() string {
685390 if r.StrengthPct != nil {686391 parts = append(parts, fmt.Sprintf("strength %.0f%%", *r.StrengthPct))687392 }688- if r.StrengthDBm != nil {689- parts = append(parts, fmt.Sprintf("strength %.1f dBm", *r.StrengthDBm))690- }691393 if r.QualityPct != nil {692394 parts = append(parts, fmt.Sprintf("quality %.0f%%", *r.QualityPct))693395 }694- if r.SNRdB != nil {695- parts = append(parts, fmt.Sprintf("SNR %.1f dB", *r.SNRdB))696- }697396 if len(parts) > 0 {698397 msg += ": the tuner measured " + strings.Join(parts, ", ") + " and couldn't lock"699398 }@@ -715,50 +414,78 @@ func rfLabel(c lineup.TunerChannel) string {
715414 return label716415 }717416 718-// tuneAntenna starts an antenna channel, watching the tuner meanwhile so719-// a channel that won't lock fails with what was measured.417+// tuneAntenna starts an antenna channel for client: a tuner (client's own418+// last one, when every tuner is busy), its program, and start, which419+// reads it. A channel that won't lock fails with what was measured.720420 func (s *Service) tuneAntenna(ctx context.Context, client, number string, start func(context.Context, tuner.Input) error) error {721- ch, err := s.tvhTuner.Channel(ctx, number)722- if errors.Is(err, tuner.ErrNotFound) && len(s.offeredNetworks()) == 0 {723- return ErrNoTuner724- }421+ freq, program, err := s.tuning(ctx, number)725422 if err != nil {726423 return err727424 }728- in, err := s.tvhTuner.Input(ctx, number)425+ sub, err := s.tuners.Open(ctx, tuner.Request{FrequencyHz: freq, Program: program, Priority: tuner.Watching, Who: number, Owner: client})426+ var busy *tuner.BusyError427+ if errors.As(err, &busy) {428+ return fmt.Errorf("every tuner is in use right now (%s), so %s can't be tuned", strings.Join(busy.Users, "; "), number)429+ }729430 if err != nil {730431 return err731432 }732- fes, _ := s.frontends(ctx, false)733- tctx, cancel := context.WithCancelCause(ctx)734- w := s.watchTune(tctx, cancel, ch.Mux, len(fes))735- err = start(tctx, in)736- cancel(nil)737- reading, on, busy := w.result()738- // A tuner that has been on it a while has had time to lock, or not.739- if reading != nil && on >= 3*s.timing.poll && ch.Mux.FrequencyHz > 0 {740- s.signals.Record(ch.Mux.FrequencyHz, signal.RF(ch.Mux.FrequencyHz), *reading)741- }742- if err == nil {743- return nil433+ wait, cancel := context.WithTimeout(ctx, s.timing.noLock)434+ p, err := sub.Wait(wait)435+ cancel()436+ if err != nil {437+ defer sub.Close()438+ return s.tuneFailed(ctx, sub, number, freq, err)439+ }440+ audio, note := tuner.AudioTracks(p)441+ in := tuner.Input{442+ Args: []string{"-fflags", "+genpts", "-f", "mpegts", "-i", "pipe:0"},443+ Video: "0:v:0", Audio: audio, Note: note, Broadcast: true,444+ Source: func(ctx context.Context, w io.Writer) error {445+ defer sub.Close()446+ return sub.Copy(ctx, w)447+ },448+ }449+ if err := start(ctx, in); err != nil {450+ sub.Close()451+ return err744452 }745- label := ""746- s.mu.Lock()747- rep, g := s.report, s.guide748- s.mu.Unlock()749- if chans, _, merr := s.matched(ctx, rep, g); merr == nil {750- for _, c := range chans {751- if c.Number == number {752- label = rfLabel(c)753- }453+ return nil454+}455+456+// tuneFailed says why a channel didn't start, with what the tuner measured457+// of its RF channel.458+func (s *Service) tuneFailed(ctx context.Context, sub *tuner.Sub, number string, freq int64, err error) error {459+ rf := signal.RF(freq)460+ var reading *signal.Reading461+ if tuners, terr := s.tuners.Tuners(ctx); terr == nil && sub.Tuner() < len(tuners) {462+ if t := tuners[sub.Tuner()]; t.FrequencyHz == freq && t.Device.StrengthPct != nil {463+ r := signal.FromTuner(t, time.Now(), signal.Active)464+ reading = &r465+ s.signals.Record(freq, rf, r)754466 }755467 }756- rf := signal.RF(ch.Mux.FrequencyHz)468+ var he *hdhr.Error757469 switch {758- case reading != nil && !reading.Lock:470+ case errors.As(err, &he) && he.Code == 803:471+ return errors.New("the tuner is scanning for channels; try again in a few minutes")472+ case errors.As(err, &he) && he.Busy():473+ return fmt.Errorf("the tuner is busy with something else, so %s can't be tuned", number)474+ case errors.Is(err, tuner.ErrNoProgram):475+ return fmt.Errorf("RF %d no longer carries %s: the tuner needs to scan for channels again", rf, number)476+ case errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil && !sub.Locked():477+ label := ""478+ s.mu.Lock()479+ rep, g := s.report, s.guide480+ s.mu.Unlock()481+ if chans, _, merr := s.matched(ctx, rep, g); merr == nil {482+ for _, c := range chans {483+ if c.Number == number {484+ label = rfLabel(c)485+ }486+ }487+ }759488 return &NoSignalError{Channel: number, RF: rf, Label: label, Reading: reading}760- case reading == nil && busy:761- return fmt.Errorf("every tuner is in use right now (by a viewer or a recording), so %s can't be tuned", number)762489 }763490 return fmt.Errorf("%s (RF %d) didn't start: %w", number, rf, err)764491 }@@ -6,6 +6,7 @@ import (
66 "fmt"77 "net/http"88 "net/http/httptest"9+ "path/filepath"910 "slices"1011 "strings"1112 "testing"@@ -13,25 +14,23 @@ import (
1314 "airwaves/internal/dvr"1415 "airwaves/internal/geo"1516 "airwaves/internal/store"16- "airwaves/internal/tvh"17- "airwaves/internal/web"1817 )1918 20-// TestNoAntenna: a server with custom channels only has no Tvheadend (even19+// TestNoAntenna: a server with custom channels only has no tuner (even2120 // when given one), lineup, listings, reception or recording; its snapshot2221 // is the custom channels, and Info says there's no antenna.2322 func TestNoAntenna(t *testing.T) {2423 ctx := t.Context()2524 s, err := New(Options{2625 Name: "friend", Mode: "server", NoAntenna: true, CacheDir: t.TempDir(),27- Tvheadend: tvh.New("http://127.0.0.1:1", web.NewClient()), Demo: true,26+ HDHomeRun: "http://127.0.0.1:1", DVRPath: filepath.Join(t.TempDir(), "dvr.json"), RecordingsDir: t.TempDir(),2827 WeatherStarPort: 8090, Config: Config{Lat: 39.74, Lon: -104.99},2928 })3029 if err != nil {3130 t.Fatal(err)3231 }33- if s.opt.Tvheadend != nil || s.tvhTuner != nil || s.dvr != nil {34- t.Error("Tvheadend kept without an antenna")32+ if s.tuners != nil || s.dvr != nil {33+ t.Error("a tuner or recording without an antenna")3534 }3635 3736 info, _ := s.Info(ctx)@@ -30,13 +30,13 @@ import (
3030 "airwaves/internal/dvr"3131 "airwaves/internal/geo"3232 "airwaves/internal/guide"33+ "airwaves/internal/hdhr"3334 "airwaves/internal/lineup"3435 "airwaves/internal/phase"3536 "airwaves/internal/signal"3637 "airwaves/internal/store"3738 "airwaves/internal/stream"3839 "airwaves/internal/tuner"39- "airwaves/internal/tvh"4040 "airwaves/internal/vchan"4141 "airwaves/internal/weather"4242 "airwaves/internal/web"@@ -45,8 +45,9 @@ import (
4545 // Version is reported to clients.4646 const Version = "0.2.0"4747 48-// ErrNoDVR is returned by recording calls when no Tvheadend is configured.49-var ErrNoDVR = errors.New("recording needs an Airwaves server with Tvheadend")48+// ErrNoDVR is returned by recording calls on a server with nowhere to49+// keep recordings.50+var ErrNoDVR = errors.New("recording needs a recordings folder on the server")5051 5152 // ErrNotRecordable is returned when asked to record a custom channel.5253 var ErrNotRecordable = errors.New("custom channels play from the server and can't be recorded")@@ -142,20 +143,17 @@ type Options struct {
142143 // ConfigPath persists Config; empty keeps it in memory.143144 ConfigPath string144145 Config Config145- // Loopback serves streams on 127.0.0.1 for an in-process player.146- Loopback bool147- Tuner tuner.Tuner148- // Tvheadend enables server tuners and recording.149- Tvheadend *tvh.Client150- DVRPath string151- // Demo fills Tvheadend with generated channels, for development before152- // there's a tuner. They're never made, and are removed, once a tuner's153- // antenna network exists.154- Demo bool155- // DemoFFmpeg is the ffmpeg path inside the Tvheadend container.156- DemoFFmpeg string157- // NoAntenna serves custom channels only: no Tvheadend, demo channels,158- // lineup or listings from the ZIP code, reception reports or recording.146+ // HDHomeRun is the tuner's address or device ID; empty finds it by147+ // discovery. SkipDeviceID is a device never to use: this server's own148+ // emulated one.149+ HDHomeRun string150+ SkipDeviceID uint32151+ // DVRPath keeps the recording rules and recordings, and RecordingsDir152+ // the recorded files; recording needs both.153+ DVRPath string154+ RecordingsDir string155+ // NoAntenna serves custom channels only: no tuner, lineup or listings156+ // from the ZIP code, reception reports or recording.159157 // The location (Config) is then only for the weather, and there's none160158 // unless it's given.161159 NoAntenna bool@@ -199,7 +197,7 @@ type Service struct {
199197 cancel context.CancelFunc200198 streams *stream.Server201199 streamErr error202- tvhTuner *tuner.Tvheadend200+ tuners *tuner.Manager203201 dvr *dvr.Manager204202 weather *weather.Source205203 // wx is the weather channel, when there's a WeatherStar display;@@ -214,34 +212,31 @@ type Service struct {
214212 215213 mu sync.Mutex216214 cfg Config217- tuner tuner.Tuner218215 report *lineup.Report219216 guide *guide.Guide220217 built time.Time221218 wxUp bool // the display at WeatherStarURL answered last time222- // fes are the ATSC tuners Tvheadend has, as of fesAt; fesKnown once223- // looked up.224- fes []tvh.Frontend225- fesAt time.Time226- fesKnown bool227- // status is the inputs and subscriptions as of statusAt; lastSample228- // the inputs at the last sample, and when.229- status tunerStatus230- statusAt time.Time231- lastSample map[string]tvh.InputStatus219+ // dev is the HDHomeRun, nil until found; devErr why the last search220+ // didn't find it.221+ dev *hdhr.Device222+ devErr string223+ // lineup is the tuner's channels as of lineupAt; scan its channel224+ // scan as of scanAt.225+ lineup []hdhr.Channel226+ lineupAt time.Time227+ scan hdhr.ScanStatus228+ scanAt time.Time229+ // lastSample is the tuners at the last sample, and when.230+ lastSample map[int]tuner.State232231 lastSampleAt time.Time233232 sweep SweepStatus234- sweepMux string // the multiplex the sweep is on233+ sweepFreq int64 // the RF channel the sweep is on235234 }236235 237236 // New builds a Service. Streaming failures (no ffmpeg) are reported by238237 // Info rather than failing construction, so the guide still works.239238 func New(opt Options) (*Service, error) {240- if opt.NoAntenna {241- opt.Tvheadend, opt.Demo = nil, false242- opt.Tuner = noAntenna{}243- }244- s := &Service{opt: opt, http: web.NewClient(), tuner: opt.Tuner, timing: defaultTiming}239+ s := &Service{opt: opt, http: web.NewClient(), timing: defaultTiming}245240 s.ctx, s.cancel = context.WithCancel(context.Background())246241 s.cfg = s.withDefaults(opt.Config)247242 if opt.ConfigPath != "" {@@ -269,20 +264,16 @@ func New(opt Options) (*Service, error) {
269264 log.Print(err)270265 }271266 s.weather = weather.NewSource(s.http)272- s.streams, s.streamErr = stream.NewServer(opt.Loopback)267+ s.streams, s.streamErr = stream.NewServer(false)273268 274- if opt.Tvheadend != nil {275- s.tvhTuner = tuner.NewTvheadend(opt.Tvheadend)276- s.tvhTuner.Networks = s.offeredNetworks277- s.tuner = s.tvhTuner278- s.dvr, err = dvr.Open(opt.DVRPath, opt.Tvheadend, s.tvhTuner)279- if err != nil {280- return nil, err269+ if !opt.NoAntenna {270+ s.tuners = tuner.NewManager()271+ if opt.DVRPath != "" && opt.RecordingsDir != "" {272+ if s.dvr, err = dvr.Open(opt.DVRPath, opt.RecordingsDir, s.tuners, s.tuning); err != nil {273+ return nil, err274+ }281275 }282276 }283- if s.tuner == nil {284- s.tuner = noTuner{}285- }286277 if opt.WeatherStarPort > 0 {287278 s.wxLocal = strings.TrimRight(opt.WeatherStarURL, "/")288279 if s.wxLocal == "" {@@ -336,29 +327,6 @@ func (s *Service) withDefaults(c Config) Config {
336327 return c.normalized()337328 }338329 339-// noAntenna is the tuner of a server with custom channels only.340-type noAntenna struct{}341-342-func (noAntenna) Device() tuner.Device {343- return tuner.Device{ID: "none", Name: "No antenna", Kind: "none", Detail: "custom channels only"}344-}345-346-func (noAntenna) Input(_ context.Context, number string) (tuner.Input, error) {347- return tuner.Input{}, fmt.Errorf("no channel %s: %w", number, ErrNoAntenna)348-}349-350-// noTuner is the tuner of a server with an antenna but no Tvheadend: it351-// has no channels.352-type noTuner struct{}353-354-func (noTuner) Device() tuner.Device {355- return tuner.Device{ID: "none", Name: "No tuner", Kind: "none", Detail: "the server has no Tvheadend"}356-}357-358-func (noTuner) Input(_ context.Context, number string) (tuner.Input, error) {359- return tuner.Input{}, fmt.Errorf("no channel %s: %w", number, ErrNoTuner)360-}361-362330 // wxOn reports whether the weather channel can be shown: there's a363331 // WeatherStar display, answering when it runs apart from the server, and a364332 // location for it to show.@@ -427,9 +395,13 @@ func (s *Service) customChannel(number string) vchan.Channel {
427395 return nil428396 }429397 430-// Close stops all streams and measuring, and saves the measurements.398+// Close stops all streams, recordings and measuring, and saves the399+// measurements. Recordings in progress carry on at the next start.431400 func (s *Service) Close() {432401 s.cancel()402+ if s.dvr != nil {403+ s.dvr.Close()404+ }433405 if s.streams != nil {434406 s.streams.Close()435407 }@@ -451,38 +423,14 @@ func (s *Service) StreamHandler() http.Handler {
451423 452424 // Info implements the Backend contract.453425 func (s *Service) Info(context.Context) (Info, error) {454- s.mu.Lock()455- t := s.tuner456- s.mu.Unlock()457- i := Info{Name: s.opt.Name, Mode: s.opt.Mode, Version: Version, Tuner: t.Device(), DVR: s.dvr != nil, WeatherStar: s.wxOn(),426+ i := Info{Name: s.opt.Name, Mode: s.opt.Mode, Version: Version, Tuner: s.tunerDevice(), DVR: s.dvr != nil, WeatherStar: s.wxOn(),458427 Antenna: !s.opt.NoAntenna}459- if s.opt.Tvheadend != nil {460- ctx, cancel := context.WithTimeout(s.ctx, 10*time.Second)461- defer cancel()462- if fes, err := s.frontends(ctx, false); err == nil {463- i.Tuner.Tuners = len(fes)464- for _, fe := range fes {465- i.Tuner.Model = cmp.Or(i.Tuner.Model, fe.Model)466- }467- if len(fes) > 0 {468- // Tvheadend's ATSC tuners receive ATSC 1.0 only.469- i.Tuner.Standards = []string{"ATSC 1.0"}470- }471- }472- }473428 if s.streamErr != nil {474429 i.Playback = s.streamErr.Error()475430 }476431 return i, nil477432 }478433 479-// SetTuner switches the live source (local mode).480-func (s *Service) SetTuner(t tuner.Tuner) {481- s.mu.Lock()482- s.tuner = t483- s.mu.Unlock()484-}485-486434 // Config returns the location settings.487435 func (s *Service) Config(context.Context) (Config, error) {488436 s.mu.Lock()@@ -843,24 +791,17 @@ func (s *Service) Tune(ctx context.Context, client, number string) (*stream.Play
843791 }844792 return s.streams.Start(ctx, client, in)845793 }846- if s.tvhTuner != nil {847- ctx = phase.NewContext(ctx, phase.New("tune "+number+" (antenna)", "antenna"))848- var pb *stream.Playback849- err := s.tuneAntenna(ctx, client, number, func(ctx context.Context, in tuner.Input) error {850- var err error851- pb, err = s.streams.Start(ctx, client, in)852- return err853- })854- return pb, err855- }856- s.mu.Lock()857- t := s.tuner858- s.mu.Unlock()859- in, err := t.Input(ctx, number)860- if err != nil {861- return nil, err794+ if s.opt.NoAntenna {795+ return nil, fmt.Errorf("no channel %s: %w", number, ErrNoAntenna)862796 }863- return s.streams.Start(ctx, client, in)797+ ctx = phase.NewContext(ctx, phase.New("tune "+number+" (antenna)", "antenna"))798+ var pb *stream.Playback799+ err := s.tuneAntenna(ctx, client, number, func(ctx context.Context, in tuner.Input) error {800+ var err error801+ pb, err = s.streams.Start(ctx, client, in)802+ return err803+ })804+ return pb, err864805 }865806 866807 // Stop ends client's playback.@@ -925,51 +866,56 @@ func (s *Service) DeleteRecording(ctx context.Context, id string) error {
925866 return s.dvr.DeleteEntry(ctx, id)926867 }927868 928-// PlayRecording streams a recording to client, starting near from seconds929-// in (zero plays from the beginning).869+// PlayRecording streams a recording to client, from seconds in (zero870+// plays from the beginning). One still recording plays as far as it has871+// got, and on as it grows.930872 func (s *Service) PlayRecording(ctx context.Context, client, id string, from float64) (*stream.Playback, error) {931873 if s.dvr == nil {932- return nil, ErrNoDVR874+ return nil, s.noDVR()933875 }934876 if s.streamErr != nil {935877 return nil, s.streamErr936878 }937- url := s.opt.Tvheadend.FileURL(id)938- audio, err := s.streams.ProbeAudio(ctx, url)879+ r, path, ok := s.dvr.Get(id)880+ if !ok {881+ return nil, fmt.Errorf("no recording %s", id)882+ }883+ if r.Status == dvr.Failed {884+ return nil, errors.New("that recording failed and can't be played")885+ }886+ audio, err := s.streams.ProbeAudio(ctx, path)939887 if err != nil || len(audio) == 0 {940888 audio = []tuner.AudioTrack{{Map: "0:a:0"}}941889 }942- // Read the file front to back: Tvheadend drops the connection when943- // ffmpeg seeks to probe the end. To resume, start the HTTP read at the944- // matching byte offset instead; MPEG-TS resynchronizes on its own.945- args := []string{"-seekable", "0"}946- if size, dur := s.recordingSize(ctx, id); from > 5 && size > 0 && dur > 0 && from < dur {947- off := int64(float64(size)*from/dur) / 188 * 188948- args = append(args, "-offset", strconv.FormatInt(off, 10))949- } else {890+ var args []string891+ if from <= 5 || from >= r.Duration(time.Now()).Seconds() {950892 from = 0893+ } else {894+ args = append(args, "-ss", strconv.FormatFloat(from, 'f', 1, 64))895+ }896+ if r.Status == dvr.Recording {897+ args = append(args, "-follow", "1")951898 }952- args = append(args, "-i", url)899+ args = append(args, "-i", path)953900 in := tuner.Input{Args: args, Video: "0:v:0", Audio: audio, Broadcast: true, VOD: true, Offset: from}954901 return s.streams.Start(ctx, client, in)955902 }956903 957-// recordingSize returns a recording's file size and duration in seconds.958-func (s *Service) recordingSize(ctx context.Context, id string) (int64, float64) {959- entries, err := s.opt.Tvheadend.Finished(ctx)960- if err != nil {961- return 0, 0904+// StopRecording ends a recording in progress early, keeping what it905+// recorded.906+func (s *Service) StopRecording(_ context.Context, id string) error {907+ if s.dvr == nil {908+ return s.noDVR()962909 }963- for _, e := range entries {964- if e.UUID == id {965- dur := float64(e.StopReal - e.StartReal)966- if dur <= 0 {967- dur = float64(e.Stop - e.Start)968- }969- return e.FileSize, dur970- }910+ return s.dvr.Stop(id)911+}912+913+// ExtendRecording makes a recording in progress run minutes longer.914+func (s *Service) ExtendRecording(_ context.Context, id string, minutes int) (time.Time, error) {915+ if s.dvr == nil {916+ return time.Time{}, s.noDVR()971917 }972- return 0, 0918+ return s.dvr.Extend(id, time.Duration(minutes)*time.Minute)973919 }974920 975921 // SaveProgress records how far a recording has been watched.@@ -1014,12 +960,17 @@ func (s *Service) recordingChannels(ctx context.Context) (*guide.Guide, []lineup
1014960 return g, dvrChannels(chans), err1015961 }1016962 1017-// Run keeps a server healthy until ctx ends: refreshes listings, keeps1018-// Tvheadend's channels matching the hardware (the antenna network once a1019-// tuner appears; demo channels before, in demo mode), measures the signal1020-// while tuners are in use, and reconciles recordings.963+// Run keeps a server healthy until ctx ends: refreshes listings, finds the964+// tuner and keeps up with it, measures the signal while tuners are in use,965+// and makes recordings.1021966 func (s *Service) Run(ctx context.Context) {1022- go s.sample(ctx)967+ if s.tuners != nil {968+ s.findTuner(ctx)969+ go s.sample(ctx)970+ if s.dvr != nil {971+ go s.dvr.Run(ctx, s.recordingChannels)972+ }973+ }1023974 tick := time.NewTicker(time.Minute)1024975 defer tick.Stop()1025976 for n := 0; ; n++ {@@ -1032,48 +983,17 @@ func (s *Service) Run(ctx context.Context) {
1032983 }1033984 }1034985 1035-// maintain runs once a minute. Reconciling is a few local Tvheadend calls,1036-// so it runs every time; mapping is asynchronous in Tvheadend, so channels1037-// mapped now are scheduled on the next pass.986+// maintain runs once a minute.1038987 func (s *Service) maintain(ctx context.Context, prune bool) {1039988 s.checkWeatherStar(ctx)1040- _, snapErr := s.snapshot(ctx, false)1041- if snapErr != nil {1042- log.Printf("snapshot: %v", snapErr)1043- }1044- if s.opt.Tvheadend == nil {1045- return1046- }1047- if err := s.setupTuners(ctx); err != nil {1048- log.Printf("tvheadend setup: %v", err)1049- }1050- if n, err := s.opt.Tvheadend.RemoveOrphanChannels(ctx); err != nil {1051- log.Printf("remove orphan channels: %v", err)1052- } else if n > 0 {1053- log.Printf("removed %d channels left without a service", n)1054- s.tvhTuner.Invalidate()1055- }1056- if n, err := s.opt.Tvheadend.MapServices(ctx); err != nil {1057- log.Printf("map services: %v", err)1058- } else if n > 0 {1059- log.Printf("mapped %d new channels", n)1060- s.tvhTuner.Invalidate()1061- }1062- s.syncScans(ctx)1063- if snapErr != nil {1064- return989+ if _, err := s.snapshot(ctx, false); err != nil {990+ log.Printf("snapshot: %v", err)1065991 }1066- // Without the tuner's lineup, recordings stay as they are rather than1067- // being unscheduled for channels that seem gone.1068- g, chans, err := s.recordingChannels(ctx)1069- if err != nil {1070- log.Printf("reconcile recordings: %v", err)992+ if s.tuners == nil {1071993 return1072994 }1073- if err := s.dvr.Reconcile(ctx, g, chans); err != nil {1074- log.Printf("reconcile recordings: %v", err)1075- }1076- if prune {995+ s.findTuner(ctx)996+ if prune && s.dvr != nil {1077997 if err := s.dvr.Retain(ctx); err != nil {1078998 log.Printf("retain recordings: %v", err)1079999 }@@ -1082,86 +1002,3 @@ func (s *Service) maintain(ctx context.Context, prune bool) {
10821002 }10831003 }10841004 }1085-1086-// setupTuners attaches real tuners to the antenna network when present.1087-// Once that network exists (a tuner has been found), demo channels are1088-// removed and never made again, even while the tuner is away; before, in1089-// demo mode, they stand in for the antenna.1090-func (s *Service) setupTuners(ctx context.Context) error {1091- c := s.opt.Tvheadend1092- fes, err := s.frontends(ctx, true)1093- if err != nil {1094- return err1095- }1096- if len(fes) > 0 {1097- changed, err := c.EnsureATSC(ctx, fes)1098- if err != nil {1099- return err1100- }1101- if changed {1102- log.Printf("antenna network created for %d tuners; Tvheadend is scanning", len(fes))1103- s.tvhTuner.Invalidate()1104- }1105- }1106- nets, err := c.Networks(ctx)1107- if err != nil {1108- return err1109- }1110- var real, demo bool1111- for _, n := range nets {1112- real = real || n.Name == tvh.ATSCNetwork1113- demo = demo || n.Name == tvh.DemoNetwork1114- }1115- switch {1116- case real && demo:1117- if err := c.RemoveNetwork(ctx, tvh.DemoNetwork); err != nil {1118- return fmt.Errorf("remove the demo channels: %w", err)1119- }1120- log.Print("removed the demo channels: a tuner's antenna network exists")1121- s.tvhTuner.Invalidate()1122- case real, !s.opt.Demo:1123- default:1124- return c.EnsureDemo(ctx, s.demoChannels())1125- }1126- return nil1127-}1128-1129-// demoChannels are the main channels of the stations in the listings (or,1130-// without listings, in the FCC's records) as generated test patterns.1131-func (s *Service) demoChannels() []tvh.DemoChannel {1132- s.mu.Lock()1133- rep, g := s.report, s.guide1134- s.mu.Unlock()1135- ffmpeg := cmp.Or(s.opt.DemoFFmpeg, "/usr/bin/ffmpeg")1136- seen := make(map[int]bool)1137- var out []tvh.DemoChannel1138- add := func(major int, name string) {1139- if major <= 0 || seen[major] {1140- return1141- }1142- seen[major] = true1143- // Tvheadend splits the command on spaces, so one word.1144- if f := strings.Fields(name); len(f) > 0 {1145- name = f[0]1146- } else {1147- name = "CH" + strconv.Itoa(major)1148- }1149- out = append(out, tvh.DemoChannel{1150- Major: major, Minor: 1, Name: name,1151- Command: tuner.DemoCommand(ffmpeg, fmt.Sprintf("%d.1", major), name),1152- })1153- }1154- if g != nil {1155- for _, c := range g.Channels {1156- if major, minor := guide.SplitNumber(c.Number); minor == 1 {1157- add(major, guide.BaseCall(c.CallSign))1158- }1159- }1160- }1161- if len(out) == 0 && rep != nil {1162- for _, st := range rep.Stations {1163- add(st.VirtualChannel, st.BaseCall)1164- }1165- }1166- return out1167-}@@ -1,11 +1,11 @@
11 // Package signal keeps what the tuners measured on each multiplex (each RF2-// channel): Tvheadend's scan results, and readings of signal strength and3-// quality taken while a tuner was on it. Nothing here is estimated: a4-// value is there because a tuner reported it, and is null otherwise.2+// channel): readings of signal strength and quality taken while a tuner3+// was on it. Nothing here is estimated: a value is there because a tuner4+// reported it, and is null otherwise.55 //6-// Readings come from Tvheadend's input status. An HDHomeRun reports7-// strength and signal quality (its SNQ) as percentages, so their dB fields8-// stay null; tuners that report decibels fill those in instead.6+// The HDHomeRun reports strength, signal-to-noise quality and symbol7+// quality as percentages; whether data arrived, and the damage in it,8+// Airwaves sees for itself.99 package signal1010 1111 import (@@ -21,13 +21,13 @@ import (
2121 "sync"2222 "time"2323 24- "airwaves/internal/tvh"24+ "airwaves/internal/tuner"2525 )2626 2727 // Sources of readings.2828 const (2929 // Active readings are taken while something had a tuner on the30- // multiplex: a viewer, a recording, or Tvheadend's own guide scan.30+ // multiplex: a viewer or a recording.3131 Active = "active"3232 // Sweep readings are taken by Measure now, on an idle tuner.3333 Sweep = "sweep"@@ -39,119 +39,49 @@ type Reading struct {
3939 Source string `json:"source"` // Active or Sweep4040 // Lock is whether data arrived: the tuner locked to the signal.4141 Lock bool `json:"lock"`42- // StrengthPct is signal strength on the tuner's relative scale, 0 to43- // 100; StrengthDBm is set instead by tuners that report dBm.42+ // StrengthPct is signal strength, QualityPct the signal-to-noise43+ // quality and SymbolPct the share of symbols received whole or44+ // corrected over the last second, all as the tuner reports them, 045+ // to 100.4446 StrengthPct *float64 `json:"strengthPct"`45- StrengthDBm *float64 `json:"strengthDbm"`46- // QualityPct is the signal-to-noise quality on the tuner's relative47- // scale (an HDHomeRun's SNQ); SNRdB is set instead by tuners that48- // report the ratio in dB.49- QualityPct *float64 `json:"qualityPct"`50- SNRdB *float64 `json:"snrDb"`51- // BER is the bit error ratio, and UNC the uncorrected blocks, from52- // tuners that count them.53- BER *float64 `json:"ber"`54- UNC *int64 `json:"unc"`47+ QualityPct *float64 `json:"qualityPct"`48+ SymbolPct *float64 `json:"symbolPct"`5549 // ErrorsPerSec is the rate of transport and continuity errors in what5650 // arrived since the reading before, on the same tuning: a picture5751 // breaking up. Null for a first reading.5852 ErrorsPerSec *float64 `json:"errorsPerSec"`5953 }6054 61-// FromInput reads an input's status as Tvheadend reports it, with the62-// subscriptions now. Whether the tuner holds a lock is first what its63-// subscriptions say: "Running" once data has arrived, "Testing" while none64-// has. The input's data rate says less: it reads 0 now and then on an65-// input with little to send, and can show the last tuning's for a moment;66-// it decides only for an input with no subscription listed. Then a tuner67-// that reports quality on the relative scale (an HDHomeRun) has none, 0,68-// exactly while its demodulator has no lock (its own status page reads69-// "Modulation Lock none, Signal Quality none" then), even while a70-// subscription that had data still reads "Running".71-func FromInput(in tvh.InputStatus, subs []tvh.Subscription, at time.Time, source string) Reading {72- r := Reading{At: at.UTC().Truncate(time.Second), Source: source, Lock: in.BPS > 0}73- if mux, net, ok := in.Mux(); ok {74- listed, running := false, false75- for _, sub := range subs {76- if sub.On(in.Input, net, mux) {77- listed, running = true, running || sub.Running()78- }79- }80- if listed {81- r.Lock = running55+// FromTuner reads a tuner as it is now: whether data arrives, and what56+// the HDHomeRun measures of the signal.57+func FromTuner(t tuner.State, at time.Time, source string) Reading {58+ pct := func(v *int) *float64 {59+ if v == nil {60+ return nil8261 }62+ return ptr(float64(*v))8363 }84- if in.SNRScale == tvh.ScaleRelative && in.SNR == 0 {85- r.Lock = false86- }87- switch in.SignalScale {88- case tvh.ScaleRelative:89- r.StrengthPct = ptr(round1(float64(in.Signal) * 100 / 65535))90- case tvh.ScaleDecibel:91- r.StrengthDBm = ptr(round1(float64(in.Signal) / 1000))92- }93- switch in.SNRScale {94- case tvh.ScaleRelative:95- r.QualityPct = ptr(round1(float64(in.SNR) * 100 / 65535))96- case tvh.ScaleDecibel:97- r.SNRdB = ptr(round1(float64(in.SNR) / 1000))64+ return Reading{65+ At: at.UTC().Truncate(time.Second), Source: source, Lock: t.Locked,66+ StrengthPct: pct(t.Device.StrengthPct), QualityPct: pct(t.Device.QualityPct), SymbolPct: pct(t.Device.SymbolPct),9867 }99- // A driver's own BER and UNC counters read 0 when it has none, so only100- // a count Tvheadend made, or one above zero, says anything.101- if in.TCBit > 0 {102- r.BER = ptr(float64(in.ECBit) / float64(in.TCBit))103- }104- switch {105- case in.TCBlock > 0:106- r.UNC = ptr(in.ECBlock)107- case in.UNC > 0:108- r.UNC = ptr(in.UNC)109- }110- return r11168 }11269 113-// SetErrors sets the error rate from the input's status at an earlier70+// SetErrors sets the error rate from the tuner's state at an earlier11471 // reading of the same tuning, secs before.115-func (r *Reading) SetErrors(prev, now tvh.InputStatus, secs float64) {72+func (r *Reading) SetErrors(prev, now tuner.State, secs float64) {11673 r.ErrorsPerSec = nil117- was, is := prev.TE+prev.CC, now.TE+now.CC118- if secs <= 0 || is < was || prev.Stream != now.Stream || prev.Input != now.Input {74+ same := prev.Number == now.Number && prev.FrequencyHz == now.FrequencyHz && prev.Since.Equal(now.Since)75+ if secs <= 0 || !same || now.Errors < prev.Errors {11976 return12077 }121- r.ErrorsPerSec = ptr(round1(float64(is-was) / secs))78+ r.ErrorsPerSec = ptr(round1(float64(now.Errors-prev.Errors) / secs))12279 }12380 12481 func ptr[T any](v T) *T { return &v }12582 12683 func round1(v float64) float64 { return math.Round(v*10) / 10 }12784 128-// Scan is how Tvheadend's last scan of a multiplex went.129-type Scan struct {130- // Lock is whether the scan found the multiplex and its services.131- Lock bool `json:"lock"`132- // Partial is set when it locked but missed some of the tables.133- Partial bool `json:"partial,omitempty"`134- // At is when the scan found it, from Tvheadend; for a scan that found135- // nothing, which Tvheadend doesn't date, when Airwaves first saw that.136- At time.Time `json:"at"`137-}138-139-// ScanOf turns a multiplex's scan result into a Scan; ok is false while140-// it has none. seen is when the result was read.141-func ScanOf(m tvh.Mux, seen time.Time) (Scan, bool) {142- switch m.ScanResult {143- case tvh.ScanOK, tvh.ScanPartial:144- at := seen145- if m.ScanLast > 0 {146- at = time.Unix(m.ScanLast, 0)147- }148- return Scan{Lock: true, Partial: m.ScanResult == tvh.ScanPartial, At: at.UTC()}, true149- case tvh.ScanFail:150- return Scan{At: seen.UTC().Truncate(time.Second)}, true151- }152- return Scan{}, false153-}154-15585 // Stat is the spread of one quantity over readings.15686 type Stat struct {15787 Min float64 `json:"min"`@@ -194,9 +124,8 @@ type Window struct {
194124 Samples int `json:"samples"`195125 Locked int `json:"locked"`196126 StrengthPct *Stat `json:"strengthPct,omitempty"`197- StrengthDBm *Stat `json:"strengthDbm,omitempty"`198127 QualityPct *Stat `json:"qualityPct,omitempty"`199- SNRdB *Stat `json:"snrDb,omitempty"`128+ SymbolPct *Stat `json:"symbolPct,omitempty"`200129 }201130 202131 func (w *Window) add(r Reading) {@@ -206,9 +135,8 @@ func (w *Window) add(r Reading) {
206135 w.Locked++207136 }208137 w.StrengthPct = w.StrengthPct.add(r.StrengthPct)209- w.StrengthDBm = w.StrengthDBm.add(r.StrengthDBm)210138 w.QualityPct = w.QualityPct.add(r.QualityPct)211- w.SNRdB = w.SNRdB.add(r.SNRdB)139+ w.SymbolPct = w.SymbolPct.add(r.SymbolPct)212140 }213141 214142 // Limits of the history kept per multiplex.@@ -225,7 +153,6 @@ const (
225153 type Mux struct {226154 FrequencyHz int64 `json:"frequencyHz"`227155 RF int `json:"rf"`228- Scan *Scan `json:"scan,omitempty"`229156 Latest *Reading `json:"latest,omitempty"`230157 Windows []Window `json:"windows,omitempty"`231158 }@@ -271,14 +198,13 @@ type Period struct {
271198 // LockedPct is the share of readings with a lock.272199 LockedPct float64 `json:"lockedPct"`273200 StrengthPct *Range `json:"strengthPct"`274- StrengthDBm *Range `json:"strengthDbm"`275201 QualityPct *Range `json:"qualityPct"`276- SNRdB *Range `json:"snrDb"`202+ SymbolPct *Range `json:"symbolPct"`277203 }278204 279205 func period(w Window) Period {280206 p := Period{From: w.From, To: w.To, Source: w.Source, Samples: w.Samples,281- StrengthPct: w.StrengthPct.rng(), StrengthDBm: w.StrengthDBm.rng(), QualityPct: w.QualityPct.rng(), SNRdB: w.SNRdB.rng()}207+ StrengthPct: w.StrengthPct.rng(), QualityPct: w.QualityPct.rng(), SymbolPct: w.SymbolPct.rng()}282208 if w.Samples > 0 {283209 p.LockedPct = round1(float64(w.Locked) * 100 / float64(w.Samples))284210 }@@ -320,9 +246,8 @@ func (m *Mux) History() *History {
320246 all.Samples += w.Samples321247 all.Locked += w.Locked322248 all.StrengthPct = merge(all.StrengthPct, w.StrengthPct)323- all.StrengthDBm = merge(all.StrengthDBm, w.StrengthDBm)324249 all.QualityPct = merge(all.QualityPct, w.QualityPct)325- all.SNRdB = merge(all.SNRdB, w.SNRdB)250+ all.SymbolPct = merge(all.SymbolPct, w.SymbolPct)326251 h.Windows = append(h.Windows, period(w))327252 }328253 h.Period = period(all)@@ -397,22 +322,6 @@ func (s *Store) Record(freq int64, rf int, r Reading) {
397322 s.dirty = true398323 }399324 400-// SetScan notes a scan result. A result that hasn't changed keeps its401-// date, so a failed scan stays dated from when it was first seen.402-func (s *Store) SetScan(freq int64, rf int, sc Scan) {403- if freq <= 0 {404- return405- }406- s.mu.Lock()407- defer s.mu.Unlock()408- m := s.mux(freq, rf)409- if old := m.Scan; old != nil && old.Lock == sc.Lock && old.Partial == sc.Partial && (!sc.Lock || !sc.At.After(old.At)) {410- return411- }412- m.Scan = &sc413- s.dirty = true414-}415-416325 // Get returns a copy of what was measured at freq; ok is false when417326 // nothing was.418327 func (s *Store) Get(freq int64) (Mux, bool) {@@ -439,10 +348,6 @@ func (s *Store) All() []Mux {
439348 440349 func clone(m *Mux) Mux {441350 c := *m442- if m.Scan != nil {443- sc := *m.Scan444- c.Scan = &sc445- }446351 if m.Latest != nil {447352 r := *m.Latest448353 c.Latest = &r@@ -450,7 +355,7 @@ func clone(m *Mux) Mux {
450355 c.Windows = make([]Window, len(m.Windows))451356 for i, w := range m.Windows {452357 c.Windows[i] = w453- for _, p := range []**Stat{&c.Windows[i].StrengthPct, &c.Windows[i].StrengthDBm, &c.Windows[i].QualityPct, &c.Windows[i].SNRdB} {358+ for _, p := range []**Stat{&c.Windows[i].StrengthPct, &c.Windows[i].QualityPct, &c.Windows[i].SymbolPct} {454359 if *p != nil {455360 st := **p456361 *p = &st@@ -524,6 +429,22 @@ func writeAtomic(path string, raw []byte) error {
524429 return os.Rename(tmp.Name(), path)525430 }526431 432+// Frequency is the center frequency (Hz) of US broadcast RF channel rf,433+// 0 for one that isn't.434+func Frequency(rf int) int64 {435+ switch {436+ case rf >= 2 && rf <= 4:437+ return int64(57+6*(rf-2)) * 1e6438+ case rf >= 5 && rf <= 6:439+ return int64(79+6*(rf-5)) * 1e6440+ case rf >= 7 && rf <= 13:441+ return int64(177+6*(rf-7)) * 1e6442+ case rf >= 14 && rf <= 36:443+ return int64(473+6*(rf-14)) * 1e6444+ }445+ return 0446+}447+527448 // RF is the US broadcast RF channel centered on freq (Hz), 0 when freq528449 // isn't one.529450 func RF(freq int64) int {Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.