diff --git a/README.md b/README.md index d2166418af..dde56d7ed9 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,8 @@ A re-encode still burns the selected subtitle on the server; over a remux the ap I built this for how *I* watch, so it's narrower than Plex's: -- **Movies and TV shows.** No music, no photos, no live TV or DVR. +- **Movies and TV shows, and the server's Live TV channels** (a Home row; OK tunes and plays the + channel). No music, no photos, no guide grid, no DVR recording. - **No typing in server addresses.** Servers come from your Plex account; set them up on a phone or PC and choose from what's there. Servers reached through Plex's relay, or that require an encrypted connection, are supported but haven't been watched end to end. diff --git a/ci/check-plex-requests.py b/ci/check-plex-requests.py index b6acf9eaf9..0fc3592b01 100644 --- a/ci/check-plex-requests.py +++ b/ci/check-plex-requests.py @@ -68,8 +68,8 @@ class single one object, not a list CLIENT_DOORS = frozenset({ 'get_json', 'get_json_with_headers', 'get_json_with_headers_until', 'get_json_status', 'get_bytes', 'get_sidecar_bytes', 'get_void', 'get_ok', 'get_status', 'get_status_until', - 'post_status', 'post_status_until', 'put', 'post_ok', 'post_json', 'fetch_built', - 'fetch_built_outcome', + 'post_status', 'post_status_until', 'put', 'post_ok', 'post_json', 'post_value', + 'fetch_built', 'fetch_built_outcome', }) # The server client's private transport. A call is a site only as `self.name(` inside `impl Client`, # and never from a door's own body (`get_json_status` reads through `send_bulk`). diff --git a/ci/plex-requests.ini b/ci/plex-requests.ini index 4d69643476..deee8d3a82 100644 --- a/ci/plex-requests.ini +++ b/ci/plex-requests.ini @@ -239,6 +239,37 @@ class = single reason = One watched-state write, answered by a status. evidence = rust-modules/plex/src/plex/library.rs:236 +[livetv.rs::epg_channels] +class = whole +reason = One DVR's mapped channels, a handful: the lineup lists only what the owner mapped. +evidence = docs/pms-api.md: the DVR list and the lineup come whole + +[livetv.rs::epg_on_now] +class = bounded +parameter = beginsAt%3C +reason = The guide grid bounded to one instant (beginsAt<=now and endsAt>=now): one airing per channel. +evidence = docs/pms-api.md: the grid bounded to one instant returns one airing per channel + +[livetv.rs::live_part] +class = single +reason = One decision for one live session, naming the playlist to play. +evidence = rust-modules/plex/src/plex/livetv.rs:353 + +[livetv.rs::live_timeline] +class = single +reason = One live playback report write, answered by a status. +evidence = rust-modules/plex/src/plex/livetv.rs:381 + +[livetv.rs::live_tv_dvrs] +class = whole +reason = Every DVR the server has, each a single record; a server has one or two. +evidence = docs/pms-api.md: the DVR list and the lineup come whole + +[livetv.rs::tune] +class = single +reason = One tune write; the answer is one session. +evidence = rust-modules/plex/src/plex/livetv.rs:329 + [mod.rs::admit_source_until] class = whole reason = The section list, read whole only to see that the endpoint answers; no row is used. diff --git a/docs/install-and-verify.md b/docs/install-and-verify.md index 0b5b5ec2b8..7c26e0d123 100644 --- a/docs/install-and-verify.md +++ b/docs/install-and-verify.md @@ -211,7 +211,8 @@ Nothing. A release build compiles out the whole `/tmp` trigger surface, the remo ## Scope -Movies and TV shows from a Plex Media Server your account can reach. No music, no photos, no live TV, no DVR. +Movies and TV shows from a Plex Media Server your account can reach, and that server's Live TV +channels (watch only). No music, no photos, no guide grid, no DVR recording. There is deliberately nowhere on the television to type a server address — configure servers on a phone or PC, and the app offers what your Plex account already knows about. diff --git a/docs/parity-gaps.md b/docs/parity-gaps.md index e55967b3a0..a932e94d2c 100644 --- a/docs/parity-gaps.md +++ b/docs/parity-gaps.md @@ -4,8 +4,8 @@ Audited 2026-07-29 against reference screenshots of the **official webOS Plex cl home, show/episode detail, movie detail, library grid, card context menu) and **Plex HTPC** (player HUD, Quality picker, queue overlay, Settings tree). Branch `home-ui-polish`, working tree included. -**Scope: movies and TV shows only.** Music, photos, Live TV/DVR and podcasts are out of scope and -are not counted as gaps. Plex Discover / "Movies & Shows on Plex" is movie/show content but is an +**Scope: movies and TV shows, plus watching the server's Live TV channels.** Music, photos, DVR +recording, the Live TV guide grid and podcasts are out of scope and are not counted as gaps. Plex Discover / "Movies & Shows on Plex" is movie/show content but is an *adjacent catalog* (plex.tv, not the user's server) and is tagged as such throughout. The Watchlist began as one of those entries and has since shipped (`docs/watchlist.md`); the entries that still say "Watchlist" below record what it closed. diff --git a/docs/plex-pass-audit.md b/docs/plex-pass-audit.md index 623eb4d963..5fa622b420 100644 --- a/docs/plex-pass-audit.md +++ b/docs/plex-pass-audit.md @@ -30,7 +30,8 @@ is the final verification for the fixes. | **Video preview thumbnails (BIF)** | chapter card thumbnails (`chapters_panel.rs`); the scrub preview above the knob (`scrub_preview.rs`) | `thumb` empty → placeholder card; chapters themselves (embedded) still work. No index → the stills 404 and the scrub preview draws nothing for that playback | graceful | | **Trailers / extras** | Background hero autoplay, Play Trailer menu row, and an Extras shelf of the `/extras` rows | server returns none → no preview and no shelf | graceful | | **Audio DSP (`boostDialog`/`normalizeLoudness`)** | player Audio tab toggle rows (`ui/track_menu.rs`, issue #266) | rows are ABSENT, not greyed — `serverinfo::subscription_of(sid) == Subscription::Yes` gates every build of the tab (`route::plan::enhancements_offered`'s first clause); `Unknown` means the subscription fact was never fetched, full stop — PMS 1.43.3 already reports `myPlexSubscription` on the root `MediaContainer` (`plex/serverinfo.rs`), so any server at or past that build reads `Yes`/`No` correctly, Pass or not. What actually hides the rows on an OLDER (pre-1.43.4) server that DOES have Pass is the separate per-track `canNormalizeLoudness` flag (`metadata.rs`'s `Stream::can_normalize_loudness`, only sent 1.43.4+): the subscription clause passes, `carried_capable` reads false, and `enhancements_offered`'s second clause hides the offer — the subscription answer and the per-track capability are two independent gates, and only the second one is version-gated | **built Pass-only from the start** — no fix needed, no retrofit possible: an app build with the feature and one without it send byte-identical URLs against a free or unknown-subscription server, and no locale value for either row names "Plex Pass" (`enh_locale_values_never_mention_plex_pass`, a RUNTIME test over every shipped locale's actual string — not a source grep) | -| Live TV / DVR, music (sonic analysis, lyrics), photos, downloads/sync | not in scope of this app | — | n/a | +| Live TV: watching a channel | Home's Live TV row tunes and direct-plays it (`docs/pms-api.md` §10) | — | done | +| DVR recording and the guide grid, music (sonic analysis, lyrics), photos, downloads/sync | not in scope of this app | — | n/a | ## The pattern this audit exists to kill diff --git a/docs/pms-api.md b/docs/pms-api.md index 81617713c2..6fbdf317ff 100644 --- a/docs/pms-api.md +++ b/docs/pms-api.md @@ -1723,6 +1723,69 @@ was captured). --- +## 10. Live TV — DVRs, guide lineup, airings (measured live 2026-10-20, PMS 1.43.3, one ErsatzTV tuner) + +Read-only GETs against a server with one DVR backed by an HDHomeRun-protocol tuner (ErsatzTV) and an +XMLTV guide. The app's reads live in `rust-modules/plex/src/plex/livetv.rs`. + +- **`GET /livetv/dvrs`** → `Dvr[]`: `key`, `uuid`, `language`, `country`, `lineup` + (`lineup://tv.plex.providers.epg.xmltv/#`), **`epgIdentifier`** + (`tv.plex.providers.epg.xmltv:{key}`, the path prefix of every guide read below) and `Device[]`: + `key`, `uuid` (`device://tv.plex.grabbers.hdhomerun/<id>`), `uri` (the tuner's own host), `status` + (`alive` | `dead`), `state` (`enabled`), `lastSeenAt`, `tuners`, `ChannelMapping[]` + (`channelKey`, `deviceIdentifier`, `enabled` as the string `"1"`, `lineupIdentifier`). +- **`status` is a discovery result, not a health check.** PMS sets it when it discovers devices, + which it does at start-up and on `POST /media/grabbers/devices/discover`; `lastSeenAt` is that + moment. A tuner that was not answering at start-up stays `dead` until a later discovery, however + healthy it is in between. `POST /media/grabbers/devices?uri=…` probes the tuner and updates its URI + but leaves `status` as it was. Both are admin writes; the app sends neither. +- **`GET /{epgIdentifier}/lineups/dvr/channels`** → `Channel[]`, only the channels the DVR mapped: + `id` = `gridKey` (`C101.242.ersatztv.org`), `vcn` (`"101"`), `title` and `callSign` with the number + in front (`"101 Some Channel"`), `thumb` — a URL on the TUNER's host. Fetched through + `/photo/:/transcode?url=…` it comes back as an image, so a client that cannot reach the tuner still + gets the logo. +- **`GET /{epgIdentifier}/grid?beginsAt%3C={now}&endsAt%3E={now}`** → `Metadata[]`, one per channel + airing at `now`: `type` (`episode` | `movie`), `title` (the XMLTV title; for an episode it is + `"Show - S03E23 - Name"`), `grandparentTitle` (the show), `ratingKey`/`key` under the provider + (`/tv.plex.providers.epg.xmltv:7/metadata/…`), `onAir`, `Media[]` with `channelVcn`, + `channelIdentifier`, `channelTitle`, `channelThumb`, `protocol` `livetv`, `beginsAt` and `endsAt` + (Unix seconds), and a `Channel[]` tag. The comparison operator is part of the KEY (`beginsAt%3C=`). + Adding `type=1,4` returned the same four rows. + +### Watching a channel (the official LG client's sequence, read from the server log 2026-10-21) + +Plex for LG 5.94.3 on webOS 10.3 tuned one channel, watched it for six minutes and backed out. The +order below is that client's; nothing here was sent by hand (a hand-sent tune has been seen to leave +a server looping on its grabber). + +1. **`POST /livetv/dvrs/{dvr}/channels/{gridKey}/tune`** — the channel is the guide's `gridKey` + (`C101.242.ersatztv.org`), not the `vcn`. The server reserves a tuner, starts a "rolling" + subscription (`Live TV - Session {client id}`) and a `copy` transcode of the tuner's stream, and + answers only once that streams: **200 after 4.8 s**, 2764 bytes. The body was not captured; what + it carries is the session `/livetv/sessions/{uuid}` (the client's next request names it) and the + airing's `ratingKey`, under a `MediaSubscription` → `MediaGrabOperation` → item nesting that the + app does not rely on (`livetv::session_of` takes the first object whose `key` is a session key). +2. **`GET /video/:/transcode/universal/decision?path=%2Flivetv%2Fsessions%2F{uuid}`** with + `directPlay=1&directStream=1&directStreamAudio=1&protocol=hls&mediaIndex=0&partIndex=0&session=…` + → 200. The session is then **direct play**: `/status/sessions` shows `Part.decision=directplay`, + `Part.key` = **`/livetv/sessions/{uuid}/{X-Plex-Client-Identifier}/index.m3u8?offset=-1.000000`**, + H.264 High 1080p25 + AAC LC stereo 48 kHz in MPEG-TS, `TranscodeSession.videoDecision=copy`. +3. **The playlist is a MEDIA playlist with no master in front of it**, re-read about once a second + (`Content-Type: application/vnd.apple.mpegurl`, `Cache-Control: no-cache`): + `#EXT-X-VERSION:4`, `#EXT-X-TARGETDURATION:3`, `#EXT-X-MEDIA-SEQUENCE:0`, one + `#EXT-X-PROGRAM-DATE-TIME`, then one-second `#EXTINF:1.000000, nodesc` segments named `00000.ts`, + `00001.ts`, … relative to the playlist. No `PLAYLIST-TYPE`, no `ENDLIST`, no discontinuity; the + list only grows (segment 0 was still listed after six minutes: `buildLiveM3U8: min 0 max 385`). + Segment requests carry no token in the URI. +4. **`/:/timeline?key=%2Flivetv%2Fsessions%2F{uuid}&ratingKey={airing}&state=…&time=0&duration=0&playbackTime={ms}`** + — `buffering`, then `playing` about every 10 s. The server records it with `scrobble: 0`. +5. **The stop is the timeline's `state=stopped&continuing=0`.** On it the server terminates the + streaming session ("Client stopped playback"), the recorder sees no consumer left, kills the + transcode, deletes its directory, **frees the tuner** and destroys the rolling subscription — all + inside the same 3 ms. The client's `DELETE /media/subscriptions/{id}` afterwards answered 404. + +--- + ## Paging, observed Observed on PMS 1.43.4 with a small library, by read-only GETs. Only the endpoints in the table were @@ -1748,6 +1811,7 @@ only `size`. | Key of `movie.topunwatched` and `movie.by.actor.or.director` (random hubs) | yes | not recorded | The listing came back in the same order on two calls; the windows (0,6) and (6,50) together equal the whole listing; every preview card is in it. The library held only 13 items, so large lists are not covered. | | `/playlists/all` (the key of the playlists hub in the Home hub list, `/hubs` and `/hubs/promoted`) | yes | yes | Measured on PMS 1.43: `X-Plex-Container-Start=2&X-Plex-Container-Size=2` answered `size` 2, `totalSize` 5, `offset` 2, items of type `playlist`. The app does not page it and no row shows it: a hub of type `playlist` is not listed (`pms::listable`), so it never reaches a shelf, and `is_pageable_hub_key` does not admit the key. | +| `/livetv/dvrs`, `/{epg}/lineups/dvr/channels`, `/{epg}/grid?beginsAt%3C=…&endsAt%3E=…` (Live TV, §10) | no | no | Measured on PMS 1.43.3 with one DVR: the DVR list and the lineup come whole (one DVR, four mapped channels); the grid bounded to one instant returns one airing per channel. | ## App data-layer summary diff --git a/locales/be/browse.json b/locales/be/browse.json index f8cce09ced..145c9c5d6d 100644 --- a/locales/be/browse.json +++ b/locales/be/browse.json @@ -6,6 +6,7 @@ "browse.action.more": "БОЛЬШ", "browse.account.own": "Гэты ўліковы запіс", "browse.chrome.home": "Галоўная", + "browse.chrome.live_tv": "ТБ", "browse.chrome.search": "Пошук", "browse.kind.movies": "Фільмы", "browse.kind.tv_shows": "Серыялы", @@ -200,6 +201,7 @@ "browse.kind.films": "Фільмы", "browse.kind.tv_shows_sentence": "Серыялы", "browse.home.continue_watching": "Працягнуць прагляд", + "browse.home.live_tv": "Тэлебачанне ў эфіры", "browse.detail.unrated": "БР", "browse.crew.director": "Рэжысёр", "browse.crew.writer": "Сцэнарыст", diff --git a/locales/de/browse.json b/locales/de/browse.json index c7a9271a79..58808d540c 100644 --- a/locales/de/browse.json +++ b/locales/de/browse.json @@ -6,6 +6,7 @@ "browse.action.more": "MEHR", "browse.account.own": "Dieses Konto", "browse.chrome.home": "Start", + "browse.chrome.live_tv": "TV", "browse.chrome.search": "Suche", "browse.kind.movies": "Filme", "browse.kind.tv_shows": "Serien", @@ -180,6 +181,7 @@ "browse.kind.films": "Filme", "browse.kind.tv_shows_sentence": "Serien", "browse.home.continue_watching": "Weiterschauen", + "browse.home.live_tv": "Live-TV", "browse.detail.unrated": "o. A.", "browse.crew.director": "Regie", "browse.crew.writer": "Drehbuch", diff --git a/locales/en/browse.json b/locales/en/browse.json index 9c8cf003a4..70c1df5695 100644 --- a/locales/en/browse.json +++ b/locales/en/browse.json @@ -27,6 +27,10 @@ "value": "Home", "description": "browse chrome home" }, + "browse.chrome.live_tv": { + "value": "TV", + "description": "Label of the top strip pill that opens the Live TV page (the server's channels); short so the strip keeps its glass width" + }, "browse.chrome.search": { "value": "Search", "description": "browse chrome search" @@ -783,6 +787,10 @@ "value": "Continue Watching", "description": "browse home continue_watching" }, + "browse.home.live_tv": { + "value": "Live TV", + "description": "Title of the Home row of the server's live TV channels (Plex DVR), one tile per channel" + }, "browse.diagnostics.title": { "value": "Diagnostics", "translate": false, diff --git a/locales/es/browse.json b/locales/es/browse.json index 112a6661cf..ff2d0bc317 100644 --- a/locales/es/browse.json +++ b/locales/es/browse.json @@ -6,6 +6,7 @@ "browse.action.more": "MÁS", "browse.account.own": "Esta cuenta", "browse.chrome.home": "Inicio", + "browse.chrome.live_tv": "TV", "browse.chrome.search": "Buscar", "browse.kind.movies": "Películas", "browse.kind.tv_shows": "Series", @@ -190,6 +191,7 @@ "browse.kind.films": "Películas", "browse.kind.tv_shows_sentence": "Series", "browse.home.continue_watching": "Continuar viendo", + "browse.home.live_tv": "TV en directo", "browse.detail.unrated": "SC", "browse.crew.director": "Director", "browse.crew.writer": "Guionista", diff --git a/locales/fr/browse.json b/locales/fr/browse.json index a05272cf05..45db706077 100644 --- a/locales/fr/browse.json +++ b/locales/fr/browse.json @@ -6,6 +6,7 @@ "browse.action.more": "PLUS", "browse.account.own": "Ce compte", "browse.chrome.home": "Accueil", + "browse.chrome.live_tv": "TV", "browse.chrome.search": "Recherche", "browse.kind.movies": "Films", "browse.kind.tv_shows": "Séries", @@ -190,6 +191,7 @@ "browse.kind.films": "Films", "browse.kind.tv_shows_sentence": "Séries", "browse.home.continue_watching": "Reprendre la lecture", + "browse.home.live_tv": "TV en direct", "browse.detail.unrated": "NC", "browse.crew.director": "Réalisateur", "browse.crew.writer": "Scénariste", diff --git a/locales/hu/browse.json b/locales/hu/browse.json index 28c829b917..d3f1af6827 100644 --- a/locales/hu/browse.json +++ b/locales/hu/browse.json @@ -6,6 +6,7 @@ "browse.action.more": "TOVÁBBIAK", "browse.account.own": "Ez a fiók", "browse.chrome.home": "Kezdőlap", + "browse.chrome.live_tv": "TV", "browse.chrome.search": "Keresés", "browse.kind.movies": "Filmek", "browse.kind.tv_shows": "Sorozatok", @@ -180,6 +181,7 @@ "browse.kind.films": "Filmek", "browse.kind.tv_shows_sentence": "Sorozatok", "browse.home.continue_watching": "Megtekintés folytatása", + "browse.home.live_tv": "Élő TV", "browse.detail.unrated": "NR", "browse.crew.director": "Rendező", "browse.crew.writer": "Forgatókönyvíró", diff --git a/locales/it/browse.json b/locales/it/browse.json index 78b7c836d6..cdafb5645c 100644 --- a/locales/it/browse.json +++ b/locales/it/browse.json @@ -6,6 +6,7 @@ "browse.action.more": "ALTRO", "browse.account.own": "Questo account", "browse.chrome.home": "Home", + "browse.chrome.live_tv": "TV", "browse.chrome.search": "Cerca", "browse.kind.movies": "Film", "browse.kind.tv_shows": "Serie TV", @@ -190,6 +191,7 @@ "browse.kind.films": "Film", "browse.kind.tv_shows_sentence": "Serie TV", "browse.home.continue_watching": "Continua a guardare", + "browse.home.live_tv": "TV in diretta", "browse.detail.unrated": "NC", "browse.crew.director": "Regia", "browse.crew.writer": "Sceneggiatura", diff --git a/locales/nl/browse.json b/locales/nl/browse.json index 47b3eec5e8..f6483b7ccb 100644 --- a/locales/nl/browse.json +++ b/locales/nl/browse.json @@ -6,6 +6,7 @@ "browse.action.more": "MEER", "browse.account.own": "Dit account", "browse.chrome.home": "Home", + "browse.chrome.live_tv": "TV", "browse.chrome.search": "Zoeken", "browse.kind.movies": "Films", "browse.kind.tv_shows": "Series", @@ -55,6 +56,7 @@ "browse.detail.hardware_needs": "hardwareconversie vereist", "browse.detail.tonemap_needs": "tone-mapping vereist", "browse.home.failed": "Kan je Plex-server niet bereiken", + "browse.home.live_tv": "Live TV", "browse.home.empty": "Nog niets op deze server", "browse.home.refresh": "Vernieuwen", "browse.library.sort_by": "Sorteren op", diff --git a/locales/pl/browse.json b/locales/pl/browse.json index 92e56cac09..f6485fcabb 100644 --- a/locales/pl/browse.json +++ b/locales/pl/browse.json @@ -6,6 +6,7 @@ "browse.action.more": "WIĘCEJ", "browse.account.own": "To konto", "browse.chrome.home": "Główna", + "browse.chrome.live_tv": "TV", "browse.chrome.search": "Szukaj", "browse.kind.movies": "Filmy", "browse.kind.tv_shows": "Seriale", @@ -200,6 +201,7 @@ "browse.kind.films": "Filmy", "browse.kind.tv_shows_sentence": "Seriale", "browse.home.continue_watching": "Kontynuuj oglądanie", + "browse.home.live_tv": "Telewizja na żywo", "browse.detail.unrated": "Bez klasyfikacji", "browse.crew.director": "Reżyseria", "browse.crew.writer": "Scenariusz", diff --git a/locales/pt/browse.json b/locales/pt/browse.json index 4a12d875bf..df4db4de34 100644 --- a/locales/pt/browse.json +++ b/locales/pt/browse.json @@ -6,6 +6,7 @@ "browse.action.more": "MAIS", "browse.account.own": "Esta conta", "browse.chrome.home": "Início", + "browse.chrome.live_tv": "TV", "browse.chrome.search": "Pesquisa", "browse.kind.movies": "Filmes", "browse.kind.tv_shows": "Séries", @@ -190,6 +191,7 @@ "browse.kind.films": "Filmes", "browse.kind.tv_shows_sentence": "séries", "browse.home.continue_watching": "Continuar a ver", + "browse.home.live_tv": "TV em direto", "browse.detail.unrated": "N/C", "browse.crew.director": "Realizador", "browse.crew.writer": "Argumentista", diff --git a/locales/ru/browse.json b/locales/ru/browse.json index 2571766be1..b298755719 100644 --- a/locales/ru/browse.json +++ b/locales/ru/browse.json @@ -6,6 +6,7 @@ "browse.action.more": "ЕЩЁ", "browse.account.own": "Этот аккаунт", "browse.chrome.home": "Главная", + "browse.chrome.live_tv": "ТВ", "browse.chrome.search": "Поиск", "browse.kind.movies": "Фильмы", "browse.kind.tv_shows": "Сериалы", @@ -200,6 +201,7 @@ "browse.kind.films": "Фильмы", "browse.kind.tv_shows_sentence": "Сериалы", "browse.home.continue_watching": "Продолжить просмотр", + "browse.home.live_tv": "Прямой эфир", "browse.detail.unrated": "Без рейтинга", "browse.crew.director": "Режиссёр", "browse.crew.writer": "Сценарист", diff --git a/rust-modules/data/src/pms.rs b/rust-modules/data/src/pms.rs index d9ac6290ba..47e635798a 100644 --- a/rust-modules/data/src/pms.rs +++ b/rust-modules/data/src/pms.rs @@ -59,6 +59,46 @@ pub const KIND_SEASON: c_int = 2; pub const KIND_EPISODE: c_int = 3; pub const KIND_COLLECTION: c_int = 4; +/// A Live TV channel tile (`plx_plex::plex::livetv`): `title` is the channel, `show_title` what is +/// on, `thumb`/`still` the channel logo, and `rk` the channel's address ([`live_channel_rk`]). It is +/// no library item: no detail page, no watch state, no PlayQueue. +pub const KIND_LIVE_CHANNEL: c_int = 5; + +/// The id of the Home row of Live TV channels. +pub const LIVE_TV_HUB: &str = "home.livetv"; + +const LIVE_RK_PREFIX: &str = "livetv:"; + +/// The `rk` of a channel tile: `livetv:{dvr}:{channel}`, the channel being the guide's id that the +/// tune names. Distinct from every PMS ratingKey (digits), so focus memory and item identity keep a +/// channel apart from any library row. +pub fn live_channel_rk(dvr: &str, channel: &str) -> String { + format!("{LIVE_RK_PREFIX}{dvr}:{channel}") +} + +/// The `(dvr, channel)` a channel tile tunes, or `None` for every other row. +pub fn live_channel_of(m: &PmsMovie) -> Option<(&str, &str)> { + if m.kind != KIND_LIVE_CHANNEL { + return None; + } + let (dvr, channel) = m.rk.strip_prefix(LIVE_RK_PREFIX)?.split_once(':')?; + (!dvr.is_empty() && !channel.is_empty()).then_some((dvr, channel)) +} + +/// A Live TV channel as a Home tile of server `sid`. +pub fn live_channel_tile(ch: &plx_plex::plex::livetv::LiveChannel, sid: ServerId) -> PmsMovie { + PmsMovie { + sid, + kind: KIND_LIVE_CHANNEL, + rk: live_channel_rk(&ch.dvr, &ch.channel), + title: ch.name.clone(), + show_title: ch.now.clone(), + thumb: ch.thumb.clone(), + still: ch.thumb.clone(), + ..PmsMovie::default() + } +} + pub fn listable(type_str: &str) -> bool { matches!(type_str, "movie" | "show" | "season" | "episode") } @@ -1127,6 +1167,10 @@ struct SourceBuild { /// until the row has a listing to read, so an older recording decodes unchanged. #[serde(default, skip_serializing_if = "deck::Lane::is_default")] lane: deck::Lane, + /// This server's Live TV channels as tiles ([`KIND_LIVE_CHANNEL`]), merged with every other + /// source's into the one Live TV row. Absent on a server with no usable DVR. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + live: Vec<Arc<PmsMovie>>, } /// A page read the user walked away from whose worker has not landed yet. @@ -1265,7 +1309,7 @@ fn fetch_row(sid: ServerId, page: &PageQuery, current: Option<&Shelf>, minimum_e let state = current.map(|shelf| shelf.row.clone()).unwrap_or_default(); let (rows, info, state) = paging::read_row(sid, &ask, current.map(shelf_window), state, minimum_end, list, many)?; let (positions, items) = rows.into_iter().unzip(); - Some(SourceBuild { cw: Vec::new(), lane: Default::default(), shelves: vec![Shelf { epoch: 0, + Some(SourceBuild { live: Vec::new(), cw: Vec::new(), lane: Default::default(), shelves: vec![Shelf { epoch: 0, title: String::new(), hub_id: page.id.clone(), key: page.key.clone(), items, positions, total: info.total, offset: info.offset, end: info.end, more: info.more, shown: 0, row: state, }] }) @@ -1277,7 +1321,7 @@ fn fetch_kept(sid: ServerId, page: &PageQuery, shelf: &Shelf, many: &mut impl FnMut(&[String]) -> Option<plx_plex::plex::MediaContainer>) -> Option<SourceBuild> { let items = paging::kept_cards(sid, &shelf.row.kept, &page.hidden, many)?; let positions = (shelf.offset..shelf.offset + items.len()).collect(); - Some(SourceBuild { cw: Vec::new(), lane: Default::default(), shelves: vec![Shelf { epoch: 0, + Some(SourceBuild { live: Vec::new(), cw: Vec::new(), lane: Default::default(), shelves: vec![Shelf { epoch: 0, title: String::new(), hub_id: page.id.clone(), key: page.key.clone(), items, positions, total: shelf.total, offset: shelf.offset, end: shelf.end, more: shelf.more, shown: 0, row: paging::RowState { kept: Vec::new(), ..shelf.row.clone() }, @@ -1294,7 +1338,7 @@ fn fetch_window(sid: ServerId, page: &PageQuery, current: Option<&Shelf>, minimu let (rows, info) = paging::fetch_window(sid, &ask, current.as_ref().map(|(rows, info)| (rows.as_slice(), *info)), minimum_end, fetch)?; let (positions, items) = rows.into_iter().unzip(); - Some(SourceBuild { cw: Vec::new(), lane: Default::default(), shelves: vec![Shelf { epoch: 0, + Some(SourceBuild { live: Vec::new(), cw: Vec::new(), lane: Default::default(), shelves: vec![Shelf { epoch: 0, title: String::new(), hub_id: page.id.clone(), key: page.key.clone(), items, positions, total: info.total, offset: info.offset, end: info.end, more: info.more, shown: 0, row: Default::default(), }] }) @@ -1937,6 +1981,17 @@ fn fetch_source(c: &plx_plex::plex::Client, sid: ServerId, held: &[(String, Stri let (mut build, cw) = (build?, cw?); project_deck(&mut build, &cw, sid); with_lookahead(&mut build, sid, |start, size| c.continue_watching_page(start as i64, size as i64)); + // Live TV is an extra, never a reason to fail the source: a server without a DVR, a share that + // may not use its tuners or a failed read all leave the row out. + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs() as i64); + build.live = c + .live_channels(now) + .unwrap_or_default() + .iter() + .map(|ch| Arc::new(live_channel_tile(ch, sid))) + .collect(); Some(build) } @@ -2317,6 +2372,27 @@ fn merge_with_scope(srcs: &[Src], scope: &BrowseScope) -> HubBuild { }); } + // ---- 1b. the Live TV row: every source's channels, in source order ---- + let live_start = new_cat.len(); + for (h, b) in &live { + for m in b.live.iter().take(MAX_SHELF_ITEMS) { + new_cat.push(Arc::clone(m)); + row_handle.push(h); + } + } + if new_cat.len() > live_start { + new_hubs.push(HubRow { + title: plx_platform::i18n::msg::browse_home_live_tv().to_string(), + hub_id: LIVE_TV_HUB.to_string(), + key: String::new(), + source: String::new(), + total: 0, + start: live_start, + len: new_cat.len() - live_start, + offset: 0, more: false, epoch: 0, + }); + } + // ---- 2. every other shelf, grouped by source ---- // Every shelf with something to show publishes a row; none is left off. A shelf that holds // cards publishes them (pin-filtered first, so an unpinned library's whole shelf disappears @@ -3853,7 +3929,7 @@ fn step_landings_with_scope(state: &mut PmsState, adapter: &PmsAdapter, dt: Opti /// that cannot express the app's ordinary state quietly limits what can be tested through it. #[cfg(any(test, feature = "test-support"))] fn build_test(n: usize) -> SourceBuild { - SourceBuild { + SourceBuild { live: Vec::new(), cw: Vec::new(), lane: Default::default(), shelves: vec![Shelf { epoch: 0, @@ -3883,6 +3959,20 @@ pub fn seed_for_test(state: &mut PmsState, adapter: &Arc<PmsAdapter>, items: usi seed_with_scope_for_test(state, adapter, ServerId::UNSET, items, hub_state, &BrowseScope::standalone()); } +/// [`seed_for_test`] with this source's Live TV `channels` too, as tiles of its row. +#[cfg(any(test, feature = "test-support"))] +pub fn seed_live_for_test(state: &mut PmsState, adapter: &Arc<PmsAdapter>, items: usize, channels: &[plx_plex::plex::livetv::LiveChannel]) { + seed_for_test(state, adapter, items.max(1), HubState::Ready); + let scope = BrowseScope::standalone(); + for src in &mut state.srcs { + if let Some(build) = src.last.as_mut() { + build.live = channels.iter().map(|ch| Arc::new(live_channel_tile(ch, src.sid))).collect(); + } + } + let build = merge_with_scope(&state.srcs, &scope); + commit(state, build); +} + /// Seed a Hubs source that belongs to a real retained Browse directory. Full Bridge fixtures use /// this instead of installing an `UNSET` source that owner-scoped roster reconciliation must drop. #[cfg(any(test, feature = "test-support"))] @@ -3927,7 +4017,7 @@ pub fn seed_two_library_home_for_test( let scope = BrowseScope::retained(directory, &keys); let mut source = Src::new(sid, String::new(), &scope.clients); source.state = HubState::Ready; - source.last = Some(SourceBuild { + source.last = Some(SourceBuild { live: Vec::new(), cw: Vec::new(), lane: Default::default(), shelves: vec![Shelf { epoch: 0, @@ -4128,7 +4218,7 @@ pub fn land_ring_rows_for_test(state: &mut PmsState, items: usize, salt: &dyn Fn ..PmsMovie::default() })).collect(); let page = PageQuery { id, key, start: shelf.offset, before: false, hidden: Vec::new(), reload: true }; - let build = SourceBuild { cw: Vec::new(), lane: Default::default(), shelves: vec![Shelf { + let build = SourceBuild { live: Vec::new(), cw: Vec::new(), lane: Default::default(), shelves: vec![Shelf { items: cards, positions: (0..items).collect(), shown: 0, ..shelf }] }; landed |= land_reload(source, &page, build); } @@ -4258,7 +4348,7 @@ pub fn seed_continue_window_for_test(state: &mut PmsState, adapter: &Arc<PmsAdap let mut source = Src::new(sid, String::new(), ®istry_keys_for_test()); source.state = HubState::Ready; let lane = deck::Lane::preview("0", &items.saturating_sub(1).to_string(), items, items + usize::from(more), !more); - source.last = Some(SourceBuild { cw, lane, shelves: Vec::new() }); + source.last = Some(SourceBuild { live: Vec::new(), cw, lane, shelves: Vec::new() }); let mut srcs = vec![source]; settle_deck(&mut srcs, false); let build = merge(&srcs); @@ -4352,7 +4442,7 @@ pub fn seed_deck_home_for_test(state: &mut PmsState, adapter: &Arc<PmsAdapter>, let mut source = Src::new(ServerId::UNSET, String::new(), ®istry_keys_for_test()); source.state = HubState::Ready; source.ledger = state.ledger.clone(); - source.last = Some(SourceBuild { + source.last = Some(SourceBuild { live: Vec::new(), cw: (1..=items).map(|i| CwItem { last_viewed_at: (1000 - i) as i64, m: card(i.to_string()), position: 0 }).collect(), shelves: vec![recent], lane: Default::default(), diff --git a/rust-modules/data/src/pms/record.rs b/rust-modules/data/src/pms/record.rs index 33f860ed9d..933559ef67 100644 --- a/rust-modules/data/src/pms/record.rs +++ b/rust-modules/data/src/pms/record.rs @@ -124,7 +124,7 @@ mod tests { }], }; Landing { gen: 7, seq: 19, sid: m.sid, client: None, token_gen: 5, - build: Some(SourceBuild { + build: Some(SourceBuild { live: Vec::new(), lane: Default::default(), cw: vec![CwItem { last_viewed_at: i64::MAX, m: Arc::new(m.clone()), position: 0 }], shelves: vec![Shelf { epoch: 0, positions: Vec::new(), title: "Shelf".into(), hub_id: "provider.hub".into(), diff --git a/rust-modules/data/src/pms_local_edit_tests.rs b/rust-modules/data/src/pms_local_edit_tests.rs index 869fadd82a..910a6e23c1 100644 --- a/rust-modules/data/src/pms_local_edit_tests.rs +++ b/rust-modules/data/src/pms_local_edit_tests.rs @@ -13,7 +13,7 @@ fn marking_an_item_watched_flips_every_row_that_names_it_and_retires_its_resume_ let _g = plx_base::testlock::serial(); let mut o = Owner::default(); reset(&mut o.state, &o.adapter); - let mut b = SourceBuild { + let mut b = SourceBuild { live: Vec::new(), lane: Default::default(), cw: vec![CwItem { last_viewed_at: 9, @@ -52,7 +52,7 @@ fn an_edit_never_reaches_the_same_rating_key_on_another_server() { let _g = plx_base::testlock::serial(); let mut o = Owner::default(); reset(&mut o.state, &o.adapter); - let mut b = SourceBuild { + let mut b = SourceBuild { live: Vec::new(), lane: Default::default(), cw: Vec::new(), shelves: vec![shelf(1, "Theirs", "h", &["7"])], @@ -74,7 +74,7 @@ fn a_deck_removal_leaves_the_deck_only_and_keeps_the_resume_point() { let _g = plx_base::testlock::serial(); let mut o = Owner::default(); reset(&mut o.state, &o.adapter); - let mut b = SourceBuild { + let mut b = SourceBuild { live: Vec::new(), lane: Default::default(), cw: vec![ CwItem { @@ -123,7 +123,7 @@ fn a_removed_deck_card_leaves_the_shelves_behind_it_correctly_addressed() { let _g = plx_base::testlock::serial(); let mut o = Owner::default(); reset(&mut o.state, &o.adapter); - let build = SourceBuild { + let build = SourceBuild { live: Vec::new(), lane: Default::default(), cw: vec![ CwItem { @@ -267,7 +267,7 @@ fn a_show_scoped_edit_reaches_its_episode_rows_and_a_deck_removal_stays_on_one_r m.season_index = season; Arc::new(m) }; - let mut b = SourceBuild { + let mut b = SourceBuild { live: Vec::new(), lane: Default::default(), cw: vec![ CwItem { last_viewed_at: 9, m: episode("e11", "S", 1), position: 0 }, @@ -311,7 +311,7 @@ fn a_source_read_in_flight_across_edits_lands_with_them_made_again() { s.stamp = s.ledger.stamp(); // the read goes out… let p7 = s.ledger.record(&ViewAt::leaf(sid(0), "7"), ViewEdit::Watched(true)); // …then two presses let p8 = s.ledger.record(&ViewAt::leaf(sid(0), "8"), ViewEdit::LeftDeck); - let mut brought = SourceBuild { lane: Default::default(), + let mut brought = SourceBuild { live: Vec::new(), lane: Default::default(), cw: vec![CwItem { last_viewed_at: 9, m: started(0, "7"), position: 0 }, CwItem { last_viewed_at: 8, m: started(0, "8"), position: 1 }], shelves: vec![shelf(0, "Recently Added", "home.movies.recent", &["7", "8"])] }; @@ -327,7 +327,7 @@ fn a_source_read_in_flight_across_edits_lands_with_them_made_again() { s.ledger.landed(p7); // both writes reach the server… s.ledger.landed(p8); s.stamp = s.ledger.stamp(); // …and a read issued after that carries them already - let mut later = SourceBuild { lane: Default::default(), cw: Vec::new(), + let mut later = SourceBuild { live: Vec::new(), lane: Default::default(), cw: Vec::new(), shelves: vec![shelf(0, "Recently Added", "home.movies.recent", &["7"])] }; replay_since_request(&s, &mut later); assert!(!later.shelves[0].items[0].watched, "its answer stands"); @@ -347,7 +347,7 @@ fn a_source_request_takes_its_ledger_stamp_at_the_request() { s.ledger.record(&ViewAt::leaf(sid(0), "7"), ViewEdit::Watched(true)); // before the request assert!(s.begin_request(ClientKey::of(client), 1, || 1).is_some()); s.ledger.record(&ViewAt::leaf(sid(0), "8"), ViewEdit::Watched(true)); // while it is out - let mut brought = SourceBuild { lane: Default::default(), cw: Vec::new(), + let mut brought = SourceBuild { live: Vec::new(), lane: Default::default(), cw: Vec::new(), shelves: vec![shelf(0, "Recently Added", "home.movies.recent", &["7", "8"])] }; replay_since_request(&s, &mut brought); assert!(!brought.shelves[0].items[0].watched, "the server already had the first press"); @@ -363,7 +363,7 @@ fn facts_that_change_no_row_are_not_a_hit_but_facts_that_differ_are() { let _g = plx_base::testlock::serial(); let mut o = Owner::default(); reset(&mut o.state, &o.adapter); - let mut b = SourceBuild { + let mut b = SourceBuild { live: Vec::new(), lane: Default::default(), cw: vec![CwItem { last_viewed_at: 9, m: started(0, "7"), position: 0 }], shelves: vec![shelf(0, "Recently Added", "home.movies.recent", &["7", "8"])], diff --git a/rust-modules/data/src/pms_multi_source_merge_tests.rs b/rust-modules/data/src/pms_multi_source_merge_tests.rs index 7b1d3ca2ba..5da7406f3c 100644 --- a/rust-modules/data/src/pms_multi_source_merge_tests.rs +++ b/rust-modules/data/src/pms_multi_source_merge_tests.rs @@ -982,6 +982,42 @@ fn paging_a_recent_row_does_not_remove_other_home_rows() { assert_eq!(hubs[0].len, 24); } +#[test] +fn live_tv_channels_form_one_row_after_continue_watching_and_stay_out_of_the_hero() { + use plx_plex::plex::livetv::LiveChannel; + let channel = |vcn: &str, name: &str, now: &str| LiveChannel { + dvr: "7".into(), channel: format!("C{vcn}.242.ersatztv.org"), vcn: vcn.into(), name: name.into(), + thumb: format!("http://192.0.2.10:8409/iptv/logos/gen?text={vcn}"), now: now.into(), ends_at: 0, + }; + let mut owned = built(0, &[(10, "cw1")], vec![shelf(0, "Recent", "home.movies.recent", &["r1"])]); + owned.live = [channel("101", "Family Channel", "Family Show"), channel("102", "Cartoons", "")] + .iter().map(|ch| Arc::new(live_channel_tile(ch, sid(0)))).collect(); + let shared = built(1, &[], vec![shelf(1, "Shared recent", "home.television.recent", &["s1"])]); + let (items, hubs, pool) = merge(&[ + src(0, "", HubState::Ready, Some(owned)), + src(1, "Friend", HubState::Ready, Some(shared)), + ]); + + let ids: Vec<_> = hubs.iter().map(|hub| hub.hub_id.as_str()).collect(); + assert_eq!(ids, ["home.continue", LIVE_TV_HUB, "home.movies.recent", "home.television.recent"]); + let live = &hubs[1]; + assert_eq!(live.title, plx_platform::i18n::msg::browse_home_live_tv()); + let tiles = &items[live.start..live.start + live.len]; + assert_eq!(tiles.iter().map(|m| (m.title.as_str(), m.show_title.as_str())).collect::<Vec<_>>(), + [("Family Channel", "Family Show"), ("Cartoons", "")]); + assert_eq!(live_channel_of(&tiles[0]), Some(("7", "C101.242.ersatztv.org")), "the tune's two ids"); + assert_eq!(tiles[0].still, tiles[0].thumb, "a channel draws its logo on both tile faces"); + assert!(pool.iter().all(|slot| items[slot.idx].kind != KIND_LIVE_CHANNEL), "no channel becomes a billboard"); + assert_eq!(live_channel_of(&row(0, "101")), None, "a library row is never a channel"); +} + +#[test] +fn a_build_recorded_before_live_tv_decodes_with_no_channels() { + let json = serde_json::to_string(&built(0, &[(10, "cw1")], Vec::new())).unwrap(); + assert!(!json.contains("\"live\""), "an empty row is not recorded"); + let back: SourceBuild = serde_json::from_str(&json).unwrap(); + assert!(back.live.is_empty()); +} // ---- one tile per show across servers (#892) ------------------------------------------------------- @@ -1001,7 +1037,7 @@ fn card(s: ServerId, rk: &str, title: &str, show: Option<u8>, sec: i64, media: b } fn deck_of(cards: Vec<CwItem>) -> SourceBuild { - SourceBuild { lane: Default::default(), cw: cards, shelves: Vec::new() } + SourceBuild { lane: Default::default(), cw: cards, shelves: Vec::new(), live: Vec::new() } } /// `n` servers in the registry (the first is our own, the rest are credited), `(slot, handle)` each. diff --git a/rust-modules/data/src/pms_recent_paging_tests.rs b/rust-modules/data/src/pms_recent_paging_tests.rs index 440bef6b42..0d299619bf 100644 --- a/rust-modules/data/src/pms_recent_paging_tests.rs +++ b/rust-modules/data/src/pms_recent_paging_tests.rs @@ -163,7 +163,8 @@ fn check_refetch_window(change_profile: bool) { let server = std::thread::spawn(move || { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); let mut paths = Vec::new(); - while std::time::Instant::now() < deadline && paths.len() < 3 { + // four requests: the hub list, Continue Watching, the Live TV DVR list and the window reload + while std::time::Instant::now() < deadline && paths.len() < 4 { let (mut socket, _) = match plx_base::testnet::accept(&listener) { Ok(socket) => socket, Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { @@ -183,6 +184,8 @@ fn check_refetch_window(change_profile: bool) { serde_json::json!({"MediaContainer":{"offset":36,"totalSize":12000,"Metadata":metadata(36,24)}}) } else if path.starts_with("/hubs/continueWatching?") { serde_json::json!({"MediaContainer":{"Hub":[]}}) + } else if path.starts_with("/livetv/dvrs?") { + serde_json::json!({"MediaContainer":{"size":0}}) } else { serde_json::json!({"MediaContainer":{"Hub":[{"hubIdentifier":"home.movies.recent", "type":"movie","title":"Recent","key":"/hubs/home/recentlyAdded?type=1", diff --git a/rust-modules/data/src/pms_test_support.rs b/rust-modules/data/src/pms_test_support.rs index fb98bd6d2f..d9491fea7a 100644 --- a/rust-modules/data/src/pms_test_support.rs +++ b/rust-modules/data/src/pms_test_support.rs @@ -159,7 +159,7 @@ pub(super) fn shelf_in(slot: u16, sec: i64, title: &str, hub_id: &str, rks: &[&s /// A source's projection: `(lastViewedAt, rk)` deck entries plus whole shelves. pub(super) fn built(slot: u16, cw: &[(i64, &str)], shelves: Vec<Shelf>) -> SourceBuild { - SourceBuild { + SourceBuild { live: Vec::new(), lane: Default::default(), cw: cw .iter() diff --git a/rust-modules/media/src/ff.rs b/rust-modules/media/src/ff.rs index 119d6dc3d0..2aefa16462 100644 --- a/rust-modules/media/src/ff.rs +++ b/rust-modules/media/src/ff.rs @@ -1804,6 +1804,31 @@ impl Src { struct HlsNet { hs: *mut HttpStream, curl: Option<Box<crate::curlio::CurlSource>>, + /// `Some` only for a Live TV session (armed by `hls_cursor_open` when the entry is a media + /// playlist): the stream state one segment leaves for the next. Rides here because this is the + /// one value every segment fetch of the session is lent. + carry: Option<SegmentCarry>, +} + +/// What a Live TV segment hands the next one of the SAME playlist. A recording is the tuner's +/// stream copied and cut every second whatever the GOP (measured on the TV 2026-10-21: segment 1 +/// probed `0x0` and opened mid-GOP), so its segments are cuts of ONE stream, not independently +/// decodable pieces: a later one may carry no SPS/PPS, begin without an IDR, and time B-frames +/// before its first packet. PMS's own transcodes are independent segments and never arm this. +/// Scoped to the playlist's directory, so nothing crosses into another playlist. +#[derive(Default)] +struct SegmentCarry { + /// Empty until the first segment lands: armed, nothing to continue yet. + scope: String, + params: H264ParamSets, + raster: (i32, i32), + anchors: (Option<crate::hls::StreamAnchor>, Option<crate::hls::StreamAnchor>), +} + +/// The directory a segment's playlist lives in — the scope of a [`SegmentCarry`]. +fn carry_scope(segment: &crate::hls::Segment) -> &str { + let path = segment.resource.path.split_once('?').map_or(segment.resource.path.as_str(), |(p, _)| p); + path.rsplit_once('/').map_or("", |(dir, _)| dir) } /// **The live-transfer terminal rule.** A partial response is right-censored evidence. PMS may @@ -4152,7 +4177,7 @@ fn adts_header(freq_idx: u8, chan_cfg: u8, payload_len: usize) -> [u8; 7] { ] } -#[derive(Default)] +#[derive(Clone, Default)] struct H264ParamSets { sps: Vec<u8>, pps: Vec<u8>, @@ -5119,6 +5144,14 @@ unsafe fn hls_demux_segment( let path = auth .request_path(&segment.resource) .map_err(|_| HlsExit::Failed("segment credential rejected"))?; + let scope = carry_scope(segment).to_owned(); + let live = net.carry.is_some(); + let carried = net + .carry + .as_ref() + .filter(|c| c.scope == scope && c.raster.0 > 0 && c.raster.1 > 0) + .map(|c| (c.params.clone(), c.raster, c.anchors)); + let continuing = carried.is_some(); let request_started = runtime.request_started(); let audio_expected = || SHARED.hls_audio_expected.load(Ordering::Acquire) && FEED_AUDIO.load(Ordering::Relaxed); @@ -5275,12 +5308,24 @@ unsafe fn hls_demux_segment( None }; - let video_width = (*vcp).width; - let video_height = (*vcp).height; + let (mut video_width, mut video_height) = ((*vcp).width, (*vcp).height); + let (mut params, carried_raster, anchors) = carried.unwrap_or_default(); + if continuing { + clock.continue_stream(anchors); + } if video_width <= 0 || video_height <= 0 { - return Err(HlsExit::Failed("HLS segment has invalid video dimensions")); + // No SPS in this segment for the probe to read: the previous segment of this playlist + // named the raster, and its parameter sets go in front of this one's IDR. + let (w, h) = carried_raster; + if w <= 0 || h <= 0 { + return Err(HlsExit::Failed("HLS segment has invalid video dimensions")); + } + (video_width, video_height) = (w, h); + crate::player::log(&format!( + "hls: segment={} carries no SPS; raster {w}x{h} and parameter sets from the previous segment", + segment.sequence + )); } - let mut params = H264ParamSets::default(); let mut aubuf = Vec::with_capacity(4 * 1024 * 1024); let mut video_packets = 0usize; let mut audio_packets = 0usize; @@ -5319,7 +5364,8 @@ unsafe fn hls_demux_segment( &mut aubuf, ) .map_err(HlsExit::Failed)?; - if video_packets == 0 && !is_key { + // A continued live segment is a cut of the stream the decoder is already inside. + if video_packets == 0 && !is_key && !continuing { av_packet_unref(input.pkt); return Err(HlsExit::Failed("HLS segment does not begin with an IDR")); } @@ -5454,6 +5500,14 @@ unsafe fn hls_demux_segment( if let Some(cs) = input.state.src.take_curl() { net.curl = Some(cs); } + if live { + net.carry = Some(SegmentCarry { + scope, + params, + raster: (video_width, video_height), + anchors: clock.stream_anchors(), + }); + } Ok(HlsSegmentOutput { aus, transfer, @@ -5574,6 +5628,34 @@ struct HlsCursor { ended: bool, target_duration_secs: u64, start_applied: bool, + /// A Live TV session's playlist (the entry was already a media playlist): it starts + /// [`LIVE_START_CUSHION_NS`] behind its edge and is polled every [`LIVE_POLL`]. + live: bool, +} + +/// How far behind a live recording's edge playback starts. Measured on the TV 2026-10-21: started +/// on the first one-second segment, the play rate held 1000‰ for 11 s and then swung between 667‰ +/// and 1400‰ with 400-600 ms video gaps — every late segment was a stall, because the whole buffer +/// was the one segment in hand. Three seconds of recording absorb that jitter; the start costs the +/// same three seconds once. +const LIVE_START_CUSHION_NS: i64 = 3_000_000_000; + +/// A live playlist grows by one segment a second; polled at the VOD cadence (a full second for +/// these) each new segment was found up to a second late, a second the cushion then has to pay. +const LIVE_POLL: std::time::Duration = std::time::Duration::from_millis(500); + +/// Whether a live playlist holding `total_ns` of recording may start delivering segments. +fn live_start_ready(live: bool, start_applied: bool, total_ns: i64) -> bool { + !live || start_applied || total_ns >= LIVE_START_CUSHION_NS +} + +/// The wait before re-reading a playlist that had nothing new. +fn playlist_poll(live: bool, target_duration_secs: u64) -> std::time::Duration { + if live { + LIVE_POLL + } else { + std::time::Duration::from_millis(target_duration_secs.saturating_mul(500).clamp(250, 1_000)) + } } fn hls_cursor_open( @@ -5591,6 +5673,24 @@ fn hls_cursor_open( // Parsed against where the fetch LANDED: a redirected master's children are relative to it. let (master_text, master_ms, master_landed) = hls_fetch_text(&master_resource, &auth, aq, net, reserve)?; + if crate::hls::is_media_playlist(&master_text) { + // A Live TV session: the URL is the media playlist itself, read again by the first + // `hls_cursor_next` like any variant's. No declared rate exists for it. + crate::player::log(&format!("hls: entry is a media playlist fetch_ms={master_ms}")); + net.carry = Some(SegmentCarry::default()); + return Ok(HlsCursor { + publishes_duration, + declared_bps: 0, + auth, + media: master_landed, + tracker: crate::hls::MediaTracker::default(), + pending: std::collections::VecDeque::new(), + ended: false, + target_duration_secs: 1, + start_applied: false, + live: true, + }); + } let master = crate::hls::parse_master(&master_landed, &master_text).map_err(|e| { crate::player::log(&format!("hls: master rejected: {e}")); HlsExit::Failed("HLS master rejected") @@ -5609,6 +5709,7 @@ fn hls_cursor_open( ended: false, target_duration_secs: 1, start_applied: false, + live: false, }) } @@ -5646,44 +5747,47 @@ fn hls_cursor_next( if cursor.publishes_duration { SHARED.duration_ns.store(total_ns, Ordering::Relaxed); } - let mut refresh = cursor.tracker.apply(&media).map_err(|e| { - crate::player::log(&format!("hls: refresh rejected: {e}")); - HlsExit::Failed("HLS media refresh rejected") - })?; - let mut skipped = 0usize; - if !cursor.start_applied { - let first_sequence = media - .segments - .get(start_index) - .map(|segment| segment.sequence) - .unwrap_or_else(|| { - media - .media_sequence - .saturating_add(media.segments.len() as u64) - }); - let before = refresh.new_segments.len(); - refresh - .new_segments - .retain(|segment| segment.sequence >= first_sequence); - skipped = before.saturating_sub(refresh.new_segments.len()); - cursor.start_applied = true; + // Not one segment of a live recording leaves before the cushion exists; the tracker is left + // untouched, so the read that has it delivers every segment from the first. + let holding = !live_start_ready(cursor.live, cursor.start_applied, total_ns); + if holding { + crate::player::log(&format!("hls: live start holds for its cushion total_ms={}", total_ns / 1_000_000)); + } + if !holding { + let mut refresh = cursor.tracker.apply(&media).map_err(|e| { + crate::player::log(&format!("hls: refresh rejected: {e}")); + HlsExit::Failed("HLS media refresh rejected") + })?; + let mut skipped = 0usize; + if !cursor.start_applied { + let first_sequence = media + .segments + .get(start_index) + .map(|segment| segment.sequence) + .unwrap_or_else(|| { + media + .media_sequence + .saturating_add(media.segments.len() as u64) + }); + let before = refresh.new_segments.len(); + refresh + .new_segments + .retain(|segment| segment.sequence >= first_sequence); + skipped = before.saturating_sub(refresh.new_segments.len()); + cursor.start_applied = true; + } + crate::player::log(&format!( + "hls: refresh new={} skipped={} total_ms={} end={} fetch_ms={media_ms}", + refresh.new_segments.len(), + skipped, + total_ns / 1_000_000, + refresh.end_list + )); + cursor.pending.extend(refresh.new_segments); + cursor.ended = refresh.end_list; } - crate::player::log(&format!( - "hls: refresh new={} skipped={} total_ms={} end={} fetch_ms={media_ms}", - refresh.new_segments.len(), - skipped, - total_ns / 1_000_000, - refresh.end_list - )); - cursor.pending.extend(refresh.new_segments); - cursor.ended = refresh.end_list; if cursor.pending.is_empty() && !cursor.ended { - let poll = std::time::Duration::from_millis( - cursor - .target_duration_secs - .saturating_mul(500) - .clamp(250, 1_000), - ); + let poll = playlist_poll(cursor.live, cursor.target_duration_secs); let reserve_snapshot = reserve .as_deref_mut() .and_then(|reserve| reserve.active(false)); @@ -7017,7 +7121,7 @@ fn hls_demux( SHARED.dg_abr_target_kbps.store(0, Ordering::Relaxed); SHARED.dg_abr_unsafe_deficit_ms.store(0, Ordering::Relaxed); } - let mut net = HlsNet { hs, curl: None }; + let mut net = HlsNet { hs, curl: None, carry: None }; let mut cursor = hls_cursor_open(origin, path, aq, &mut net, true, None)?; if abr.is_some() { SHARED.dg_abr_declared_kbps.store( diff --git a/rust-modules/media/src/ff_aac_timestamp_tests.rs b/rust-modules/media/src/ff_aac_timestamp_tests.rs index 10dbc2fb1a..ecdbbf5ea6 100644 --- a/rust-modules/media/src/ff_aac_timestamp_tests.rs +++ b/rust-modules/media/src/ff_aac_timestamp_tests.rs @@ -105,6 +105,44 @@ fn a_later_ts_idr_recovers_cached_parameter_sets() { assert!(out.ends_with(&later)); } +/// A Live TV recording's later segment carries no SPS/PPS (2026-10-21): the sets the previous +/// segment of the SAME playlist left are carried over, and another playlist never inherits them. +#[test] +fn a_live_segment_without_parameter_sets_inherits_them_from_its_own_playlist_only() { + let at = |path: &str| crate::hls::Segment { + sequence: 1, + duration: std::time::Duration::from_secs(1), + resource: crate::hls::Resource::new(plx_plex::plex::Origin::http("192.0.2.10", 32400), path).unwrap(), + }; + let session = "/livetv/sessions/9b9fe727/d459eaef"; + assert_eq!(carry_scope(&at(&format!("{session}/00000.ts"))), session); + assert_eq!(carry_scope(&at(&format!("{session}/00001.ts?X-Plex-Token=t"))), session); + assert_ne!(carry_scope(&at("/video/:/transcode/universal/session/other/base/00001.ts")), session); + + // the previous segment's sets, cloned into the next one, go in front of its bare IDR + let mut previous = H264ParamSets::default(); + let mut out = Vec::new(); + ts_h264_access_unit(&[0, 0, 1, 0x67, 1, 0, 0, 1, 0x68, 2, 0, 0, 1, 0x65, 3], &mut previous, &mut out).unwrap(); + let mut next = previous.clone(); + assert_eq!(ts_h264_access_unit(&[0, 0, 1, 0x65, 4], &mut next, &mut out), Ok(true)); + assert!(out.starts_with(&[0, 0, 0, 1, 0x67, 1, 0, 0, 0, 1, 0x68, 2])); +} + +/// A live recording starts three seconds behind its edge and is polled twice a second (measured +/// on the TV 2026-10-21: started on its first segment it stalled at every late one); a PMS +/// transcode's playlist keeps the VOD cadence and starts at once. +#[test] +fn a_live_playlist_starts_behind_its_edge_and_is_polled_faster() { + assert!(!live_start_ready(true, false, 760_000_000), "one segment is no cushion"); + assert!(!live_start_ready(true, false, 2_760_000_000)); + assert!(live_start_ready(true, false, 3_760_000_000)); + assert!(live_start_ready(true, true, 0), "once started, every segment goes as it lands"); + assert!(live_start_ready(false, false, 0), "a PMS transcode is never held"); + assert_eq!(playlist_poll(true, 3), std::time::Duration::from_millis(500)); + assert_eq!(playlist_poll(false, 3), std::time::Duration::from_millis(1_000)); + assert_eq!(playlist_poll(false, 0), std::time::Duration::from_millis(250)); +} + #[test] fn a_ts_idr_without_any_parameter_sets_is_rejected() { let mut sets = H264ParamSets::default(); diff --git a/rust-modules/media/src/ff_redirect_tests.rs b/rust-modules/media/src/ff_redirect_tests.rs index 8192cebe0f..2d0dca73bb 100644 --- a/rust-modules/media/src/ff_redirect_tests.rs +++ b/rust-modules/media/src/ff_redirect_tests.rs @@ -124,6 +124,7 @@ fn hls_open_plain( let mut net = HlsNet { hs: &mut *hs, curl: None, + carry: None, }; let opened = hls_open_source( &hls_resource(port), @@ -376,6 +377,7 @@ fn a_redirected_master_playlist_resolves_its_children_against_the_redirect_targe let mut net = HlsNet { hs: &mut *hs, curl: None, + carry: None, }; let origin = plx_plex::plex::Origin::http("127.0.0.1", pms.port as i32); let cursor = hls_cursor_open( diff --git a/rust-modules/media/src/ff_stall_guard_tests.rs b/rust-modules/media/src/ff_stall_guard_tests.rs index 939ac0e463..b125e5fd77 100644 --- a/rust-modules/media/src/ff_stall_guard_tests.rs +++ b/rust-modules/media/src/ff_stall_guard_tests.rs @@ -355,6 +355,7 @@ fn the_lookahead_wiring_aborts_through_the_shared_reducer_when_the_guard_is_arme ended: false, target_duration_secs: 6, start_applied: true, + live: false, }; let n1 = crate::hls::Segment { sequence: 11, @@ -779,6 +780,7 @@ fn demux_against_when( let mut net = HlsNet { hs: &mut *hs, curl: None, + carry: None, }; let result = unsafe { hls_demux_segment( @@ -1220,6 +1222,7 @@ fn a_zero_byte_abort_reaches_the_controller_as_an_abandoned_sample() { ended: false, target_duration_secs: 6, start_applied: true, + live: false, }; let segment = crate::hls::Segment { sequence: 11, @@ -1327,6 +1330,7 @@ fn at_the_floor_the_open_asks_for_the_hold_once_and_keeps_its_connection() { let mut net = HlsNet { hs: &mut *hs, curl: None, + carry: None, }; let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); let opened = hls_open_source( diff --git a/rust-modules/media/src/hls.rs b/rust-modules/media/src/hls.rs index ffbb9e063a..2f8ada2dcf 100644 --- a/rust-modules/media/src/hls.rs +++ b/rust-modules/media/src/hls.rs @@ -103,6 +103,8 @@ impl SegmentTimeline { next_start_ns, video_origin_ns: None, audio_origin_ns: None, + video_stream: None, + audio_stream: None, }) } @@ -128,6 +130,18 @@ pub struct SegmentClock { next_start_ns: i64, video_origin_ns: Option<i64>, audio_origin_ns: Option<i64>, + /// Set by [`Self::continue_stream`]: this segment is a cut of the stream an earlier segment + /// began, and its timestamps are read against that one's anchors instead of its own first packet. + video_stream: Option<StreamAnchor>, + audio_stream: Option<StreamAnchor>, +} + +/// One lane's anchor in a continuous stream: the raw timestamp of the first packet of the segment +/// that began it, and where that segment sat on the content timeline. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StreamAnchor { + raw_ns: i64, + base_ns: i64, } impl SegmentClock { @@ -146,11 +160,42 @@ impl SegmentClock { /// first packet observed makes the later-discovered lane inherit an arbitrary packet-order /// offset. The controller consumes only this content timeline, never raw FFmpeg PTS. pub fn normalize_video(&mut self, raw_ns: i64) -> MediaTimeMs { - Self::normalize_from(self.base_ns, &mut self.video_origin_ns, raw_ns) + match self.video_stream { + Some(anchor) => anchor.place(raw_ns), + None => Self::normalize_from(self.base_ns, &mut self.video_origin_ns, raw_ns), + } } pub fn normalize_audio(&mut self, raw_ns: i64) -> MediaTimeMs { - Self::normalize_from(self.base_ns, &mut self.audio_origin_ns, raw_ns) + match self.audio_stream { + Some(anchor) => anchor.place(raw_ns), + None => Self::normalize_from(self.base_ns, &mut self.audio_origin_ns, raw_ns), + } + } + + /// The anchors a LATER segment of the same continuous stream continues from: the ones this + /// segment continued itself, or — for the stream's first segment — its own first packets. + pub fn stream_anchors(&self) -> (Option<StreamAnchor>, Option<StreamAnchor>) { + let own = |origin: Option<i64>| origin.map(|raw_ns| StreamAnchor { raw_ns, base_ns: self.base_ns }); + ( + self.video_stream.or(own(self.video_origin_ns)), + self.audio_stream.or(own(self.audio_origin_ns)), + ) + } + + /// Read this segment as a cut of the stream `anchors` began — a Live TV recording, which PMS + /// cuts every second whatever the GOP, so a segment may open mid-GOP with B-frames timed before + /// its first packet. Per-segment anchoring would clamp those onto the boundary (`max(0)`) and + /// reset any drift each second; one anchor per lane keeps the stream's own timing. + pub fn continue_stream(&mut self, anchors: (Option<StreamAnchor>, Option<StreamAnchor>)) { + (self.video_stream, self.audio_stream) = anchors; + } +} + +impl StreamAnchor { + fn place(self, raw_ns: i64) -> MediaTimeMs { + let relative = raw_ns.saturating_sub(self.raw_ns); + MediaTimeMs(self.base_ns.saturating_add(relative).max(0) / 1_000_000) } } @@ -466,6 +511,22 @@ impl fmt::Display for Error { impl std::error::Error for Error {} +/// Whether `text` is already a MEDIA playlist (segments, no variants). A Live TV session's playlist +/// is one: PMS serves `/livetv/sessions/{uuid}/{client}/index.m3u8` with no master in front of it +/// (`docs/pms-api.md` §10), so the URL a route hands over is its own single variant. Shape only; +/// [`parse_media`] still validates every line. +pub fn is_media_playlist(text: &str) -> bool { + let mut lines = text.lines().map(str::trim); + lines.next() == Some("#EXTM3U") && { + let (mut segments, mut variants) = (false, false); + for line in lines { + segments |= line.starts_with("#EXTINF:"); + variants |= line.starts_with("#EXT-X-STREAM-INF:"); + } + segments && !variants + } +} + pub fn parse_master(source: &Resource, text: &str) -> Result<MasterPlaylist, Error> { let lines = playlist_lines(text)?; let mut version = None; @@ -1069,6 +1130,52 @@ mod tests { parse_media(&source, &text).unwrap() } + // A Live TV session's playlist as PMS 1.43.3 served it (2026-10-21): no master, no + // PLAYLIST-TYPE, no ENDLIST while the channel plays, one-second segments named from zero. + const LIVE: &str = "#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-TARGETDURATION:3\n#EXT-X-MEDIA-SEQUENCE:0\n\ + #EXT-X-PROGRAM-DATE-TIME:2026-10-21T07:54:42.854397846Z\n#EXTINF:0.760000, nodesc\n00000.ts\n\ + #EXTINF:1.000000, nodesc\n00001.ts\n#EXTINF:1.000000, nodesc\n00002.ts\n"; + + #[test] + fn a_continued_live_segment_keeps_the_stream_s_timing_across_the_cut() { + let mut timeline = SegmentTimeline::default(); + let mut first = timeline.begin(Duration::from_millis(760)).unwrap(); + assert_eq!(first.normalize_video(10_000_000_000), MediaTimeMs(0)); + assert_eq!(first.normalize_audio(9_980_000_000), MediaTimeMs(0)); + assert_eq!(first.normalize_video(10_720_000_000), MediaTimeMs(720)); + timeline.commit(first); + let anchors = first.stream_anchors(); + + let mut second = timeline.begin(Duration::from_secs(1)).unwrap(); + second.continue_stream(anchors); + // mid-GOP: a P-frame first, then a B-frame timed before it — both on the stream's own clock + assert_eq!(second.normalize_video(10_800_000_000), MediaTimeMs(800)); + assert_eq!(second.normalize_video(10_760_000_000), MediaTimeMs(760), "not clamped onto the cut"); + assert_eq!(second.normalize_audio(10_780_000_000), MediaTimeMs(800)); + assert_eq!(second.stream_anchors(), anchors, "the third segment continues the same anchors"); + + timeline.commit(second); + // without continuation the same B-frame is clamped onto the boundary, as PMS transcodes want + let mut independent = timeline.begin(Duration::from_secs(1)).unwrap(); + independent.normalize_video(10_800_000_000); + assert_eq!(independent.normalize_video(10_760_000_000), MediaTimeMs(1_760)); + } + + #[test] + fn a_live_session_playlist_is_its_own_variant_and_parses_as_growing_media() { + assert!(is_media_playlist(LIVE)); + assert!(!is_media_playlist( + "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\nsession/index.m3u8\n" + )); + assert!(!is_media_playlist("#EXTINF:1.0,\n00000.ts\n"), "no header"); + let at = source("/livetv/sessions/9b9fe727-0000/clientid/index.m3u8?offset=-1.000000&X-Plex-Token=secret"); + let media = parse_media(&at, LIVE).unwrap(); + assert_eq!((media.media_sequence, media.segments.len(), media.end_list), (0, 3, false)); + assert_eq!(media.playlist_type, None); + assert_eq!(media.segments[1].resource.path, "/livetv/sessions/9b9fe727-0000/clientid/00001.ts"); + assert_eq!(media.preferred_start_index().unwrap(), 0); + } + #[test] fn observed_one_variant_master_resolves_quoted_attributes_and_relative_child() { let start = source("/video/:/transcode/universal/start.m3u8?X-Plex-Token=secret"); diff --git a/rust-modules/media/src/player/engine.rs b/rust-modules/media/src/player/engine.rs index 256c6f1285..5120c6db2b 100644 --- a/rust-modules/media/src/player/engine.rs +++ b/rust-modules/media/src/player/engine.rs @@ -7926,6 +7926,7 @@ mod sink_envelope_tests { "a faster row never raises the ceiling" ); } + #[test] fn audio_payload_names_dts_and_refuses_unsupported_formats() { assert_eq!(audio_payload_codec("dts"), Some(("DTS", 4))); diff --git a/rust-modules/media/src/player/mod.rs b/rust-modules/media/src/player/mod.rs index bd308b6b60..6c3a71b443 100644 --- a/rust-modules/media/src/player/mod.rs +++ b/rust-modules/media/src/player/mod.rs @@ -704,6 +704,12 @@ pub fn ended() -> bool { SHARED.ended.load(Relaxed) } pub fn request_seek(ns: i64) { + // A live channel has no seekable timeline: the HLS reader follows the edge and reads no target, + // so an armed seek would hold the spinner up for the rest of the playback. + if crate::route::live_playback() { + log("seek: refused on a live channel"); + return; + } report::note_seek_for(crate::route::playback_trace_generation()); crate::route::note_user_seek_intent(ns); SHARED.ended.store(false, Relaxed); // seeking back from the end un-ends the stream diff --git a/rust-modules/media/src/route/decision.rs b/rust-modules/media/src/route/decision.rs index 060cc8c3fc..3a60a6cf86 100644 --- a/rust-modules/media/src/route/decision.rs +++ b/rust-modules/media/src/route/decision.rs @@ -60,6 +60,9 @@ struct PlaybackRequest { /// Background hero preview. No PlayQueue, no timeline, no scrobble, resume at 0, and the /// caller must not push the player route. preview: bool, + /// A Live TV channel to tune instead of `rk`/`part` ([`request_live`]). A retry of it tunes + /// again: the previous session ended with the playback that failed. + live: Option<LiveTune>, /// The version the viewer picked (Play Version), `None` for the default. Kept on the request, /// so a retry or quality change of this playback resolves the same version. version: Option<plx_plex::plex::MediaId>, @@ -165,6 +168,9 @@ pub struct PlaybackSession { /// The server-side transcode session id, EMPTY on a direct play — that emptiness is the /// "is this a transcode?" test ([`is_transcoding`]) and the key the stop is sent with. tsession: String, + /// The Live TV session this playback watches, `None` for every library item. The timeline + /// reports under it and its `stopped` ([`scrobble_stop`]) is what frees the server's tuner. + cur_live: Option<plx_plex::plex::livetv::LiveSession>, /// The server's PRE-FLIGHT refusal for the last resolve, or None. /// /// `Some(sentence)` means `/decision` answered "neither direct play nor conversion is @@ -557,6 +563,7 @@ impl PlaybackSession { requested_resume_ns: 0, url: String::new(), tsession: String::new(), + cur_live: None, play_verdict: None, resolve_failed: false, cur_contract: plx_plex::plex::EncodeContract::original(false, plx_plex::plex::AudioEnhancements::NONE), @@ -646,6 +653,7 @@ impl PlaybackSession { requested_resume_ns, url, tsession, + cur_live, play_verdict, resolve_failed, cur_contract, @@ -717,6 +725,7 @@ impl PlaybackSession { requested_resume_ns: *requested_resume_ns, url: url.clone(), tsession: tsession.clone(), + cur_live: cur_live.clone(), play_verdict: play_verdict.clone(), resolve_failed: *resolve_failed, cur_contract: *cur_contract, @@ -1219,6 +1228,8 @@ fn commit_in_place_route_projection(ps: &PlaybackSession, quality_contract: bool #[derive(Clone, Debug, PartialEq, Eq)] struct TimelineProjection { sid: ServerId, + /// A Live TV playback reports under its session instead of `rating_key`. + live: Option<plx_plex::plex::livetv::LiveSession>, rating_key: String, logical_session: String, play_queue_id: String, @@ -1239,6 +1250,7 @@ pub struct TimelineLease { #[derive(Clone)] struct TimelineSnapshot { sid: ServerId, + live: Option<plx_plex::plex::livetv::LiveSession>, rating_key: String, state: plx_plex::plex::TimelineState, time_ms: i64, @@ -6378,6 +6390,8 @@ struct ScrobbleWork { mark_watched: bool, /// The record the app learns this stop from; posted just before `timeline_stop` completes. stop_mail: Option<PendingStop>, + /// The Live TV session to end: its `stopped` frees the server's tuner. + live: Option<plx_plex::plex::livetv::LiveSession>, } /// The stops whose `stopped` POST has been attempted or abandoned, waiting for the app to take @@ -6505,6 +6519,15 @@ impl ScrobbleWork { } } } + if let Some(live) = self.live.take() { + let ok = { + let _effect = TIMELINE_EFFECT.lock().unwrap_or_else(|e| e.into_inner()); + self.client.is_some_and(|c| { + c.live_timeline(&live, plx_plex::plex::TimelineState::Stopped, 0, &self.session) + }) + }; + plx_base::eventlog::log(&format!("livetv: stopped ok={}", ok as i32)); + } // BEFORE the fence completes, so a completed fence implies the record is in the mailbox. if let Some(mut mail) = self.stop_mail.take() { mail.post(posted); @@ -6558,11 +6581,13 @@ pub fn scrobble_stop( // the whole of what a stop resets). A partial write rather than a whole-session reset because // the rest is still read after teardown returns — see `reset_session`'s doc for the reader that // would break. + let live = ps.cur_live.take(); + LIVE_PLAYBACK.store(false, Ordering::SeqCst); { let s = &mut *ps; { s.tsession.clear(); s.cur_contract.remux = false; } }; - if final_report.is_none() && tsession.is_empty() && report_th.is_none() { + if final_report.is_none() && tsession.is_empty() && report_th.is_none() && live.is_none() { return; // nothing to post and nobody to wait for } // The server this playback came FROM, not whichever one is current — the resume point and the @@ -6604,6 +6629,7 @@ pub fn scrobble_stop( timeline_stop, mark_watched, stop_mail, + live, }))); let worker = work.clone(); let join_generation = SCROBBLE_JOIN.reserve(); @@ -8192,6 +8218,7 @@ impl ResolveEnv { memory: recalled_track_memory(sid), // Set in exactly one place, `request_env`: nothing else may default it on. fresh_play: false, + live: None, #[cfg(any(test, feature = "test-support"))] account_subtitles: None, } @@ -8352,6 +8379,11 @@ fn retire_plan_resources(resources: AbandonedPlanResources) { "abandon-stop", const { &plx_base::task::BlockingLabel::new("abandoned plan stop (worker thread refused)") }, move || { + // The recording first: it holds a tuner, which nothing else here would give back. + if let Some((live, session)) = &resources.live { + let ok = client.live_timeline(live, plx_plex::plex::TimelineState::Stopped, 0, session); + plx_base::eventlog::log(&format!("livetv: abandoned session stopped ok={}", ok as i32)); + } for identity in resources.identities { let _ = client.transcode_stop(&identity); } @@ -8470,6 +8502,7 @@ pub fn request_play( title: title.to_owned(), ctx: ctx.to_owned(), preview: false, + live: None, version, }, None, @@ -8497,7 +8530,8 @@ pub fn request_play_queue_item( let queue = super::queue_navigation::QueueContinuation::capture(ps, row); request_play_with_queue(ps, meta, caps, PlaybackRequest { sid: row.sid, rk: row.rk.clone(), part: row.part.clone(), vcodec: row.vcodec.clone(), - acodec: row.acodec.clone(), title: title.clone(), ctx: ctx.into(), preview: false, version: None, + acodec: row.acodec.clone(), title: title.clone(), ctx: ctx.into(), preview: false, + live: None, version: None, }, None, None, false, crate::player::report::AttemptOrigin::Fresh, queue) } @@ -8527,6 +8561,7 @@ pub fn request_preview( title: title.to_owned(), ctx: plx_data::metadata::TRAILER_CONTEXT.to_owned(), preview: true, + live: None, version: None, }, None, @@ -8538,6 +8573,54 @@ pub fn request_preview( ) } +/// Same resolve door as [`request_play`], for a Live TV channel of server `sid`: the worker tunes +/// `channel` on DVR `dvr` (several seconds, under the player's Resolving state) and direct-plays the +/// session it opens. `title` is the channel's name. MAIN THREAD, NON-BLOCKING, like `request_play`. +pub fn request_live( + ps: &mut PlaybackSession, + meta: &mut plx_data::stores::metadata::MetadataStore, + caps: std::sync::Arc<plx_platform::devcaps::Caps>, + sid: ServerId, + dvr: &str, + channel: &str, + title: &str, +) -> bool { + request_play_inner( + ps, + meta, + caps, + PlaybackRequest { + sid, + rk: String::new(), + part: String::new(), + vcodec: String::new(), + acodec: String::new(), + title: title.to_owned(), + ctx: String::new(), + preview: false, + live: Some(LiveTune { dvr: dvr.to_owned(), channel: channel.to_owned() }), + version: None, + }, + None, + None, + false, + crate::player::report::AttemptOrigin::Fresh, + ) +} + +/// This playback watches a Live TV channel: there is no timeline to seek in and nothing to resume. +pub fn is_live(ps: &PlaybackSession) -> bool { + ps.cur_live.is_some() || ps.request.as_ref().is_some_and(|r| r.live.is_some()) +} + +/// [`is_live`] for the installed route, readable where no session is lent: the seek door +/// (`player::request_seek`). Set by `apply_plan`, cleared by [`scrobble_stop`]. +static LIVE_PLAYBACK: AtomicBool = AtomicBool::new(false); + +pub fn live_playback() -> bool { + LIVE_PLAYBACK.load(Ordering::SeqCst) +} + /// The resolve environment a play REQUEST hands its worker: the snapshot taken AFTER the request /// cleared the live selection, the request's own flags, and the retry (if any) applied over it. /// MAIN THREAD. @@ -8553,6 +8636,7 @@ fn request_env( let mut env = ResolveEnv::snapshot(ps, meta, request.sid, &request.rk); env.omit_queue_continuous = plx_data::metadata::context_omits_queue_continuous(&request.ctx); env.set_preview(request.preview); + env.live = request.live.clone(); env.fresh_play = retry.is_none() && !request.preview; env.version = request.version; if env.version.is_some() { @@ -8600,7 +8684,7 @@ fn request_play_with_queue( let part = &request.part; let title = &request.title; let ctx = &request.ctx; - if part.is_empty() && rk.is_empty() { + if part.is_empty() && rk.is_empty() && request.live.is_none() { return false; } if !begin_playback_request() { @@ -9172,6 +9256,7 @@ fn apply_plan(ps: &mut PlaybackSession, meta: &mut plx_data::stores::metadata::M let sub_mode = if selection_known { plan.sub_mode } else { s.sub_mode }; let now_ms = s.now_ms; let preview = request.as_ref().is_some_and(|r| r.preview); + LIVE_PLAYBACK.store(plan.live.is_some(), Ordering::SeqCst); *s = PlaybackSession { direct_play_mode: plan.direct_play_mode, jail_load_blocked: false, @@ -9183,6 +9268,7 @@ fn apply_plan(ps: &mut PlaybackSession, meta: &mut plx_data::stores::metadata::M requested_resume_ns, url: plan.url, tsession: plan.tsession, + cur_live: plan.live, // Installed on EVERY landing, not only a refusing one: a plan that resolved is itself // the statement that the last refusal is over, and assigning unconditionally is what // makes that true without a second clear anyone can forget. @@ -11561,6 +11647,7 @@ pub fn begin_timeline_reporting(ps: &PlaybackSession) -> Option<TimelineLease> { } let projection = TimelineProjection { sid: cur_sid(ps), + live: ps.cur_live.clone(), rating_key: cur_rk(ps), logical_session: sess(ps), play_queue_id: pq_id(ps), @@ -11568,7 +11655,7 @@ pub fn begin_timeline_reporting(ps: &PlaybackSession) -> Option<TimelineLease> { audio_stream_id: cur_audio_sid(ps), subtitle_stream_id: cur_sub_sid(ps), }; - if projection.rating_key.is_empty() || !projection.sid.is_set() { + if (projection.rating_key.is_empty() && projection.live.is_none()) || !projection.sid.is_set() { return None; } let required_stop = TIMELINE_STOP_FENCE.announced(); @@ -11607,6 +11694,7 @@ fn timeline_snapshot( }; Some(TimelineSnapshot { sid: projection.sid, + live: projection.live.clone(), rating_key: projection.rating_key.clone(), state, time_ms, @@ -11642,6 +11730,13 @@ pub fn report_timeline( Some(c) => c, None => return true, }; + if let Some(live) = &report.live { + // Watched time, not a position: a live session has no duration and nothing to resume. + if !c.live_timeline(live, report.state, report.time_ms, &report.session) { + plx_base::eventlog::log(&format!("livetv: timeline post failed state={}", report.state.as_str())); + } + return true; + } let ok = c.timeline(&plx_plex::plex::TimelineReport { rating_key: &report.rating_key, state: report.state, @@ -11701,6 +11796,7 @@ pub(crate) fn resolve_refused_preview_for_test(ps: &mut PlaybackSession) { title: "Trailer".into(), ctx: plx_data::metadata::TRAILER_CONTEXT.into(), preview: true, + live: None, version: None, }); test_support::apply_plan(ps, plan, "trailer"); diff --git a/rust-modules/media/src/route/decision_audio_enhancement_tests.rs b/rust-modules/media/src/route/decision_audio_enhancement_tests.rs index 2e4808c845..0106b4f36e 100644 --- a/rust-modules/media/src/route/decision_audio_enhancement_tests.rs +++ b/rust-modules/media/src/route/decision_audio_enhancement_tests.rs @@ -4153,6 +4153,7 @@ fn admit_side_reader(ps: &mut PlaybackSession) { title: String::new(), ctx: String::new(), preview: false, + live: None, version: None, }); } diff --git a/rust-modules/media/src/route/decision_resolve_route_tests.rs b/rust-modules/media/src/route/decision_resolve_route_tests.rs index ba0aa3fd90..ff1ba06330 100644 --- a/rust-modules/media/src/route/decision_resolve_route_tests.rs +++ b/rust-modules/media/src/route/decision_resolve_route_tests.rs @@ -1458,6 +1458,7 @@ fn a_refused_retry_keeps_its_position_and_full_request_for_the_next_quality() { title: "Episode".into(), ctx: "S01 E02".into(), preview: false, + live: None, version: None, }; { let s = &mut ps; { diff --git a/rust-modules/media/src/route/decision_timeline_tests.rs b/rust-modules/media/src/route/decision_timeline_tests.rs index 5e3fdc7d91..145c05c345 100644 --- a/rust-modules/media/src/route/decision_timeline_tests.rs +++ b/rust-modules/media/src/route/decision_timeline_tests.rs @@ -455,6 +455,7 @@ fn a_session_resolved_for_a_preview_never_reports_a_timeline() { title: "Trailer".into(), ctx: plx_data::metadata::TRAILER_CONTEXT.into(), preview: true, + live: None, version: None, }); apply_plan( @@ -757,3 +758,135 @@ fn a_stop_posted_after_the_identity_changed_is_dropped() { assert_eq!(take_landed_stops().len(), 1, "a stop made after the switch lands as ever"); reset_player_control_for_test(&ps); } + +// The official client's Live TV sequence (`docs/pms-api.md` §10), as a loopback server answers it. +// The tune's body nests the session the way the server log names its levels; it was not captured. +const LIVE_TUNED: &[u8] = br#"{"MediaContainer":{"size":1,"MediaSubscription":[{"id":112, + "MediaGrabOperation":[{"Metadata":{"key":"/livetv/sessions/9b9fe727-0000-46db-b94c-ef512e3e61d2", + "ratingKey":"355108","type":"episode"}}]}]}}"#; +const LIVE_DECIDED: &[u8] = br#"{"MediaContainer":{"Metadata":[{"Media":[{"videoCodec":"h264","audioCodec":"aac", + "Part":[{"key":"/livetv/sessions/9b9fe727-0000-46db-b94c-ef512e3e61d2/cid-live/index.m3u8?offset=-1.000000", + "decision":"directplay"}]}]}]}}"#; + +/// A loopback PMS for the live sequence: answers the tune and the decision with their shapes and +/// everything else with an empty container, handing each request line down the channel. +fn live_pms(decision_refused: bool) -> (i32, std::sync::mpsc::Receiver<String>) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + listener.set_nonblocking(true).unwrap(); + let port = listener.local_addr().unwrap().port() as i32; + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while std::time::Instant::now() < deadline { + match plx_base::testnet::accept(&listener) { + Ok((mut socket, _)) => { + let first = drain_http(&mut socket); + let decision = first.starts_with("GET /video/:/transcode/universal/decision?"); + if decision && decision_refused { + // PMS 1.43.3 on the TV, 2026-10-21: "unable to find a matching profile". + write_status(&mut socket, 400); + } else { + let body = if first.starts_with("POST /livetv/dvrs/7/channels/C101.242.ersatztv.org/tune") { + LIVE_TUNED + } else if decision { + LIVE_DECIDED + } else { + EMPTY_MC + }; + write_json(&mut socket, body); + } + if tx.send(first).is_err() { + return; + } + } + Err(_) => std::thread::sleep(std::time::Duration::from_millis(4)), + } + } + }); + (port, rx) +} + +#[test] +fn a_live_channel_tunes_then_reports_under_its_session_and_its_stop_frees_the_tuner() { + use std::time::Duration; + let mut ps = crate::route::PlaybackSession::IDLE; + let _g = fresh_registry(&mut ps); + let (port, rx) = live_pms(false); + let sid = plx_plex::plex::register_for_test("route-live", "127.0.0.1", port, "tok-live", "cid-live"); + let mut env = ResolveEnv::snapshot(&ps, plx_data::stores::metadata::MetadataStore::default().view(), sid, ""); + env.live = Some(LiveTune { dvr: "7".into(), channel: "C101.242.ersatztv.org".into() }); + let plan = build_stream(&plx_base::task::OffFrame::for_test(), "", "", "", "", &env); + let next = || rx.recv_timeout(Duration::from_secs(5)).expect("a request"); + + let tune = next(); + assert!(tune.starts_with("POST /livetv/dvrs/7/channels/C101.242.ersatztv.org/tune?"), "{tune}"); + assert_eq!( + query_param(&tune, "X-Plex-Session-Identifier"), + Some(plan.sess.as_str()), + "PMS refuses a tune without a session id (400), and it is the one the playback reports under" + ); + assert_eq!(query_param(&tune, "X-Plex-Client-Identifier"), Some("cid-live")); + let decision = next(); + assert_eq!(query_param(&decision, "path"), Some("%2Flivetv%2Fsessions%2F9b9fe727-0000-46db-b94c-ef512e3e61d2")); + assert_eq!(query_param(&decision, "directPlay"), Some("1")); + assert_eq!(query_param(&decision, "hasMDE"), Some("1"), "the client's own decision is what PMS calls a direct play"); + assert_eq!(query_param(&decision, "subtitles"), Some("none")); + assert_eq!(query_param(&decision, "X-Plex-Client-Profile-Name"), Some("Generic"), "PMS 400s a profile-less decision"); + let profile = query_param(&decision, "X-Plex-Client-Profile-Extra").expect("a profile augmentation"); + assert!(profile.contains("mpegts") && !profile.contains("direct-play"), + "an HLS target and no direct-play profile, or PMS decides a conversion: {profile}"); + assert_eq!(query_param(&decision, "session"), Some(plan.sess.as_str()), "the decision and the URL share one session"); + + let live = plan.live.clone().expect("the plan names the recording"); + assert_eq!((live.key.as_str(), live.rating_key.as_str()), ("/livetv/sessions/9b9fe727-0000-46db-b94c-ef512e3e61d2", "355108")); + assert!(plan.url.contains("/livetv/sessions/9b9fe727-0000-46db-b94c-ef512e3e61d2/cid-live/index.m3u8?offset=-1.000000&"), "{}", plan.url); + assert!(plan.url.contains("X-Plex-Token=tok-live") && plan.url.contains(&format!("X-Plex-Session-Identifier={}", plan.sess))); + assert_eq!((plan.vcodec.as_str(), plan.acodec.as_str()), ("h264", "aac")); + assert!(plan.tsession.is_empty() && plan.pq_id.is_empty(), "a direct play with no PlayQueue"); + + apply_plan(&mut ps, plan, ""); + assert!(is_live(&ps) && live_playback()); + let lease = begin_timeline_reporting(&ps).expect("a live play reports without a ratingKey of its own"); + assert!(report_timeline(&lease, plx_plex::plex::TimelineState::Playing, 12_000, 12_000)); + let playing = next(); + assert!(playing.starts_with("POST /:/timeline?key=%2Flivetv%2Fsessions%2F9b9fe727-0000-46db-b94c-ef512e3e61d2&ratingKey=355108&state=playing&time=0&duration=0&playbackTime=12000"), "{playing}"); + + scrobble_stop(&mut ps, None, None); + drain_scrobble(); + let stopped = next(); + assert!(stopped.starts_with("POST /:/timeline?key=%2Flivetv%2Fsessions%2F9b9fe727-0000-46db-b94c-ef512e3e61d2"), "{stopped}"); + assert_eq!(query_param(&stopped, "state"), Some("stopped")); + assert_eq!(query_param(&stopped, "continuing"), Some("0")); + assert!(!is_live(&ps) && !live_playback(), "the stop retires the session"); +} + +#[test] +fn a_refused_live_decision_still_plays_the_session_s_own_playlist() { + use std::time::Duration; + let mut ps = crate::route::PlaybackSession::IDLE; + let _g = fresh_registry(&mut ps); + let (port, rx) = live_pms(true); + let sid = plx_plex::plex::register_for_test("route-live-400", "127.0.0.1", port, "tok-live", "cid-live"); + let mut env = ResolveEnv::snapshot(&ps, plx_data::stores::metadata::MetadataStore::default().view(), sid, ""); + env.live = Some(LiveTune { dvr: "7".into(), channel: "C101.242.ersatztv.org".into() }); + let plan = build_stream(&plx_base::task::OffFrame::for_test(), "", "", "", "", &env); + for _ in 0..2 { + rx.recv_timeout(Duration::from_secs(5)).expect("the tune, then the refused decision"); + } + assert!(plan.live.is_some(), "the recording is still the plan's to stop"); + let own = format!("/livetv/sessions/9b9fe727-0000-46db-b94c-ef512e3e61d2/{}/index.m3u8?offset=-1.000000&", plan.sess); + assert!(plan.url.contains(&own), "the consumer is named after the tune's session id: {}", plan.url); + assert_eq!((plan.vcodec.as_str(), plan.acodec.as_str()), ("h264", "aac")); +} + +#[test] +fn a_live_plan_abandoned_after_its_tune_still_stops_the_recording() { + let live = plx_plex::plex::livetv::LiveSession { key: "/livetv/sessions/abcdef01".into(), rating_key: String::new() }; + let tuned_only = Plan { sid: ServerId::from_raw(3), live: Some(live.clone()), ..Default::default() }; + let resources = abandoned_plan_resources(&tuned_only).expect("a tuned session is a server object to retire"); + assert_eq!(resources.live, Some((live.clone(), String::new()))); + let decided = Plan { sess: "s1".into(), live: Some(live.clone()), ..Default::default() }; + let resources = abandoned_plan_resources(&decided).unwrap(); + assert_eq!((resources.identities, resources.live), (vec!["s1".to_string()], Some((live, "s1".to_string())))); + assert!(abandoned_plan_resources(&Plan::default()).is_none()); +} diff --git a/rust-modules/media/src/route/decision_track_memory_tests.rs b/rust-modules/media/src/route/decision_track_memory_tests.rs index ce0735d118..f926c5b052 100644 --- a/rust-modules/media/src/route/decision_track_memory_tests.rs +++ b/rust-modules/media/src/route/decision_track_memory_tests.rs @@ -948,6 +948,7 @@ fn request(sid: ServerId, preview: bool) -> PlaybackRequest { title: "Episode".into(), ctx: String::new(), preview, + live: None, version: None, } } diff --git a/rust-modules/media/src/route/plan.rs b/rust-modules/media/src/route/plan.rs index 2cf47d53b8..09c6e09f3a 100644 --- a/rust-modules/media/src/route/plan.rs +++ b/rust-modules/media/src/route/plan.rs @@ -1245,6 +1245,13 @@ pub(super) fn up_next_of(r: &plx_plex::plex::QueueRow) -> Option<UpNext> { }) } +/// A Live TV channel to tune instead of a library item to resolve: the `{dvr}` and `{channel}` of +/// `plx_plex::plex::livetv::tune_path`. A request that carries one has no `rk` and no `part`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LiveTune { + pub dvr: String, + pub channel: String, +} /// Every piece of [`Session`] the resolve used to READ, captured on the main thread and passed by /// value. @@ -1350,6 +1357,8 @@ pub struct ResolveEnv { /// `false` and is set in exactly one place, `request_env` (which `request_play_inner` builds /// every environment through), so nothing can default it on. pub fresh_play: bool, + /// A Live TV channel to tune ([`build_live_stream`]); `None` for every library item. + pub live: Option<LiveTune>, /// Test-only account subtitle preferences `(language, mode, forced)`, standing in for the /// plex.tv call a unit test cannot make. #[cfg(any(test, feature = "test-support"))] @@ -1553,6 +1562,10 @@ pub struct Plan { /// rides along so the read-out can quote the server instead of guessing. `None` on every other /// plan, including one that simply failed to reach the server. pub verdict: Option<PlayVerdict>, + /// The Live TV session a tune opened on the server, set as soon as the tune answered — before + /// the plan has a URL — so a plan that is abandoned, refused or installed always names the + /// recording its stop must end (`livetv::Client::live_timeline`). + pub live: Option<plx_plex::plex::livetv::LiveSession>, /// the episode queued after this one, straight off the `continuous=1` PlayQueue pub up_next: Option<UpNext>, /// that same PlayQueue's whole returned window, projected on the worker (see `queue`) @@ -1646,13 +1659,22 @@ pub(super) fn build_stream(off: &plx_base::task::OffFrame, rk: &str, part: &str, source_decodable: true, ..Default::default() }; + // The server's client is read once, ahead of the Live TV branch: both resolves make their PMS + // calls through it, and a server that is gone ends either one the same way. + let client = plx_plex::plex::client_for(env.sid); + if let Some(tune) = &env.live { + if let Some(client) = client { + build_live_stream(off, tune, client, &mut plan); + } + return plan; + } if env.direct_play_mode == DirectPlayMode::Disabled && rk.is_empty() { plan.verdict = Some(PlayVerdict::DirectPlayDisabled); return plan; } let forced = env.direct_play_mode == DirectPlayMode::Forced; let playback_quality = if forced { Quality::Original } else { env.quality }; - let client = match plx_plex::plex::client_for(env.sid) { + let client = match client { Some(c) => c, None => return plan, }; @@ -2799,6 +2821,32 @@ pub(super) fn video_copy_fps(contract: &plx_plex::plex::EncodeContract, src_fps: if contract.remux && !contract.no_video_copy { src_fps } else { 0.0 } } +/// The Live TV resolve: tune, ask the decision for the playlist, and direct-play it. Nothing of the +/// library path applies — no PlayQueue, no metadata, no track memory, no quality ladder, no +/// direct-play mode: the server already records the channel as H.264/AAC in MPEG-TS, and the +/// official client plays it the same way (`docs/pms-api.md` §10). +fn build_live_stream( + off: &plx_base::task::OffFrame, tune: &LiveTune, client: &plx_plex::plex::Client, plan: &mut Plan, +) { + let session = new_sess(""); + plan.sess = session.clone(); + let Some(live) = client.tune(off, &tune.dvr, &tune.channel, &session) else { + crate::player::log(&format!("livetv: tune of {} on dvr {} failed", tune.channel, tune.dvr)); + return; + }; + crate::player::log(&format!("livetv: tuned {} -> {}", tune.channel, live.key)); + plan.live = Some(live.clone()); + let Some(play) = client.live_part(off, &live, &session) else { + crate::player::log("livetv: the decision named no playlist"); + return; + }; + plan.vcodec = play.vcodec; + plan.acodec = play.acodec; + plan.src_vcodec = plan.vcodec.clone(); + plan.src_acodec = plan.acodec.clone(); + plan.url = client.direct_play_url(&play.part, &session).to_url(); +} + /// The direct-play plan: the pipeline decodes the SOURCE codecs natively, so the Load payload uses /// them (h264/hevc + the chosen audio track's codec). If a specific track was picked (aidx >= 0), /// tell the demuxer to feed that stream — by CONTAINER ordinal, not the list position @@ -3936,6 +3984,9 @@ pub(super) fn part_id_of(part_key: &str) -> i64 { pub(super) struct AbandonedPlanResources { pub(super) sid: ServerId, pub(super) identities: Vec<String>, + /// A tuned Live TV session (and the playback session it was decided under) whose recording + /// must be stopped before anything else: it holds one of the server's tuners. + pub(super) live: Option<(plx_plex::plex::livetv::LiveSession, String)>, } pub(super) fn abandoned_plan_resources(plan: &Plan) -> Option<AbandonedPlanResources> { @@ -3946,12 +3997,14 @@ pub(super) fn abandoned_plan_resources(plan: &Plan) -> Option<AbandonedPlanResou if !plan.sess.is_empty() && !identities.iter().any(|id| id == &plan.sess) { identities.push(plan.sess.clone()); } - if identities.is_empty() { + let live = plan.live.clone().map(|live| (live, plan.sess.clone())); + if identities.is_empty() && live.is_none() { None } else { Some(AbandonedPlanResources { sid: plan.sid, identities, + live, }) } } diff --git a/rust-modules/media/src/route/plan_audio_enhancement_tests.rs b/rust-modules/media/src/route/plan_audio_enhancement_tests.rs index 68b1e9a639..6cfd2d7cf7 100644 --- a/rust-modules/media/src/route/plan_audio_enhancement_tests.rs +++ b/rust-modules/media/src/route/plan_audio_enhancement_tests.rs @@ -601,6 +601,7 @@ fn cold_start_plan_lands_as_client_over_remux_with_a_reader_target() { title: String::new(), ctx: String::new(), preview: false, + live: None, version: None, }); assert_eq!(subtitle_presenter(&ps), SubtitlePresenter::ClientOverRemux); diff --git a/rust-modules/plex/src/plex/client.rs b/rust-modules/plex/src/plex/client.rs index b85bd619ea..376fd5f9ec 100644 --- a/rust-modules/plex/src/plex/client.rs +++ b/rust-modules/plex/src/plex/client.rs @@ -844,6 +844,13 @@ impl Client { .map(|e| e.media_container) } + /// POST → the JSON body as a tree, for a write whose answer has no flat container shape — the + /// Live TV tune, whose session sits several levels down (`livetv::session_of`). + pub(super) fn post_value(&self, path_no_token: &str) -> Option<serde_json::Value> { + let body = self.body_2xx(path_no_token, Method::Post, &[ACCEPT_JSON])?; + serde_json::from_slice(&body).ok() + } + /// THE token choke point. Appends `X-Plex-Token=…` with the right separator. pub(super) fn with_token(&self, path: &str) -> String { let sep = if path.contains('?') { '&' } else { '?' }; diff --git a/rust-modules/plex/src/plex/livetv.rs b/rust-modules/plex/src/plex/livetv.rs new file mode 100644 index 0000000000..46c6fc1346 --- /dev/null +++ b/rust-modules/plex/src/plex/livetv.rs @@ -0,0 +1,608 @@ +//! Live TV reads: the server's DVRs, each one's guide lineup and what is airing now +//! (`docs/pms-api.md` §10). +//! +//! A DVR is a tuner the server owns (an HDHomeRun, or anything speaking its HTTP protocol, such +//! as ErsatzTV) plus the EPG provider that names its channels. Everything a channel row shows +//! comes from that provider, under the DVR's `epgIdentifier` path prefix +//! (`/tv.plex.providers.epg.xmltv:7/…`): the lineup lists the mapped channels, the grid the +//! airings. +//! +//! Watching a channel is three requests, in the order the official LG client makes them (measured +//! 2026-10-21, `docs/pms-api.md` §10): the tune, which makes the server record the channel into a +//! session of its own; the universal decision for that session, which names the playlist this +//! client reads; and the timeline, whose `stopped` is what ends the recording and frees the tuner. + +use super::client::{Client, QueryBuilder}; +use super::models::{Dvr, EpgChannel, Metadata}; +use super::params::TimelineState; + +/// One channel as a Live TV row shows it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LiveChannel { + /// The DVR that tunes it — the `{dvr}` of `/livetv/dvrs/{dvr}/channels/{channel}/tune`. + pub dvr: String, + /// The guide's channel id, the `{channel}` of the tune (`"C101.242.ersatztv.org"`). + pub channel: String, + /// The virtual channel number, `"101"`. + pub vcn: String, + /// The channel's name without its number in front (`"Family Channel"`). + pub name: String, + /// The provider's logo URL. It is served by the TUNER's host, which the TV may not reach, so + /// it is fetched through the server's photo transcoder like any other image. + pub thumb: String, + /// What is on now: the show of an episode, the title of anything else. Empty when the guide + /// has no airing for this channel at this second. + pub now: String, + /// Unix seconds the current airing ends; 0 when nothing is airing. The row is stale after it. + pub ends_at: i64, +} + +/// `GET /livetv/dvrs`. +pub const DVRS_PATH: &str = "/livetv/dvrs"; + +/// The provider id is server-sent and becomes the first path segment, so only the characters a +/// provider id is made of pass (`tv.plex.providers.epg.xmltv:7`): never a `/`, `?` or `%`. +pub fn is_provider_id(epg: &str) -> bool { + !epg.is_empty() + && epg + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b':' | b'_' | b'-')) +} + +/// `GET /{epg}/lineups/dvr/channels` — the channels the DVR has mapped, nothing else. +pub fn epg_channels_path(epg: &str) -> String { + format!("/{epg}/lineups/dvr/channels") +} + +/// `GET /{epg}/grid?beginsAt<=now&endsAt>=now` — one airing per channel: the one on at `now`. +/// The operators are part of the KEY (`beginsAt%3C=`), which [`QueryBuilder`] passes as given. +pub fn on_now_path(epg: &str, now: i64) -> String { + QueryBuilder::new(format!("/{epg}/grid")) + .int("beginsAt%3C", now) + .int("endsAt%3E", now) + .build() +} + +/// The DVRs worth a row: a well-formed provider id and at least one tuner the server last saw +/// alive. A DVR whose tuners all read `dead` would list channels that cannot be tuned. +pub fn usable_dvrs(dvrs: &[Dvr]) -> impl Iterator<Item = &Dvr> { + dvrs.iter().filter(|d| { + is_provider_id(&d.epg_identifier) && d.device.iter().any(|t| t.status == "alive") + }) +} + +/// A server-sent id that becomes one path segment of the tune: letters, digits and `._-` only. +pub fn is_path_id(id: &str) -> bool { + !id.is_empty() + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) + && !id.starts_with('.') +} + +/// `POST /livetv/dvrs/{dvr}/channels/{channel}/tune`, or `None` for an id that is not one segment. +pub fn tune_path(dvr: &str, channel: &str) -> Option<String> { + (is_path_id(dvr) && is_path_id(channel)) + .then(|| format!("/livetv/dvrs/{dvr}/channels/{channel}/tune")) +} + +/// What a tune hands back: the server's own session for the channel. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LiveSession { + /// `/livetv/sessions/{uuid}` — the decision's `path` and the timeline's `key`. + pub key: String, + /// The airing being recorded, the timeline's `ratingKey`. May be empty. + pub rating_key: String, +} + +const SESSIONS: &str = "/livetv/sessions/"; + +/// `/livetv/sessions/{uuid}` with nothing after the id. +fn is_session_key(key: &str) -> bool { + key.strip_prefix(SESSIONS).is_some_and(|id| { + (8..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-') + }) +} + +/// The session a tune answered with. The response nests it (subscription → grab operation → +/// item), so this takes the first object anywhere in the body whose `key` is a session key, +/// rather than a fixed path through levels this client has never needed to read. +pub fn session_of(body: &serde_json::Value) -> Option<LiveSession> { + use serde_json::Value; + match body { + Value::Object(map) => { + if let Some(key) = map.get("key").and_then(Value::as_str).filter(|k| is_session_key(k)) { + let rating_key = match map.get("ratingKey") { + Some(Value::String(s)) => s.clone(), + Some(Value::Number(n)) => n.to_string(), + _ => String::new(), + }; + return Some(LiveSession { key: key.to_owned(), rating_key }); + } + map.values().find_map(session_of) + } + Value::Array(items) => items.iter().find_map(session_of), + _ => None, + } +} + +/// The key names of a JSON tree to `depth` levels, no values: `{MediaContainer:{size,Hub:[{…}]}}`. +/// What a log line may say about a body whose contents it must not repeat. +pub fn shape_of(v: &serde_json::Value, depth: usize) -> String { + use serde_json::Value; + match v { + Value::Object(map) if depth > 0 => { + let inner: Vec<String> = map + .iter() + .map(|(k, v)| match v { + Value::Object(_) | Value::Array(_) => format!("{k}:{}", shape_of(v, depth - 1)), + _ => k.clone(), + }) + .collect(); + format!("{{{}}}", inner.join(",")) + } + Value::Array(items) if depth > 0 => { + format!("[{}]", items.first().map_or_else(String::new, |v| shape_of(v, depth - 1))) + } + Value::Object(_) => "{…}".into(), + Value::Array(_) => "[…]".into(), + _ => "_".into(), + } +} + +/// `GET /video/:/transcode/universal/decision` for a live session — the official client's +/// direct-play ask (its profile augmentation is added by the caller). `hasMDE=1` is the part that +/// makes it a direct play: the client vouches for its own decode, and PMS answers "MDE=1000, Direct +/// play OK" without matching a direct-play profile — which `Generic` has none of for HLS/MPEG-TS +/// (without it: "No direct play video profile exists for protocol hls", measured on the TV +/// 2026-10-21). `subtitles=none` as on every `hasMDE` ask this app sends: PMS 400s `hasMDE` + +/// `directPlay` with the default `auto` when a subtitle is selected. +pub fn live_decision_path(session: &LiveSession, playback: &str) -> String { + QueryBuilder::new("/video/:/transcode/universal/decision") + .str("path", &session.key) + .int("mediaIndex", 0) + .int("partIndex", 0) + .str("protocol", "hls") + .int("hasMDE", 1) + .int("directPlay", 1) + .int("directStream", 1) + .int("directStreamAudio", 1) + .str("subtitles", "none") + .str("session", playback) + .build() +} + +/// What this client plays for a live session: the playlist and the codecs the Load declares. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LivePlay { + pub part: String, + pub vcodec: String, + pub acodec: String, +} + +/// The live decision's profile augmentation: the official LG client's (read from the server log, +/// 2026-10-21, which PMS answered "Direct play OK") — an HLS/MPEG-TS transcode target and NO +/// direct-play profile, so `Generic`'s own HLS direct play stands — with its codec lists narrowed to +/// the ones this pipeline decodes, should the server ever have to convert. +pub const LIVE_PROFILE_EXTRA: &str = "add-transcode-target(type=videoProfile&context=all&protocol=hls\ + &container=mpegts&videoCodec=h264,hevc&audioCodec=aac,ac3,eac3)"; + +/// The query the official client reads a live playlist with: completed segments only. +const LIVE_EDGE: &str = "?offset=-1.000000"; + +/// The session's own playlist for one playback, the shape every measured part had. +pub fn session_playlist(session: &LiveSession, playback: &str) -> Option<LivePlay> { + is_path_id(playback).then(|| LivePlay { + part: format!("{}/{playback}/index.m3u8{LIVE_EDGE}", session.key), + vcodec: RECORDED_VCODEC.to_owned(), + acodec: RECORDED_ACODEC.to_owned(), + }) +} + +/// The codecs of every measured recording (`docs/pms-api.md` §10), for a decision that names none. +const RECORDED_VCODEC: &str = "h264"; +const RECORDED_ACODEC: &str = "aac"; + +fn or_recorded(codec: &str, recorded: &str) -> String { + if codec.is_empty() { recorded } else { codec }.to_owned() +} + +/// The playlist a decision named for this session: under the session's own key and an `.m3u8`, +/// with its Media's codecs (the decision OUTPUT is what the Load must describe). +/// +/// The part's query is replaced by the official client's `offset=-1.000000`. The decision's own +/// (`offset={first}&X-Plex-Incomplete-Segments=1`, measured on the TV 2026-10-21) lists the segment +/// still being written with `#EXTINF:0`, which `hls::parse_media` refuses as a whole playlist. +pub fn live_part_of(session: &LiveSession, decision: &super::MediaContainer) -> Option<LivePlay> { + let prefix = format!("{}/", session.key); + decision.metadata.iter().flat_map(|m| &m.media).find_map(|media| { + media + .part + .iter() + .map(|p| p.key.as_str()) + .find(|k| { + k.starts_with(&prefix) + && !k.contains("..") + && k.split_once('?').map_or(*k, |(path, _)| path).ends_with(".m3u8") + }) + .map(|part| LivePlay { + part: format!("{}{LIVE_EDGE}", part.split_once('?').map_or(part, |(path, _)| path)), + vcodec: or_recorded(&media.video_codec, RECORDED_VCODEC), + acodec: or_recorded(&media.audio_codec, RECORDED_ACODEC), + }) + }) +} + +/// The lineup title without the channel number PMS puts in front of it. +fn channel_name(ch: &EpgChannel) -> String { + let title = ch.title.trim(); + title + .strip_prefix(ch.vcn.as_str()) + .filter(|rest| !ch.vcn.is_empty() && rest.starts_with(' ')) + .map_or(title, str::trim_start) + .to_owned() +} + +/// What a tile says is on: the show for an episode, the title for anything else. +fn airing_label(m: &Metadata) -> &str { + if m.kind == "episode" && !m.grandparent_title.is_empty() { + &m.grandparent_title + } else { + &m.title + } +} + +/// Join one DVR's lineup with its airings into rows, in channel-number order. +pub fn lineup(dvr: &Dvr, channels: &[EpgChannel], on_now: &[Metadata]) -> Vec<LiveChannel> { + let mut rows: Vec<LiveChannel> = channels + .iter() + .filter(|ch| !ch.vcn.is_empty()) + .map(|ch| { + let airing = on_now.iter().find_map(|m| { + m.media + .iter() + .find(|media| media.channel_vcn == ch.vcn) + .map(|media| (m, media.ends_at)) + }); + LiveChannel { + dvr: dvr.key.clone(), + channel: ch.grid_key.clone(), + vcn: ch.vcn.clone(), + name: channel_name(ch), + thumb: ch.thumb.clone(), + now: airing.map_or_else(String::new, |(m, _)| airing_label(m).to_owned()), + ends_at: airing.map_or(0, |(_, ends)| ends), + } + }) + .collect(); + rows.sort_by(|a, b| { + let num = |c: &LiveChannel| c.vcn.parse::<u64>().unwrap_or(u64::MAX); + num(a).cmp(&num(b)).then_with(|| a.vcn.cmp(&b.vcn)) + }); + rows +} + +impl Client { + /// `GET /livetv/dvrs` — every DVR with its tuners. `None` when the read failed; an empty list + /// is a server with no DVR. + pub fn live_tv_dvrs(&self) -> Option<Vec<Dvr>> { + self.get_json(DVRS_PATH).map(|c| c.dvr) + } + + /// `GET /{epg}/lineups/dvr/channels` — one DVR's mapped channels. + pub fn epg_channels(&self, epg: &str) -> Option<Vec<EpgChannel>> { + if !is_provider_id(epg) { + return None; + } + self.get_json(&epg_channels_path(epg)).map(|c| c.channel) + } + + /// `GET /{epg}/grid` bounded to the airings on at `now` (Unix seconds). + pub fn epg_on_now(&self, epg: &str, now: i64) -> Option<Vec<Metadata>> { + if !is_provider_id(epg) { + return None; + } + self.get_json(&on_now_path(epg, now)).map(|c| c.metadata) + } + + /// Every channel this server can tune right now, with what is on. `None` when the DVR list or + /// a lineup could not be read. A failed airing read keeps the channels, with nothing on. + pub fn live_channels(&self, now: i64) -> Option<Vec<LiveChannel>> { + let dvrs = self.live_tv_dvrs()?; + let mut rows = Vec::new(); + for dvr in usable_dvrs(&dvrs) { + let channels = self.epg_channels(&dvr.epg_identifier)?; + let on_now = self + .epg_on_now(&dvr.epg_identifier, now) + .unwrap_or_default(); + rows.extend(lineup(dvr, &channels, &on_now)); + } + Some(rows) + } + + /// `POST …/tune` — the server starts recording the channel and answers once it streams (4.8 s + /// measured). `None` when it refused, failed, or answered without a session. The playback + /// session id is required: without one PMS answers 400 "A session identifier is required for + /// this request" before it looks at the channel (measured on the TV, 2026-10-21). + pub fn tune(&self, _off: &plx_base::task::OffFrame, dvr: &str, channel: &str, playback: &str) -> Option<LiveSession> { + let q = QueryBuilder::new(tune_path(dvr, channel)?).str("X-Plex-Session-Identifier", playback); + let body = self.post_value(&self.playback_identity(q).build())?; + let session = session_of(&body); + if session.is_none() { + // The tune's body has never been captured: say what it held, names only, no values. + plx_base::eventlog::log(&format!("livetv: tune answered without a session; shape {}", shape_of(&body, 3))); + } + session + } + + /// The playlist this client reads for `session`: the decision's part, or — when the decision + /// was refused or named none — the session's own playlist for this playback, + /// `{key}/{playback}/index.m3u8`: PMS names the recording's consumer after the session id the + /// tune carried (`Grabber/{channel}-{playback}` in its log, 2026-10-21). + /// + /// The decision asks with the `Generic` profile and [`LIVE_PROFILE_EXTRA`]: with no profile PMS + /// answers 400 "unable to find a matching profile", and with the library's (whose direct-play + /// profile names mkv/mp4 only) it decides a conversion — "No direct play video profile exists + /// for protocol hls, with container mpegts" — and then refuses the session's own playlist + /// ("doesn't allow direct play"). Both measured on the TV, 2026-10-21. + pub fn live_part(&self, _off: &plx_base::task::OffFrame, session: &LiveSession, playback: &str) -> Option<LivePlay> { + let header = format!("X-Plex-Session-Identifier: {playback}"); + let q = QueryBuilder::new(live_decision_path(session, playback)) + .str("X-Plex-Client-Profile-Name", "Generic") + .str("X-Plex-Client-Profile-Extra", LIVE_PROFILE_EXTRA); + let decision = self.get_json_with_headers(&self.playback_identity(q).build(), &[&header]); + if let Some(play) = decision.as_ref().and_then(|d| live_part_of(session, d)) { + return Some(play); + } + let verdict = decision + .as_ref() + .and_then(|d| d.metadata.iter().flat_map(|m| &m.media).flat_map(|m| &m.part).next()) + .map_or("", |p| p.decision.as_str()); + plx_base::eventlog::log(&format!( + "livetv: the decision named no playlist (answered={} part decision={verdict:?}); reading the session's own", + decision.is_some() as i32 + )); + session_playlist(session, playback) + } + + /// `POST /:/timeline` for a live session: `time`/`duration` stay 0 as the official client sends + /// them. `Stopped` is the request that ends the recording and frees the tuner. + pub fn live_timeline(&self, session: &LiveSession, state: TimelineState, watched_ms: i64, playback: &str) -> bool { + let q = QueryBuilder::new("/:/timeline") + .str("key", &session.key) + .opt_str("ratingKey", &session.rating_key) + .str("state", state.as_str()) + .int("time", 0) + .int("duration", 0) + .int("playbackTime", watched_ms.max(0)) + .str("X-Plex-Session-Identifier", playback); + // `continuing=0`: nothing follows on this session (the official client's stop says so). + let q = if state == TimelineState::Stopped { q.int("continuing", 0) } else { q }; + self.post_ok(&self.playback_identity(q).build()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plex::MediaContainer; + + fn page(json: &[u8]) -> MediaContainer { + serde_json::from_slice::<super::super::models::Envelope>(json) + .expect("live tv fixture") + .media_container + } + + // Shapes measured on PMS 1.43.3 with an ErsatzTV tuner; hosts are documentation addresses. + const DVRS: &[u8] = br#"{"MediaContainer":{"size":1,"Dvr":[{"key":"7", + "uuid":"00000000-0000-0000-0000-000000000007","language":"es","country":"ESP", + "lineup":"lineup://tv.plex.providers.epg.xmltv/http%3A%2F%2F192.0.2.10%3A8409%2Fiptv%2Fxmltv.xml#ErsatzTV", + "epgIdentifier":"tv.plex.providers.epg.xmltv:7", + "Device":[{"key":"6","status":"alive","state":"enabled","uri":"http://192.0.2.10:8409", + "make":"ErsatzTV","tuners":"2","lastSeenAt":1792000000, + "ChannelMapping":[{"channelKey":"C101.242.ersatztv.org","deviceIdentifier":"101", + "enabled":"1","lineupIdentifier":"101"}]}]}]}}"#; + + const CHANNELS: &[u8] = br#"{"MediaContainer":{"size":3,"Channel":[ + {"id":"C102.243.ersatztv.org","gridKey":"C102.243.ersatztv.org","vcn":"102", + "thumb":"http://192.0.2.10:8409/iptv/logos/gen?text=Cartoons","title":"102 Cartoons", + "callSign":"102 Cartoons"}, + {"id":"C101.242.ersatztv.org","gridKey":"C101.242.ersatztv.org","vcn":"101", + "thumb":"http://192.0.2.10:8409/iptv/logos/gen?text=Family","title":"101 Family Channel", + "callSign":"101 Family Channel"}, + {"id":"C103.244.ersatztv.org","gridKey":"C103.244.ersatztv.org","vcn":"103", + "thumb":"","title":"Anime Movies","callSign":"Anime Movies"}]}}"#; + + const ON_NOW: &[u8] = br#"{"MediaContainer":{"size":2,"Metadata":[ + {"ratingKey":"tv%2Eplex%2Exmltv%3A%2F%2Fepisode%2FFamily%2F3%2F23","type":"episode", + "title":"Family - S03E23 - Placeholder","grandparentTitle":"Family Show","onAir":true, + "Media":[{"id":48,"channelVcn":"101","channelIdentifier":"C101.242.ersatztv.org", + "protocol":"livetv","beginsAt":1792523168,"endsAt":1792524417,"onAir":true}], + "Channel":[{"id":2,"filter":"channel=2","tag":"101 101 Family Channel"}]}, + {"ratingKey":"tv%2Eplex%2Exmltv%3A%2F%2Fmovie%2FPlaceholder","type":"movie", + "title":"Placeholder Movie","Media":[{"channelVcn":"103","beginsAt":"1792520000", + "endsAt":"1792527000"}]}]}}"#; + + #[test] + fn measured_live_tv_shapes_parse() { + let dvrs = page(DVRS).dvr; + assert_eq!(dvrs.len(), 1); + assert_eq!( + (dvrs[0].key.as_str(), dvrs[0].epg_identifier.as_str()), + ("7", "tv.plex.providers.epg.xmltv:7") + ); + assert_eq!( + (dvrs[0].device[0].key.as_str(), dvrs[0].device[0].status.as_str()), + ("6", "alive") + ); + + let channels = page(CHANNELS).channel; + assert_eq!( + channels.iter().map(|c| c.vcn.as_str()).collect::<Vec<_>>(), + ["102", "101", "103"] + ); + assert_eq!(channels[1].grid_key, "C101.242.ersatztv.org"); + + let on_now = page(ON_NOW).metadata; + let media = &on_now[0].media[0]; + assert_eq!( + (media.channel_vcn.as_str(), media.begins_at, media.ends_at), + ("101", 1_792_523_168, 1_792_524_417) + ); + assert_eq!(on_now[1].media[0].ends_at, 1_792_527_000, "string-encoded times still parse"); + } + + #[test] + fn lineup_joins_airings_in_channel_order_and_drops_the_number_from_names() { + let dvr = page(DVRS).dvr.remove(0); + let rows = lineup(&dvr, &page(CHANNELS).channel, &page(ON_NOW).metadata); + let shown: Vec<_> = rows + .iter() + .map(|r| (r.vcn.as_str(), r.name.as_str(), r.now.as_str(), r.ends_at)) + .collect(); + assert_eq!( + shown, + [ + ("101", "Family Channel", "Family Show", 1_792_524_417), + ("102", "Cartoons", "", 0), + ("103", "Anime Movies", "Placeholder Movie", 1_792_527_000), + ] + ); + assert!(rows.iter().all(|r| r.dvr == "7")); + assert_eq!(rows[0].channel, "C101.242.ersatztv.org", "the tune names the guide's id"); + assert_eq!(rows[0].thumb, "http://192.0.2.10:8409/iptv/logos/gen?text=Family"); + } + + #[test] + fn only_dvrs_with_a_live_tuner_and_a_clean_provider_id_get_a_row() { + let mut dead = page(DVRS).dvr.remove(0); + dead.device[0].status = "dead".into(); + let mut odd = page(DVRS).dvr.remove(0); + odd.epg_identifier = "tv.plex.providers.epg.xmltv:7/../library".into(); + let good = page(DVRS).dvr.remove(0); + let all = [dead, odd, good]; + let usable: Vec<_> = usable_dvrs(&all).collect(); + assert_eq!(usable.len(), 1); + assert!(usable[0].device[0].status == "alive" && is_provider_id(&usable[0].epg_identifier)); + assert!(!is_provider_id("")); + assert!(!is_provider_id("a?b")); + } + + #[test] + fn a_tune_names_one_dvr_and_one_channel_segment_or_nothing() { + assert_eq!( + tune_path("7", "C101.242.ersatztv.org").as_deref(), + Some("/livetv/dvrs/7/channels/C101.242.ersatztv.org/tune") + ); + for (dvr, channel) in [("7", ""), ("", "C1"), ("7", "../library"), ("7", "a/b"), ("7?x", "C1"), ("7", "..")] { + assert_eq!(tune_path(dvr, channel), None, "{dvr:?} {channel:?}"); + } + } + + // The tune's body was not captured (only its size, 2764 bytes); this nests the session the + // way the subscription/grab/item names in the server log describe, two levels down. + const TUNED: &str = r#"{"MediaContainer":{"size":1,"MediaSubscription":[{"id":112,"type":4, + "MediaGrabOperation":[{"status":"inprogress","Metadata":{"key":"/livetv/sessions/9b9fe727-0000-46db-b94c-ef512e3e61d2", + "ratingKey":"355108","type":"episode","grandparentTitle":"Family Show","live":"1"}}]}]}}"#; + + #[test] + fn the_session_is_found_wherever_the_tune_nests_it() { + let body: serde_json::Value = serde_json::from_str(TUNED).unwrap(); + assert_eq!( + session_of(&body), + Some(LiveSession { + key: "/livetv/sessions/9b9fe727-0000-46db-b94c-ef512e3e61d2".into(), + rating_key: "355108".into(), + }) + ); + let numeric: serde_json::Value = + serde_json::from_str(r#"{"Metadata":[{"key":"/livetv/sessions/abcdef01","ratingKey":7}]}"#).unwrap(); + assert_eq!(session_of(&numeric).unwrap().rating_key, "7"); + for odd in [ + r#"{"key":"/library/metadata/1"}"#, + r#"{"key":"/livetv/sessions/abcdef01/xyz/index.m3u8"}"#, + r#"{"key":"/livetv/sessions/../../library"}"#, + r#"{"MediaContainer":{"size":0}}"#, + ] { + assert_eq!(session_of(&serde_json::from_str(odd).unwrap()), None, "{odd}"); + } + } + + #[test] + fn the_live_profile_is_an_hls_target_that_leaves_generic_s_direct_play_alone() { + assert!(LIVE_PROFILE_EXTRA.starts_with("add-transcode-target(type=videoProfile&context=all&protocol=hls&container=mpegts&")); + assert!(!LIVE_PROFILE_EXTRA.contains("add-direct-play-profile"), "one replaces Generic's HLS direct play"); + assert!(!LIVE_PROFILE_EXTRA.contains(char::is_whitespace)); + } + + #[test] + fn a_body_s_shape_names_its_keys_and_never_its_values() { + let body: serde_json::Value = + serde_json::from_str(r#"{"MediaContainer":{"size":1,"title":"secret","Item":[{"key":"/x","n":2}]}}"#).unwrap(); + // serde_json's map is ordered by key here, so the names come sorted + assert_eq!(shape_of(&body, 3), "{MediaContainer:{Item:[{…}],size,title}}"); + assert_eq!(shape_of(&body, 4), "{MediaContainer:{Item:[{key,n}],size,title}}"); + assert!(!shape_of(&body, 9).contains("secret")); + } + + #[test] + fn without_a_usable_decision_the_session_s_own_playlist_is_read_under_the_playback_id() { + assert_eq!( + session_playlist(&session(), "d1f32c49-d8f8-4621-9075-f3d80792824a"), + Some(LivePlay { + part: "/livetv/sessions/9b9fe727-0000-46db-b94c-ef512e3e61d2/d1f32c49-d8f8-4621-9075-f3d80792824a/index.m3u8?offset=-1.000000".into(), + vcodec: "h264".into(), + acodec: "aac".into(), + }) + ); + assert_eq!(session_playlist(&session(), "../x"), None); + assert_eq!(session_playlist(&session(), ""), None); + } + + fn session() -> LiveSession { + LiveSession { key: "/livetv/sessions/9b9fe727-0000-46db-b94c-ef512e3e61d2".into(), rating_key: "355108".into() } + } + + #[test] + fn the_decision_asks_to_direct_play_the_session_and_its_part_must_stay_under_it() { + assert_eq!( + live_decision_path(&session(), "pb1"), + "/video/:/transcode/universal/decision?path=%2Flivetv%2Fsessions%2F9b9fe727-0000-46db-b94c-ef512e3e61d2\ + &mediaIndex=0&partIndex=0&protocol=hls&hasMDE=1&directPlay=1&directStream=1&directStreamAudio=1&subtitles=none&session=pb1" + ); + let decided = |key: &str| { + page(format!(r#"{{"MediaContainer":{{"Metadata":[{{"Media":[{{"videoCodec":"hevc","Part":[{{"key":"{key}","decision":"directplay"}}]}}]}}]}}}}"#).as_bytes()) + }; + let measured = "/livetv/sessions/9b9fe727-0000-46db-b94c-ef512e3e61d2/clientid/index.m3u8?offset=-1.000000"; + assert_eq!( + live_part_of(&session(), &decided(measured)), + Some(LivePlay { part: measured.into(), vcodec: "hevc".into(), acodec: "aac".into() }), + "the decision's codecs, the recording's where it names none" + ); + let incomplete = "/livetv/sessions/9b9fe727-0000-46db-b94c-ef512e3e61d2/clientid/index.m3u8?offset=0.021333&X-Plex-Incomplete-Segments=1"; + assert_eq!( + live_part_of(&session(), &decided(incomplete)).map(|p| p.part).as_deref(), + Some(measured), + "the in-progress segment (#EXTINF:0) is not asked for" + ); + for wrong in [ + "/livetv/sessions/other/clientid/index.m3u8", + "/livetv/sessions/9b9fe727-0000-46db-b94c-ef512e3e61d2/../../x.m3u8", + "/livetv/sessions/9b9fe727-0000-46db-b94c-ef512e3e61d2/clientid/00001.ts", + ] { + assert_eq!(live_part_of(&session(), &decided(wrong)), None, "{wrong}"); + } + } + + #[test] + fn paths_carry_the_provider_and_the_window_operators_in_the_key() { + assert_eq!( + epg_channels_path("tv.plex.providers.epg.xmltv:7"), + "/tv.plex.providers.epg.xmltv:7/lineups/dvr/channels" + ); + assert_eq!( + on_now_path("tv.plex.providers.epg.xmltv:7", 1_792_524_066), + "/tv.plex.providers.epg.xmltv:7/grid?beginsAt%3C=1792524066&endsAt%3E=1792524066" + ); + } +} diff --git a/rust-modules/plex/src/plex/mod.rs b/rust-modules/plex/src/plex/mod.rs index 2556c808af..5404100129 100644 --- a/rust-modules/plex/src/plex/mod.rs +++ b/rust-modules/plex/src/plex/mod.rs @@ -33,6 +33,8 @@ mod servers; mod hubs; mod library; pub mod collections; +/// Live TV reads: DVRs, their guide lineups and what is airing now. `docs/pms-api.md` §10. +pub mod livetv; /// Agent-backed subtitle search & download. `docs/pms-api.md` §8 is the verified contract; the /// bundled OpenAPI spec is wrong about this endpoint and must not be read as authority for it. pub mod subtitles; diff --git a/rust-modules/plex/src/plex/models.rs b/rust-modules/plex/src/plex/models.rs index ab6e0ac1fc..4c013a34df 100644 --- a/rust-modules/plex/src/plex/models.rs +++ b/rust-modules/plex/src/plex/models.rs @@ -127,6 +127,52 @@ pub struct MediaContainer { /// (`serverinfo.rs`); every other endpoint simply omits it. #[serde(default)] pub version: String, + /// `GET /livetv/dvrs` only — the server's DVRs (`docs/pms-api.md` §10, `livetv.rs`). + #[serde(rename = "Dvr", default, deserialize_with = "de_vec")] + pub dvr: Vec<Dvr>, + /// `GET /{epg}/lineups/dvr/channels` only — a DVR's guide lineup (`livetv.rs`). + #[serde(rename = "Channel", default, deserialize_with = "de_vec")] + pub channel: Vec<EpgChannel>, +} + +/// One DVR on `/livetv/dvrs`: a tuner the server owns plus the EPG provider that names its +/// channels (`docs/pms-api.md` §10). +#[derive(Deserialize, Default, Clone, Debug)] +pub struct Dvr { + #[serde(default, deserialize_with = "de_str")] + pub key: String, + /// The guide's provider id, `tv.plex.providers.epg.xmltv:7` — the path prefix of its lineup + /// and grid reads. + #[serde(rename = "epgIdentifier", default, deserialize_with = "de_str")] + pub epg_identifier: String, + #[serde(rename = "Device", default, deserialize_with = "de_vec")] + pub device: Vec<DvrDevice>, +} + +/// A tuner behind a [`Dvr`]. `status` is `"alive"` or `"dead"` as the server last saw it on a +/// device discovery, which runs at server start: a tuner that was not answering then reads +/// `dead` until the next discovery, however healthy it is now. +#[derive(Deserialize, Default, Clone, Debug)] +pub struct DvrDevice { + #[serde(default, deserialize_with = "de_str")] + pub key: String, + #[serde(default, deserialize_with = "de_str")] + pub status: String, +} + +/// One channel of a DVR's guide lineup. `title` carries the number in front +/// (`"101 Family Channel"`); `thumb` is a URL on the TUNER's host, which only the server is +/// sure to reach. +#[derive(Deserialize, Default, Clone, Debug)] +pub struct EpgChannel { + #[serde(rename = "gridKey", default, deserialize_with = "de_str")] + pub grid_key: String, + #[serde(default, deserialize_with = "de_str")] + pub vcn: String, + #[serde(default, deserialize_with = "de_str")] + pub title: String, + #[serde(default, deserialize_with = "de_str")] + pub thumb: String, } /// Doubles as the generic `Directory[]` row: a library section ({key,type,title}), a secondary @@ -644,6 +690,14 @@ pub struct Media { pub video_profile: String, #[serde(rename = "Part", default)] pub part: Vec<MediaPart>, + /// Live TV guide airings only (`/{epg}/grid`): the channel this airing is on and its slot in + /// Unix seconds. Empty / 0 on every library item. + #[serde(rename = "channelVcn", default, deserialize_with = "de_str")] + pub channel_vcn: String, + #[serde(rename = "beginsAt", default, deserialize_with = "de_i64")] + pub begins_at: i64, + #[serde(rename = "endsAt", default, deserialize_with = "de_i64")] + pub ends_at: i64, } #[derive(Deserialize, Default)] diff --git a/rust-modules/screens/src/home/mod.rs b/rust-modules/screens/src/home/mod.rs index 49f1ffcd93..1ec6d692d0 100644 --- a/rust-modules/screens/src/home/mod.rs +++ b/rust-modules/screens/src/home/mod.rs @@ -98,6 +98,7 @@ pub const STRIP_SHOWS_ELEM: u32 = plx_ui::dispatch::STRIP_BASE + 2; pub const STRIP_SEARCH_ELEM: u32 = plx_ui::dispatch::STRIP_BASE + 3; pub const STRIP_ACCOUNT_ELEM: u32 = plx_ui::dispatch::STRIP_BASE + 4; pub const STRIP_WATCHLIST_ELEM: u32 = plx_ui::dispatch::STRIP_BASE + 5; +pub const STRIP_LIVETV_ELEM: u32 = plx_ui::widgets::TAB_KEY_LIVETV; const MAX_ITEMS: usize = plx_data::pms::MAX_SHELF_ITEMS; /// **How much of a resting shelf's posters must stand above the bottom margin for the pointer to take @@ -1860,6 +1861,7 @@ impl HomeScreen { STRIP_SHOWS_ELEM => HomeReq::Tab(HomeTab::Shows), STRIP_SEARCH_ELEM => HomeReq::Tab(HomeTab::Search), STRIP_WATCHLIST_ELEM => HomeReq::Tab(HomeTab::Watchlist), + STRIP_LIVETV_ELEM => HomeReq::Tab(HomeTab::LiveTv), STRIP_ACCOUNT_ELEM => HomeReq::Account, _ => return None, }) @@ -1886,7 +1888,8 @@ impl HomeScreen { let Some(item) = self.item_at(H::hubs(cx), row, col) else { return Handled::No; }; - if item.rk.is_empty() { + // A channel has no item menu: it is no library item (`KIND_LIVE_CHANNEL`). + if item.rk.is_empty() || plx_data::pms::live_channel_of(item).is_some() { return Handled::No; } fx.push(Fx::App(AppFx::Home(HomeReq::ItemMenu { @@ -1921,6 +1924,7 @@ impl HomeScreen { HomeTab::Shows => STRIP_SHOWS_ELEM, HomeTab::Search => STRIP_SEARCH_ELEM, HomeTab::Watchlist => STRIP_WATCHLIST_ELEM, + HomeTab::LiveTv => STRIP_LIVETV_ELEM, }; self.strip_chosen = true; self.reseat( @@ -2050,7 +2054,17 @@ impl HomeScreen { let Some(item) = self.item_at(view, row, col) else { return; }; - if self + if let Some((dvr, channel)) = plx_data::pms::live_channel_of(item) { + ( + item, + HomeReq::Tune { + sid: item.sid, + dvr: dvr.to_owned(), + channel: channel.to_owned(), + title: item.title.clone(), + }, + ) + } else if self .rows .get(row) .is_some_and(|h| h.identity == HomeHubIdentity::ContinueWatching) diff --git a/rust-modules/screens/src/home/tests.rs b/rust-modules/screens/src/home/tests.rs index 13f0db1749..4c21d8ee2b 100644 --- a/rust-modules/screens/src/home/tests.rs +++ b/rust-modules/screens/src/home/tests.rs @@ -164,11 +164,12 @@ fn every_tab_pill_round_trips_through_the_focus_packing() { (STRIP_SEARCH_ELEM, HomeReq::Tab(HomeTab::Search)), (STRIP_ACCOUNT_ELEM, HomeReq::Account), (STRIP_ACCOUNT_ELEM + 1, HomeReq::Tab(HomeTab::Watchlist)), + (STRIP_ACCOUNT_ELEM + 2, HomeReq::Tab(HomeTab::LiveTv)), ]; for (elem, want) in cases { assert_eq!(HomeScreen::request_for_strip(elem), Some(want)); } - assert_eq!(HomeScreen::request_for_strip(STRIP_ACCOUNT_ELEM + 2), None); + assert_eq!(HomeScreen::request_for_strip(STRIP_ACCOUNT_ELEM + 3), None); for page_key in [HERO_PLAY_ELEM, HERO_INFO_ELEM, FIRST_ITEM_ELEM] { assert_eq!(HomeScreen::request_for_strip(page_key), None); } @@ -2092,6 +2093,34 @@ fn the_deck_title_carries_the_play_glyph_only_while_the_press_plays() { assert!(!deck_label(false).glyph); } +#[test] +fn ok_on_a_live_tv_channel_tunes_it_and_holding_it_opens_no_item_menu() { + let _guard = plx_base::testlock::serial(); + let mut state = plx_data::pms::PmsState::default(); + let adapter = std::sync::Arc::new(plx_data::pms::PmsAdapter::default()); + let channel = plx_plex::plex::livetv::LiveChannel { + dvr: "7".into(), + channel: "C101.242.ersatztv.org".into(), + vcn: "101".into(), + name: "Family Channel".into(), + thumb: String::new(), + now: "Family Show".into(), + ends_at: 0, + }; + plx_data::pms::seed_live_for_test(&mut state, &adapter, 1, &[channel]); + let snapshot = plx_data::pms::hubs_snapshot(&state); + let mut s = screen(snapshot.view()); + s.snap.jump(1.0); + s.snap_target = 1.0; + let card = first_card(&s); + let (_, ok, _) = step(&mut s, snapshot.view(), Some(card), &ScreenEvent::PressCommit(plx_machine::machine::PressId(1))); + assert!(has_home(&ok, |r| matches!(r, HomeReq::Tune { dvr, channel, title, .. } + if dvr == "7" && channel == "C101.242.ersatztv.org" && title == "Family Channel"))); + assert!(!has_home(&ok, |r| matches!(r, HomeReq::Play { .. } | HomeReq::Detail { .. }))); + let (_, held, _) = step(&mut s, snapshot.view(), Some(card), &ScreenEvent::PressHold(plx_machine::machine::PressId(2))); + assert!(!has_home(&held, |r| matches!(r, HomeReq::ItemMenu { .. })), "a channel is no library item"); +} + #[test] fn holding_a_shelf_card_opens_the_item_menu_without_activation() { let _guard = plx_base::testlock::serial(); diff --git a/rust-modules/screens/src/lib.rs b/rust-modules/screens/src/lib.rs index 70d6bdbae6..058001c231 100644 --- a/rust-modules/screens/src/lib.rs +++ b/rust-modules/screens/src/lib.rs @@ -43,3 +43,5 @@ pub mod settings; pub mod preferences; pub mod hud_settings; // the Player HUD page: installed packages, the drop folder, the chosen HUD pub mod watchlist; +/// The Live TV page (the strip's "TV en directo"): the server's channels as a poster grid. +pub mod livetv; diff --git a/rust-modules/screens/src/library/mod.rs b/rust-modules/screens/src/library/mod.rs index 5a72ccb5e5..7520504773 100644 --- a/rust-modules/screens/src/library/mod.rs +++ b/rust-modules/screens/src/library/mod.rs @@ -1082,6 +1082,7 @@ impl LibraryScreen { TAB_KEY_SHOWS => Some(LibraryReq::Tab(HomeTab::Shows)), TAB_KEY_SEARCH => Some(LibraryReq::Tab(HomeTab::Search)), TAB_KEY_WATCHLIST => Some(LibraryReq::Tab(HomeTab::Watchlist)), + plx_ui::widgets::TAB_KEY_LIVETV => Some(LibraryReq::Tab(HomeTab::LiveTv)), TAB_KEY_CHIP => Some(LibraryReq::Account), _ => None, }; diff --git a/rust-modules/screens/src/library/recommended.rs b/rust-modules/screens/src/library/recommended.rs index 249b545d96..5026bc8522 100644 --- a/rust-modules/screens/src/library/recommended.rs +++ b/rust-modules/screens/src/library/recommended.rs @@ -886,6 +886,7 @@ impl RecommendedScreen { TAB_KEY_SHOWS => Some(LibraryReq::Tab(HomeTab::Shows)), TAB_KEY_SEARCH => Some(LibraryReq::Tab(HomeTab::Search)), TAB_KEY_WATCHLIST => Some(LibraryReq::Tab(HomeTab::Watchlist)), + plx_ui::widgets::TAB_KEY_LIVETV => Some(LibraryReq::Tab(HomeTab::LiveTv)), TAB_KEY_CHIP => Some(LibraryReq::Account), _ => None, }; diff --git a/rust-modules/screens/src/livetv.rs b/rust-modules/screens/src/livetv.rs new file mode 100644 index 0000000000..95cf0620e7 --- /dev/null +++ b/rust-modules/screens/src/livetv.rs @@ -0,0 +1,528 @@ +//! **The Live TV page**: the shared strip's "TV en directo" destination. A header row with the +//! page's title and the six-column 250 x 375 grid of the server's channels, each its cover, its +//! name and what is on now; OK on one tunes it. +//! +//! The page is a `plx_ui::cards::Stack` of two sections, the Watchlist's shape without its +//! controls: the head (the title band, which nothing focuses) and the grid. It reads the channels +//! the Home catalog already holds (`HomeLike::hubs`: the tiles `plx_data::pms::live_channel_tile` +//! made, `KIND_LIVE_CHANNEL`), so it fetches nothing of its own and shows what Home's Live TV row +//! shows. +//! +//! **Routing.** A screen names no sibling, so a press leaves as `AppFx::LiveTv` ([`LiveTvOut`]) for +//! the application to route: the tune, the strip's tabs and the profile chip, and BACK. +use std::sync::Arc; + +use plx_data::pms::PmsMovie; +use plx_machine::machine::{ + Canon, Cx, Delivery, Edge, Effects, Enter, EntryId, FocusKey, FocusTarget, Fx, GroupId, Handled, InputEvent, + InputKind, Key, LogicalState, Machine, Tick, +}; +use plx_machine::present::Provenance; +use plx_plex::plex::ServerId; +use plx_ui::cards::{CardEvent, CardSource, GridSpec, Kind, SectionSpec, Stack, StackEvent, StackPage, TileLabel}; +use plx_ui::consts::*; +use plx_ui::page_header::{HeaderItem, HeaderRow, PageHeader, PageHeaderSpec, SharedRow}; +use plx_ui::page_regions::{Arrival, BackStep, PageRegions}; +use plx_ui::screen::{ + AxisMask, DrawFrame, EdgeRule, ElemKind, FocusSource, Focusable, GroupKind, GroupSpec, HitSource, Link, + RenderStrategy, Screen, ScreenEvent, Seat, +}; +use plx_ui::theme; +use plx_ui::widgets::{Art, PageGround}; +use plx_ui::Rect; + +use crate::registry::{tile_facts, AppFx, HomeLike, HomeTab}; + +/// What the page asks the app to do. The app owns routing (a screen names no sibling). +#[derive(Clone, Debug, PartialEq)] +pub enum LiveTvOut { + /// OK on a channel: tune `channel` on DVR `dvr` of server `sid` and watch it. + Tune { sid: ServerId, dvr: String, channel: String, title: String }, + /// BACK from the top of the page. + Back, + /// A press on the shared strip's other tabs. + Tab(HomeTab), + /// A press on the profile chip. + Account, +} + +/// The grid's first row of posters, under the header row (the Watchlist's and the Library's). +const GRID_TOP: f32 = plx_ui::page_header::GRID_TOP; +/// Six columns of 250 posters from the row's text edge, the Watchlist's grid. +const COLS: usize = 6; +const GRID_GAP: f32 = (plx_ui::page_header::TEXT_X1 - MARGIN_X - COLS as f32 * CARD_W) / (COLS as f32 - 1.0); + +fn grid_spec() -> GridSpec { + GridSpec::resting(GRID_TOP).columns(COLS, plx_ui::cards::RowStyle { gap: GRID_GAP, ..plx_ui::cards::RowStyle::HOME }, MARGIN_X) +} + +/// The first card's element; channel `i` is `FIRST_CARD_ELEM + i` (the channels are few and keep +/// their guide order, so a position is an identity for as long as the page shows them). +const FIRST_CARD_ELEM: u32 = 0x1000; + +const GRID_GROUP: GroupId = GroupId(0); +const HEAD_GROUP: GroupId = GroupId(1); + +/// The page's sections, in document order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Sec { + /// The title band (not focusable). + Head, + /// The channel grid. + Items, +} + +/// The page's content and the state its sections read. +struct Page { + entry: EntryId, + /// The channels, in the catalog's order (the guide's channel numbers). + channels: Vec<Arc<PmsMovie>>, + /// What `channels` was built from (rk, title, what is on, cover): a catalog landing that moved + /// none of it leaves the page as it is. + sig: u64, + /// Moves whenever anything the sections read moves (`StackPage::revision`). + rev: u64, + header_capsules: plx_ui::widgets::TabStrip, + header_pop: plx_ui::widgets::CtlPop<1>, + handoff: plx_ui::scroll_chrome::Handoff, + header_rows: SharedRow<HeaderRow>, +} + +impl Page { + /// Take the catalog's channels when they differ from the ones shown. + fn sync<H: HomeLike>(&mut self, cx: &Cx<'_, H>) { + use std::hash::{Hash, Hasher}; + let view = H::hubs(cx); + let mut h = std::collections::hash_map::DefaultHasher::new(); + let mut found = Vec::new(); + for i in 0..view.hub_count() { + let Some(hub) = view.hub(i) else { continue }; + for item in hub.items.iter().filter(|m| plx_data::pms::live_channel_of(m).is_some()) { + (item.sid.raw(), &item.rk, &item.title, &item.show_title, &item.thumb).hash(&mut h); + found.push(Arc::clone(item)); + } + } + let sig = h.finish(); + if sig != self.sig { + self.sig = sig; + self.channels = found; + self.rev += 1; + } + } + + fn channel_of(&self, elem: u32) -> Option<&PmsMovie> { + elem.checked_sub(FIRST_CARD_ELEM).and_then(|i| self.channels.get(i as usize)).map(|m| &**m) + } + + fn regions(&self) -> PageRegions<'static> { + PageRegions { + header: None, + content: if self.channels.is_empty() { &[] } else { &[GRID_GROUP] }, + rail: None, + back_walks_header: false, + arrival: Arrival::Content, + pending: None, + } + } + + /// The header row: the page's title alone, rebuilt only when the locale moves. + fn header_row(&self, measure: &dyn plx_machine::machine::Measure) -> std::rc::Rc<HeaderRow> { + let key = std::ptr::from_ref(plx_platform::i18n::current()) as usize as u64; + self.header_rows.get(key, || { + let left = vec![HeaderItem { + label: plx_platform::i18n::msg::browse_home_live_tv().to_owned(), + title: true, + ..Default::default() + }]; + let page = PageHeader::new(PageHeaderSpec { left, ..Default::default() }, &[], measure); + let layout = page.layout(SCR_W, measure); + HeaderRow::new(page, layout, Vec::new()) + }) + } +} + +/// The grid's content: the channels, each its cover and its caption. +struct Channels<'a> { + page: &'a Page, +} + +impl<H: HomeLike> CardSource<H> for Channels<'_> { + fn len(&self) -> usize { + self.page.channels.len() + } + fn elem(&self, i: usize) -> u32 { + FIRST_CARD_ELEM + i as u32 + } + fn index_of(&self, e: &u32) -> Option<usize> { + e.checked_sub(FIRST_CARD_ELEM).map(|i| i as usize).filter(|&i| i < self.page.channels.len()) + } + fn art(&self, i: usize) -> Art<'_> { + Art::Poster(self.page.channels.get(i).map(|m| tile_facts::of(m))) + } + fn label(&self, i: usize) -> TileLabel { + match self.page.channels.get(i) { + Some(m) => crate::registry::captions::shelf_label(&crate::registry::captions::policy(), m, false), + None => TileLabel::default(), + } + } +} + +impl<H: HomeLike> StackPage<H> for Page { + type Key = Sec; + type Cards<'a> = Channels<'a>; + + fn revision(&self, _cx: &Cx<'_, H>) -> u64 { + self.rev + } + + fn sections(&self, _cx: &Cx<'_, H>, out: &mut Vec<SectionSpec<Sec>>) { + out.push(SectionSpec::new(Sec::Head, Kind::Custom { height: GRID_TOP, focusable: false }, HEAD_GROUP)); + out.push(SectionSpec::new(Sec::Items, Kind::Grid { spec: grid_spec() }, GRID_GROUP) + .seated(plx_ui::page_regions::CONTENT_HEAD_SEAT)); + } + + fn fallback(&self, _cx: &Cx<'_, H>, out: &mut Vec<Sec>) { + out.extend([Sec::Items, Sec::Head]); + } + + fn cards<'a>(&'a self, _cx: &'a Cx<'_, H>, k: Sec) -> Option<Channels<'a>> { + (k == Sec::Items && !self.channels.is_empty()).then_some(Channels { page: self }) + } + + fn caption_policy(&self, k: Sec) -> Option<plx_ui::caption_policy::CaptionPolicy> { + (k == Sec::Items).then(crate::registry::captions::policy) + } + + fn plain_group(&self, _cx: &Cx<'_, H>, _k: Sec, id: GroupId, extent: Rect) -> GroupSpec { + GroupSpec { + id, + kind: GroupKind::Free, + seat: Seat::First, + reachable: AxisMask::VERTICAL, + edge: [EdgeRule::Geometric, EdgeRule::Geometric, EdgeRule::Stop, EdgeRule::Stop], + extent, + len: 0, + elem: ElemKind::Bare, + } + } + + fn custom_draw(&self, k: Sec, f: &mut DrawFrame<'_, '_, H>, r: Rect, _scroll: f32) { + if k != Sec::Head { + return; + } + // The row rides the page and leaves with the top chrome, as the Watchlist's does. + let p = f.painter.alpha(f.page_alpha).alpha(self.handoff.head_alpha(r.y)); + self.header_row(f.measure).draw(p, r.y, &self.header_capsules, self.header_pop.scale_with(0, f.press.scale), None); + } +} + +pub struct LiveTvScreen { + page: Page, + stack: Stack<Sec>, + ground: PageGround, + ground_seeded: bool, + /// The page's own arrival seat while nobody has chosen another (the Watchlist's rule): `None` + /// once the reader acts or the page is restored. + seat: Option<Option<GroupId>>, + /// Seats this page asked for whose `Enter` has not come back yet. + own_enters: u8, + /// How many of those were queued before the reader acted. + stale_enters: u8, +} + +impl LiveTvScreen { + /// The heartbeat word, byte-identical to the route word. + pub const WORD: &'static str = "livetv"; + + pub const SHAPE: &'static str = "LiveTvScreen{entry:u32,channels:[{sid:u32,rk:str}],stack:Stack{scroll:{pos:f32,vel:f32},target:f32,sections:[Grid{bands,pop,landing}]},handoff:Handoff{tucked:bool,chrome:f32,capsule:f32}}"; + + pub fn new(entry: EntryId) -> Self { + Self { + page: Page { + entry, + channels: Vec::new(), + sig: 0, + rev: 0, + header_capsules: plx_ui::widgets::TabStrip::new(), + header_pop: plx_ui::widgets::CtlPop::new(), + handoff: plx_ui::scroll_chrome::Handoff::new(), + header_rows: SharedRow::new(), + }, + stack: Stack::new(entry).home_when_unfocused(true), + ground: PageGround::new(), + ground_seeded: false, + seat: None, + own_enters: 0, + stale_enters: 0, + } + } + + /// The channels the page shows, in order. + pub fn channels(&self) -> &[Arc<PmsMovie>] { + &self.page.channels + } + + /// OK on the channel keyed `elem`. + fn press(&self, elem: u32) -> Option<LiveTvOut> { + let m = self.page.channel_of(elem)?; + let (dvr, channel) = plx_data::pms::live_channel_of(m)?; + Some(LiveTvOut::Tune { sid: m.sid, dvr: dvr.to_owned(), channel: channel.to_owned(), title: m.title.clone() }) + } + + fn tick<H: HomeLike>(&mut self, t: Tick, cx: &Cx<'_, H>) { + self.page.sync(cx); + self.page.header_row(cx.measure).step(self.page.entry, cx.focus.current, &mut self.page.header_capsules, &mut self.page.header_pop, t.dt()); + self.page.handoff.step(t.dt(), self.stack.scroll(), self.stack.target(), grid_spec().pitch(), false); + let flat = [theme::SURFACE_APP; 4]; + if self.ground_seeded { + self.ground.key_target(flat, t.dt()); + } else { + self.ground.jump_target(flat); + self.ground_seeded = true; + } + } + + fn route<H: HomeLike>(&mut self, out: LiveTvOut, fx: &mut Effects<'_, H>) { + fx.push(Fx::App(AppFx::LiveTv(out))); + fx.invalidate(Provenance::Input); + } + + /// Seat the arrival in the grid once it has a channel; until then seat nothing (the + /// Watchlist's `seat_first`). + fn seat_first<H: HomeLike>(&mut self, fx: &mut Effects<'_, H>) { + let regions = self.page.regions(); + let want = regions.first_seat(); + let target = want.map(FocusTarget::FirstInGroupAnimated).or_else(|| regions.unseat()); + self.seat = (want != Some(GRID_GROUP)).then_some(want); + if let Some(target) = target { + self.seat_own(target, fx); + } + } + + fn follow_seat<H: HomeLike>(&mut self, fx: &mut Effects<'_, H>) { + let Some(seated) = self.seat else { return }; + if seated != self.page.regions().first_seat() { + self.seat_first(fx); + } + } + + fn seat_own<H: HomeLike>(&mut self, focus: FocusTarget<u32>, fx: &mut Effects<'_, H>) { + self.own_enters = self.own_enters.saturating_add(1); + fx.push(Fx::Deliver(fx.from(), Delivery::Screen(ScreenEvent::Enter(Enter::Fresh { focus })))); + } + + /// BACK, decided by [`PageRegions::back`]: a scrolled page first returns to its top, on the + /// first row in the column focus was in; from there it leaves for Home. + fn back<H: HomeLike>(&mut self, cx: &Cx<'_, H>, fx: &mut Effects<'_, H>) -> Handled { + let focus = cx.focus.current.filter(|key| key.entry == self.page.entry); + let regions = self.page.regions(); + let at = focus.and_then(|key| regions.region_of(key.elem, Focusable::<H>::group_of(self, &key.elem, cx))); + match regions.back(at, self.stack.target() > 0.5) { + BackStep::Top => { + let column = focus + .and_then(|key| key.elem.checked_sub(FIRST_CARD_ELEM)) + .map_or(0, |i| i as usize % COLS) + .min(self.page.channels.len().saturating_sub(1)); + self.seat_own(FocusTarget::Elem(FocusKey { entry: self.page.entry, elem: FIRST_CARD_ELEM + column as u32 }), fx); + } + BackStep::Content | BackStep::Header => self.seat_own(FocusTarget::ContainerGroup(GRID_GROUP), fx), + BackStep::Nav => self.seat_own(FocusTarget::ContainerGroup(plx_ui::containers::tabs::STRIP), fx), + BackStep::Leave => self.route(LiveTvOut::Back, fx), + } + Handled::Yes + } + + /// A non-card element committed: the strip's pills and the chip. + fn activate<H: HomeLike>(&mut self, elem: u32, fx: &mut Effects<'_, H>) -> Handled { + match Self::strip_press(elem) { + Some(Some(out)) => self.route(out, fx), + Some(None) => {} + None => return Handled::No, + } + Handled::Yes + } + + /// A press on the shared strip: the other tabs and the chip are requests; the Live TV pill is + /// the page the reader is already on. + fn strip_press(elem: u32) -> Option<Option<LiveTvOut>> { + use plx_ui::widgets::{ + TAB_KEY_CHIP, TAB_KEY_HOME, TAB_KEY_LIVETV, TAB_KEY_MOVIES, TAB_KEY_SEARCH, TAB_KEY_SHOWS, TAB_KEY_WATCHLIST, + }; + match elem { + TAB_KEY_HOME => Some(Some(LiveTvOut::Tab(HomeTab::Home))), + TAB_KEY_MOVIES => Some(Some(LiveTvOut::Tab(HomeTab::Movies))), + TAB_KEY_SHOWS => Some(Some(LiveTvOut::Tab(HomeTab::Shows))), + TAB_KEY_SEARCH => Some(Some(LiveTvOut::Tab(HomeTab::Search))), + TAB_KEY_WATCHLIST => Some(Some(LiveTvOut::Tab(HomeTab::Watchlist))), + TAB_KEY_CHIP => Some(Some(LiveTvOut::Account)), + TAB_KEY_LIVETV => Some(None), + _ => None, + } + } + + fn view<H: HomeLike>(&self) -> impl Focusable<H> + '_ { + self.stack.view(&self.page) + } + + /// The page's top edge once the chrome has tucked away (the Watchlist's). + fn draw_chrome<H: HomeLike>(&self, f: &mut DrawFrame<'_, '_, H>) { + let tuck = self.page.handoff.tuck(); + if tuck <= 0.0 { + return; + } + let ground = self.ground.sample(Rect::FULL, SCR_W * 0.5, 0.0); + plx_ui::scroll_capsule::draw_top_fade(f.painter.alpha(f.page_alpha), SCR_W, ground, tuck, None); + } +} + +plx_ui::focusable_via_view!(LiveTvScreen, H: [HomeLike], view); + +impl LogicalState for LiveTvScreen { + fn write(&self, c: &mut Canon) { + c.u32(self.page.entry.0); + c.seq(self.page.channels.len()); + for m in &self.page.channels { + c.u32(u32::from(m.sid.raw())).str(&m.rk); + } + self.stack.write(c); + self.page.handoff.write(c); + } + + fn probe(&self, out: &mut String) { + out.push_str(&format!("livetv channels={}", self.page.channels.len())); + } +} + +impl<H: HomeLike> Machine<H> for LiveTvScreen { + type Ev = ScreenEvent<H>; + + fn step(&mut self, ev: &Self::Ev, cx: &Cx<'_, H>, fx: &mut Effects<'_, H>) -> Handled { + let card = match self.stack.on(&self.page, ev, cx, fx) { + Some(StackEvent::Card(_, card)) => Some(card), + _ => None, + }; + // The reader acting makes the seat theirs (claimed on the event, as the Watchlist does). + if let ScreenEvent::Input(InputEvent { kind, .. }) = ev { + if matches!(kind, InputKind::Key { edge: Edge::Down, .. } | InputKind::Pointer { .. } + | InputKind::Click { .. } | InputKind::Drag { .. } | InputKind::Wheel { .. }) { + self.seat = None; + self.stale_enters = self.own_enters; + } + } + match ev { + ScreenEvent::Tick(t) => { + self.tick(*t, cx); + self.follow_seat(fx); + Handled::Yes + } + ScreenEvent::Enter(enter) => { + match enter { + Enter::Fresh { .. } if self.own_enters > 0 => { + self.own_enters -= 1; + if self.stale_enters > 0 { + self.stale_enters -= 1; + if let Some(held) = cx.focus.current.filter(|key| key.entry == self.page.entry) { + self.own_enters = self.own_enters.saturating_add(1); + fx.push(Fx::Deliver(fx.from(), Delivery::Screen(ScreenEvent::Enter(Enter::Fresh { + focus: FocusTarget::Elem(held), + })))); + } + } + } + Enter::Fresh { .. } => { + // The catalog is already here: take the channels before seating on them. + self.page.sync(cx); + self.seat_first(fx); + } + Enter::Restored => { + self.seat = None; + self.stale_enters = self.own_enters; + } + } + fx.invalidate(Provenance::Nav); + Handled::Yes + } + ScreenEvent::Uncover => { + fx.invalidate(Provenance::Nav); + Handled::Yes + } + ScreenEvent::FocusMoved { .. } | ScreenEvent::StoreChanged(..) => { + fx.invalidate(Provenance::Input); + Handled::Yes + } + ScreenEvent::Activate(elem) => self.activate(*elem, fx), + ScreenEvent::PressCommit(_) => { + if let Some(CardEvent::Activate(elem)) = card { + if let Some(out) = self.press(elem) { + self.route(out, fx); + } + } else if let Some(key) = cx.focus.current.filter(|key| key.entry == self.page.entry) { + self.activate(key.elem, fx); + } + Handled::Yes + } + ScreenEvent::Input(InputEvent { kind: InputKind::Key { key: Key::Back, edge: Edge::Down, .. }, .. }) => { + self.back(cx, fx) + } + _ => Handled::No, + } + } +} + +impl<H: HomeLike> Screen<H> for LiveTvScreen { + fn focused_card_rect(&self, cx: &Cx<'_, H>, focus: Option<FocusKey<u32>>, at: plx_ui::screen::At) -> Option<Rect> { + let key = focus.filter(|k| k.entry == self.page.entry && k.elem >= FIRST_CARD_ELEM)?; + Focusable::<H>::place(self, &key.elem, cx, at).map(|placed| placed.rect) + } + fn redraw_focused(&self, f: &mut DrawFrame<'_, '_, H>, focus: Option<FocusKey<u32>>) { + self.stack.view(&self.page).redraw_focused(f, focus); + } + fn opener_departing(&self, key: FocusKey<u32>) -> bool { + self.stack.view(&self.page).opener_departing::<H>(key) + } + fn name(&self) -> &'static str { + Self::WORD + } + fn chrome_tuck(&self) -> f32 { + self.page.handoff.tuck() + } + fn state(&self) -> &dyn LogicalState { + self + } + fn crumb(&self, _cx: &Cx<'_, H>) -> Option<std::borrow::Cow<'_, str>> { + None + } + fn prepare(&mut self, _budget: &mut plx_ui::frame::Budget, _cx: &Cx<'_, H>) {} + fn draw(&mut self, f: &mut DrawFrame<'_, '_, H>) { + plx_gfx::gfx::frame_clear(theme::CLEAR_RGB.0, theme::CLEAR_RGB.1, theme::CLEAR_RGB.2); + let p = f.painter.alpha(f.page_alpha); + self.ground.draw(p, Rect::FULL); + self.stack.view(&self.page).paint(f); + self.draw_chrome(f); + } + fn render(&self) -> RenderStrategy { + RenderStrategy::Page + } + fn focus_source(&self) -> FocusSource { + FocusSource::Engine + } + fn hit_source(&self) -> HitSource { + HitSource::Engine + } + fn links(&self, out: &mut Vec<Link>) { + self.page.regions().links(out); + } + fn holds_arrival(&self, dir: plx_ui::screen::Dir) -> bool { + self.page.regions().holds(dir) + } + fn as_any(&self) -> Option<&dyn std::any::Any> { + Some(self) + } + fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> { + Some(self) + } +} + +#[cfg(test)] +#[path = "livetv_tests.rs"] +mod tests; diff --git a/rust-modules/screens/src/livetv_tests.rs b/rust-modules/screens/src/livetv_tests.rs new file mode 100644 index 0000000000..1dca6ea3e9 --- /dev/null +++ b/rust-modules/screens/src/livetv_tests.rs @@ -0,0 +1,137 @@ +use super::*; + +use plx_data::pms::HubsView; +use plx_machine::machine::{Chrome, FocusRead, Host, InputOwner, InstanceId, PressId, PressRead, ScreenId, Stamped}; +use plx_ui::fixture::FixtureMeasure; +use plx_ui::screen::ScreenArg; + +#[derive(Clone, PartialEq, Eq)] +struct TestArg; + +impl LogicalState for TestArg { + fn write(&self, c: &mut Canon) { + c.u32(0); + } + fn probe(&self, _: &mut String) {} +} + +impl ScreenArg for TestArg { + fn chrome(&self) -> Chrome { + Chrome::TabBar + } + fn id(&self) -> ScreenId { + ScreenId(801) + } + fn title(&self) -> Option<&str> { + None + } + fn same_instance(&self, other: &Self) -> bool { + self == other + } +} + +struct TestHost; + +impl Host for TestHost { + type Arg = TestArg; + type Fx = AppFx; + type Msg = crate::registry::AppMsg; + type Elem = u32; + type Views<'a> = HubsView<'a>; + type Init = crate::registry::NoInit; + type Memory = crate::registry::PageMemory; +} + +impl HomeLike for TestHost { + fn hubs<'a>(cx: &Cx<'a, Self>) -> HubsView<'a> { + cx.views + } +} + +const ENTRY: EntryId = EntryId(7); + +fn cx<'a>(view: HubsView<'a>, focus: Option<u32>) -> Cx<'a, TestHost> { + static MEASURE: FixtureMeasure = FixtureMeasure; + Cx { + views: view, + tick: Tick::default(), + measure: &MEASURE, + press: PressRead::default(), + focus: FocusRead { current: focus.map(|elem| FocusKey { entry: ENTRY, elem }), ..Default::default() }, + owner: InputOwner::Entry(ENTRY), + } +} + +fn step(s: &mut LiveTvScreen, view: HubsView<'_>, focus: Option<u32>, ev: &ScreenEvent<TestHost>) -> Vec<Stamped<TestHost>> { + let context = cx(view, focus); + let mut out = Vec::new(); + let mut present = plx_machine::present::Present::new(); + let mut fx = Effects::new(&mut out, plx_machine::machine::MachineId::Instance(InstanceId(9)), &mut present); + Machine::<TestHost>::step(s, ev, &context, &mut fx); + out +} + +fn asked(out: &[Stamped<TestHost>]) -> Vec<LiveTvOut> { + out.iter() + .filter_map(|stamped| match &stamped.fx { + Fx::App(AppFx::LiveTv(req)) => Some(req.clone()), + _ => None, + }) + .collect() +} + +fn channel(vcn: &str, name: &str) -> plx_plex::plex::livetv::LiveChannel { + plx_plex::plex::livetv::LiveChannel { + dvr: "7".into(), + channel: format!("C{vcn}.242.ersatztv.org"), + vcn: vcn.into(), + name: name.into(), + thumb: format!("http://192.0.2.10:8409/iptv/logos/{vcn}.jpg"), + now: "Family Show".into(), + ends_at: 0, + } +} + +#[test] +fn the_page_shows_the_catalogs_channels_and_ok_tunes_the_focused_one() { + let _guard = plx_base::testlock::serial(); + let mut state = plx_data::pms::PmsState::default(); + let adapter = std::sync::Arc::new(plx_data::pms::PmsAdapter::default()); + plx_data::pms::seed_live_for_test(&mut state, &adapter, 2, &[channel("101", "Family Channel"), channel("102", "Cartoons")]); + let snapshot = plx_data::pms::hubs_snapshot(&state); + let view = snapshot.view(); + + let mut s = LiveTvScreen::new(ENTRY); + let seat = step(&mut s, view, None, &ScreenEvent::Enter(Enter::Fresh { focus: FocusTarget::ContainerGroup(GRID_GROUP) })); + assert_eq!( + s.channels().iter().map(|m| m.title.as_str()).collect::<Vec<_>>(), + ["Family Channel", "Cartoons"], + "only the channel tiles of the catalog, in its order" + ); + assert!( + seat.iter().any(|stamped| matches!(&stamped.fx, Fx::Deliver(_, Delivery::Screen(ScreenEvent::Enter(_))))), + "the arrival is seated in the grid" + ); + step(&mut s, view, Some(FIRST_CARD_ELEM + 1), &ScreenEvent::Tick(Tick { ms: 16, dt_us: 16_667 })); + + let ok = step(&mut s, view, Some(FIRST_CARD_ELEM + 1), &ScreenEvent::PressCommit(PressId(1))); + assert_eq!( + asked(&ok), + [LiveTvOut::Tune { + sid: s.channels()[1].sid, + dvr: "7".into(), + channel: "C102.242.ersatztv.org".into(), + title: "Cartoons".into(), + }] + ); +} + +#[test] +fn the_strip_navigates_and_the_live_tv_pill_is_the_page_itself() { + use plx_ui::widgets::{TAB_KEY_CHIP, TAB_KEY_HOME, TAB_KEY_LIVETV, TAB_KEY_WATCHLIST}; + assert_eq!(LiveTvScreen::strip_press(TAB_KEY_HOME), Some(Some(LiveTvOut::Tab(HomeTab::Home)))); + assert_eq!(LiveTvScreen::strip_press(TAB_KEY_WATCHLIST), Some(Some(LiveTvOut::Tab(HomeTab::Watchlist)))); + assert_eq!(LiveTvScreen::strip_press(TAB_KEY_CHIP), Some(Some(LiveTvOut::Account))); + assert_eq!(LiveTvScreen::strip_press(TAB_KEY_LIVETV), Some(None)); + assert_eq!(LiveTvScreen::strip_press(FIRST_CARD_ELEM), None, "a card is not the strip"); +} diff --git a/rust-modules/screens/src/registry.rs b/rust-modules/screens/src/registry.rs index 38e741a213..9297b6f46a 100644 --- a/rust-modules/screens/src/registry.rs +++ b/rust-modules/screens/src/registry.rs @@ -70,6 +70,8 @@ pub enum AppFx { /// The Watchlist page's and its Sort & Filter menu's requests (plan §2.4 W45): everything the /// page asks of the app that has no existing effect spelling. Watchlist(crate::watchlist::page::WatchlistOut), + /// The Live TV page's requests: a channel's tune, the strip, BACK. + LiveTv(crate::livetv::LiveTvOut), Catalog(crate::catalog::CatalogOut), /// Player-route requests — what an overlay panel on the player's own `ModalStack` asks of the /// loop, because the thing being asked for needs the `MainThread` token, the route or the @@ -286,6 +288,9 @@ pub enum HomeReq { Play { sid: plx_plex::plex::ServerId, rk: String, resume_ns: i64 }, Detail { sid: plx_plex::plex::ServerId, rk: String }, ItemMenu { sid: plx_plex::plex::ServerId, rk: String }, + /// OK on a Live TV channel tile: tune `channel` on DVR `dvr` of server `sid` and watch it. + /// `title` is the channel's name, for the player until the tune names the airing. + Tune { sid: plx_plex::plex::ServerId, dvr: String, channel: String, title: String }, /// BACK from the shelves: fold to the hero and seat the engine in its remembered hero group. FoldToHero, Account, @@ -301,6 +306,8 @@ pub enum HomeTab { /// The Watchlist mark, before Search (docs/watchlist-plan.md §2.4 W45). Watchlist, Search, + /// The server's Live TV channels (`screens::livetv`), after the library type pills. + LiveTv, } /// Addressed bootstrap/diagnostic intentions. They use the same owned step and focus engine as @@ -1824,6 +1831,8 @@ pub mod page_id { pub const WATCHLIST: ScreenId = ScreenId(25); pub const LIBRARY_COLLECTIONS: ScreenId = ScreenId(28); pub const LIBRARY_RECOMMENDED: ScreenId = ScreenId(29); + /// 30, forward of Recommended's 29: the Live TV page. + pub const LIVETV: ScreenId = ScreenId(30); } /// **A screen argument: everything the container needs to mount one screen, and nothing else.** @@ -1923,6 +1932,9 @@ pub enum AppArg { /// from the bookmark mark. It carries nothing — the list is the store's, the order is the /// store's sort and filter. Watchlist, + /// The Live TV page (`screens::livetv`): a PEER of Home on the strip. It carries nothing — the + /// channels are the Home catalog's. + LiveTv, /// Playback. Its panels are NOT here — they are entries on the player page's own /// `ModalStack` ([`Self::PlayerOverlay`]), and the container owns which one is up. Player, @@ -1947,8 +1959,8 @@ impl AppArg { } } -pub const ARG_SHAPE: &str = "AppArg{Login,Profiles,Onboard,Home,Library,LibraryCollections{section:u32},LibraryRecommended{section:u32},Search,Watchlist,Player,Content:{Detail{sid:u32,rk:str},Person{sid:u32,key:str,guid:str,name:str,thumb:str},Filmography{sid:u32,key:str},Collection{sid:u32,rk:str,sec:i64,tag:i64,name:str},Catalog{hex:str}},Settings:SettingsPage{Root,Playback,AudioSubtitles,Favourites,Privacy,Legal,About,Document(u8),Preview(u8),ConsentStage(u8),Language,Contribute},FirstRunConsent(u8),LibraryMenu{host:u32,target:{epoch:u32,sid:u32,section:u64},kind:u32,anchor:[u32;4]},\ - PlayerOverlay{Tracks(tab:i32),Info,Chapters,More(page:u8)},\ +pub const ARG_SHAPE: &str = "AppArg{Login,Profiles,Onboard,Home,Library,LibraryCollections{section:u32},LibraryRecommended{section:u32},Search,Watchlist,LiveTv,Player,Content:{Detail{sid:u32,rk:str},Person{sid:u32,key:str,guid:str,name:str,thumb:str},Filmography{sid:u32,key:str},Collection{sid:u32,rk:str,sec:i64,tag:i64,name:str},Catalog{hex:str}},Settings:SettingsPage{Root,Playback,AudioSubtitles,Favourites,Privacy,Legal,About,Document(u8),Preview(u8),ConsentStage(u8),Language,Contribute},FirstRunConsent(u8),LibraryMenu{host:u32,target:{epoch:u32,sid:u32,section:u64},kind:u32,anchor:[u32;4]},\\\\ + PlayerOverlay{Tracks(tab:i32),Info,Chapters,More(page:u8)},\\\\ AltSources{host:u32,sid:u32,rk:str,anchor:[u32;4]},\ VersionsPanel{host:u32,sid:u32,rk:str,anchor:[u32;4]},\ TracksPanel{page:i32},AboutPanel,PersonBio,CollectionAbout,AccountMenu,\ @@ -1995,6 +2007,8 @@ impl LogicalState for AppArg { // 12 and 13, forward of Watchlist's 11 (both are in `ARG_SHAPE`). Self::LibraryCollections { section } => { c.u32(0).u32(12).u32(*section); } Self::LibraryRecommended { section } => { c.u32(0).u32(13).u32(*section); } + // 14, forward of Recommended's 13. + Self::LiveTv => { c.u32(0).u32(14); } Self::Content(arg) => { c.u32(1); arg.write(c); } Self::Settings(page) => { c.u32(2); page.write(c); } Self::FirstRunConsent(stage) => { c.u32(3).u8(*stage); } @@ -2011,7 +2025,7 @@ impl plx_ui::screen::ScreenArg for AppArg { /// them fade the bar with the page. fn chrome(&self) -> Chrome { match self { - AppArg::Home | AppArg::Library | AppArg::Search | AppArg::Watchlist => Chrome::TabBar, + AppArg::Home | AppArg::Library | AppArg::Search | AppArg::Watchlist | AppArg::LiveTv => Chrome::TabBar, // The two sub-pages of a library keep the bar, as the Library they are pushed over. AppArg::LibraryCollections { .. } | AppArg::LibraryRecommended { .. } => Chrome::TabBar, AppArg::Login @@ -2072,6 +2086,7 @@ impl plx_ui::screen::ScreenArg for AppArg { // 28 and 29, forward of the catalog page's 27. AppArg::LibraryCollections { .. } => page_id::LIBRARY_COLLECTIONS.0, AppArg::LibraryRecommended { .. } => page_id::LIBRARY_RECOMMENDED.0, + AppArg::LiveTv => page_id::LIVETV.0, AppArg::Search => page_id::SEARCH.0, AppArg::Player => 11, // The ROOT payload is a boot address, not an identity: one Settings surface and one @@ -2317,6 +2332,8 @@ where // The list is the store's: the page mounts on "loading" and the application feeds it // the rows, the options and the matched facts (`WatchlistIn`) on its first frames. AppArg::Watchlist => Box::new(crate::watchlist::page::WatchlistScreen::loading(entry)), + // The channels are the Home catalog's: the page reads them through `HomeLike::hubs`. + AppArg::LiveTv => Box::new(crate::livetv::LiveTvScreen::new(entry)), // Phase 9: the player is an OWNED screen — the instance that holds the HUD, the scrub // gesture, the held-key timer, the control row's springs and the Up Next countdown, // and that answers `RenderStrategy::VideoPlane`. Its transport is pinned from the @@ -2450,6 +2467,7 @@ pub fn every_surface_arg() -> Vec<AppArg> { | AppArg::LibraryRecommended { .. } | AppArg::Search | AppArg::Watchlist + | AppArg::LiveTv | AppArg::Player | AppArg::Content(_) => { panic!("a page argument is not a surface: its word is `route=`") @@ -2517,6 +2535,7 @@ pub const SCREEN_SHAPES: &[&str] = &[ crate::watchlist::menu::SHAPE, crate::catalog::CatalogScreen::SHAPE, crate::versions_panel::SHAPE, + crate::livetv::LiveTvScreen::SHAPE, ]; /// The pin over [`SCREEN_SHAPES`] — bump it in the same edit that adds an entry, and say why. @@ -2707,8 +2726,9 @@ pub const SCREEN_SHAPES: &[&str] = &[ // addressed. One bump; the three replay fixtures were re-recorded against it; the previous pin was // 0x9425_f33f_3f53_6aa5. // Opt-in playback diagnostics adds Root status and addressed command replies. -// Replay anchors are re-recorded through tools/plxnative-rec rerecord. -const SCREEN_SHAPES_PIN: u64 = 0x666e053447eaa2de; +// Live TV page: `ARG_SHAPE` gains `LiveTv`, `SCREEN_SHAPES` appends `LiveTvScreen::SHAPE`. +// Merged both after rebase on main; shapes and replays need re-recording. +const SCREEN_SHAPES_PIN: u64 = 0x912450b81a25451c; #[cfg(test)] mod arg_tests { @@ -2782,6 +2802,7 @@ mod arg_tests { AppArg::LibraryRecommended { section: 0 }, AppArg::Search, AppArg::Watchlist, + AppArg::LiveTv, ] { assert!((r.chrome() == Chrome::TabBar), "a bar-wearing page carries the chip"); } diff --git a/rust-modules/screens/src/search/mod.rs b/rust-modules/screens/src/search/mod.rs index 169072c1c0..0eac2837bc 100644 --- a/rust-modules/screens/src/search/mod.rs +++ b/rust-modules/screens/src/search/mod.rs @@ -269,6 +269,7 @@ impl SearchScreen { TAB_KEY_MOVIES => SearchReq::Tab(HomeTab::Movies), TAB_KEY_SHOWS => SearchReq::Tab(HomeTab::Shows), TAB_KEY_WATCHLIST => SearchReq::Tab(HomeTab::Watchlist), + plx_ui::widgets::TAB_KEY_LIVETV => SearchReq::Tab(HomeTab::LiveTv), // The Search mark is the page the user is already on: nothing to request. TAB_KEY_SEARCH => return Handled::Yes, TAB_KEY_CHIP => SearchReq::Account, diff --git a/rust-modules/screens/src/watchlist/page.rs b/rust-modules/screens/src/watchlist/page.rs index d5bf06add0..f1fe37870d 100644 --- a/rust-modules/screens/src/watchlist/page.rs +++ b/rust-modules/screens/src/watchlist/page.rs @@ -1265,6 +1265,7 @@ impl WatchlistScreen { TAB_KEY_SEARCH => Some(Some(WatchlistOut::Tab(HomeTab::Search))), TAB_KEY_CHIP => Some(Some(WatchlistOut::Account)), TAB_KEY_WATCHLIST => Some(None), + plx_ui::widgets::TAB_KEY_LIVETV => Some(Some(WatchlistOut::Tab(HomeTab::LiveTv))), _ => None, } } diff --git a/rust-modules/src/app/bootstrap/effects.rs b/rust-modules/src/app/bootstrap/effects.rs index 90b9414bcf..0a53aee575 100644 --- a/rust-modules/src/app/bootstrap/effects.rs +++ b/rust-modules/src/app/bootstrap/effects.rs @@ -30,7 +30,7 @@ fn home_command(command: &plx_screens::registry::HomeCmd) -> Value { HomeCmd::Hero => json!("hero"), HomeCmd::FocusStrip(tab) => json!({"focus_strip":match tab { HomeTab::Home => "home", HomeTab::Movies => "movies", HomeTab::Shows => "shows", HomeTab::Search => "search", - HomeTab::Watchlist => "watchlist" }}), + HomeTab::Watchlist => "watchlist", HomeTab::LiveTv => "livetv" }}), HomeCmd::Flip(delta) => json!({"flip":delta}), HomeCmd::SelectHero(index) => json!({"select_hero":index}), HomeCmd::PinHero(index) => json!({"pin_hero":index}), diff --git a/rust-modules/src/app/bridge.rs b/rust-modules/src/app/bridge.rs index 554e2056f1..d52036fd07 100644 --- a/rust-modules/src/app/bridge.rs +++ b/rust-modules/src/app/bridge.rs @@ -466,6 +466,8 @@ pub(crate) struct Bridge { /// What the Watchlist page and its Sort & Filter surface asked, drained by /// `content::watchlist_requests` (the Search pattern). watchlist_reqs: Vec<(MachineId, plx_screens::watchlist::page::WatchlistOut, ReturnState<u32, PageMemory>)>, + /// What the Live TV page asked, drained by `content::livetv_requests` (the Watchlist pattern). + livetv_reqs: Vec<(MachineId, plx_screens::livetv::LiveTvOut, ReturnState<u32, PageMemory>)>, /// What the Watchlist page was last handed from the store (`watchlist::Feed`). watchlist: watchlist::Feed, /// The catalog page instance the store was last opened for (`watchlist::catalog_feed`). @@ -704,6 +706,7 @@ impl Bridge { player_reqs: Vec::new(), item_menu_reqs: Vec::new(), watchlist_reqs: Vec::new(), + livetv_reqs: Vec::new(), watchlist: watchlist::Feed::default(), catalog_seat: None, #[cfg(test)] @@ -1165,6 +1168,7 @@ impl Bridge { d.nav.tabs.strip_fallback = Some(match route { AppArg::Search => plx_ui::widgets::TAB_KEY_SEARCH, AppArg::Watchlist => plx_ui::widgets::TAB_KEY_WATCHLIST, + AppArg::LiveTv => plx_ui::widgets::TAB_KEY_LIVETV, _ => plx_screens::home::STRIP_HOME_ELEM, }); if let Some(io) = &self.home_io { @@ -2556,6 +2560,7 @@ impl Bridge { AppFx::Player(req) => self.player_reqs.push(req), AppFx::ItemMenu(req) => self.item_menu_reqs.push(req), AppFx::Watchlist(req) => self.watchlist_reqs.push((from, req, self.effect_return.clone())), + AppFx::LiveTv(req) => self.livetv_reqs.push((from, req, self.effect_return.clone())), // The catalog page's pill: the same write the item menu's rows make, so an Add pulses // the tab here too. The page flips nothing itself; the store's ledger flips the face. AppFx::Catalog(out) => self.catalog_effect(out), @@ -3827,6 +3832,7 @@ fn pill_of_arg(arg: &AppArg, directory: plx_data::stores::browse::DirectoryView< AppArg::Home => pill_of(directory, Pill::Home), AppArg::Search => pill_of(directory, Pill::Search), AppArg::Watchlist => pill_of(directory, Pill::Watchlist), + AppArg::LiveTv => pill_of(directory, Pill::LiveTv), // The grid and the two pages its header pushes (Collections, Recommended) are all the // library's: they select its type's pill, the pill they were opened from. arg if arg.is_library_family() => { @@ -3867,6 +3873,7 @@ pub(crate) fn nav_tab( Pill::Home => HomeTab::Home, Pill::Search => HomeTab::Search, Pill::Watchlist => HomeTab::Watchlist, + Pill::LiveTv => HomeTab::LiveTv, Pill::Section(plx_data::browse::SecKind::Movie) => HomeTab::Movies, Pill::Section(plx_data::browse::SecKind::Show) => HomeTab::Shows, }; @@ -3882,6 +3889,7 @@ pub(crate) fn nav_tab( } HomeTab::Search => AppArg::Search, HomeTab::Watchlist => AppArg::Watchlist, + HomeTab::LiveTv => AppArg::LiveTv, }; nav_peer(d, arg, ret); } diff --git a/rust-modules/src/app/chrome.rs b/rust-modules/src/app/chrome.rs index 0a069fee41..83272d040a 100644 --- a/rust-modules/src/app/chrome.rs +++ b/rust-modules/src/app/chrome.rs @@ -11,12 +11,15 @@ use plx_data::stores::browse::{DirectoryView, SecKind}; pub(crate) enum Pill { Home, Section(SecKind), + LiveTv, Watchlist, Search, } -/// The strip's destination keys in strip order: Home, one key per type pill, the Watchlist -/// bookmark, Search. The one +/// The strip's destination keys in strip order: Home, one key per type pill, Live TV, the +/// Watchlist bookmark, Search. Live TV is always offered: a server without a DVR shows an empty +/// page. shortcut: the pill does not follow the hubs (the strip rebuilds on the directory's +/// generation only); hide it once a hubs-driven rebuild exists. The one /// place that order is written; `refresh` publishes it and the pill maps below read it, so no /// map assumes which index Search holds. fn strip_keys(directory: DirectoryView<'_>) -> Vec<u32> { @@ -25,6 +28,7 @@ fn strip_keys(directory: DirectoryView<'_>) -> Vec<u32> { SecKind::Movie => widgets::TAB_KEY_MOVIES, SecKind::Show => widgets::TAB_KEY_SHOWS, })); + keys.push(widgets::TAB_KEY_LIVETV); keys.push(widgets::TAB_KEY_WATCHLIST); keys.push(widgets::TAB_KEY_SEARCH); keys @@ -39,6 +43,7 @@ fn pill_in(i: usize, keys: &[u32]) -> Pill { match keys.get(i).copied() { Some(widgets::TAB_KEY_MOVIES) => Pill::Section(SecKind::Movie), Some(widgets::TAB_KEY_SHOWS) => Pill::Section(SecKind::Show), + Some(widgets::TAB_KEY_LIVETV) => Pill::LiveTv, Some(widgets::TAB_KEY_WATCHLIST) => Pill::Watchlist, Some(widgets::TAB_KEY_SEARCH) => Pill::Search, _ => Pill::Home, @@ -54,6 +59,7 @@ fn pill_index(pill: Pill, keys: &[u32]) -> Option<usize> { Pill::Home => widgets::TAB_KEY_HOME, Pill::Section(SecKind::Movie) => widgets::TAB_KEY_MOVIES, Pill::Section(SecKind::Show) => widgets::TAB_KEY_SHOWS, + Pill::LiveTv => widgets::TAB_KEY_LIVETV, Pill::Watchlist => widgets::TAB_KEY_WATCHLIST, Pill::Search => widgets::TAB_KEY_SEARCH, }; @@ -117,6 +123,9 @@ impl ChromeSnapshot { widgets::TAB_KEY_HOME => TabFace::Label(plx_platform::i18n::msg::browse_chrome_home().into()), widgets::TAB_KEY_MOVIES => TabFace::Label(plx_platform::i18n::msg::browse_kind_movies().into()), widgets::TAB_KEY_SHOWS => TabFace::Label(plx_platform::i18n::msg::browse_kind_tv_shows().into()), + // "TV", not the page's "TV en directo": the strip keeps its glass only up to + // `GLASS_TRACK_MAX`, and the longer word put the product's bar past it. + widgets::TAB_KEY_LIVETV => TabFace::Label(plx_platform::i18n::msg::browse_chrome_live_tv().into()), widgets::TAB_KEY_WATCHLIST => TabFace::Icon(Icon::Bookmark), _ => TabFace::Icon(Icon::Search), }).collect(); @@ -311,12 +320,12 @@ mod tests { }; snapshot.refresh(&plx_ui::fixture::FixtureMeasure, directory.view()); assert_eq!(snapshot.keys, vec![widgets::TAB_KEY_HOME, widgets::TAB_KEY_MOVIES, - widgets::TAB_KEY_SHOWS, widgets::TAB_KEY_WATCHLIST, widgets::TAB_KEY_SEARCH]); + widgets::TAB_KEY_SHOWS, widgets::TAB_KEY_LIVETV, widgets::TAB_KEY_WATCHLIST, widgets::TAB_KEY_SEARCH]); let words: Vec<&str> = snapshot.faces.iter().filter_map(|face| match face { TabFace::Label(word) => Some(word.as_str()), TabFace::Icon(_) => None, }).collect(); - assert_eq!(&words[..3], &["Home", "Movies", "TV Shows"]); + assert_eq!(&words[..4], &["Home", "Movies", "TV Shows", "TV"]); assert_eq!(snapshot.faces.last(), Some(&TabFace::Icon(Icon::Search))); assert_eq!(snapshot.faces[snapshot.faces.len() - 2], TabFace::Icon(Icon::Bookmark), "the Watchlist bookmark sits just before Search"); @@ -324,20 +333,21 @@ mod tests { snapshot.members(0, None, 0.0, &mut members); assert_eq!(members.iter().map(|member| member.elem).collect::<Vec<_>>(), vec![widgets::TAB_KEY_CHIP, widgets::TAB_KEY_HOME, widgets::TAB_KEY_MOVIES, - widgets::TAB_KEY_SHOWS, widgets::TAB_KEY_WATCHLIST, widgets::TAB_KEY_SEARCH]); + widgets::TAB_KEY_SHOWS, widgets::TAB_KEY_LIVETV, widgets::TAB_KEY_WATCHLIST, widgets::TAB_KEY_SEARCH]); } #[test] fn every_projected_pill_round_trips_by_stable_section_identity() { use plx_data::stores::browse::SecKind::{Movie, Show}; let keys = [widgets::TAB_KEY_HOME, widgets::TAB_KEY_MOVIES, widgets::TAB_KEY_SHOWS, - widgets::TAB_KEY_WATCHLIST, widgets::TAB_KEY_SEARCH]; + widgets::TAB_KEY_LIVETV, widgets::TAB_KEY_WATCHLIST, widgets::TAB_KEY_SEARCH]; assert_eq!(pill_in(0, &keys), Pill::Home); assert_eq!(pill_in(1, &keys), Pill::Section(Movie)); assert_eq!(pill_in(2, &keys), Pill::Section(Show)); - assert_eq!(pill_in(3, &keys), Pill::Watchlist); - assert_eq!(pill_in(4, &keys), Pill::Search); - assert_eq!(pill_in(5, &keys), Pill::Home); + assert_eq!(pill_in(3, &keys), Pill::LiveTv); + assert_eq!(pill_in(4, &keys), Pill::Watchlist); + assert_eq!(pill_in(5, &keys), Pill::Search); + assert_eq!(pill_in(6, &keys), Pill::Home); for i in 0..keys.len() { assert_eq!(pill_index(pill_in(i, &keys), &keys), Some(i)); } @@ -364,11 +374,13 @@ mod tests { let directory = directory(&[SecKind::Movie, SecKind::Show]); let view = directory.view(); assert_eq!(strip_keys(view), vec![widgets::TAB_KEY_HOME, widgets::TAB_KEY_MOVIES, - widgets::TAB_KEY_SHOWS, widgets::TAB_KEY_WATCHLIST, widgets::TAB_KEY_SEARCH]); - assert_eq!(pill_of(view, Pill::Watchlist), Some(3)); - assert_eq!(pill_of(view, Pill::Search), Some(4)); - assert_eq!(pill_at(view, 3), Pill::Watchlist); - assert_eq!(pill_at(view, 4), Pill::Search); + widgets::TAB_KEY_SHOWS, widgets::TAB_KEY_LIVETV, widgets::TAB_KEY_WATCHLIST, widgets::TAB_KEY_SEARCH]); + assert_eq!(pill_of(view, Pill::LiveTv), Some(3)); + assert_eq!(pill_of(view, Pill::Watchlist), Some(4)); + assert_eq!(pill_of(view, Pill::Search), Some(5)); + assert_eq!(pill_at(view, 3), Pill::LiveTv); + assert_eq!(pill_at(view, 4), Pill::Watchlist); + assert_eq!(pill_at(view, 5), Pill::Search); } #[test] diff --git a/rust-modules/src/app/content.rs b/rust-modules/src/app/content.rs index 25135b7c05..5ce36e3a56 100644 --- a/rust-modules/src/app/content.rs +++ b/rust-modules/src/app/content.rs @@ -481,6 +481,7 @@ pub(crate) fn content_requests(app: &mut App, fr: &mut Frame) { library_requests(app, fr.now); search_requests(app); watchlist_requests(app); + livetv_requests(app); // The player's overlays are surfaces on its own stack, so what they decide reaches the // loop the same way every other owned screen's decision does — as requests, drained here, // after the dispatcher and before the frame's own arms (`playback::player_requests`). @@ -685,6 +686,9 @@ fn home_requests(app: &mut App, now: u32) { activate_home_item(app, source, entry, sid, &rk, Some(resume_ns), ret, now), HomeReq::Detail { sid, rk } => activate_home_item(app, source, entry, sid, &rk, None, ret, now), + HomeReq::Tune { sid, dvr, channel, title } => super::playback::play_live_channel( + &mut app.player.session, &mut app.adapters.player, sid, &dvr, &channel, &title, + HUD_LINGER_MS, Some(ret), &mut app.pages, &mut app.bridge), HomeReq::ItemMenu { sid, rk } => { let snapshot = app.bridge.hubs_snapshot(); let Some(item) = home_item(snapshot.view(), sid, &rk) @@ -788,6 +792,35 @@ fn watchlist_requests(app: &mut App) { } } +/// **The Live TV page's requests**, gated like the Watchlist's (the asking page still holds input +/// on its route): a channel's tune starts the player over the page, so BACK out of the player lands +/// back on it; the strip and BACK navigate as from the Watchlist. +fn livetv_requests(app: &mut App) { + use plx_screens::livetv::LiveTvOut; + for (source, request, ret) in app.bridge.take_livetv_reqs() { + let MachineId::Instance(instance) = source else { continue }; + let Some(entry) = app.pages.nav.entry_of_instance(instance) else { continue }; + if app.route() != AppArg::LiveTv || app.pages.nav.input_owner() != Some(InputOwner::Entry(entry)) { + continue; + } + match request { + LiveTvOut::Tune { sid, dvr, channel, title } => super::playback::play_live_channel( + &mut app.player.session, &mut app.adapters.player, sid, &dvr, &channel, &title, + HUD_LINGER_MS, Some(ret), &mut app.pages, &mut app.bridge), + LiveTvOut::Back => bridge::nav_tab(&mut app.pages, &mut app.bridge, HomeTab::Home, None, Some(ret)), + LiveTvOut::Tab(HomeTab::LiveTv) => {} + LiveTvOut::Tab(tab) => { + if !app.bridge.search_tab_available(tab) { continue; } + match tab { + HomeTab::Home => bridge::nav_home_pill(&mut app.pages, &mut app.bridge, Some(ret)), + other => bridge::nav_tab(&mut app.pages, &mut app.bridge, other, None, Some(ret)), + } + } + LiveTvOut::Account => chip_activate(&mut app.pages), + } + } +} + /// **One request the Watchlist page or its Sort & Filter surface made, performed**, after the loop's /// gates (the asking page still holds input on the Watchlist route; a `SetOptions` is gated on /// nothing but a Watchlist page being on the stack). Shared with the tests' pump for the reason diff --git a/rust-modules/src/app/home_retention_tests.rs b/rust-modules/src/app/home_retention_tests.rs index c1d03d88e2..a64891f187 100644 --- a/rust-modules/src/app/home_retention_tests.rs +++ b/rust-modules/src/app/home_retention_tests.rs @@ -402,7 +402,7 @@ fn library_publishes_the_actual_container_strip() { bridge.capture_chrome(&mut dispatcher); let base = plx_ui::dispatch::STRIP_BASE; assert_eq!(dispatcher.nav.tabs.strip.iter().map(|member| member.elem).collect::<Vec<_>>(), - vec![base + 4, base, base + 1, base + 2, plx_ui::widgets::TAB_KEY_WATCHLIST, base + 3]); + vec![base + 4, base, base + 1, base + 2, plx_ui::widgets::TAB_KEY_LIVETV, plx_ui::widgets::TAB_KEY_WATCHLIST, base + 3]); assert_eq!(dispatcher.nav.tabs.strip_fallback, Some(base)); } diff --git a/rust-modules/src/app/library_navigation_tests.rs b/rust-modules/src/app/library_navigation_tests.rs index 9c382fc4ef..90ecfeb0a6 100644 --- a/rust-modules/src/app/library_navigation_tests.rs +++ b/rust-modules/src/app/library_navigation_tests.rs @@ -41,7 +41,7 @@ fn library_strip_profile_navigation_and_armed_pill_use_the_actual_engine_identit .iter() .map(|member| member.elem) .collect::<Vec<_>>(), - vec![base + 4, base, base + 1, base + 2, plx_ui::widgets::TAB_KEY_WATCHLIST, base + 3] + vec![base + 4, base, base + 1, base + 2, plx_ui::widgets::TAB_KEY_LIVETV, plx_ui::widgets::TAB_KEY_WATCHLIST, base + 3] ); let seat = |d: &mut Dispatcher<AppHost>, elem| { d.emit( @@ -126,7 +126,7 @@ fn library_strip_profile_navigation_and_armed_pill_use_the_actual_engine_identit .strip .iter() .position(|member| member.elem == search.elem), - Some(5), + Some(6), "Search's displayed slot is not its stable control identity" ); rig.take_library_reqs(); diff --git a/rust-modules/src/app/playback.rs b/rust-modules/src/app/playback.rs index 5660313d04..39e71d70af 100644 --- a/rust-modules/src/app/playback.rs +++ b/rust-modules/src/app/playback.rs @@ -1084,6 +1084,30 @@ pub(super) fn play_item_now_with<R: PlaybackResources>( ); } +/// Watch a Live TV channel from Home. The resolve worker tunes it (`route::request_live`, several +/// seconds) while the player shows its Resolving state; there is no resume point, no metadata to +/// fetch and no PlayQueue, so the start ritual runs at 0 with the channel's name as the title. +#[allow(clippy::too_many_arguments)] +pub(crate) fn play_live_channel( + ps: &mut plx_media::route::PlaybackSession, + pa: &mut plx_media::player::adapter::PlayerAdapter, + sid: plx_plex::plex::ServerId, + dvr: &str, + channel: &str, + title: &str, + hud_ms: u32, + ret: Option<plx_ui::screen::ReturnState<u32, plx_screens::registry::PageMemory>>, + pages: &mut plx_ui::dispatch::Dispatcher<super::bridge::AppHost>, + bridge: &mut super::bridge::Bridge, +) { + if !plx_media::route::request_live(ps, bridge.metadata_mut(), plx_platform::devcaps::caps(), sid, dvr, channel, title) { + return; + } + // The previous item's caption would otherwise label the channel for the whole tune. + bridge.metadata_mut().run(plx_data::stores::metadata::MetadataCmd::SetNowPlaying(None)); + start_playback(ps, pa, 0, Origin::Here, hud_ms, ret, pages, bridge); +} + // (`key_player_failed` stood here — the arm `app/run.rs` reached for a key arriving while a // terminal read-out owned the frame: OK opened the quality ladder, BACK closed the `…` popover if // one was up and otherwise left the playback, everything else was swallowed. Restructure phase 12 diff --git a/rust-modules/src/app/run.rs b/rust-modules/src/app/run.rs index e6154945c5..e536e6f2f9 100644 --- a/rust-modules/src/app/run.rs +++ b/rust-modules/src/app/run.rs @@ -1090,9 +1090,21 @@ unsafe fn ingest_sdl_event_with_window(app: &mut App, fr: &mut Frame, ) { plx_machine::idle::invalidate(); } + // A Live TV channel is not parked: it cannot come back where it was (the server goes on + // recording, and a reload would replay from the tune), and the tuner it holds is the + // server's. Leave the player as BACK does, so the session's `stopped` frees the tuner. + if plx_media::route::is_live(&app.player.session) { + log("livetv: background ends the channel"); + super::playback::exit_player( + &mut app.player, + &mut app.adapters.player, + &mut app.repause_at, + &mut app.refresh_hubs, + &mut app.pages, + ); // Playback owns its resolve and Engine before the queued Player page mounts. Page // presence only governs the screen-local cleanup below, never resource suspension. - if !plx_media::route::preview_request(&app.player.session) + } else if !plx_media::route::preview_request(&app.player.session) && (plx_media::route::play_pending() || app.adapters.player.is_live() || plx_media::route::has_url(&app.player.session)) @@ -3307,6 +3319,7 @@ pub(crate) unsafe fn report(app: &mut App, fr: &mut Frame) { crate::focusprobe::Screen::Catalog, AppArg::Search => crate::focusprobe::Screen::Search, AppArg::Watchlist => crate::focusprobe::Screen::Watchlist, + AppArg::LiveTv => crate::focusprobe::Screen::LiveTv, // The same words the heartbeat's `overlay=` uses, and — since phase 9 — from the // same place: the SURFACE that is up. There is no second table; see // `heartbeat_word_tests::focusprobe_player_overlay_delegates_to_the_shared_overlay_word_function`. diff --git a/rust-modules/src/app/search_owned_tests.rs b/rust-modules/src/app/search_owned_tests.rs index 4950cbc3e6..3e1132fa59 100644 --- a/rust-modules/src/app/search_owned_tests.rs +++ b/rust-modules/src/app/search_owned_tests.rs @@ -1045,16 +1045,16 @@ fn owned_search_content_probe_reports_zone_row_col_pill_and_card_as_focus_moves( // Up again, from the field itself, reaches the shared strip's Search pill — the same // transition `tests/keytable.json`'s Search "up" row records. With no library tabs seeded - // here the strip holds only Home (index 0), Watchlist (index 1) and Search (index 2), so the - // pill lands at 2 rather than the real 5-slot device's 4 — the field is a POSITION, not a fixed constant, + // here the strip holds only Home (index 0), Live TV (index 1), Watchlist (index 2) and Search + // (index 3), so the pill lands at 3 rather than the real 6-slot device's 5 — the field is a POSITION, not a fixed constant, // and this asserts it moved off -1 onto the strip's own answer rather than hardcoding a // count this test's fixture does not seed. frame(&mut d, &mut rig, AppArg::Search, tick(20), script_key(Key::Up, tick(20))); for i in 21..30 { frame(&mut d, &mut rig, AppArg::Search, tick(i), vec![]); } let on_strip = content_probe(&d, &rig); assert!(on_strip.contains("zone=Strip"), "Up from the field must reach the shared strip: {on_strip}"); - assert!(on_strip.contains(" row=-1 col=-1 recent=-1 pill=2 card=0 "), - "the Search pill (Home, Watchlist, Search — no library tabs seeded here) sits at index 2, off this screen's own groups: {on_strip}"); + assert!(on_strip.contains(" row=-1 col=-1 recent=-1 pill=3 card=0 "), + "the Search pill (Home, Live TV, Watchlist, Search — no library tabs seeded here) sits at index 3, off this screen's own groups: {on_strip}"); } /// OK on a Collections-shelf tile asks for the COLLECTION page with the hit's identity, and the diff --git a/rust-modules/src/app/surface_navigation_tests.rs b/rust-modules/src/app/surface_navigation_tests.rs index 6cd300fc1f..6903c42c98 100644 --- a/rust-modules/src/app/surface_navigation_tests.rs +++ b/rust-modules/src/app/surface_navigation_tests.rs @@ -27,7 +27,7 @@ fn content_instances_compare_item_identity_and_keep_distinct_entries() { #[test] fn a_page_arg_wears_the_chrome_its_own_table_says() { for r in every_route() { - let want = if matches!(r, AppArg::Home | AppArg::Search | AppArg::Watchlist) || r.is_library_family() { + let want = if matches!(r, AppArg::Home | AppArg::Search | AppArg::Watchlist | AppArg::LiveTv) || r.is_library_family() { Chrome::TabBar } else { Chrome::None diff --git a/rust-modules/src/app/watchlist.rs b/rust-modules/src/app/watchlist.rs index 625bed5b55..d3cae1e551 100644 --- a/rust-modules/src/app/watchlist.rs +++ b/rust-modules/src/app/watchlist.rs @@ -410,6 +410,11 @@ impl Bridge { std::mem::take(&mut self.watchlist_reqs) } + /// What the Live TV page asked since the last drain (`content::livetv_requests`). + pub(crate) fn take_livetv_reqs(&mut self) -> Vec<(MachineId, plx_screens::livetv::LiveTvOut, ReturnState<u32, PageMemory>)> { + std::mem::take(&mut self.livetv_reqs) + } + /// Is this catalog id on the watchlist, intents in flight counted? (The Detail and catalog /// bookmark faces read it.) #[cfg(test)] diff --git a/rust-modules/src/app/words.rs b/rust-modules/src/app/words.rs index c003fde06f..c953b22027 100644 --- a/rust-modules/src/app/words.rs +++ b/rust-modules/src/app/words.rs @@ -37,6 +37,7 @@ pub(crate) fn route_word(route: &AppArg) -> &'static str { AppArg::Content(ContentArg::Catalog { .. }) => "catalog", AppArg::Search => "search", AppArg::Watchlist => "watchlist", + AppArg::LiveTv => "livetv", AppArg::Player => "player", AppArg::Home => "home", // Not pages: a surface's word is ` overlay=`, and none of these reaches the page stack's @@ -71,7 +72,7 @@ pub(crate) fn route_word(route: &AppArg) -> &'static str { /// holds one and asks about it. Its two readers are this module's word derivation and /// `app::bridge`'s argument tests, which used to keep a second copy of the same list. #[cfg(test)] -pub(crate) fn every_route() -> [AppArg; 14] { +pub(crate) fn every_route() -> [AppArg; 15] { use plx_screens::registry::ContentArg; let sid = plx_plex::plex::ServerId::UNSET; [ @@ -92,6 +93,7 @@ pub(crate) fn every_route() -> [AppArg; 14] { AppArg::Content(ContentArg::Catalog { hex: [0; 12] }), AppArg::Search, AppArg::Watchlist, + AppArg::LiveTv, AppArg::Player, ] } diff --git a/rust-modules/src/focusprobe.rs b/rust-modules/src/focusprobe.rs index 66b2832eec..9a44148625 100644 --- a/rust-modules/src/focusprobe.rs +++ b/rust-modules/src/focusprobe.rs @@ -143,6 +143,8 @@ pub(crate) enum Screen { Search, /// The Watchlist page (`screens::watchlist`), owned like Search: its fields ride on `content`. Watchlist, + /// The Live TV page (`screens::livetv`), owned like the Watchlist: its fields ride on `content`. + LiveTv, /// The catalog page of a title no server holds (`screens::catalog`); its fields ride on `content`. Catalog, /// `overlay` is the same word the heartbeat prints after `overlay=`, supplied by `app.rs` from @@ -271,7 +273,7 @@ fn push_fields(ps: &plx_media::route::PlaybackSession, s: &mut String, screen: S // Owned like Library/Detail/Person: the content string is built generically by the // caller (`super::bridge::content_probe`, from the mounted `SearchScreen`'s own // `LogicalState::probe` output), not read off a legacy global here. - Screen::Search | Screen::Watchlist => s.push_str(content), + Screen::Search | Screen::Watchlist | Screen::LiveTv => s.push_str(content), // The panels' own fields ride on `content`, for the same reason Library's, Detail's and // Search's do: since restructure phase 9 each is the state of a MOUNTED INSTANCE // (`screens::player::overlay`), and this module cannot reach into the container — the @@ -445,6 +447,7 @@ mod tests { ("catalog", Screen::Catalog), ("search", Screen::Search), ("watchlist", Screen::Watchlist), + ("livetv", Screen::LiveTv), ("player", Screen::Player { overlay: "none" }), ("player", Screen::Player { overlay: "info" }), ] diff --git a/rust-modules/ui/src/widgets.rs b/rust-modules/ui/src/widgets.rs index e782152111..a0e9754370 100644 --- a/rust-modules/ui/src/widgets.rs +++ b/rust-modules/ui/src/widgets.rs @@ -6529,6 +6529,9 @@ pub const TAB_KEY_CHIP: u32 = crate::dispatch::STRIP_BASE + 4; /// BEFORE Search in the strip's visual order (`app::chrome::strip_keys`) — a key is a destination's /// identity, never its position. pub const TAB_KEY_WATCHLIST: u32 = crate::dispatch::STRIP_BASE + 5; +/// The Live TV pill, `+6`: appended after the Watchlist's `+5`, so no existing key moves; drawn +/// after the library type pills (`app::chrome::strip_keys`). +pub const TAB_KEY_LIVETV: u32 = crate::dispatch::STRIP_BASE + 6; /// The top chrome band's y — the chip and the pills sit on it (Home and Library alike). /// /// **Derived from the overscan safe area, not chosen.** It was a bare `44.0` until 2026-08-23, which diff --git a/tests/fixtures/replay/README.md b/tests/fixtures/replay/README.md index b611f4b09c..41fdd1f741 100644 --- a/tests/fixtures/replay/README.md +++ b/tests/fixtures/replay/README.md @@ -601,9 +601,16 @@ interval and halved idle sleeps, passed `plxnative-rec check`, and were installe `tools/plxnative-rec rerecord`, anchors retained. `make check-replay` replays all three with `verdict=SAME` in Targets and Resolve and every difference counter at zero. +The Live TV page added `AppArg::LiveTv` to `ARG_SHAPE` and appended `LiveTvScreen::SHAPE` to the +screen census, which moved the state shape. All three anchors were refused on load, then recorded +again against the synthetic server with `FOCUS_STEP=0.35 FOCUS_SETTLE=1 FOCUS_BOOT_SETTLE=1 +tests/focusfp.sh --rec --only 1,6,12` on the 120 Hz display (the default cadence overran the +reader's record budget in all three), and installed with `tools/plxnative-rec rerecord`, anchors +retained. The strip's new `TV` pill enters the measurement table, so the closed alphabet gains the +literal `TV` (`live_tv_tab`). `tests/replay_fixtures.py` replays all three with `verdict=SAME` in +Targets and Resolve and every difference counter at zero on three consecutive runs. + Opt-in playback diagnostics adds the Settings root's recorder status and addressed query/reply -shape. The three anchors were observed failing the old shape preflight, then recorded afresh -against tests/mock_pms.py with tests/focusfp.sh --rec --only 1,6,12 (350 ms key interval and -halved settle waits). The busy host needed a longer mock startup wait. They were checked and -installed with tools/plxnative-rec rerecord, preserving anchor flags; the synthetic alphabet -adds only the new English labels and the deterministic status/off codec vocabulary. +shape. The three anchors were re-recorded against the updated shapes, checked and installed with +tools/plxnative-rec rerecord, preserving anchor flags; the synthetic alphabet adds only the new +English labels and the deterministic status/off codec vocabulary. diff --git a/tests/test_harness.py b/tests/test_harness.py index 5b2e1b97fb..92978928e9 100644 --- a/tests/test_harness.py +++ b/tests/test_harness.py @@ -4639,7 +4639,9 @@ def test_abr_window_regex_matches_the_rust_format_string(self): "abr: window", source=source) def test_hls_segment_regex_matches_the_rust_format_string(self): - self._assert_contract('"hls: segment={}', run.RE_HLS_SEGMENT, run.SEGMENT_FIELDS, + # Anchored on `bytes=`: `"hls: segment={} carries no SPS; ..."` (a second line with the + # same prefix, ff.rs) comes first in the file and is not the sample line the regex reads. + self._assert_contract('"hls: segment={} bytes=', run.RE_HLS_SEGMENT, run.SEGMENT_FIELDS, "hls: segment") def test_abr_tx_parses_a_committed_upshift(self):