Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions ci/check-plex-requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
31 changes: 31 additions & 0 deletions ci/plex-requests.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion docs/install-and-verify.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions docs/parity-gaps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion docs/plex-pass-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
64 changes: 64 additions & 0 deletions docs/pms-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<url-encoded XMLTV URL>#<title>`), **`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
Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions locales/be/browse.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "Серыялы",
Expand Down Expand Up @@ -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": "Сцэнарыст",
Expand Down
2 changes: 2 additions & 0 deletions locales/de/browse.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions locales/en/browse.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions locales/es/browse.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions locales/fr/browse.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions locales/hu/browse.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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ó",
Expand Down
2 changes: 2 additions & 0 deletions locales/it/browse.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions locales/nl/browse.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions locales/pl/browse.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions locales/pt/browse.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions locales/ru/browse.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "Сериалы",
Expand Down Expand Up @@ -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": "Сценарист",
Expand Down
Loading
Loading