feat(portals): find the same movie in your other playlists

A movie that exists in several imported Xtream playlists now shows a
"Sources N" chip on its detail page and in the player. Switching playlist
mid-film keeps the timecode, a preferred source can be pinned per movie, and
a failed stream offers the alternatives instead of a dead end.

The governing rule is that a guess is never presented as a fact. Every
metadata value carries where it came from — `api` (the provider said so),
`parsed` (inferred from the title) or `probe` (we contacted the stream).
Facts render as plain tags, guesses are prefixed `~` in a warning colour, and
an unknown value renders no tag at all plus a "check" affordance. Ranking and
failover read through `factualOnly()`, so a filename claiming 4K is
structurally unable to outrank a source that was actually reached. A probe
that could not complete reports "unknown", never "unavailable".

Scope is deliberately narrow: Xtream to Xtream, movies only, Electron only.
Stalker never reaches the `content` table and M3U is a JSON blob whose search
forces live content; both are additive later, since the candidate type
already carries all three portal kinds. In the PWA every entry point is gated
off and the chip renders nothing.

Auto-failover is opt-in and off by default. Each source is tried at most once
per session, so it terminates structurally, and the switch is never silent —
the toast names the new playlist, offers an undo, and warns that the dub may
differ only when both sides state an audio track as fact.

Notable details:
- Playlist names are routinely the pasted URL, credentials included. They are
  never rendered raw; a short host-only label is derived instead.
- Quality is derived from pixel width, not height: a 2.39:1 1080p master is
  1920x800, and bucketing that by height would publish "720p" as a fact.
- Switching is a single `inlinePlayback.set()` so the player and engine
  survive and re-seek; the carried position is read before the 15s
  persistence throttle so it does not rewind.
- Sources from one playlist collapse into a group, since the same film often
  appears there several times under different stream ids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-27 08:34:56 +02:00
1 parent e2300bea11
commit 004cb65fe6
101 files changed
+7183 -192

No files matched your search

