fix(portals): stop the source list from losing the real alternatives

Six review findings, all in how multi-source decides what to show and what
it is playing.

Discovery: the current playlist is now excluded in SQL rather than after the
fact, so its own duplicate rows can no longer spend the whole row budget
before a single alternative is read. The short-title scan matches the token
as a word instead of a substring and orders by title length in a wider
window, so "Titanic" and "The Italian Job" cannot push the real "It" out of
it.

Session: metadata enrichment re-runs discovery for the film already on
screen. That is a refresh, not a new session — a second identity key
(playlistId:contentId) now separates the two, so the source the user
switched to keeps playing and stays named, the tried set stays burned, the
position survives and a switch in flight still commits.

Resume: the multi-source controller no longer records the engine's pre-seek
timeupdate at ~0. The playback service's one-shot latch now reports whether
the position can be believed, and until it can, the requested start time
stands in — so a switch during the initial seek does not restart the film.

UI: the in-player sources picker gets the same auto-failover setting and
match kind as the detail page's, instead of always rendering the default and
dropping the toggle. The caption counts distinct playlists, not stream
variants, since the popover groups a portal's copies under that portal.

Session mechanics and the pin toggle move into their own modules to keep the
host service inside the line budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-27 21:25:07 +02:00
1 parent 1936b45d0f
commit 93caa1c5a6
18 files changed
+883 -178

No files matched your search

