diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-multi-source.controller.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-multi-source.controller.ts index 1dbf5b0a9..1633fb91e 100644 --- a/libs/portal/shared/data-access/src/lib/multi-source/vod-multi-source.controller.ts +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-multi-source.controller.ts @@ -134,10 +134,11 @@ export class VodMultiSourceController { * Ranking deliberately ignores guessed metadata — a filename claiming 4K * must not outrank a source we actually reached. Order: * 1. never tried this session (a hard filter, not a preference) - * 2. probed reachable - * 3. not known to have failed recently - * 4. richer FACTUAL metadata - * 5. exact title match over fuzzy + * 2. pinned by the user for this movie + * 3. probed reachable + * 4. not known to have failed recently + * 5. richer FACTUAL metadata + * 6. exact title match over fuzzy */ pickFailoverTarget(): VodSourceDescriptor | null { const untried = this.sources().filter( @@ -159,6 +160,11 @@ export class VodMultiSourceController { function score(source: VodSourceDescriptor): number { let value = 0; + // An explicit user choice outranks anything we inferred about the others. + if (source.isPinned) { + value += 1000; + } + if (source.probe.status === 'ok') { value += 100; } 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 701599fc2..1351792c4 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 @@ -408,7 +408,14 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.playback.resumeVod(vodItem); } - onPrimaryAction(vodItem: XtreamVodDetails | null): void { + async onPrimaryAction(vodItem: XtreamVodDetails | null): Promise { + // A pinned source is an explicit "play this movie from here", so it + // outranks the playlist the route happens to be on. Falls through to + // the normal path when nothing is pinned or the pin cannot resolve. + if (await this.multiSource.playPinnedSource()) { + return; + } + this.playback.onPrimaryAction(vodItem); } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-current-row.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-current-row.ts new file mode 100644 index 000000000..1cb7cd17b --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-current-row.ts @@ -0,0 +1,24 @@ +import type { VodSourceCandidate } from '@iptvnator/shared/interfaces'; +import type { VodMultiSourceMovie } from './vod-multi-source-identity'; + +/** + * The row standing for the source the route is already playing. + * + * Discovery only returns OTHER playlists, so without this the popover would + * list the alternatives and say nothing about where the film is coming from + * right now — and the "playing" badge would have nothing to attach to. + */ +export function currentSourceRow( + movie: VodMultiSourceMovie +): VodSourceCandidate { + return { + id: `${movie.playlistId}:xtream:${movie.contentId}`, + playlistId: movie.playlistId, + playlistName: movie.playlistName, + portalType: 'xtream', + contentId: movie.contentId, + rawTitle: movie.title, + matchConfidence: 'exact', + year: movie.year ?? null, + }; +} 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 2ce2c73c5..eee98109c 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 @@ -9,9 +9,7 @@ import { StreamProbeService, VodSourcePinService, } from '@iptvnator/services'; -import type { - VodSourceCandidate, -} from '@iptvnator/shared/interfaces'; +import type { VodSourceCandidate } from '@iptvnator/shared/interfaces'; import { VodMultiSourceHostService } from './vod-multi-source-host.service'; import type { VodMultiSourceMovie } from './vod-multi-source-identity'; 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 0abdcb409..4e5a29b48 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 @@ -233,22 +233,70 @@ describe('VodMultiSourceHostService', () => { expect(service.isExhausted()).toBe(true); }); - it('does not retry a source it could not resolve', async () => { + it('keeps going when the best candidate cannot be resolved', async () => { await loadMovie([ALT_TWO, ALT_THREE]); vodAutoFailover.set(true); + // Top-ranked source has a dead account: get_vod_info yields nothing. resolver.resolve.mockResolvedValueOnce(null); - await expect(service.failover()).resolves.toBeNull(); - - const failedId = resolvedIds()[0]; - expect(rowFor(failedId)?.isActive).toBe(false); - expect(rowFor(failedId)?.isTried).toBe(true); - expect(startPlayback).not.toHaveBeenCalled(); - + // Production calls failover() once, on the original failure — giving up + // here would strand a perfectly healthy lower-ranked source. await expect(service.failover()).resolves.not.toBeNull(); - expect(resolvedIds()[1]).not.toBe(failedId); - expect(rowFor(resolvedIds()[1])?.isActive).toBe(true); + const [failedId, playingId] = resolvedIds(); + expect(playingId).not.toBe(failedId); + expect(rowFor(failedId)?.isActive).toBe(false); + expect(rowFor(failedId)?.isTried).toBe(true); + expect(rowFor(playingId)?.isActive).toBe(true); + expect(startPlayback).toHaveBeenCalledTimes(1); + }); + + it('gives up once every candidate fails to resolve', async () => { + await loadMovie([ALT_TWO, ALT_THREE]); + vodAutoFailover.set(true); + resolver.resolve.mockResolvedValue(null); + + // Each attempt marks its source tried, so this terminates rather than + // spinning through the same candidate forever. + await expect(service.failover()).resolves.toBeNull(); + expect(startPlayback).not.toHaveBeenCalled(); + expect(service.isExhausted()).toBe(true); + }); + + it('plays the pinned source instead of the route playlist', async () => { + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }); + await loadMovie([ALT_TWO, ALT_THREE]); + + // Without this the persisted preference is only an icon: reopening the + // movie would still start the playlist the route is on. + await expect(service.playPinnedSource()).resolves.toBe(true); + expect(rowFor(ALT_TWO.id)?.isActive).toBe(true); + }); + + it('leaves Play alone when nothing is pinned', async () => { + await loadMovie([ALT_TWO]); + + await expect(service.playPinnedSource()).resolves.toBe(false); + expect(startPlayback).not.toHaveBeenCalled(); + }); + + it('prefers the pinned source when failing over', async () => { + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: ALT_THREE.playlistId, + contentId: ALT_THREE.contentId, + portalType: 'xtream', + }); + await loadMovie([ALT_TWO, ALT_THREE]); + vodAutoFailover.set(true); + + await expect(service.failover()).resolves.not.toBeNull(); + expect(rowFor(ALT_THREE.id)?.isActive).toBe(true); }); it('round-trips a pin under the movie match key', async () => { 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 5185c34cf..9c7553568 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 @@ -10,7 +10,6 @@ import { VodMultiSourceController, VodSourceDiscoveryService, VodSourceResolverService, - audioDiffersFactually, } from '@iptvnator/portal/shared/data-access'; import { SettingsStore, @@ -21,8 +20,16 @@ import { vodMultiSourceMovieKey, type VodMultiSourceMovie, } from './vod-multi-source-identity'; +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, +} from './vod-multi-source-pin'; import { - buildVodSourceMatchKey, buildVodSourceMatchKeyCandidates, type ResolvedPortalPlayback, type VodSourceCandidate, @@ -48,6 +55,12 @@ 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; @@ -174,18 +187,7 @@ export class VodMultiSourceHostService { return; } - // The playing source is part of the list, so the popover can show it - // as current rather than pretending the movie came from nowhere. - const current: VodSourceCandidate = { - id: `${movie.playlistId}:xtream:${movie.contentId}`, - playlistId: movie.playlistId, - playlistName: movie.playlistName, - portalType: 'xtream', - contentId: movie.contentId, - rawTitle: movie.title, - matchConfidence: 'exact', - year: movie.year ?? null, - }; + const current = currentSourceRow(movie); this.controller.setSources( [current, ...result.sources], @@ -193,19 +195,38 @@ export class VodMultiSourceHostService { ); this.controller.setActiveSource(current.id); - const pin = await this.pins.get(this.matchKeys); + const pin = await readPin(this.pins, this.matchKeys); if (token !== this.discoveryToken) { return; } if (pin) { - this.controller.setPinnedSource( - `${pin.playlistId}:${pin.portalType}:${pin.contentId}` - ); + this.controller.setPinnedSource(pinnedSourceIdOf(pin)); } this.publish(); } + /** + * The pinned source, when it is not the one the route already plays. + * + * "Make this the main source" has to survive reopening the movie, or the + * persisted preference is just an icon. The host consults this before its + * normal Play so the pin decides where playback starts. + */ + readonly pendingPinnedSourceId = computed(() => { + const pinned = this._sources().find((source) => source.isPinned); + return pinned && !pinned.isActive ? pinned.id : null; + }); + + /** + * Start from the pinned source if there is one. Returns false when there is + * nothing pinned to honour, leaving the caller's own Play path in charge. + */ + async playPinnedSource(): Promise { + const pinnedId = this.pendingPinnedSourceId(); + return pinnedId ? this.play(pinnedId) : false; + } + /** Play from a specific source once; does not change the pin. */ async play(sourceId: string): Promise { const candidate = this.controller.findSource(sourceId); @@ -215,7 +236,7 @@ export class VodMultiSourceHostService { this._busySourceId.set(sourceId); try { - return await this.switchTo(candidate); + return (await this.switchTo(candidate)) === 'switched'; } finally { this._busySourceId.set(null); } @@ -232,7 +253,7 @@ export class VodMultiSourceHostService { ); if (alreadyPinned) { - await this.pins.clear(this.matchKeys); + await erasePin(this.pins, this.matchKeys); this.controller.setPinnedSource(null); this.publish(); return; @@ -243,60 +264,22 @@ export class VodMultiSourceHostService { return; } - // Written under the most-trusted key; lookups pass every alias, so a - // TMDB id arriving later does not orphan this row. - const matchKey = this.matchKeys[0] ?? buildVodSourceMatchKey(candidate); - if (!matchKey) { - return; + if (await writePin(this.pins, this.matchKeys, candidate)) { + this.controller.setPinnedSource(sourceId); + this.publish(); } - - await this.pins.set({ - matchKey, - playlistId: candidate.playlistId, - contentId: candidate.contentId, - portalType: candidate.portalType, - }); - this.controller.setPinnedSource(sourceId); - this.publish(); } /** User-triggered availability check for one row. */ async check(sourceId: string): Promise { - const candidate = this.controller.findSource(sourceId); - if (!candidate) { - return; - } - - // Same hazard as a switch: two awaits follow, and the user may open a - // different movie meanwhile. Writing a probe result into the new - // movie's controller would attach it to an unrelated source. - const session = this.discoveryToken; - const controller = this.controller; - - controller.setProbe(sourceId, { status: 'probing' }); - this.publish(); - - const resolved = await this.resolver.resolve(candidate); - if (session !== this.discoveryToken) { - return; - } - - if (!resolved) { - // We could not even build a URL — that is "cannot check", not - // "this source is dead". - controller.setProbe(sourceId, { status: 'unknown' }); - this.publish(); - return; - } - - controller.updateSource(resolved.candidate); - const result = await this.probes.probe(resolved.playback.streamUrl); - if (session !== this.discoveryToken) { - return; - } - - controller.setProbe(sourceId, result); - this.publish(); + await probeSource(sourceId, { + controller: this.controller, + resolver: this.resolver, + probes: this.probes, + isCurrent: (session) => session === this.discoveryToken, + session: this.discoveryToken, + publish: () => this.publish(), + }); } /** @@ -311,13 +294,28 @@ export class VodMultiSourceHostService { return null; } - const target = this.controller.pickFailoverTarget(); - if (!target) { - 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 switched = await this.switchTo(target); - return switched ? this._lastSwitch() : 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. + } } /** True once every alternative has been attempted this session. */ @@ -330,9 +328,11 @@ export class VodMultiSourceHostService { this.controller.setResumeSeconds(seconds); } - private async switchTo(candidate: VodSourceCandidate): Promise { + private async switchTo( + candidate: VodSourceCandidate + ): Promise { if (!this.bindings) { - return false; + return 'superseded'; } const session = this.discoveryToken; @@ -354,7 +354,7 @@ export class VodMultiSourceHostService { }); if (!this.isCurrentSwitch(session, attempt)) { // Superseded mid-flight — dropping the result is the whole point. - return false; + return 'superseded'; } if (!resolved) { @@ -363,7 +363,7 @@ export class VodMultiSourceHostService { // never started playing either. controller.markTried(candidate.id); this.publish(); - return false; + return 'unresolvable'; } controller.updateSource(resolved.candidate); @@ -372,15 +372,16 @@ export class VodMultiSourceHostService { controller.setResumeSeconds(resumeSeconds); this.bindings.startPlayback(resolved.playback); - this._lastSwitch.set({ - playlistName: candidate.playlistName, - resumeSeconds, - audioMayDiffer: audioDiffersFactually(previous, resolved.candidate), - quality: resolved.candidate.quality?.value, - container: resolved.candidate.container?.value, - }); + this._lastSwitch.set( + buildSwitchNotice( + candidate, + resolved.candidate, + previous, + resumeSeconds + ) + ); this.publish(); - return true; + return 'switched'; } private publish(): void { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-identity.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-identity.spec.ts new file mode 100644 index 000000000..48f9821b8 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-identity.spec.ts @@ -0,0 +1,108 @@ +import { + resolveVodMultiSourceMovie, + vodMultiSourceMovieKey, + type VodMultiSourceMovie, +} from './vod-multi-source-identity'; + +const BASE = { + playlistId: 'playlist-1', + playlistName: 'Portal One', + vodId: 101, + vodInfo: null, + catalogItem: null, +}; + +function movie(overrides: Partial = {}) { + return { + playlistId: 'playlist-1', + playlistName: 'Portal One', + contentId: 101, + title: 'Dune', + year: null, + ...overrides, + } as VodMultiSourceMovie; +} + +describe('resolveVodMultiSourceMovie', () => { + it('is null until a title is knowable', () => { + expect(resolveVodMultiSourceMovie(BASE)).toBeNull(); + }); + + it('is null without a usable playlist or vod id', () => { + const withTitle = { ...BASE, catalogItem: { title: 'Dune' } }; + expect( + resolveVodMultiSourceMovie({ ...withTitle, playlistId: null }) + ).toBeNull(); + expect( + resolveVodMultiSourceMovie({ ...withTitle, vodId: 0 }) + ).toBeNull(); + }); + + it('takes the title from the catalog row before metadata arrives', () => { + const resolved = resolveVodMultiSourceMovie({ + ...BASE, + catalogItem: { title: 'Dune' }, + }); + + expect(resolved).toEqual( + expect.objectContaining({ title: 'Dune', contentId: 101 }) + ); + }); + + it('prefers the provider metadata title once it lands', () => { + const resolved = resolveVodMultiSourceMovie({ + ...BASE, + catalogItem: { title: 'Dune' }, + vodInfo: { + name: 'Dune: Part Two', + releasedate: '2024-02-27', + tmdb_id: 693134, + } as never, + }); + + expect(resolved?.title).toBe('Dune: Part Two'); + expect(resolved?.year).toBe(2024); + expect(resolved?.tmdbId).toBe(693134); + }); + + it('falls back to the playlist id when the name is empty', () => { + const resolved = resolveVodMultiSourceMovie({ + ...BASE, + playlistName: ' ', + catalogItem: { title: 'Dune' }, + }); + + expect(resolved?.playlistName).toBe('playlist-1'); + }); +}); + +describe('vodMultiSourceMovieKey', () => { + it('is stable for the same movie described the same way', () => { + expect(vodMultiSourceMovieKey(movie())).toBe( + vodMultiSourceMovieKey(movie()) + ); + }); + + it('changes when enrichment supplies the release year', () => { + // The catalog title usually arrives before get_vod_info. If the key + // ignored the year, discovery would stay yearless forever because the + // host would never see the identity change. + expect(vodMultiSourceMovieKey(movie({ year: 2021 }))).not.toBe( + vodMultiSourceMovieKey(movie()) + ); + }); + + it('changes when enrichment supplies a TMDB id', () => { + // Without this, a `tmdb:`-keyed pin saved earlier would never be found, + // because the pin lookup keys are only rebuilt on a reload. + expect(vodMultiSourceMovieKey(movie({ tmdbId: 438631 }))).not.toBe( + vodMultiSourceMovieKey(movie()) + ); + }); + + it('changes for a different movie in the same playlist', () => { + expect(vodMultiSourceMovieKey(movie({ contentId: 202 }))).not.toBe( + vodMultiSourceMovieKey(movie()) + ); + }); +}); 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 315daf913..52917be8c 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 @@ -64,11 +64,20 @@ export function resolveVodMultiSourceMovie(input: { } /** - * Identity key for "this is still the same movie". + * Identity key for "this is still the same movie, described the same way". * - * Includes the title because it can arrive after the ids do — the first pass - * may run against the catalog title and a better one may follow. + * Includes every field that changes matching or pin lookup, not just the ids. + * The catalog title usually arrives before `get_vod_info` supplies the TMDB id + * and release year; if the key ignored those, enrichment would never trigger a + * reload, discovery would stay yearless and a `tmdb:`-keyed pin would never be + * found. */ export function vodMultiSourceMovieKey(movie: VodMultiSourceMovie): string { - return `${movie.playlistId}:${movie.contentId}:${movie.title}`; + return [ + movie.playlistId, + movie.contentId, + movie.title, + movie.year ?? '', + movie.tmdbId ?? '', + ].join(':'); } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-notice.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-notice.ts new file mode 100644 index 000000000..30fa125f1 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-notice.ts @@ -0,0 +1,25 @@ +import { audioDiffersFactually } from '@iptvnator/portal/shared/data-access'; +import type { VodSourceCandidate } from '@iptvnator/shared/interfaces'; +import type { VodMultiSourceSwitchNotice } from './vod-multi-source-host.service'; + +/** + * Builds what the user is told after a switch. + * + * A switch is never silent, so this always produces a notice. The dub warning + * is deliberately conservative: it fires only when BOTH sides state an audio + * track as fact, because warning on a guess would train people to ignore it. + */ +export function buildSwitchNotice( + candidate: VodSourceCandidate, + resolved: VodSourceCandidate, + previous: VodSourceCandidate | null | undefined, + resumeSeconds: number +): VodMultiSourceSwitchNotice { + return { + playlistName: candidate.playlistName, + resumeSeconds, + audioMayDiffer: audioDiffersFactually(previous, resolved), + quality: resolved.quality?.value, + container: resolved.container?.value, + }; +} 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 new file mode 100644 index 000000000..7e904abda --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts @@ -0,0 +1,61 @@ +import type { VodSourcePinService } from '@iptvnator/services'; +import { + buildVodSourceMatchKey, + type VodSourceCandidate, + type VodSourcePin, +} from '@iptvnator/shared/interfaces'; + +/** + * Reading and writing the per-movie pinned source. + * + * Split out of the host service to keep it inside its line budget; the pin is a + * self-contained errand with its own key handling, and nothing else in the + * host needs to know how a match key is chosen. + */ + +/** The row id the controller uses, derived from a stored pin. */ +export function pinnedSourceIdOf(pin: VodSourcePin): string { + return `${pin.playlistId}:${pin.portalType}:${pin.contentId}`; +} + +export async function readPin( + pins: Pick, + matchKeys: readonly string[] +): Promise { + return matchKeys.length > 0 ? pins.get([...matchKeys]) : null; +} + +/** + * Persist the pin for `candidate`. + * + * Written under the most-trusted key while lookups pass every alias, so a TMDB + * id arriving after the fact does not orphan the row. + */ +export async function writePin( + pins: Pick, + matchKeys: readonly string[], + candidate: VodSourceCandidate +): Promise { + const matchKey = matchKeys[0] ?? buildVodSourceMatchKey(candidate); + if (!matchKey) { + return false; + } + + await pins.set({ + matchKey, + playlistId: candidate.playlistId, + contentId: candidate.contentId, + portalType: candidate.portalType, + }); + return true; +} + +/** Clears every alias, so unpinning is not undone by a stale row. */ +export async function erasePin( + pins: Pick, + matchKeys: readonly string[] +): Promise { + if (matchKeys.length > 0) { + await pins.clear([...matchKeys]); + } +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-probe.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-probe.ts new file mode 100644 index 000000000..dc82285e9 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-probe.ts @@ -0,0 +1,57 @@ +import type { + VodMultiSourceController, + VodSourceResolverService, +} from '@iptvnator/portal/shared/data-access'; +import type { StreamProbeService } from '@iptvnator/services'; + +/** + * The on-demand availability check for a single row. + * + * Two awaits happen here — resolving a URL, then contacting it — and the user + * can open another movie in between. Writing either result into the + * replacement controller would attach a stale verdict to an unrelated source, + * so the session is revalidated after each one. + */ +export interface ProbeSourceDeps { + controller: VodMultiSourceController; + resolver: Pick; + probes: Pick; + session: number; + isCurrent: (session: number) => boolean; + publish: () => void; +} + +export async function probeSource( + sourceId: string, + deps: ProbeSourceDeps +): Promise { + const { controller, session, isCurrent, publish } = deps; + const candidate = controller.findSource(sourceId); + if (!candidate) { + return; + } + + controller.setProbe(sourceId, { status: 'probing' }); + publish(); + + const resolved = await deps.resolver.resolve(candidate); + if (!isCurrent(session)) { + return; + } + + if (!resolved) { + // No URL could be built — that is "cannot check", not "this is dead". + controller.setProbe(sourceId, { status: 'unknown' }); + publish(); + return; + } + + controller.updateSource(resolved.candidate); + const result = await deps.probes.probe(resolved.playback.streamUrl); + if (!isCurrent(session)) { + return; + } + + controller.setProbe(sourceId, result); + publish(); +} diff --git a/libs/services/src/lib/stream-probe.service.spec.ts b/libs/services/src/lib/stream-probe.service.spec.ts new file mode 100644 index 000000000..dd0f77790 --- /dev/null +++ b/libs/services/src/lib/stream-probe.service.spec.ts @@ -0,0 +1,132 @@ +import { Injector, runInInjectionContext } from '@angular/core'; +import { StreamProbeService } from './stream-probe.service'; + +type ProbeFn = jest.Mock< + Promise<{ status: number; url: string; latencyMs?: number }>, + [string, ('GET' | 'HEAD')?] +>; + +/** + * jsdom owns `window`, so the bridge is attached to it rather than replacing + * it — assigning over `globalThis.window` silently does nothing here. + */ +function withBridge(probeStreamUrl: ProbeFn | undefined) { + const host = window as unknown as { electron?: unknown }; + if (probeStreamUrl) { + host.electron = { probeStreamUrl }; + } else { + delete host.electron; + } + const injector = Injector.create({ providers: [] }); + return runInInjectionContext(injector, () => new StreamProbeService()); +} + +function ok(status: number, latencyMs = 42): ProbeFn { + return jest.fn().mockResolvedValue({ status, url: 'u', latencyMs }); +} + +describe('StreamProbeService', () => { + afterEach(() => { + delete (window as unknown as { electron?: unknown }).electron; + }); + + it('reports unknown, not fail, without a bridge', async () => { + const service = withBridge(undefined); + + expect(service.isAvailable).toBe(false); + await expect(service.probe('http://x/y.mkv')).resolves.toEqual({ + status: 'unknown', + }); + }); + + it('maps a success status to ok with its latency', async () => { + const probe = ok(200); + const service = withBridge(probe); + + const result = await service.probe('http://x/y.mkv'); + + expect(probe).toHaveBeenCalledWith('http://x/y.mkv', 'HEAD'); + expect(result).toEqual( + expect.objectContaining({ status: 'ok', latencyMs: 42 }) + ); + }); + + it('maps a refusal to fail', async () => { + const service = withBridge(ok(403)); + + await expect(service.probe('http://x/y.mkv')).resolves.toEqual( + expect.objectContaining({ status: 'fail', httpStatus: 403 }) + ); + }); + + it('maps status 0 to unknown — never to unavailable', async () => { + // 0 covers a timeout and a policy rejection as well as a dead host, so + // it means "could not check", not "the source is dead". + const service = withBridge(ok(0)); + + await expect(service.probe('http://x/y.mkv')).resolves.toEqual( + expect.objectContaining({ status: 'unknown', httpStatus: 0 }) + ); + }); + + it.each([405, 501])( + 'retries with a ranged GET when the server refuses HEAD with %i', + async (status) => { + // Plenty of stream servers reject HEAD yet serve the media over + // GET; reporting those as unavailable would be a confident lie. + const probe = jest + .fn() + .mockResolvedValueOnce({ status, url: 'u' }) + .mockResolvedValueOnce({ status: 206, url: 'u', latencyMs: 30 }) + .mockName('probeStreamUrl') as ProbeFn; + const service = withBridge(probe); + + const result = await service.probe('http://x/y.mkv'); + + expect(probe).toHaveBeenNthCalledWith(1, 'http://x/y.mkv', 'HEAD'); + expect(probe).toHaveBeenNthCalledWith(2, 'http://x/y.mkv', 'GET'); + expect(result.status).toBe('ok'); + } + ); + + it('does not retry a plain refusal', async () => { + const probe = ok(403); + const service = withBridge(probe); + + await service.probe('http://x/y.mkv'); + + expect(probe).toHaveBeenCalledTimes(1); + }); + + it('serves a repeat probe from cache', async () => { + const probe = ok(200); + const service = withBridge(probe); + + await service.probe('http://x/y.mkv'); + await service.probe('http://x/y.mkv'); + + expect(probe).toHaveBeenCalledTimes(1); + }); + + it('collapses concurrent probes of the same url into one request', async () => { + const probe = ok(200); + const service = withBridge(probe); + + await Promise.all([ + service.probe('http://x/y.mkv'), + service.probe('http://x/y.mkv'), + ]); + + expect(probe).toHaveBeenCalledTimes(1); + }); + + it('does not cache an unknown outcome', async () => { + // Caching "could not measure" would keep a good source looking + // unverified for the whole TTL. + const probe = ok(0); + const service = withBridge(probe); + + await service.probe('http://x/y.mkv'); + expect(service.peek('http://x/y.mkv')).toBeNull(); + }); +}); diff --git a/libs/services/src/lib/stream-probe.service.ts b/libs/services/src/lib/stream-probe.service.ts index 01740e63b..e12d6929d 100644 --- a/libs/services/src/lib/stream-probe.service.ts +++ b/libs/services/src/lib/stream-probe.service.ts @@ -80,7 +80,16 @@ export class StreamProbeService { let result: VodSourceProbeResult; try { - const response = await window.electron.probeStreamUrl(url, method); + let response = await window.electron.probeStreamUrl(url, method); + + // Plenty of stream servers reject HEAD outright yet serve the media + // happily over GET. Reporting those as unavailable would be a + // confident lie, so retry once with the ranged GET the main process + // already supports. + if (method === 'HEAD' && refusesHeadRequests(response.status)) { + response = await window.electron.probeStreamUrl(url, 'GET'); + } + result = { status: toProbeStatus(response.status), httpStatus: response.status, @@ -110,6 +119,11 @@ export class StreamProbeService { * redirect-safety policy refused. That is NOT evidence the source is dead, so * it maps to 'unknown' and the UI keeps offering a check. */ +/** Statuses that mean "not this method", not "not this resource". */ +function refusesHeadRequests(httpStatus: number): boolean { + return httpStatus === 405 || httpStatus === 501; +} + function toProbeStatus(httpStatus: number): VodSourceProbeResult['status'] { if (httpStatus === 0) { return 'unknown';