feat(playlist): open recognized M3U movies in the VOD detail view (#1420)

M3U entries recognized as movie files now open in the portals' two-state VOD
detail view, fed by TMDB metadata instead of the empty EPG zone. Watch-first:
activation still plays immediately, with plot, cast, rating and artwork below
the player; Escape reveals the Browse hero.

Recognition is a synchronous URL-shape heuristic (movie container extension or
an Xtream-style /movie/ path; radio, DASH, /series/ paths and episode-marker
names keep today's live layout), gated on TMDB enrichment plus the new
default-on Settings.m3uVodDetails toggle. Works in Electron and the PWA.

Review follow-ups included: the playback payload no longer carries TMDB fields
(its identity is the player's source-application key), the persisted volume
reaches the player and survives Browse → Play, the enrichment guard keys on
the full lookup identity, and the saved engine mounts first time instead of
briefly falling back to Video.js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-13 17:18:06 +02:00
1 parent 82a2948ffd
commit f7bb3a13db
49 files changed
+2344 -146

No files matched your search

+70
View File
@@ -1062,6 +1062,76 @@ bypass Playwright interception) and
`electron-backend-e2e:src/dash-clearkey.e2e.ts` (real ClearKey EME in
Electron).
## Movie Recognition (VOD Detail View)
M3U playlists routinely mix live channels with movie FILES ("Movies"/"VOD"
groups, Xtream-derived exports). A recognized movie swaps the player + EPG
zone for the same two-state VOD detail shell the portals use, fed by TMDB
metadata. Works in both Electron and the PWA.
**Gate** (`VideoPlayerComponent.showMovieDetail`): the branch activates only
when ALL hold — the URL-shape heuristic recognizes the channel, TMDB
enrichment is enabled (`TmdbEnrichmentService.isEnabled()`), and
`Settings.m3uVodDetails` is not `false` (default on; the checkbox lives in
Settings → Metadata (TMDB) under the master toggle). The decision is
**synchronous** — layout is chosen at activation; the async TMDB lookup only
patches metadata into an already-mounted view, so a missed match never causes
a layout jump.
**Detection** (`isLikelyM3uMovie` in
`libs/shared/m3u-utils/src/lib/m3u-vod-detection.util.ts`): a movie-file
container extension (`mkv`, `mp4`, `avi`, … — deliberately NOT `ts`/`m3u8`/
`m4s` which deliver live streams, and not `mpd` which has its own DASH path)
OR an Xtream-style `/movie/`, `/movies/`, `/vod/` path segment. Exclusions
fail toward today's live layout: `radio="true"`, DASH URLs, `/series/` paths,
and names carrying an episode marker (`S01E02`, `1x02`, "2 серия",
"Season 3" — the season word list is shared with the title normalizer).
Known accepted cost: "Star Wars: Episode 1" is skipped.
**Flow** (`m3u-vod-detail/` in `libs/playlist/m3u/feature-player`):
- `M3uVodDetailComponent` hosts `PortalDetailShellComponent` +
`PortalInlinePlayerComponent`. **Watch-first**: clicking a channel keeps
M3U zapping semantics — playback starts immediately, the About block
renders below where the EPG zone would be; Escape/close reveals the Browse
hero, Play re-enters watch. The hero back arrow is hidden (the channel
sidebar next to the detail IS the navigation).
- The parent passes its already-built `embeddedPlayback()`
(`ResolvedPortalPlayback` with headers/EXTVLCOPT resolved) plus its shared
persisted `volume()`; the host only overrides `isLive: false` (seek
bar/VOD semantics). The payload's OBJECT IDENTITY is the player's
source-application key (`createWebPlayerApplicationState` mints a new
source revision for any new payload), so it must never depend on TMDB
signals — folding the resolved title/poster in there restarted the movie
the moment enrichment landed. Metadata lives in the hero/About
presentation only.
- `PortalInlinePlayerComponent` gained an optional `volume` input
(default `1`) for this host: the M3U player owns a persisted volume shared
across its channels, while the portals keep the engines' own default. The
parent re-reads that bus per channel AND on the host's `playbackStarted`
output, because Browse → Play remounts the engine without a channel change
and the engines only ever write to the bus.
**Coverage:** `apps/web-e2e/src/m3u-movie-details.e2e.ts` drives the workflow
against the real composition with a routed TMDB mock — recognition, async
metadata, the live-channel fallback, and the volume the remount must keep.
- `M3uVodMetadataService` (component-provided) calls
`TmdbEnrichmentService.enrichMovie({ title: channel.name, year:
releaseTagYear(channel.name) })` — the resolver already normalizes titles
and caches verdicts; the service only adds the release-year hint and a
staleness guard keyed on `channel.id` so zapping mid-request drops the
stale resolution.
- No TMDB match keeps the thin provider presentation (entry name + logo) in
the detail layout rather than jumping back to the EPG view.
- External MPV/VLC users keep Browse: the m3u-state effects launch the
external player on activation exactly as before, and
`inlinePlayerAvailable=false` means the host never mounts an inline player.
**Out of scope (v1)**: series episodes (detection skips them), playback
position persistence for M3U movies, clickable cast chips (no
`actor/:personId` route in the M3U tree), and downloads.
## Interfaces
### Channel Interface
+38 -28
View File
@@ -17,6 +17,12 @@ Related:
cast, director, genres, rating, artwork) via a **field-level merge** — the
provider stays authoritative for stream-related data and for any field
TMDB cannot fill.
- The M3U player is a consumer too: an entry recognized as a movie file opens
in the VOD detail shell fed purely by an `enrichMovie` lookup (there is no
provider payload to merge into). Gated by the additional
`Settings.m3uVodDetails` toggle (default on) below the master switch; see
"Movie Recognition (VOD Detail View)" in
`docs/architecture/m3u-playlist-module.md`.
- Enrichment is **opt-in** via `Settings > Metadata (TMDB)` because it sends
movie/series titles to a third-party API. Default: disabled.
- The detail view renders provider data **immediately**; enrichment runs
@@ -109,12 +115,13 @@ Wrong metadata is worse than no metadata, so id resolution is conservative:
"chapter two"), so a name mismatch alone says more about our inputs
than about the id.
A 404 is the one hard verdict: the id is recorded as dead
(`badProviderId:<id>` row), skipped next time, and the title search
takes over. Transient failures (auth, rate limit, 5xx, offline) neither
disable the id nor trigger a search — that request would hit the same
outage, and a title match that did come back would be weaker evidence
than the id already in hand.
A 404 is the one hard verdict: the id is recorded as dead
(`badProviderId:<id>` row), skipped next time, and the title search
takes over. Transient failures (auth, rate limit, 5xx, offline) neither
disable the id nor trigger a search — that request would hit the same
outage, and a title match that did come back would be weaker evidence
than the id already in hand.
2. Otherwise `/search/movie` (or `/search/tv`) runs with the normalized
title. Normalization strips bracketed tags, quality markers (`4K`,
`1080p`, `MULTI`, …), leading language prefixes (`EN - `), diacritics,
@@ -407,7 +414,10 @@ supports CORS so the PWA calls the API directly).
`Settings.tmdb?: { enabled: boolean; apiKey?: string }`
(`libs/shared/interfaces/src/lib/tmdb.interface.ts`). The settings page has
a "Metadata (TMDB)" section: enable toggle, optional API key override with a
"check key" button (validates against `/configuration`), and a cache panel
"check key" button (validates against `/configuration`), the M3U
movie-recognition toggle (root-level `Settings.m3uVodDetails`, shown only
while TMDB is enabled and bound via `[formControl]` because it is not part of
the `tmdb` form group), and a cache panel
showing the stored row count plus payload size with a button that drops the
lot. Sizing is a full table scan, so it runs only once that section is the
active one, and a failed read or clear says so instead of showing an empty
@@ -544,27 +554,27 @@ since shipped.)
position (no TMDB involved); the watch-progress bar is limited to
movie/series heroes.
The query is built to **match what the detail view searched with**, not
just what the card displays. A title alone is weaker identity than the
detail page had: without a year `pickConfidentMatch` requires a single
exact title match, which common titles never satisfy, and the miss lands
in the negative cache under a lookup key the detail view's hit can never
be found at. Stalker rows carry those facts —
`extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads
`info.name`, `info.o_name`, `info.releasedate` and `info.tmdb_id` off the
stored entry, mirroring `enrichStalkerSelectionWithTmdb` field for field.
A `'movie'` verdict gets a second attempt under `'tv'` — `'movie'` is what
every row falls back to when nothing says otherwise, and an embedded-VOD
series is a `'movie'` activity row but a show on TMDB. That retry **drops
the id**, which is only ever valid for the media type it was resolved
under: `/movie/<tv id>` resolves to an unrelated film. A `'tv'` verdict
gets no retry — it is reached only on positive evidence (series category,
`is_series`, or a non-empty episode array), and the gate cannot tell an
adaptation sharing its show's name and year from the show itself.
The query is built to **match what the detail view searched with**, not
just what the card displays. A title alone is weaker identity than the
detail page had: without a year `pickConfidentMatch` requires a single
exact title match, which common titles never satisfy, and the miss lands
in the negative cache under a lookup key the detail view's hit can never
be found at. Stalker rows carry those facts —
`extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads
`info.name`, `info.o_name`, `info.releasedate` and `info.tmdb_id` off the
stored entry, mirroring `enrichStalkerSelectionWithTmdb` field for field.
A `'movie'` verdict gets a second attempt under `'tv'` — `'movie'` is what
every row falls back to when nothing says otherwise, and an embedded-VOD
series is a `'movie'` activity row but a show on TMDB. That retry **drops
the id**, which is only ever valid for the media type it was resolved
under: `/movie/<tv id>` resolves to an unrelated film. A `'tv'` verdict
gets no retry — it is reached only on positive evidence (series category,
`is_series`, or a non-empty episode array), and the gate cannot tell an
adaptation sharing its show's name and year from the show itself.
Note the id in a stored Stalker entry is not a provider claim. Stalker
portals never send one; its only source is a match this app already made
and gated.
Note the id in a stored Stalker entry is not a provider claim. Stalker
portals never send one; its only source is a match this app already made
and gated.
### Stalker backdrops on activity rows
@@ -588,7 +598,7 @@ negative-match TTL.
Only a **confirmed 404** is recorded. Transient failures (401, 429, 5xx,
offline) leave no marker — they say nothing about the id. Neither does a
title mismatch: that id exists and may be correct for a *different* item,
title mismatch: that id exists and may be correct for a _different_ item,
and since the row is keyed by id alone and shared across playlists,
recording per-item mismatches here would deny the direct lookup to every
other item that legitimately uses the same id.