+4 -4
View File
@@ -820,12 +820,12 @@ engine` (restart required) or
**VOD Multi-Source** (alternative sources for a movie):
- Finds the same movie in the user's other imported playlists and adds a "Sources N" chip to the Xtream VOD action row (only when ≥1 alternative exists), plus a `.source-caption` line reporting where playback is coming from. The chip opens a 460px anchored `MatMenu` popover (`libs/ui/components/src/lib/vod-sources/`), reused unchanged on the playback-error screen.
- Finds the same movie in the user's other imported playlists and adds a "Sources N" chip to the Xtream VOD action row (only when ≥1 alternative exists), plus a `.source-caption` line reporting where playback is coming from. The chip opens a 460px anchored CDK-overlay popover (`libs/ui/components/src/lib/vod-sources/`; not `MatMenu`, which caps its width at 280px), reused unchanged in the inline player's now-playing bar and on the playback-error screen. Both chips are handed the same `matchKind` and `vodAutoFailover` and both write the setting back. The chip counts alternative **streams**; the caption ("also found in N other playlists") counts distinct **playlists** via `alternativePlaylistCount`, because the popover groups one portal's copies under that portal.
- Scope v1 is **Xtream ↔ Xtream, movies only, Electron only**. Stalker never reaches the `content` table and M3U is a JSON blob whose search forces `content_type:'live'`; both are additive later since `VodSourceCandidate.portalType` already carries all three. In the PWA every entry point is gated off by a bridge `typeof` check and the chip renders nothing.
- **Metadata provenance is the core contract.** Every field is `{value, provenance}` where `api`/`probe` are facts (plain tag), `parsed` is a title-regex guess (tag prefixed `~`, warn colour), and absent renders **no tag at all** plus a `check` chip. `factualOnly()` in `vod-source-metadata.util.ts` is the only accessor allowed for ranking/failover, so guesses are structurally unable to influence a decision. `VodSourceProbeStatus` separates `fail` (contacted and refused) from `unknown` (timed out / blocked / no capability) — an unchecked source is never shown as offline. Quality is derived from pixel **width** because letterboxing crops height.
- Discovery (`DB_FIND_TITLE_SOURCES`, trigram FTS over `content_title_fts`) is lazy and returns only what the `content` table can prove; titles whose tokens are all shorter than three characters ("Up", "It") fall back to a bounded LIKE scan, since the trigram tokenizer cannot index them at all. Resolution is deferred to click/pin/check because `content` stores no `container_extension` and `constructVodUrl` returns `''` without one — each alternative costs a live `get_vod_info` against the foreign playlist's credentials.
- Switching = one `inlinePlayback.set({...next, startTime})`, never null-then-set, so the player and engine survive and re-seek. The carried position is read *before* the 15s persistence throttle, and `VodDetailsPlaybackService` uses a one-shot `resumeSettled` latch so a resuming engine's `timeupdate` at ~0 cannot overwrite the resume point.
- Pins are keyed portal-agnostically (`tmdb:{id}` else `title:{base}:{year}`, `vod_source_pins` table); lookups pass every alias most-trusted-first so a late TMDB id does not orphan a title-keyed pin. A pin is not decoration: the primary Play action starts from the pinned source, and it outranks everything else in failover ranking. The movie-identity key covers title, year and tmdbId, so TMDB enrichment re-triggers discovery and rebuilds the pin keys.
- Discovery (`DB_FIND_TITLE_SOURCES`, trigram FTS over `content_title_fts`) is lazy and returns only what the `content` table can prove; titles whose tokens are all shorter than three characters ("Up", "It") fall back to a scan, since the trigram tokenizer cannot index them at all. Both queries are row-bounded, so what fills the window matters: the current playlist is excluded **in SQL** (its own duplicate rows would otherwise crowd out every alternative), and the scan matches the token as a whole word (`' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*'`) ordered by title length with a wider budget, so "Titanic" and "The Italian Job" cannot push the real "It" out. Resolution is deferred to click/pin/check because `content` stores no `container_extension` and `constructVodUrl` returns `''` without one — each alternative costs a live `get_vod_info` against the foreign playlist's credentials.
- Switching = one `inlinePlayback.set({...next, startTime})`, never null-then-set, so the player and engine survive and re-seek. The carried position is read *before* the 15s persistence throttle, and `VodDetailsPlaybackService` uses a one-shot `resumeSettled` latch so a resuming engine's `timeupdate` at ~0 cannot overwrite the resume point. `handleInlineTimeUpdate` returns that verdict and the route feeds multi-source the requested `startTime` until the engine reaches it — one latch for both, or a switch during the initial seek would restart the film.
- Pins are keyed portal-agnostically (`tmdb:{id}` else `title:{base}:{year}`, `vod_source_pins` table); lookups pass every alias most-trusted-first so a late TMDB id does not orphan a title-keyed pin. A pin is not decoration: the primary Play action starts from the pinned source, and it outranks everything else in failover ranking. Two identity keys: `vodMultiSourceMovieKey` (title, year, tmdbId) makes TMDB enrichment re-trigger discovery and rebuild the pin keys, while `vodMultiSourceSessionKey` (`playlistId:contentId`) decides whether that rerun is a refresh or a new session — a refresh keeps the active source, its resolved facts, the tried set, the live position and any switch in flight; only a different film resets them.
- Auto-failover is `Settings.vodAutoFailover`, **opt-in and off by default**, web engines only. Each source is tried at most once per session (`triedSourceIds` only grows), so it terminates structurally, and it continues past candidates that fail to resolve rather than stopping at the first one — `switchTo` reports whether it was unresolvable (keep going) or superseded (stop), since only the former marks the candidate tried. The switch is never silent: the toast names the new playlist, offers Undo, and warns "dub may differ" only when both sides state an audio track as fact.
- HEAD probe reuses the main-process handler extracted to `apps/electron-backend/src/app/events/stream-probe.ts` (`STREAM_PROBE_URL`; `XTREAM_PROBE_URL` still delegates there for catchup). No ffprobe — the binary is not bundled.
- See `docs/architecture/vod-multi-source.md`
@@ -1,3 +1,5 @@
import type { SQL } from 'drizzle-orm';
import { SQLiteSyncDialect } from 'drizzle-orm/sqlite-core';
import type { AppDatabase } from '../database.types';
import { findTitleSources } from './title-sources.operations';
@@ -6,6 +8,19 @@ function createDbMock(rows: unknown[] = []) {
return { db: { all } as unknown as AppDatabase, all };
}
/**
* The statement as SQLite will actually see it.
*
* `better-sqlite3` is built against the Electron ABI and cannot be loaded by
* Jest, so the row-shaping tests below run against a mock that returns rows
* whatever the query says. What rows the DATABASE is asked for is therefore
* only observable here — and it is exactly what the window-crowding fixes
* changed.
*/
function compiledQuery(all: jest.Mock, call = 0) {
return new SQLiteSyncDialect().sqlToQuery(all.mock.calls[call][0] as SQL);
}
function createFailingDbMock() {
const all = jest.fn().mockRejectedValue(new Error('fts syntax'));
return { db: { all } as unknown as AppDatabase, all };
@@ -56,6 +71,31 @@ describe('title-sources.operations', () => {
);
});
it('scans on a word boundary, shortest first, in a wider window', async () => {
// A substring scan for "It" matches "Titanic" and "The Italian
// Job"; 60 of those sorted by title can push the real "It" out of
// the window, and discovery then reports no sources at all. The
// scan therefore asks for the token as a WORD, orders by title
// length (a match is the title plus decoration: "IT (2017)"), and
// gets a wider budget than the relevance-ranked FTS path.
const scan = createDbMock([]);
await findTitleSources(scan.db, { title: 'It' });
const scanQuery = compiledQuery(scan.all);
expect(scanQuery.sql).toContain('GLOB ?');
expect(scanQuery.sql).not.toContain('LIKE ?');
expect(scanQuery.params).toContain('*[^a-z0-9]it[^a-z0-9]*');
expect(scanQuery.sql).toContain('ORDER BY LENGTH(c.title)');
const fts = createDbMock([]);
await findTitleSources(fts.db, { title: 'Dune' });
const [scanLimit] = scanQuery.params.slice(-1) as number[];
const [ftsLimit] = compiledQuery(fts.all).params.slice(
-1
) as number[];
expect(scanLimit).toBeGreaterThan(ftsLimit);
});
it('does not offer a scan hit whose title merely contains the query', async () => {
// The scan is deliberately loose; the two-tier normalized check
// afterwards is what keeps "Up" from matching "Upgrade".
@@ -176,6 +216,47 @@ describe('title-sources.operations', () => {
expect(matches[0].playlistId).toBe('playlist-2');
});
it('excludes the current playlist inside both queries, not after them', async () => {
// Filtering afterwards is not enough: the current playlist often
// lists the film several times, and those rows would spend the row
// budget the alternatives need — the chip then vanishes even
// though other playlists have the movie.
const fts = createDbMock([duneRow]);
await findTitleSources(fts.db, {
title: 'Dune',
excludePlaylistId: 'playlist-1',
});
const ftsQuery = compiledQuery(fts.all);
expect(ftsQuery.sql).toContain('cat.playlist_id <> ?');
expect(ftsQuery.params).toContain('playlist-1');
expect(ftsQuery.sql.indexOf('cat.playlist_id <> ?')).toBeLessThan(
ftsQuery.sql.indexOf('LIMIT')
);
// The short-title scan is a separate statement and needs it too.
const scan = createDbMock([duneRow]);
await findTitleSources(scan.db, {
title: 'It',
excludePlaylistId: 'playlist-1',
});
const scanQuery = compiledQuery(scan.all);
expect(scanQuery.sql).toContain('cat.playlist_id <> ?');
expect(scanQuery.params).toContain('playlist-1');
expect(scanQuery.sql.indexOf('cat.playlist_id <> ?')).toBeLessThan(
scanQuery.sql.indexOf('LIMIT')
);
});
it('leaves the queries unfiltered when no playlist is excluded', async () => {
const { db, all } = createDbMock([duneRow]);
await findTitleSources(db, { title: 'Dune' });
expect(compiledQuery(all).sql).not.toContain('cat.playlist_id <>');
});
it('collapses the same film listed in several categories', async () => {
const { db } = createDbMock([
duneRow,
@@ -1,4 +1,4 @@
import { sql } from 'drizzle-orm';
import { sql, type SQL } from 'drizzle-orm';
import {
normalizeTitleKeys,
titleYearsCompatible,
@@ -22,6 +22,14 @@ import type { AppDatabase } from '../database.types';
const CANDIDATE_LIMIT = 60;
/**
* The scan path gets its own, wider window. FTS ranks by relevance, so the
* real match is near the top of its 60; the scan can only order by title, and
* its predicate is a word-boundary match rather than an equality — several
* genuinely different films can share the token ("It Follows", "Bring It On").
*/
const SCAN_CANDIDATE_LIMIT = 200;
/** A row as it comes back from SQLite, before match confirmation. */
interface TitleSourceRow {
content_id: number;
@@ -50,15 +58,46 @@ function buildFtsMatchQuery(normalizedTitle: string): string {
return tokens.join(' AND ');
}
/**
* The playlist the user is already on, removed IN SQL.
*
* Filtering it out afterwards is not enough: a playlist that lists the film in
* a dozen categories would spend the whole row budget on rows that are then
* all discarded, and the alternatives it was supposed to make room for would
* never be read at all.
*/
function excludePlaylistClause(
excludePlaylistId: string | null | undefined
): SQL {
return excludePlaylistId
? sql`AND cat.playlist_id <> ${excludePlaylistId}`
: sql``;
}
/**
* The trigram tokenizer cannot index tokens shorter than three characters, so
* a title like "Up", "It" or "Us" produces an empty MATCH expression. Those are
* real movies, and silently returning nothing for them means the chip never
* appears. This fallback scans instead — slower, but bounded by the same limit
* and only reachable for the handful of titles FTS structurally cannot serve.
* appears. This fallback scans instead — slower, and only reachable for the
* handful of titles FTS structurally cannot serve.
*
* The predicate is a WORD-boundary match, not a substring one: `LIKE '%it%'`
* matches "Titanic" and "The Italian Job", so alphabetically earlier noise
* could fill the window and push the real "It" out of it. Padding both sides
* lets one GLOB pattern cover the start, middle and end of the title at once.
* Rows come back shortest-title-first because a matching title is the base
* plus decoration ("IT (2017) 1080p"), so the true matches sort ahead of the
* longer films that merely contain the word.
*
* Still a necessary-not-sufficient filter — `normalizeTitleKeys` below is what
* confirms a match. 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.
*/
function scanCandidateQuery(base: string) {
const like = `%${base.replace(/[\\%_]/g, '\\$&')}%`;
function scanCandidateQuery(base: string, excludePlaylist: SQL) {
// Normalized, so it holds letters, digits and spaces only — nothing GLOB
// would read as a metacharacter.
const token = base.split(' ')[0];
const wordMatch = `*[^a-z0-9]${token}[^a-z0-9]*`;
return sql`
SELECT
c.id AS content_id,
@@ -74,13 +113,14 @@ function scanCandidateQuery(base: string) {
WHERE c.type = 'movie'
AND cat.hidden = 0
AND p.type = 'xtream'
AND LOWER(c.title) LIKE ${like} ESCAPE '\\'
ORDER BY c.title
LIMIT ${CANDIDATE_LIMIT}
AND ' ' || LOWER(c.title) || ' ' GLOB ${wordMatch}
${excludePlaylist}
ORDER BY LENGTH(c.title), c.title
LIMIT ${SCAN_CANDIDATE_LIMIT}
`;
}
function ftsCandidateQuery(matchQuery: string) {
function ftsCandidateQuery(matchQuery: string, excludePlaylist: SQL) {
return sql`
SELECT
c.id AS content_id,
@@ -98,6 +138,7 @@ function ftsCandidateQuery(matchQuery: string) {
AND c.type = 'movie'
AND cat.hidden = 0
AND p.type = 'xtream'
${excludePlaylist}
ORDER BY rank, c.title
LIMIT ${CANDIDATE_LIMIT}
`;
@@ -124,14 +165,16 @@ export async function findTitleSources(
// that does not still find each other; the year gate below re-tightens it.
const matchQuery = buildFtsMatchQuery(wanted.base);
const excludePlaylist = excludePlaylistClause(request.excludePlaylistId);
let rows: TitleSourceRow[];
try {
rows = matchQuery
? ((await db.all(
ftsCandidateQuery(matchQuery)
ftsCandidateQuery(matchQuery, excludePlaylist)
)) as TitleSourceRow[])
: ((await db.all(
scanCandidateQuery(wanted.base)
scanCandidateQuery(wanted.base, excludePlaylist)
)) as TitleSourceRow[]);
} catch {
// Malformed FTS query for exotic titles — no sources, not a crash
@@ -144,6 +187,8 @@ export async function findTitleSources(
const seen = new Set<string>();
for (const row of rows) {
// SQL already excluded these; this keeps the guarantee a property of
// the function rather than of one WHERE clause.
if (
request.excludePlaylistId &&
row.playlist_id === request.excludePlaylistId
+60 -9
View File
@@ -97,6 +97,21 @@ and so its ranking logic is testable without TestBed.
| 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
@@ -110,15 +125,47 @@ 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.
## Short titles
### Rediscovery vs. a new session
The trigram tokenizer cannot index tokens under three characters, so a title
like "Up", "It" or "Us" produces an empty `MATCH` expression. Discovery falls
back to a bounded `LIKE` scan for exactly those titles rather than returning
nothing — the two-tier normalized confirmation still runs afterwards, so the
looser query does not admit "Upgrade" for "Up". The scan is slower and cannot
use the title index, which is acceptable because it is only reachable for the
handful of titles FTS structurally cannot serve.
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.
## The candidate window
Both queries take a bounded number of rows, so what fills that window decides
whether an alternative is findable at all:
- **The current playlist is excluded in SQL**, not afterwards. It routinely
lists a film in several categories, and those rows would otherwise spend the
budget before a single other playlist was read.
- **Short titles scan on a word boundary.** 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", and enough of those sorted by title push the real film out of
the window — so the scan asks for the token as a word
(`' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*'`), orders by title
length (a match is the title plus decoration: "IT (2017) 1080p"), and gets a
wider budget than the relevance-ranked FTS path. 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".
## Why resolution is lazy
@@ -154,7 +201,11 @@ Three details make the position survive:
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.
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
@@ -279,12 +279,19 @@ export class VodDetailsPlaybackService {
this.resumeSettled = false;
}
/**
* @returns whether `currentTime` can be believed yet — false while the
* engine is still on its way to `startTime`. Multi-source switching needs
* the same answer, and one latch has to serve both or they disagree.
*/
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
}): boolean {
const playback = this.inlinePlayback();
if (!playback?.contentInfo) return;
// Nothing to persist against and the latch never ran, so the answer is
// whatever it already was — not a fresh "no".
if (!playback?.contentInfo) return this.resumeSettled;
// A resuming engine can emit timeupdates at ~0 before it finishes
// seeking to startTime. Writing one would overwrite the very position
@@ -295,12 +302,13 @@ export class VodDetailsPlaybackService {
// is still saved normally.
if (!this.resumeSettled) {
const startTime = playback.startTime ?? 0;
if (startTime > 0 && event.currentTime < startTime - 5) return;
if (startTime > 0 && event.currentTime < startTime - 5)
return false;
this.resumeSettled = true;
}
const now = Date.now();
if (now - this.lastSaveTime <= 15000) return;
if (now - this.lastSaveTime <= 15000) return true;
this.lastSaveTime = now;
const position: PlaybackPositionData = {
@@ -313,6 +321,7 @@ export class VodDetailsPlaybackService {
position
);
this.vodPlaybackPosition.set(position);
return true;
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
@@ -283,6 +283,11 @@
[playback]="playback"
[alternativeSources]="multiSource.alternatives()"
[allSources]="multiSource.sources()"
[sourcesMatchKind]="multiSource.matchKind()"
[sourcesAutoFailoverEnabled]="
multiSource.autoFailoverEnabled()
"
(sourcesAutoFailoverToggled)="setAutoFailover($event)"
(alternativeSourceRequested)="playFromSource($event)"
(sourcePinRequested)="pinSource($event)"
(sourceCheckRequested)="checkSource($event)"
@@ -19,6 +19,7 @@ import {
} from '@iptvnator/shared/interfaces';
import { DownloadsService, SettingsStore } from '@iptvnator/services';
import { MatSnackBar } from '@angular/material/snack-bar';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
import { VodDetailsRouteComponent } from './vod-details-route.component';
describe('VodDetailsRouteComponent', () => {
@@ -297,6 +298,37 @@ describe('VodDetailsRouteComponent', () => {
expect(host.querySelector('button.play-btn')).not.toBeNull();
});
it('holds the resume point until the engine has seeked to it', () => {
const component = fixture.componentInstance;
const playback = fixture.debugElement.injector.get(
VodDetailsPlaybackService
);
playback.inlinePlayback.set({
streamUrl: 'http://example.com/movie/650020.mp4',
title: 'City of McFarland',
startTime: 2538,
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 650020,
contentType: 'vod',
},
});
const reported = jest.spyOn(component.multiSource, 'reportPosition');
// A resuming engine emits timeupdates at ~0 on its way to 2538. That
// is not where the film is, and multi-source must not switch or fail
// over back to the beginning because of it.
component.handleInlineTimeUpdate({ currentTime: 0.2, duration: 7744 });
expect(reported).toHaveBeenLastCalledWith(2538);
component.handleInlineTimeUpdate({ currentTime: 2540, duration: 7744 });
expect(reported).toHaveBeenLastCalledWith(2540);
// One-shot latch, not a filter: a deliberate seek backwards counts.
component.handleInlineTimeUpdate({ currentTime: 12, duration: 7744 });
expect(reported).toHaveBeenLastCalledWith(12);
});
it('renders usable metadata when backdrop_path is absent at runtime', () => {
selectedItem.set({
info: {
@@ -485,7 +485,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
playlistDisplayLabel(active.playlistName, active.playlistId),
...facts,
].join(' · '),
alternativeCount: this.multiSource.alternativeCount(),
// The caption speaks of PLAYLISTS ("also found in 2 others"), so
// three copies inside one portal must not read as three portals.
alternativeCount: this.multiSource.alternativePlaylistCount(),
};
});
@@ -546,10 +548,21 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
currentTime: number;
duration: number;
}): void {
const settled = this.playback.handleInlineTimeUpdate(event);
// Ahead of the service's 15s persistence throttle, so a source switch
// resumes from where playback actually is rather than up to 15s back.
this.multiSource.reportPosition(event.currentTime);
this.playback.handleInlineTimeUpdate(event);
//
// Until the engine has finished seeking to the resume point it reports
// ~0, and that is not where the film is — it is where it has not got
// to yet. Feeding it to multi-source would make a switch or a failure
// during those first seconds restart the movie from the beginning, so
// the position we asked the engine for stands in until it arrives.
this.multiSource.reportPosition(
settled
? event.currentTime
: (this.inlinePlayback()?.startTime ?? 0)
);
}
showCopyNotification(): void {
@@ -157,6 +157,26 @@ describe('VodMultiSourceHostService — stale resolutions', () => {
expect(rowFor(ALT_TWO.id)?.isActive).toBe(false);
});
it('keeps a switch alive when the same movie is rediscovered', async () => {
await loadMovie([ALT_TWO]);
const slow = createDeferred<ReturnType<typeof resolvedFor>>();
resolver.resolve.mockReturnValueOnce(slow.promise);
const pending = service.play(ALT_TWO.id);
// Enrichment lands mid-resolution and reruns discovery for the SAME
// film. That is a refresh, not a navigation: cancelling here would
// make the user's click do nothing at all.
await loadMovie([ALT_TWO], { ...MOVIE_A, tmdbId: 603 });
slow.resolve(resolvedFor(ALT_TWO, 0));
await expect(pending).resolves.toBe(true);
expect(startPlayback).toHaveBeenCalledTimes(1);
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
});
it('drops a probe result whose movie was navigated away from', async () => {
await loadMovie([ALT_TWO]);
@@ -0,0 +1,200 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
VodSourceDiscoveryService,
VodSourceResolverService,
} from '@iptvnator/portal/shared/data-access';
import {
SettingsStore,
StreamProbeService,
VodSourcePinService,
} from '@iptvnator/services';
import type { VodSourceCandidate } from '@iptvnator/shared/interfaces';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import type { VodMultiSourceMovie } from './vod-multi-source-identity';
import {
ALT_THREE,
ALT_TWO,
CURRENT_A_ID,
MOVIE_A,
MOVIE_B,
PROBE_OK,
createDeferred,
resolveWith,
type DiscoveryResult,
} from './vod-multi-source-host.fixtures';
/**
* When a session starts, and when it merely refreshes.
*
* These two are one question: metadata enrichment reruns discovery for the
* film already on screen, and getting that wrong either loses the session or
* never rebuilds the pin keys.
*/
describe('VodMultiSourceHostService — session lifecycle', () => {
let service: VodMultiSourceHostService;
const movie = signal<VodMultiSourceMovie | null>(null);
const vodAutoFailover = signal(false);
const startPlayback = jest.fn();
const discovery = { isAvailable: true, discover: jest.fn() };
const resolver = { resolve: jest.fn() };
const pins = { get: jest.fn(), set: jest.fn(), clear: jest.fn() };
const probes = { probe: jest.fn() };
/** Runs pending root effects, then drains the work they started. */
async function flushEffects(): Promise<void> {
for (let index = 0; index < 6; index += 1) {
TestBed.tick();
await Promise.resolve();
}
}
async function loadMovie(
sources: VodSourceCandidate[],
target: VodMultiSourceMovie = MOVIE_A
): Promise<void> {
discovery.discover.mockResolvedValue({
sources,
matchKind: 'title-year',
});
await service.load(target);
}
function rowFor(sourceId: string) {
return service.sources().find((source) => source.id === sourceId);
}
beforeEach(() => {
jest.resetAllMocks();
movie.set(null);
vodAutoFailover.set(false);
discovery.isAvailable = true;
discovery.discover.mockResolvedValue({
sources: [],
matchKind: 'title-year',
});
resolver.resolve.mockImplementation(resolveWith());
pins.get.mockResolvedValue(null);
pins.set.mockResolvedValue(true);
pins.clear.mockResolvedValue(true);
probes.probe.mockResolvedValue(PROBE_OK);
TestBed.configureTestingModule({
providers: [
VodMultiSourceHostService,
{ provide: VodSourceDiscoveryService, useValue: discovery },
{ provide: VodSourceResolverService, useValue: resolver },
{ provide: VodSourcePinService, useValue: pins },
{ provide: StreamProbeService, useValue: probes },
{ provide: SettingsStore, useValue: { vodAutoFailover } },
],
});
service = TestBed.inject(VodMultiSourceHostService);
TestBed.runInInjectionContext(() =>
service.bind({ startPlayback, movie })
);
});
it('discovers once per bound movie identity', async () => {
movie.set(MOVIE_A);
await flushEffects();
expect(discovery.discover).toHaveBeenCalledTimes(1);
expect(discovery.discover).toHaveBeenCalledWith({
title: 'The Matrix',
year: 1999,
currentPlaylistId: 'playlist-1',
});
movie.set({ ...MOVIE_A });
await flushEffects();
expect(discovery.discover).toHaveBeenCalledTimes(1);
movie.set(MOVIE_B);
await flushEffects();
expect(discovery.discover).toHaveBeenCalledTimes(2);
});
it('rediscovers the same movie once enrichment describes it better', async () => {
movie.set(MOVIE_A);
await flushEffects();
// The year and TMDB id arrive with get_vod_info, after the catalog
// title opened the page. Discovery has to run again — that is how a
// yearless search gets its year and a `tmdb:` pin becomes findable.
movie.set({ ...MOVIE_A, tmdbId: 603 });
await flushEffects();
expect(discovery.discover).toHaveBeenCalledTimes(2);
});
it('keeps the playing source when enrichment reloads the same movie', async () => {
await loadMovie([ALT_TWO, ALT_THREE]);
await expect(service.play(ALT_TWO.id)).resolves.toBe(true);
service.reportPosition(2538);
await loadMovie([ALT_TWO, ALT_THREE], { ...MOVIE_A, tmdbId: 603 });
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
expect(rowFor(CURRENT_A_ID)?.isActive).toBe(false);
// The source it already left stays burned, so "each source at most
// once" still holds across the refresh.
expect(rowFor(CURRENT_A_ID)?.isTried).toBe(true);
expect(service.previousSourceId()).toBe(CURRENT_A_ID);
vodAutoFailover.set(true);
await expect(service.failover()).resolves.not.toBeNull();
expect(rowFor(ALT_THREE.id)?.isActive).toBe(true);
// And the live position survived, so it does not restart the film.
expect(resolver.resolve).toHaveBeenLastCalledWith(
expect.objectContaining({ id: ALT_THREE.id }),
{ startTime: 2538 }
);
});
it('resets the failover session when a new movie loads', async () => {
await loadMovie([ALT_TWO]);
vodAutoFailover.set(true);
await expect(service.failover()).resolves.not.toBeNull();
await expect(service.failover()).resolves.toBeNull();
expect(service.previousSourceId()).toBe(CURRENT_A_ID);
await loadMovie([ALT_TWO], MOVIE_B);
expect(service.previousSourceId()).toBeNull();
expect(service.lastSwitch()).toBeNull();
await expect(service.failover()).resolves.not.toBeNull();
});
it('discards a discovery that resolves after the movie changed', async () => {
const first = createDeferred<DiscoveryResult>();
const second = createDeferred<DiscoveryResult>();
discovery.discover
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const loadA = service.load(MOVIE_A);
const loadB = service.load(MOVIE_B);
first.resolve({ sources: [ALT_TWO], matchKind: 'title-year' });
await loadA;
expect(service.sources()).toEqual([]);
// Abandoned on arrival: it never even looks the stale pin up.
expect(pins.get).not.toHaveBeenCalled();
second.resolve({ sources: [ALT_THREE], matchKind: 'title-year' });
await loadB;
expect(pins.get).toHaveBeenCalledTimes(1);
expect(service.sources().map((source) => source.id)).toEqual([
'playlist-1:xtream:202',
ALT_THREE.id,
]);
});
});
@@ -23,12 +23,9 @@ import {
API_AC3,
CURRENT_A_ID,
MOVIE_A,
MOVIE_B,
PARSED_DUB,
PROBE_OK,
createDeferred,
resolveWith,
type DiscoveryResult,
} from './vod-multi-source-host.fixtures';
describe('VodMultiSourceHostService', () => {
@@ -42,14 +39,6 @@ describe('VodMultiSourceHostService', () => {
const pins = { get: jest.fn(), set: jest.fn(), clear: jest.fn() };
const probes = { probe: jest.fn() };
/** Runs pending root effects, then drains the work they started. */
async function flushEffects(): Promise<void> {
for (let index = 0; index < 6; index += 1) {
TestBed.tick();
await Promise.resolve();
}
}
async function loadMovie(
sources: VodSourceCandidate[],
target: VodMultiSourceMovie = MOVIE_A
@@ -119,26 +108,6 @@ describe('VodMultiSourceHostService', () => {
);
});
it('discovers once per bound movie identity', async () => {
movie.set(MOVIE_A);
await flushEffects();
expect(discovery.discover).toHaveBeenCalledTimes(1);
expect(discovery.discover).toHaveBeenCalledWith({
title: 'The Matrix',
year: 1999,
currentPlaylistId: 'playlist-1',
});
movie.set({ ...MOVIE_A });
await flushEffects();
expect(discovery.discover).toHaveBeenCalledTimes(1);
movie.set(MOVIE_B);
await flushEffects();
expect(discovery.discover).toHaveBeenCalledTimes(2);
});
it('counts only sources other than the playing one as alternatives', async () => {
await loadMovie([]);
@@ -154,6 +123,24 @@ describe('VodMultiSourceHostService', () => {
expect(service.alternativeCount()).toBe(1);
});
it('counts playlists, not copies, for the "found in N others" caption', async () => {
// Two copies inside ONE other playlist. The popover groups them under
// that single portal, so a caption reading "also found in 2 other
// playlists" would contradict the list it invites the user to open.
const secondCopy: VodSourceCandidate = {
...ALT_TWO,
id: `${ALT_TWO.playlistId}:xtream:999`,
contentId: 999,
};
await loadMovie([ALT_TWO, secondCopy]);
expect(service.alternativeCount()).toBe(2);
expect(service.alternativePlaylistCount()).toBe(1);
await loadMovie([ALT_TWO, secondCopy, ALT_THREE]);
expect(service.alternativePlaylistCount()).toBe(2);
});
it('carries the live timecode into the resolve call and the playback', async () => {
await loadMovie([ALT_TWO]);
service.reportPosition(2538);
@@ -344,46 +331,4 @@ describe('VodMultiSourceHostService', () => {
);
expect(rowFor(ALT_TWO.id)?.probe).toEqual(PROBE_OK);
});
it('discards a discovery that resolves after the movie changed', async () => {
const first = createDeferred<DiscoveryResult>();
const second = createDeferred<DiscoveryResult>();
discovery.discover
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const loadA = service.load(MOVIE_A);
const loadB = service.load(MOVIE_B);
first.resolve({ sources: [ALT_TWO], matchKind: 'title-year' });
await loadA;
expect(service.sources()).toEqual([]);
// Abandoned on arrival: it never even looks the stale pin up.
expect(pins.get).not.toHaveBeenCalled();
second.resolve({ sources: [ALT_THREE], matchKind: 'title-year' });
await loadB;
expect(pins.get).toHaveBeenCalledTimes(1);
expect(service.sources().map((source) => source.id)).toEqual([
'playlist-1:xtream:202',
ALT_THREE.id,
]);
});
it('resets the failover session when a new movie loads', async () => {
await loadMovie([ALT_TWO]);
vodAutoFailover.set(true);
await expect(service.failover()).resolves.not.toBeNull();
await expect(service.failover()).resolves.toBeNull();
expect(service.previousSourceId()).toBe(CURRENT_A_ID);
await loadMovie([ALT_TWO], MOVIE_B);
expect(service.previousSourceId()).toBeNull();
expect(service.lastSwitch()).toBeNull();
await expect(service.failover()).resolves.not.toBeNull();
});
});
@@ -18,16 +18,21 @@ import {
} from '@iptvnator/services';
import {
vodMultiSourceMovieKey,
vodMultiSourceSessionKey,
type VodMultiSourceMovie,
} from './vod-multi-source-identity';
import {
applyDiscoveredSources,
runFailover,
type SwitchOutcome,
} from './vod-multi-source-session';
import { buildSwitchNotice } from './vod-multi-source-notice';
import { currentSourceRow } from './vod-multi-source-current-row';
import { probeSource } from './vod-multi-source-probe';
import {
erasePin,
pinnedSourceIdOf,
readPin,
writePin,
togglePinnedSource,
} from './vod-multi-source-pin';
import {
buildVodSourceMatchKeyCandidates,
@@ -55,12 +60,6 @@ export interface VodMultiSourceBindings {
movie: Signal<VodMultiSourceMovie | null>;
}
/**
* Why a switch attempt ended. `unresolvable` is the only outcome failover may
* continue past — `superseded` means something newer already owns the screen.
*/
type SwitchOutcome = 'switched' | 'unresolvable' | 'superseded';
export interface VodMultiSourceSwitchNotice {
playlistName: string;
resumeSeconds: number;
@@ -81,22 +80,27 @@ export class VodMultiSourceHostService {
private controller = new VodMultiSourceController();
private bindings: VodMultiSourceBindings | null = null;
private matchKeys: string[] = [];
/** Guards against a stale discovery resolving after the user moved on. */
/** Bumped per `load()`: discards a discovery the next one superseded. */
private discoveryToken = 0;
private lastMovieKey: string | null = null;
/**
* Bumped by every switch. Two things can go wrong across the `await` in
* `switchTo`: a newer switch may already have committed (the slower
* resolution would then overwrite the user's latest choice and the Undo
* target), or the user may have navigated to another movie entirely (the
* continuation would activate the old film's source in the new session and
* restart it from that session's zero resume position).
* Bumped when the FILM changes, and only then — a rediscovery of the same
* film (enrichment described it better) must not cancel a switch or probe
* in flight for it. Without this guard the continuation would activate the
* old film's source in the new session and restart it from that session's
* zero resume position.
*/
private sessionToken = 0;
/**
* Bumped by every switch, so a slower resolution cannot overwrite the
* user's latest choice, nor the source Undo points back to.
*/
private switchToken = 0;
private lastMovieKey: string | null = null;
private movieIdentity: string | null = null;
/** True while `session`/`switch` still describe the operation in flight. */
private isCurrentSwitch(session: number, attempt: number): boolean {
return session === this.discoveryToken && attempt === this.switchToken;
return session === this.sessionToken && attempt === this.switchToken;
}
/** The source that was playing before the most recent switch. */
private readonly _previousSourceId = signal<string | null>(null);
@@ -118,7 +122,17 @@ export class VodMultiSourceHostService {
);
/** The chip appears only when there is genuinely somewhere else to go. */
readonly hasAlternatives = computed(() => this.alternatives().length > 0);
/** Alternative STREAMS — what the "Sources N" chip counts. */
readonly alternativeCount = computed(() => this.alternatives().length);
/**
* Alternative PLAYLISTS. One playlist listing the film three times is one
* other place to watch it, and the popover already groups those three
* under that single playlist — so "also found in N other playlists" has to
* count portals, not copies, or it contradicts the list it opens.
*/
readonly alternativePlaylistCount = computed(
() => new Set(this.alternatives().map((s) => s.playlistId)).size
);
readonly matchKind = computed(() => this.controller.matchKind());
/** Opt-in and off by default — a silent switch is never acceptable. */
@@ -152,6 +166,7 @@ export class VodMultiSourceHostService {
// flight for the PREVIOUS movie would pass the staleness guard
// and start its playback over the page the user is leaving.
this.discoveryToken++;
this.sessionToken++;
return;
}
@@ -166,18 +181,33 @@ export class VodMultiSourceHostService {
}
/**
* Start a fresh session for a movie. The router REUSES this component for
* detail→detail navigation, so this must fully reset — including the
* tried-source set.
* (Re)discover the sources for a movie.
*
* A DIFFERENT film starts a fresh session — the router REUSES this
* component for detail→detail navigation, so everything resets, above all
* the tried-source set that makes failover terminate.
*
* The SAME film arriving again is a refresh, not a new session: enrichment
* supplies its year and TMDB id after the page opened, which changes the
* movie key on purpose (discovery gets the year, a `tmdb:` pin becomes
* findable) but changes nothing about what is playing. Resetting there
* would take the film back off the source the user switched to and hand
* failover a clean tried-set for sources it has already burned.
*/
async load(movie: VodMultiSourceMovie): Promise<void> {
const token = ++this.discoveryToken;
const identity = vodMultiSourceSessionKey(movie);
const sameMovie = identity === this.movieIdentity;
this.movieIdentity = identity;
this.controller = new VodMultiSourceController();
this._sources.set([]);
this._lastSwitch.set(null);
this._busySourceId.set(null);
this._previousSourceId.set(null);
if (!sameMovie) {
this.sessionToken++;
this.controller = new VodMultiSourceController();
this._sources.set([]);
this._lastSwitch.set(null);
this._busySourceId.set(null);
this._previousSourceId.set(null);
}
this.matchKeys = buildVodSourceMatchKeyCandidates(movie);
if (!this.discovery.isAvailable) {
@@ -193,13 +223,12 @@ export class VodMultiSourceHostService {
return;
}
const current = currentSourceRow(movie);
this.controller.setSources(
[current, ...result.sources],
applyDiscoveredSources(
this.controller,
currentSourceRow(movie),
result.sources,
result.matchKind
);
this.controller.setActiveSource(current.id);
const pin = await readPin(this.pins, this.matchKeys);
if (token !== this.discoveryToken) {
@@ -250,28 +279,18 @@ export class VodMultiSourceHostService {
/** Pin or unpin this source as the movie's preferred one. */
async togglePin(sourceId: string): Promise<void> {
if (this.matchKeys.length === 0) {
return;
}
const alreadyPinned = this._sources().some(
const isPinned = this._sources().some(
(source) => source.id === sourceId && source.isPinned
);
const pinned = await togglePinnedSource(
this.pins,
this.matchKeys,
this.controller.findSource(sourceId),
isPinned
);
if (alreadyPinned) {
await erasePin(this.pins, this.matchKeys);
this.controller.setPinnedSource(null);
this.publish();
return;
}
const candidate = this.controller.findSource(sourceId);
if (!candidate) {
return;
}
if (await writePin(this.pins, this.matchKeys, candidate)) {
this.controller.setPinnedSource(sourceId);
if (pinned !== undefined) {
this.controller.setPinnedSource(pinned);
this.publish();
}
}
@@ -282,8 +301,8 @@ export class VodMultiSourceHostService {
controller: this.controller,
resolver: this.resolver,
probes: this.probes,
isCurrent: (session) => session === this.discoveryToken,
session: this.discoveryToken,
isCurrent: (session) => session === this.sessionToken,
session: this.sessionToken,
publish: () => this.publish(),
});
}
@@ -300,28 +319,10 @@ export class VodMultiSourceHostService {
return null;
}
// Keep going past candidates that cannot be resolved at all. A dead
// account or a failing get_vod_info on the top-ranked source must not
// end the attempt: production only calls this once, on the original
// failure, so giving up here would strand a healthy lower-ranked
// source. `switchTo` marks each attempt tried, so this terminates.
for (;;) {
const target = this.controller.pickFailoverTarget();
if (!target) {
return null;
}
const outcome = await this.switchTo(target);
if (outcome === 'switched') {
return this._lastSwitch();
}
if (outcome === 'superseded') {
// A newer switch or another movie already owns the screen.
return null;
}
// 'unresolvable': the candidate is now marked tried, so the next
// pick is strictly a different source and the loop terminates.
}
const switched = await runFailover(this.controller, (candidate) =>
this.switchTo(candidate)
);
return switched ? this._lastSwitch() : null;
}
/** True once every alternative has been attempted this session. */
@@ -341,7 +342,7 @@ export class VodMultiSourceHostService {
return 'superseded';
}
const session = this.discoveryToken;
const session = this.sessionToken;
const attempt = ++this.switchToken;
// Snapshot the controller: `load()` swaps in a fresh one for a new
// movie, and the continuation below must never touch that one.
@@ -81,3 +81,16 @@ export function vodMultiSourceMovieKey(movie: VodMultiSourceMovie): string {
movie.tmdbId ?? '',
].join(':');
}
/**
* Identity of the FILM, made of the only two things that cannot change while
* it stays on screen.
*
* The key above deliberately reacts to metadata, so enrichment re-runs
* discovery. This one answers the question that arises at that same moment:
* is this a different movie — reset everything — or the same one being
* described better, whose session must survive?
*/
export function vodMultiSourceSessionKey(movie: VodMultiSourceMovie): string {
return `${movie.playlistId}:${movie.contentId}`;
}
@@ -59,3 +59,34 @@ export async function erasePin(
await pins.clear([...matchKeys]);
}
}
/**
* Pin or unpin `candidate`, and report the id the controller should now hold.
*
* `undefined` means the toggle did not happen at all — no key to store it
* under, no such row, or the write failed — and the caller must leave the
* current pin exactly as it was rather than showing one that was not saved.
*/
export async function togglePinnedSource(
pins: Pick<VodSourcePinService, 'set' | 'clear'>,
matchKeys: readonly string[],
candidate: VodSourceCandidate | null,
isPinned: boolean
): Promise<string | null | undefined> {
if (matchKeys.length === 0) {
return undefined;
}
if (isPinned) {
await erasePin(pins, matchKeys);
return null;
}
if (!candidate) {
return undefined;
}
return (await writePin(pins, matchKeys, candidate))
? candidate.id
: undefined;
}
@@ -0,0 +1,90 @@
import type { VodMultiSourceController } from '@iptvnator/portal/shared/data-access';
import type {
VodSourceCandidate,
VodSourceMatchKind,
} from '@iptvnator/shared/interfaces';
/**
* Session mechanics for one open movie: how a discovery folds into the state
* that is already there, and how failover walks the ranked candidates.
*
* Pure functions over the controller — no I/O, no signals — so the host
* service stays the wiring layer and these stay testable on their own.
*/
/**
* Why a switch attempt ended. `unresolvable` is the only outcome failover may
* continue past — `superseded` means something newer already owns the screen.
*/
export type SwitchOutcome = 'switched' | 'unresolvable' | 'superseded';
/**
* Put a discovery result into the controller without losing the session.
*
* Discovery runs again for the SAME movie when metadata enrichment finally
* supplies its year and TMDB id, and by then the user may have switched to
* another playlist. Re-listing the sources must not quietly hand the film
* back to the route's own playlist: the player would keep streaming the
* source it switched to while every caption named a different one, and
* failover would see the playing source as untried.
*/
export function applyDiscoveredSources(
controller: VodMultiSourceController,
current: VodSourceCandidate,
discovered: VodSourceCandidate[],
matchKind: VodSourceMatchKind
): void {
const activeId = controller.activeSourceId();
// Only a source the user actually switched to needs protecting. When the
// route's own row is playing, the refreshed one supersedes it — it is the
// same row, now carrying the enriched title and year.
const switchedTo =
activeId && activeId !== current.id
? controller.findSource(activeId)
: null;
controller.setSources([current, ...discovered], matchKind);
const playing = switchedTo && controller.findSource(switchedTo.id);
if (!playing) {
controller.setActiveSource(current.id);
return;
}
// Catalog rows only ever carry guesses parsed off the title. Whatever the
// resolve turned into facts for the playing source stays fact.
controller.updateSource(switchedTo);
controller.setActiveSource(switchedTo.id);
}
/**
* Move to the best untried source after a playback failure.
*
* Keeps going past candidates that cannot be resolved at all: a dead account
* or a failing `get_vod_info` on the top-ranked source must not end the
* attempt, because production calls this once — on the original failure — so
* giving up here would strand a healthy lower-ranked source. Every attempt is
* marked tried, which is what makes the loop terminate.
*/
export async function runFailover(
controller: VodMultiSourceController,
switchTo: (candidate: VodSourceCandidate) => Promise<SwitchOutcome>
): Promise<boolean> {
for (;;) {
const target = controller.pickFailoverTarget();
if (!target) {
return false;
}
const outcome = await switchTo(target);
if (outcome === 'switched') {
return true;
}
if (outcome === 'superseded') {
// A newer switch or another movie already owns the screen.
return false;
}
// 'unresolvable': the candidate is now marked tried, so the next pick
// is strictly a different source and the loop terminates.
}
}
@@ -0,0 +1,153 @@
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { SettingsStore } from '@iptvnator/services';
import { VodSourcesChipComponent } from '@iptvnator/ui/components';
import type { VodSourceDescriptor } from '@iptvnator/shared/interfaces';
import type { PortalInlinePlayerComponent as PortalInlinePlayerComponentInstance } from './portal-inline-player.component';
jest.unstable_mockModule('video.js', () => ({
default: jest.fn(),
}));
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
@Component({
selector: 'app-web-player-view',
standalone: true,
template: '<div data-test-id="stub-web-player-view"></div>',
})
class StubWebPlayerViewComponent {
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly alternativeSources = input<unknown[]>([]);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly externalFallbackRequested = output<unknown>();
readonly alternativeSourceRequested = output<string>();
readonly playbackFailed = output<unknown>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
}
function descriptor(id: string, isActive: boolean): VodSourceDescriptor {
return {
id,
playlistId: id,
playlistName: `Portal ${id}`,
portalType: 'xtream',
contentId: 1,
rawTitle: 'The Matrix',
matchConfidence: 'exact',
year: 1999,
isActive,
isPinned: false,
isTried: isActive,
probe: { status: 'idle' },
};
}
/**
* The picker inside the player is the SAME picker as the detail page's, so the
* settings it shows and changes have to be the same ones.
*/
describe('PortalInlinePlayerComponent sources picker', () => {
let PortalInlinePlayerComponent: typeof import('./portal-inline-player.component').PortalInlinePlayerComponent;
let WebPlayerViewComponent: typeof import('../web-player-view/web-player-view.component').WebPlayerViewComponent;
let fixture: ComponentFixture<PortalInlinePlayerComponentInstance>;
let component: PortalInlinePlayerComponentInstance;
beforeAll(async () => {
({ PortalInlinePlayerComponent } = await import(
'./portal-inline-player.component'
));
({ WebPlayerViewComponent } = await import(
'../web-player-view/web-player-view.component'
));
});
afterEach(() => {
fixture?.destroy();
});
beforeEach(async () => {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [PortalInlinePlayerComponent, TranslateModule.forRoot()],
providers: [
{
provide: SettingsStore,
useValue: {
player: signal('html5'),
playerAmbientMode: signal(false),
playerUpNextRail: signal(false),
stripCountryPrefix: signal(false),
},
},
],
})
.overrideComponent(PortalInlinePlayerComponent, {
remove: { imports: [WebPlayerViewComponent] },
add: { imports: [StubWebPlayerViewComponent] },
})
.compileComponents();
fixture = TestBed.createComponent(PortalInlinePlayerComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/movie.mkv',
title: 'The Matrix',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 1,
contentType: 'vod',
},
});
fixture.componentRef.setInput('allSources', [
descriptor('playlist-1', true),
descriptor('playlist-2', false),
]);
fixture.componentRef.setInput('alternativeSources', [
descriptor('playlist-2', false),
]);
});
function chip(): VodSourcesChipComponent {
return fixture.debugElement.query(By.directive(VodSourcesChipComponent))
.componentInstance;
}
it('shows auto-failover as it actually is, not as off by default', () => {
fixture.componentRef.setInput('sourcesAutoFailoverEnabled', true);
fixture.detectChanges();
expect(chip().autoFailoverEnabled()).toBe(true);
});
it('relays a toggle to the host that owns the setting', () => {
const toggled = jest.fn();
component.sourcesAutoFailoverToggled.subscribe(toggled);
fixture.detectChanges();
chip().autoFailoverToggled.emit(false);
expect(toggled).toHaveBeenCalledWith(false);
});
it('states the match kind the host reports', () => {
fixture.componentRef.setInput('sourcesMatchKind', 'title');
fixture.detectChanges();
// The picker prints this as a claim about how the sources were found,
// so a default it was never told is a guess presented as a fact.
expect(chip().matchKind()).toBe('title');
});
});
@@ -32,12 +32,17 @@
class="player-shell__sources"
[sources]="allSources()"
[count]="alternativeSources().length"
[matchKind]="sourcesMatchKind()"
[resumeLabel]="sourcesResumeLabel()"
[autoFailoverEnabled]="sourcesAutoFailoverEnabled()"
(playRequested)="
alternativeSourceRequested.emit($event)
"
(pinRequested)="sourcePinRequested.emit($event)"
(checkRequested)="sourceCheckRequested.emit($event)"
(autoFailoverToggled)="
sourcesAutoFailoverToggled.emit($event)
"
/>
}
<button
@@ -20,6 +20,7 @@ import {
ResolvedPortalPlayback,
VideoPlayer,
type VodSourceDescriptor,
type VodSourceMatchKind,
} from '@iptvnator/shared/interfaces';
import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util';
import type { PlaybackDiagnosticCode } from '../playback-diagnostics/playback-diagnostics.model';
@@ -211,6 +212,16 @@ export class PortalInlinePlayerComponent {
readonly allSources = input<VodSourceDescriptor[]>([]);
/** Formatted resume timecode shown in the picker header. */
readonly sourcesResumeLabel = input<string | null>(null);
/** How the sources were matched — the picker states it, so it must be true. */
readonly sourcesMatchKind = input<VodSourceMatchKind>('title-year');
/**
* Auto-failover is one persisted setting, and this picker is the same
* picker as the detail page's. Without these two the in-player copy would
* show the toggle off while it is on, and switching it would change
* nothing.
*/
readonly sourcesAutoFailoverEnabled = input(false);
readonly sourcesAutoFailoverToggled = output<boolean>();
readonly sourcePinRequested = output<string>();
readonly sourceCheckRequested = output<string>();
readonly alternativeSourceRequested = output<string>();