+19 -8
View File
@@ -51,15 +51,26 @@ These tokens are the base for interactive emphasis:
- `--app-selection-border`
- `--app-selection-glow`
Use Material surface tokens for neutral surfaces:
Use the app's own surface tokens for neutral surfaces (defined for both themes
in `apps/web/src/m3-theme.scss`):
- `--mat-sys-surface`
- `--mat-sys-surface-container-low`
- `--mat-sys-surface-container`
- `--mat-sys-surface-container-high`
- `--mat-sys-outline-variant`
- `--mat-sys-on-surface`
- `--mat-sys-on-surface-variant`
- `--app-shell-bg` / `--app-rail-bg` / `--app-header-bg` / `--app-content-bg`
- `--app-widget-bg` / `--app-widget-header-bg` — panels and popovers
- `--app-card-hover-bg` — raised or hovered rows
- `--app-widget-border` / `--app-rail-border` — hairlines
- `--app-on-surface` — primary text
- `--app-eyebrow-color` — secondary/muted text
**Do not reach for `--mat-sys-*` surface tokens.** Angular Material's system
tokens are *not* emitted globally in this app — only `--mat-sys-on-surface`
exists. `var(--mat-sys-surface-container-high)` and friends resolve to nothing,
which usually goes unnoticed because the surrounding Material component
supplies its own default and masks it. Outside a Material component — a CDK
overlay, a projected panel — the same reference silently produces a fully
transparent, unreadable surface.
If a `--mat-sys-*` token is genuinely needed, always give it a real fallback:
`var(--mat-sys-on-primary, #fff)`.
Do not hardcode unrelated accent colors for selected state when these tokens already exist.
+1
View File
@@ -106,6 +106,7 @@ and JSON summary output. CI uploads the merged Tier A report to Codecov with the
| --------------------- | ----------------------------------------------- |
| Web app browser flows | `pnpm nx run web-e2e:e2e -- --project=chromium` |
| Electron flows | `pnpm nx run electron-backend-e2e:e2e` |
| VOD multi-source | `pnpm nx run electron-backend-e2e:e2e-ci--src/vod-multi-source.e2e.ts` |
Use atomized E2E targets when available, for example
`pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts`.
+185
View File
@@ -0,0 +1,185 @@
# VOD Multi-Source
Finds the same movie in the user's other imported playlists, lets them switch
which one it streams from without losing the timecode, and turns the
playback-error screen into a recovery point.
The point is not choice for its own sake — it is rescuing a viewing when the
current source is dead, serves an unsupported codec, or buffers badly.
## Scope (v1)
| | |
|---|---|
| Source types | **Xtream ↔ Xtream only** |
| Content | Movies only — series are not offered a source chip |
| Environment | **Electron only** — the chip renders nothing in the PWA |
| Auto-failover | Opt-in, **off by default** (`Settings.vodAutoFailover`) |
| Pin scope | Per movie (a global portal priority is out of scope) |
| Stream probe | HEAD → reachable + latency. **No codec probing** |
Stalker never reaches the `content` table (it would need a live authenticated
`get_ordered_list&search=` per portal), and M3U playlists are stored as a JSON
blob whose search path forces `content_type: 'live'`. Both are additive later
without changing the contracts — `VodSourceCandidate.portalType` already carries
`'xtream' | 'stalker' | 'm3u'` and discovery sits behind a service interface.
`ffprobe`/`ffmpeg` are not dependencies of this app and are not bundled, so
`provenance: 'probe'` means reachability and latency only. There is deliberately
no feature flag for codec probing: it would gate a code path with no binary
behind it.
## The honesty rule
This is the part to preserve if anything here is refactored.
Every metadata value carries **where it came from**:
| provenance | produced by | rendered as |
|---|---|---|
| `api` | `get_vod_info` — container, codec, audio, dimensions | plain tag |
| `parsed` | regex over the title/filename | tag prefixed `~`, warn colour |
| `probe` | HEAD → reachable + latency | `ok` / `fail` status tag |
| *absent* | — | **no tag at all** + a `check` chip |
Three rules follow, and each is enforced in code rather than by convention:
1. **A guess never ranks.** `factualOnly()`
(`libs/portal/shared/data-access/.../vod-source-metadata.util.ts`) is the only
sanctioned accessor for ranking and failover decisions, and `parsed` values
are structurally unreachable through it. A filename claiming 4K cannot
outrank a source that was actually reached.
2. **Unknown is not "unavailable".** `VodSourceProbeStatus` separates `fail`
(contacted and refused) from `unknown` (timed out, blocked by the redirect
policy, or no probe capability). A probe returning HTTP status `0` maps to
`unknown`; the UI keeps offering a check and never shows the source as dead.
3. **Empty beats wrong.** Quality is derived from pixel *width*, because
letterboxed masters are cropped vertically — a 2.39:1 1080p film is 1920×800,
and 800 alone is indistinguishable from a 1280×800 encode. With no width, a
height is trusted only within 5% of a standard frame height; otherwise no tag
is emitted.
Provenance is per-field and changes over time: at discovery a row has only
`parsed` tags, because the `content` table stores no container, codec or audio.
Facts arrive when the source is resolved.
## Architecture
```
VOD details page (Xtream)
└── VodMultiSourceHostService component-provided, one per open movie
├── VodMultiSourceController session state + failover ranking
├── VodSourceDiscoveryService ──► DB_FIND_TITLE_SOURCES (trigram FTS)
├── VodSourceResolverService ──► foreign playlist creds → get_vod_info
│ → constructVodUrl
├── VodSourcePinService ──► DB_GET/SET/CLEAR_VOD_SOURCE_PIN
└── StreamProbeService ──► STREAM_PROBE_URL (main process)
```
`VodMultiSourceHostService` is **component-provided, never root-provided**: the
controller's "each source is tried at most once" set must die with the movie, or
a later film inherits a poisoned failover history.
`VodMultiSourceController` is a plain class, not a service, for the same reason —
and so its ranking logic is testable without TestBed.
### Ownership
| Concern | Location |
|---|---|
| DTOs, match key | `libs/shared/interfaces/src/lib/vod-source*.ts` |
| Pin table | `libs/shared/database/src/lib/vod-source-pins.schema.ts` |
| Discovery SQL | `apps/electron-backend/.../operations/title-sources.operations.ts` |
| Pin CRUD | `apps/electron-backend/.../operations/vod-source-pin.operations.ts` |
| Probe handler | `apps/electron-backend/src/app/events/stream-probe.ts` |
| Discovery / resolve / rank | `libs/portal/shared/data-access/src/lib/multi-source/` |
| Probe + pin clients | `libs/services/src/lib/{stream-probe,vod-source-pin}.service.ts` |
| Row / popover / chip | `libs/ui/components/src/lib/vod-sources/` |
| Page wiring | `libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-*.ts` |
## Identity
Provider ids cannot key a pin — the same film has a different `stream_id` in
every portal, which is the problem being solved.
`buildVodSourceMatchKey()` produces `tmdb:{id}` when a usable TMDB id exists,
otherwise `title:{normalizedBase}:{year}` via the shared `normalizeTitleKeys`.
Lookups pass **every** alias, most-trusted first
(`buildVodSourceMatchKeyCandidates`). A movie pinned before TMDB enrichment
landed is stored under its title key and prefers a `tmdb:` key afterwards;
reading both means the id arriving later does not orphan the pin, and unpinning
clears every alias so a stale row cannot resurrect it.
## Why resolution is lazy
The `content` table stores no `container_extension`, and
`XtreamUrlService.constructVodUrl` returns `''` without one. A discovered source
is therefore **not playable from the database** — every alternative costs one
live `get_vod_info` against a foreign playlist's credentials, which can fail
offline, on an expired account, or behind Cloudflare.
So: rows render instantly from the FTS match with `parsed` provenance, and a URL
is resolved only when the user clicks play, pins, or checks. Containers are
memoised per `(playlistId, streamId)` for the session, and a container learned
earlier is reused if the portal later goes unreachable. Resolution is never
fanned out on page load.
## Switching without losing the timecode
Every playback host renders `@if (inlinePlayback(); as playback)`, so re-`set()`ing
that signal with a new `{streamUrl, startTime}` swaps the source **in place** —
the player component, `WebPlayerView` and the engine all survive, and
`WebPlayerViewComponent`'s effect rebuilds the source and clears the diagnostic.
Three details make the position survive:
- The carried position is the **live** one. `handleInlineTimeUpdate` reports to
`VodMultiSourceHostService.reportPosition()` *before* the 15-second
persistence throttle, so a switch does not rewind by up to 15 seconds.
- It is a single `.set()`, never `null` then set — a null in between would
destroy the player subtree and lose the engine.
- `playback_positions` is keyed `(playlistId, contentXtreamId, contentType)`, so
a switch changes the key. The resolved playback carries the **new** source's
`contentInfo`, and that source's row takes over.
A resuming engine can emit a `timeupdate` at ~0 before it finishes seeking.
`VodDetailsPlaybackService` guards this with a one-shot `resumeSettled` latch —
a filter would have broken deliberate seek-backwards.
## Failover
Only fires when `Settings.vodAutoFailover` is on. Ranking (`pickFailoverTarget`):
1. never tried this session — a **hard filter**, not a preference
2. probed reachable; probed failing is penalised
3. not known to have failed recently
4. richer **factual** metadata (via `factualOnly`)
5. exact title match over fuzzy
Termination is structural: `triedSourceIds` only ever grows within a session, so
an N-source movie fails over at most N−1 times and then shows the honest error
screen. Returning to an earlier source by hand does not clear the set.
A source that cannot even be resolved is marked tried without becoming active,
so it is not re-picked.
The switch is **always announced** — another source can carry a different dub or
cut. The toast offers Undo, and adds a dub warning when
`audioDiffersFactually()` is true, which requires **both** sides to state an
audio track as fact. Two guesses, or a guess against a fact, stay silent.
Web engines only (HTML5/hls.js, Video.js, ArtPlayer). Embedded MPV suppresses
shared diagnostics and owns its own error block; external MPV/VLC are
fire-and-forget with no error channel back.
## PWA
Discovery, foreign-playlist reads, the pin table and the probe are all
main-process. A browser HEAD to an arbitrary IPTV host is CORS-blocked, and
`no-cors` yields an opaque response where 200, 403 and 404 are
indistinguishable — so the PWA cannot answer these questions honestly rather
than merely lacking a convenience.
Every entry point is gated on a bridge `typeof` check (`isAvailable`), matching
`CatalogTitleMatchService`. In the PWA the chip renders nothing and the
auto-failover setting is hidden.