mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
fix(portals): make the pin decide playback and keep failover going
Second round of Greptile review findings.
**A pin had no behavioural effect.** Loading a stored pin only decorated the
row: Play still started the route's playlist and failover ranking ignored
`isPinned`, so "make this the main source" survived a restart as an icon and
nothing else. The primary action now starts from the pinned source when one is
set, and the pin outranks everything else in failover ranking.
**Failover stopped at the first unresolvable candidate.** An expired account or
a failing `get_vod_info` on the top-ranked source ended the attempt, and since
production calls `failover()` only once — on the original playback failure — a
healthy lower-ranked source was never reached. It now continues through untried
candidates. `switchTo` reports why it stopped so the loop can tell "could not
resolve, try the next one" from "something newer owns the screen"; without that
distinction a superseded switch would have spun forever, because only the
former marks the candidate tried.
**Identity ignored enrichment.** The key was `playlistId:contentId:title`, so
when `get_vod_info` added a TMDB id and release year to an unchanged title the
host saw no change, never reloaded, and kept yearless discovery and title-only
pin keys — a `tmdb:`-keyed pin could never be found. The key now covers every
field that affects matching.
**A server refusing HEAD read as unavailable.** Some stream hosts answer 405 or
501 to HEAD yet serve the media over GET. The probe now retries once with the
ranged GET the main process already supported, instead of caching a working
source as failed and penalising it during failover.
Greptile also flagged a missing token check after the resolve await in
`switchTo`; that guard landed in 4db3a2fd and sits on the line directly below.
Answered on the thread rather than changed.
The host service passed 400 lines again, so the pin, probe, switch-notice and
current-row concerns moved into focused modules beside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4db3a2fdea
commit
5514def368
13 files changed
+600
-110
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
+58
-10
@@ -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 () => {
|
||||
|
||||
@@ -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<VodMultiSourceMovie | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a switch attempt ended. `unresolvable` is the only outcome failover may
|
||||
* continue past — `superseded` means something newer already owns the screen.
|
||||
*/
|
||||
type SwitchOutcome = 'switched' | 'unresolvable' | 'superseded';
|
||||
|
||||
export interface VodMultiSourceSwitchNotice {
|
||||
playlistName: string;
|
||||
resumeSeconds: number;
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
private async switchTo(
|
||||
candidate: VodSourceCandidate
|
||||
): Promise<SwitchOutcome> {
|
||||
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 {
|
||||
|
||||
@@ -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<VodMultiSourceMovie> = {}) {
|
||||
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())
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(':');
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<VodSourcePinService, 'get'>,
|
||||
matchKeys: readonly string[]
|
||||
): Promise<VodSourcePin | null> {
|
||||
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<VodSourcePinService, 'set'>,
|
||||
matchKeys: readonly string[],
|
||||
candidate: VodSourceCandidate
|
||||
): Promise<boolean> {
|
||||
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<VodSourcePinService, 'clear'>,
|
||||
matchKeys: readonly string[]
|
||||
): Promise<void> {
|
||||
if (matchKeys.length > 0) {
|
||||
await pins.clear([...matchKeys]);
|
||||
}
|
||||
}
|
||||
@@ -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<VodSourceResolverService, 'resolve'>;
|
||||
probes: Pick<StreamProbeService, 'probe'>;
|
||||
session: number;
|
||||
isCurrent: (session: number) => boolean;
|
||||
publish: () => void;
|
||||
}
|
||||
|
||||
export async function probeSource(
|
||||
sourceId: string,
|
||||
deps: ProbeSourceDeps
|
||||
): Promise<void> {
|
||||
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();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user