Files
iptvnator/docs/architecture/vod-multi-source.md
T
4grayandClaude Opus 5 f0c99fac87 fix(portals): stop a remake matching, and let a pin survive its own playlist
Three findings from the latest review pass.

`normalizeTitleKeys` strips bracketed segments as tag noise, so "Dune (1984)"
normalizes to exactly "dune" — an EXACT match for the 2021 film, ranked above
every fuzzy one, with the year never consulted because that tier skipped the
gate. Auto-failover could switch the user to the other film entirely. The
year is now read out of brackets too, and a stated disagreement rejects the
row on either tier.

Playback positions are keyed by (playlist, stream), so watching through a
pinned alternative stores progress under ITS ids while the page loads the
route copy's row. Starting the pin therefore resumed from a position
belonging to a different copy — usually zero. It now loads its own.

And a pin can point at another copy of the film inside the playlist being
viewed, which discovery excludes wholesale: the pinned row was absent from
the list, so nothing showed as pinned and Play ignored the preference. The
pin is now read before discovery, which keeps that one row.

Moves the pin-shaped decisions into the pin module, where the persistence
helpers already live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 02:28:26 +02:00

19 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, sent with the playlist's own playback headers. 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 (retried as a ranged GET when the server answers 405/501, since plenty serve media over GET while refusing HEAD) 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

One picker, two places, two counts

The chip on the action row and the chip in the inline player's now-playing bar are the same component, so both are handed the same matchKind and the same vodAutoFailover value and both write the setting back. A copy that rendered the toggle off while it was on — and did nothing when flipped — would be worse than not offering it.

The two numbers around it are deliberately different, because their sentences are: the chip says "Sources N" and counts alternative streams, while the caption says "also found in N other playlists" and counts distinct playlists (alternativePlaylistCount). The popover groups a portal's three copies of a film under that one portal, so counting rows there would contradict the list the caption invites the user to open.

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.

Enrichment adds BOTH identifying fields late, so the same movie can already be pinned under any poorer form of itself:

pinned when stored under
after enrichment tmdb:{id}
before the TMDB id title:{base}:{year}
before the year too title:{base}:

buildVodSourceMatchKeyCandidates returns all three, most-trusted first. Reading every alias is what keeps a late TMDB id from orphaning an earlier pin.

A write clears every alias and then stores only the canonical key — both halves matter, and each rules out the other's shortcut:

  • Writing only the top key would leave the lower-trust aliases pointing at whatever was pinned before, and a reopen that reads one of those (enrichment has not landed, or its request failed) starts the source the user replaced.
  • Writing the decision into every alias is not the fix either: the yearless form is shared by every remake, so a known-year pin stored there would answer for a different film — pin Dune (2021), open Dune (1984) before its year arrives, and it starts the 2021 source. That alias stays readable and unwritten.

The row only changes once the write lands. A pin the database refused is worse than no pin at all — the icon promises the preference will be there next time, and it will not be — so togglePinnedSource reports "nothing happened" and the controller is left exactly as it was.

Rediscovery vs. a new session

Two keys, deliberately, because the host has two different questions to answer when enrichment lands:

  • vodMultiSourceMovieKey covers title, year and TMDB id. Enrichment changing any of them re-runs discovery — that is how a yearless search gets its year and a tmdb:-keyed pin becomes findable.
  • vodMultiSourceSessionKey is playlistId:contentId — the film itself. It is what decides whether that rerun is a refresh or a new session.

