mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
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>
186 lines
8.9 KiB
Markdown
186 lines
8.9 KiB
Markdown
# 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.
|