Files
iptvnator/docs/architecture/vod-multi-source.md
T
4grayandClaude Opus 5 004cb65fe6 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>
2026-07-27 08:34:56 +02:00

8.9 KiB
Raw Blame History

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.