A refresh keeps the controller: the source the user switched to stays active (with the facts its resolve produced, rather than the catalog's guesses), the tried set stays burned, the live position stays, and a switch already in flight still commits. Rebuilding there would take the film off the source it is actually streaming, and hand failover a clean slate for sources it has already spent. Only a different film resets — including the tried set, which is what makes failover terminate.

A rerun can also legitimately drop the playing row: the year the enrichment supplies makes the year gate reject a copy the yearless search had admitted ("Dune" 1984 while watching the 2021 film). Off the list is right — it is not the same film. Off the screen is not, so applyDiscoveredSources keeps it as a row and leaves it active; a caption naming a playlist that is not streaming anything would be a lie about the one thing this feature exists to state.

What the queries are allowed to miss

A source that exists but is never read is indistinguishable, to the user, from one that does not exist — the chip simply does not appear. So:

  • The current playlist is excluded in SQL, not afterwards. It routinely lists a film in several categories, and those rows would otherwise spend the FTS window before a single other playlist was read.

  • Duplicates collapse in SQL too, for the same reason. One playlist can list a film in dozens of categories, and those rows rank identically, so GROUP BY cat.playlist_id, c.xtream_id runs before the limit. Collapsing them only in TypeScript afterwards cannot recover the playlists the window never reached.

  • Short titles scan on a word boundary, and take no window at all. The trigram tokenizer cannot index tokens under three characters, so "Up", "It" or "Us" produce an empty MATCH and fall back to a scan. A substring scan matches "Titanic" and "The Italian Job" for "It", so the scan asks for the token as a word (' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*') and orders by title length (a match is the title plus decoration: "IT (2017) 1080p").

    The FTS path keeps its 60-row window because it ranks by relevance — the best rows are the ones it keeps. A scan cannot rank, so a window there would silently decide which valid sources the user may see, and it would not even buy anything: the GLOB cannot use an index, so SQLite reads every row either way and a LIMIT saves transfer, not work. What bounds the scan instead is its predicate — reaching it means the movie's entire title is one or two characters, and only films carrying that exact word come back.

    Like the LIKE it replaces it compares ASCII-lowercased text, so a non-ASCII short title is no better and no worse served than before.

Both remain necessary-not-sufficient filters: the two-tier normalized confirmation still runs afterwards, so the looser query never admits "Upgrade" for "Up".

One row inside the excluded playlist is kept when the caller names it (keepContentId): a pin can point at another copy of the film in the playlist being viewed, and dropping that row would leave the preference pointing at nothing. The host therefore reads the pin BEFORE discovery.

Same title, different film

The year gate applies to both match tiers, not just the year-stripped one. normalizeTitleKeys strips bracketed segments wholesale — they usually hold quality and language tags — so "Dune (1984)" and "Dune" normalize to the same string and the exact tier would accept the remake without ever consulting a year, ranked above every fuzzy match. Discovery reads a bracketed year out of the raw title first, and when both sides state a year and they disagree, it is not the same movie. An unknown year still never blocks.

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. External players have no timeupdate at all, so for them the polled playback_positions value IS the live one and is reported directly — seeding it would freeze the resume point where playback started.
  • 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.

The position also has to exist before anything plays. Nothing reports a live one until the first timeupdate, so a pinned source started straight off the Resume button would resolve at zero and restart the film. The controller is therefore seeded from the persisted position (seedResumeSeconds), one-way: once a live position exists it wins, because the stored one lags it by up to the save throttle and applying it would visibly rewind.

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. handleInlineTimeUpdate returns that verdict and the route feeds multi-source the startTime it asked for until the engine gets there, so a switch or a failure during the initial seek does not resolve the next source at zero and restart the film. One latch serves both, because two would eventually disagree.

Failover

Only fires when Settings.vodAutoFailover is on, and it first awaits a discovery still in flight — a stream can fail faster than SQLite answers, and concluding "nowhere to go" against an empty controller would strand the user on the error screen with alternatives landing a moment later. stillOwnsScreen() does that wait and then re-checks the session, because the user can navigate during it and the controller afterwards may belong to a different film; the pinned-Play path takes the same wait, or a persisted pin would lose to worker latency. 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, and failover continues to the next candidate rather than giving up — production calls failover() only once, on the original failure, so stopping at a dead top-ranked source would strand every healthy one below it. switchTo therefore reports which of two things happened: unresolvable (marked tried, keep going) or superseded (something newer owns the screen, stop). Without that distinction the loop would spin on a superseded target forever, because only the first outcome marks the candidate tried.

A pin is not decoration. The primary Play action starts from the pinned source when one is set, and the pin outranks every other signal in the ranking above. Loading a pin that only decorated its row would mean "make this the main source" survived a restart as an icon and nothing else.

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.

External players and an alternative source

A switch goes through the same inline-vs-external fork a normal Play takes, so with MPV or VLC configured the alternative opens in the external player — and that session carries the OTHER playlist's ids. matchedExternalPlayback would disown it: the primary button never became Stop, stopping found no session, and another click opened a second player. The page therefore claims a session that matches either the route's own stream or the alternative multi-source says is active (VodDetailsPlaybackBindings.activeSource).

ownsContent() answers that question once, for both consumers: the session matcher AND the playback-position bridge. They cannot be allowed to disagree — a page that shows a Stop button for a session whose progress it discards keeps the resume point at wherever playback began, so a switch an hour later rewinds the whole session.

Stop then has to win over the pin. The primary action consults the pin first — that is what makes "make this the main source" decide where playback starts — but when a session is already running the same button reads Stop, and doing anything other than stopping would launch a second player while the first kept going.

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.