diff --git a/CLAUDE.md b/CLAUDE.md index 2deeaa761..ddc4114a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` diff --git a/apps/electron-backend/src/app/database/operations/title-sources.operations.spec.ts b/apps/electron-backend/src/app/database/operations/title-sources.operations.spec.ts index dafd9b592..1cc06d2d6 100644 --- a/apps/electron-backend/src/app/database/operations/title-sources.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/title-sources.operations.spec.ts @@ -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, diff --git a/apps/electron-backend/src/app/database/operations/title-sources.operations.ts b/apps/electron-backend/src/app/database/operations/title-sources.operations.ts index 4cea96c69..67466b617 100644 --- a/apps/electron-backend/src/app/database/operations/title-sources.operations.ts +++ b/apps/electron-backend/src/app/database/operations/title-sources.operations.ts @@ -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(); 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 diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index 0c19643b0..b2e93ecc3 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -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 diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index 2816180e2..b80a00484 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -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 { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index 956b43b1e..b61cc70ea 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -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)" diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts index 82b56b42d..4f2885e8a 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts @@ -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: { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 1351792c4..17771f813 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -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 { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts index f405b9aa6..3d71a1133 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts @@ -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>(); + 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]); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts new file mode 100644 index 000000000..1f95dac8d --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts @@ -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(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 { + for (let index = 0; index < 6; index += 1) { + TestBed.tick(); + await Promise.resolve(); + } + } + + async function loadMovie( + sources: VodSourceCandidate[], + target: VodMultiSourceMovie = MOVIE_A + ): Promise { + 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(); + const second = createDeferred(); + 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, + ]); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts index 4e5a29b48..6a1e5defc 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts @@ -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 { - 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(); - const second = createDeferred(); - 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(); - }); }); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts index 239f53340..aa4100f04 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts @@ -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; } -/** - * 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(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 { 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 { - 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. diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-identity.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-identity.ts index 52917be8c..311d55590 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-identity.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-identity.ts @@ -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}`; +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts index 7e904abda..8d404dc75 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts @@ -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, + matchKeys: readonly string[], + candidate: VodSourceCandidate | null, + isPinned: boolean +): Promise { + 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; +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-session.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-session.ts new file mode 100644 index 000000000..997f2e114 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-session.ts @@ -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 +): Promise { + 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. + } +} diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts new file mode 100644 index 000000000..01f1e87c7 --- /dev/null +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts @@ -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: '
', +}) +class StubWebPlayerViewComponent { + readonly streamUrl = input.required(); + readonly title = input(''); + readonly mediaTitle = input(null); + readonly playback = input(null); + readonly startTime = input(0); + readonly seriesNavigation = input(null); + readonly alternativeSources = input([]); + readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly externalFallbackRequested = output(); + readonly alternativeSourceRequested = output(); + readonly playbackFailed = output(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); +} + +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; + 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'); + }); +}); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html index fb8323623..f8cd203c7 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html @@ -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) + " /> }