mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
fix(portals): stop stale source resolutions from committing
Addresses three defects Greptile found in the multi-source review. **Concurrent switches committed out of order.** Selecting a second source before the first resolution returned let the slower request overwrite the newer selection and repoint Undo at itself. `switchTo` now takes a sequence number and drops its result if a newer switch already committed. **Stale switches crossed movie sessions.** Navigating to another film while a resolution was in flight let the continuation activate the old film's source inside the new controller — and restart it from that session's zero resume position. The controller is now snapshotted per operation and the movie session is revalidated after every await. `check()` had the same hazard across its two awaits and is guarded the same way. **Short titles skipped discovery entirely.** The trigram tokenizer cannot index tokens under three characters, so "Up", "It" or "Us" produced an empty MATCH expression and the query was discarded before SQLite was consulted — the chip could never appear for those films. Discovery now falls back to a bounded scan when FTS structurally cannot serve the title; the existing two-tier normalized confirmation still rejects loose hits like "Upgrade". Each fix carries a regression test; all three were mutation-checked by removing the guard and confirming exactly those tests fail. The previous test asserting that short titles return nothing encoded the bug and has been replaced. The host spec passed 400 lines, so its fixtures moved to a shared module and the race suite into its own file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
004cb65fe6
commit
4db3a2fdea
6 files changed
+392
-108
No files matched your search
@@ -40,15 +40,32 @@ describe('title-sources.operations', () => {
|
||||
expect(all).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns nothing when no token survives the trigram minimum', async () => {
|
||||
const { db, all } = createDbMock([duneRow]);
|
||||
it('still searches when no token survives the trigram minimum', async () => {
|
||||
// "up" is two chars, so the trigram tokenizer cannot index it. That
|
||||
// is a real movie title, and it must not silently return nothing —
|
||||
// the query falls back to a scan instead of being discarded.
|
||||
const upRow = { ...duneRow, title: 'Up', xtream_id: 77 };
|
||||
const { db, all } = createDbMock([upRow]);
|
||||
|
||||
const result = await findTitleSources(db, { title: 'Up' });
|
||||
|
||||
expect(all).toHaveBeenCalledTimes(1);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual(
|
||||
expect.objectContaining({ xtreamId: 77, matchConfidence: 'exact' })
|
||||
);
|
||||
});
|
||||
|
||||
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".
|
||||
const { db } = createDbMock([
|
||||
{ ...duneRow, title: 'Upgrade', xtream_id: 78 },
|
||||
]);
|
||||
|
||||
// "up" is two chars — the trigram tokenizer cannot match it, so
|
||||
// there is no FTS query left to run.
|
||||
await expect(
|
||||
findTitleSources(db, { title: 'Up' })
|
||||
).resolves.toEqual([]);
|
||||
expect(all).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns nothing instead of throwing when the FTS query fails', async () => {
|
||||
|
||||
@@ -50,6 +50,59 @@ function buildFtsMatchQuery(normalizedTitle: string): string {
|
||||
return tokens.join(' AND ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function scanCandidateQuery(base: string) {
|
||||
const like = `%${base.replace(/[\\%_]/g, '\\$&')}%`;
|
||||
return sql`
|
||||
SELECT
|
||||
c.id AS content_id,
|
||||
c.title AS title,
|
||||
c.xtream_id AS xtream_id,
|
||||
c.poster_url AS poster_url,
|
||||
cat.xtream_id AS category_xtream_id,
|
||||
cat.playlist_id AS playlist_id,
|
||||
p.name AS playlist_name
|
||||
FROM content AS c
|
||||
INNER JOIN categories AS cat ON c.category_id = cat.id
|
||||
INNER JOIN playlists AS p ON cat.playlist_id = p.id
|
||||
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}
|
||||
`;
|
||||
}
|
||||
|
||||
function ftsCandidateQuery(matchQuery: string) {
|
||||
return sql`
|
||||
SELECT
|
||||
c.id AS content_id,
|
||||
c.title AS title,
|
||||
c.xtream_id AS xtream_id,
|
||||
c.poster_url AS poster_url,
|
||||
cat.xtream_id AS category_xtream_id,
|
||||
cat.playlist_id AS playlist_id,
|
||||
p.name AS playlist_name
|
||||
FROM content_title_fts
|
||||
INNER JOIN content AS c ON c.id = content_title_fts.rowid
|
||||
INNER JOIN categories AS cat ON c.category_id = cat.id
|
||||
INNER JOIN playlists AS p ON cat.playlist_id = p.id
|
||||
WHERE content_title_fts MATCH ${matchQuery}
|
||||
AND c.type = 'movie'
|
||||
AND cat.hidden = 0
|
||||
AND p.type = 'xtream'
|
||||
ORDER BY rank, c.title
|
||||
LIMIT ${CANDIDATE_LIMIT}
|
||||
`;
|
||||
}
|
||||
|
||||
export async function findTitleSources(
|
||||
db: AppDatabase,
|
||||
request: FindTitleSourcesRequest
|
||||
@@ -60,38 +113,26 @@ export async function findTitleSources(
|
||||
}
|
||||
|
||||
const wanted = normalizeTitleKeys(rawTitle);
|
||||
// Search on the year-stripped form so a portal that tags the year and one
|
||||
// that does not still find each other; the year gate below re-tightens it.
|
||||
const matchQuery = buildFtsMatchQuery(wanted.base);
|
||||
if (!wanted.base || !matchQuery) {
|
||||
if (!wanted.base) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// The year the caller knows, falling back to a release tag on the title.
|
||||
const wantedYear = request.year ?? wanted.trailingYear ?? null;
|
||||
|
||||
// Search on the year-stripped form so a portal that tags the year and one
|
||||
// that does not still find each other; the year gate below re-tightens it.
|
||||
const matchQuery = buildFtsMatchQuery(wanted.base);
|
||||
|
||||
let rows: TitleSourceRow[];
|
||||
try {
|
||||
rows = (await db.all(sql`
|
||||
SELECT
|
||||
c.id AS content_id,
|
||||
c.title AS title,
|
||||
c.xtream_id AS xtream_id,
|
||||
c.poster_url AS poster_url,
|
||||
cat.xtream_id AS category_xtream_id,
|
||||
cat.playlist_id AS playlist_id,
|
||||
p.name AS playlist_name
|
||||
FROM content_title_fts
|
||||
INNER JOIN content AS c ON c.id = content_title_fts.rowid
|
||||
INNER JOIN categories AS cat ON c.category_id = cat.id
|
||||
INNER JOIN playlists AS p ON cat.playlist_id = p.id
|
||||
WHERE content_title_fts MATCH ${matchQuery}
|
||||
AND c.type = 'movie'
|
||||
AND cat.hidden = 0
|
||||
AND p.type = 'xtream'
|
||||
ORDER BY rank, c.title
|
||||
LIMIT ${CANDIDATE_LIMIT}
|
||||
`)) as TitleSourceRow[];
|
||||
rows = matchQuery
|
||||
? ((await db.all(
|
||||
ftsCandidateQuery(matchQuery)
|
||||
)) as TitleSourceRow[])
|
||||
: ((await db.all(
|
||||
scanCandidateQuery(wanted.base)
|
||||
)) as TitleSourceRow[]);
|
||||
} catch {
|
||||
// Malformed FTS query for exotic titles — no sources, not a crash
|
||||
return [];
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
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,
|
||||
MOVIE_A,
|
||||
MOVIE_B,
|
||||
PROBE_OK,
|
||||
createDeferred,
|
||||
resolveWith,
|
||||
resolvedFor,
|
||||
} from './vod-multi-source-host.fixtures';
|
||||
|
||||
describe('VodMultiSourceHostService — stale resolutions', () => {
|
||||
let service: VodMultiSourceHostService;
|
||||
|
||||
const movie = signal<VodMultiSourceMovie | null>(null);
|
||||
const vodAutoFailover = signal(false);
|
||||
const startPlayback = jest.fn();
|
||||
const discovery = { isAvailable: true, discover: jest.fn() };
|
||||
const resolver = { resolve: jest.fn() };
|
||||
const pins = { get: jest.fn(), set: jest.fn(), clear: jest.fn() };
|
||||
const probes = { probe: jest.fn() };
|
||||
|
||||
/** Runs pending root effects, then drains the work they started. */
|
||||
|
||||
async function loadMovie(
|
||||
sources: VodSourceCandidate[],
|
||||
target: VodMultiSourceMovie = MOVIE_A
|
||||
): Promise<void> {
|
||||
discovery.discover.mockResolvedValue({
|
||||
sources,
|
||||
matchKind: 'title-year',
|
||||
});
|
||||
await service.load(target);
|
||||
}
|
||||
|
||||
function rowFor(sourceId: string) {
|
||||
return service.sources().find((source) => source.id === sourceId);
|
||||
}
|
||||
|
||||
/** Two consecutive switches, so the "previous" side carries real audio. */
|
||||
|
||||
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('drops a slower switch that a newer selection already superseded', async () => {
|
||||
await loadMovie([ALT_TWO, ALT_THREE]);
|
||||
|
||||
const slow = createDeferred<ReturnType<typeof resolvedFor>>();
|
||||
resolver.resolve
|
||||
.mockReturnValueOnce(slow.promise)
|
||||
.mockImplementation(resolveWith());
|
||||
|
||||
// User clicks ALT_TWO, then changes their mind and clicks
|
||||
// ALT_THREE before the first resolution comes back.
|
||||
const first = service.play(ALT_TWO.id);
|
||||
const second = await service.play(ALT_THREE.id);
|
||||
expect(second).toBe(true);
|
||||
|
||||
slow.resolve(resolvedFor(ALT_TWO, 0));
|
||||
await expect(first).resolves.toBe(false);
|
||||
|
||||
// The late arrival must not steal the active source back, nor
|
||||
// repoint Undo at itself.
|
||||
expect(rowFor(ALT_THREE.id)?.isActive).toBe(true);
|
||||
expect(rowFor(ALT_TWO.id)?.isActive).toBe(false);
|
||||
expect(startPlayback).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
streamUrl: expect.stringContaining(String(ALT_THREE.contentId)),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('drops a switch whose movie was navigated away from', async () => {
|
||||
await loadMovie([ALT_TWO]);
|
||||
service.reportPosition(2538);
|
||||
|
||||
const slow = createDeferred<ReturnType<typeof resolvedFor>>();
|
||||
resolver.resolve.mockReturnValueOnce(slow.promise);
|
||||
|
||||
const pending = service.play(ALT_TWO.id);
|
||||
|
||||
// A different movie opens while the resolution is still in flight.
|
||||
await loadMovie([ALT_TWO], MOVIE_B);
|
||||
startPlayback.mockClear();
|
||||
|
||||
slow.resolve(resolvedFor(ALT_TWO, 2538));
|
||||
await expect(pending).resolves.toBe(false);
|
||||
|
||||
// The old film's source must not start inside the new session —
|
||||
// that would also restart it from the new session's zero position.
|
||||
expect(startPlayback).not.toHaveBeenCalled();
|
||||
expect(rowFor(ALT_TWO.id)?.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it('drops a probe result whose movie was navigated away from', async () => {
|
||||
await loadMovie([ALT_TWO]);
|
||||
|
||||
const slow = createDeferred<ReturnType<typeof resolvedFor>>();
|
||||
resolver.resolve.mockReturnValueOnce(slow.promise);
|
||||
|
||||
const pending = service.check(ALT_TWO.id);
|
||||
await loadMovie([ALT_TWO], MOVIE_B);
|
||||
|
||||
slow.resolve(resolvedFor(ALT_TWO, undefined));
|
||||
await pending;
|
||||
|
||||
// A stale probe must not attach itself to the new movie's row.
|
||||
expect(probes.probe).not.toHaveBeenCalled();
|
||||
expect(rowFor(ALT_TWO.id)?.probe.status).toBe('idle');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Shared fixtures for the multi-source host specs, extracted so the
|
||||
* race-condition suite can live in its own file rather than duplicating setup.
|
||||
*/
|
||||
import type {
|
||||
ResolvedPortalPlayback,
|
||||
VodSourceCandidate,
|
||||
VodSourceField,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { VodMultiSourceMovie } from './vod-multi-source-identity';
|
||||
|
||||
export const MOVIE_A: VodMultiSourceMovie = {
|
||||
playlistId: 'playlist-1',
|
||||
playlistName: 'Playlist One',
|
||||
contentId: 101,
|
||||
title: 'The Matrix',
|
||||
year: 1999,
|
||||
};
|
||||
export const MOVIE_B: VodMultiSourceMovie = {
|
||||
...MOVIE_A,
|
||||
contentId: 202,
|
||||
title: 'Blade Runner',
|
||||
year: 1982,
|
||||
}; // a second movie, to prove load() fully resets the session
|
||||
export const CURRENT_A_ID = 'playlist-1:xtream:101';
|
||||
|
||||
export const API_AC3: VodSourceField = { value: 'ac3', provenance: 'api' };
|
||||
export const API_AAC: VodSourceField = { value: 'aac', provenance: 'api' };
|
||||
export const PARSED_DUB: VodSourceField = {
|
||||
value: 'Дубляж',
|
||||
provenance: 'parsed',
|
||||
};
|
||||
export const PROBE_OK = { status: 'ok', httpStatus: 200, latencyMs: 42 };
|
||||
|
||||
export type DiscoveryResult = {
|
||||
sources: VodSourceCandidate[];
|
||||
matchKind: string;
|
||||
};
|
||||
export type AudioPicker = (c: VodSourceCandidate) => VodSourceField | undefined;
|
||||
|
||||
export function alternative(index: number): VodSourceCandidate {
|
||||
return {
|
||||
id: `playlist-${index}:xtream:${900 + index}`,
|
||||
playlistId: `playlist-${index}`,
|
||||
playlistName: `Playlist ${index}`,
|
||||
portalType: 'xtream',
|
||||
contentId: 900 + index,
|
||||
rawTitle: 'The Matrix',
|
||||
matchConfidence: 'exact',
|
||||
year: 1999,
|
||||
};
|
||||
}
|
||||
|
||||
export const [ALT_TWO, ALT_THREE] = [alternative(2), alternative(3)];
|
||||
|
||||
export function resolvedFor(
|
||||
candidate: VodSourceCandidate,
|
||||
startTime: number | undefined,
|
||||
audio?: VodSourceField
|
||||
) {
|
||||
const playback: ResolvedPortalPlayback = {
|
||||
streamUrl: `http://${candidate.playlistId}/${candidate.contentId}.mkv`,
|
||||
title: candidate.rawTitle,
|
||||
isLive: false,
|
||||
startTime,
|
||||
};
|
||||
return { playback, candidate: { ...candidate, audio } };
|
||||
}
|
||||
|
||||
/** Stands in for the resolver: echoes the start time, injects audio facts. */
|
||||
export function resolveWith(audioFor?: AudioPicker) {
|
||||
return async (
|
||||
candidate: VodSourceCandidate,
|
||||
options?: { startTime?: number }
|
||||
) => resolvedFor(candidate, options?.startTime, audioFor?.(candidate));
|
||||
}
|
||||
|
||||
export function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
+14
-68
@@ -10,80 +10,26 @@ import {
|
||||
VodSourcePinService,
|
||||
} from '@iptvnator/services';
|
||||
import type {
|
||||
ResolvedPortalPlayback,
|
||||
VodSourceCandidate,
|
||||
VodSourceField,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
|
||||
import type { VodMultiSourceMovie } from './vod-multi-source-identity';
|
||||
|
||||
const MOVIE_A: VodMultiSourceMovie = {
|
||||
playlistId: 'playlist-1',
|
||||
playlistName: 'Playlist One',
|
||||
contentId: 101,
|
||||
title: 'The Matrix',
|
||||
year: 1999,
|
||||
};
|
||||
const MOVIE_B: VodMultiSourceMovie = {
|
||||
...MOVIE_A,
|
||||
contentId: 202,
|
||||
title: 'Blade Runner',
|
||||
year: 1982,
|
||||
}; // a second movie, to prove load() fully resets the session
|
||||
const CURRENT_A_ID = 'playlist-1:xtream:101';
|
||||
|
||||
const API_AC3: VodSourceField = { value: 'ac3', provenance: 'api' };
|
||||
const API_AAC: VodSourceField = { value: 'aac', provenance: 'api' };
|
||||
const PARSED_DUB: VodSourceField = { value: 'Дубляж', provenance: 'parsed' };
|
||||
const PROBE_OK = { status: 'ok', httpStatus: 200, latencyMs: 42 };
|
||||
|
||||
type DiscoveryResult = { sources: VodSourceCandidate[]; matchKind: string };
|
||||
type AudioPicker = (c: VodSourceCandidate) => VodSourceField | undefined;
|
||||
|
||||
function alternative(index: number): VodSourceCandidate {
|
||||
return {
|
||||
id: `playlist-${index}:xtream:${900 + index}`,
|
||||
playlistId: `playlist-${index}`,
|
||||
playlistName: `Playlist ${index}`,
|
||||
portalType: 'xtream',
|
||||
contentId: 900 + index,
|
||||
rawTitle: 'The Matrix',
|
||||
matchConfidence: 'exact',
|
||||
year: 1999,
|
||||
};
|
||||
}
|
||||
|
||||
const [ALT_TWO, ALT_THREE] = [alternative(2), alternative(3)];
|
||||
|
||||
function resolvedFor(
|
||||
candidate: VodSourceCandidate,
|
||||
startTime: number | undefined,
|
||||
audio?: VodSourceField
|
||||
) {
|
||||
const playback: ResolvedPortalPlayback = {
|
||||
streamUrl: `http://${candidate.playlistId}/${candidate.contentId}.mkv`,
|
||||
title: candidate.rawTitle,
|
||||
isLive: false,
|
||||
startTime,
|
||||
};
|
||||
return { playback, candidate: { ...candidate, audio } };
|
||||
}
|
||||
|
||||
/** Stands in for the resolver: echoes the start time, injects audio facts. */
|
||||
function resolveWith(audioFor?: AudioPicker) {
|
||||
return async (
|
||||
candidate: VodSourceCandidate,
|
||||
options?: { startTime?: number }
|
||||
) => resolvedFor(candidate, options?.startTime, audioFor?.(candidate));
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
import {
|
||||
ALT_THREE,
|
||||
ALT_TWO,
|
||||
API_AAC,
|
||||
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', () => {
|
||||
let service: VodMultiSourceHostService;
|
||||
|
||||
@@ -71,6 +71,20 @@ export class VodMultiSourceHostService {
|
||||
/** Guards against a stale discovery resolving after the user moved on. */
|
||||
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).
|
||||
*/
|
||||
private switchToken = 0;
|
||||
|
||||
/** True while `session`/`switch` still describe the operation in flight. */
|
||||
private isCurrentSwitch(session: number, attempt: number): boolean {
|
||||
return session === this.discoveryToken && attempt === this.switchToken;
|
||||
}
|
||||
/** The source that was playing before the most recent switch. */
|
||||
private readonly _previousSourceId = signal<string | null>(null);
|
||||
|
||||
@@ -253,21 +267,35 @@ export class VodMultiSourceHostService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.controller.setProbe(sourceId, { status: 'probing' });
|
||||
// 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".
|
||||
this.controller.setProbe(sourceId, { status: 'unknown' });
|
||||
controller.setProbe(sourceId, { status: 'unknown' });
|
||||
this.publish();
|
||||
return;
|
||||
}
|
||||
|
||||
this.controller.updateSource(resolved.candidate);
|
||||
controller.updateSource(resolved.candidate);
|
||||
const result = await this.probes.probe(resolved.playback.streamUrl);
|
||||
this.controller.setProbe(sourceId, result);
|
||||
if (session !== this.discoveryToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
controller.setProbe(sourceId, result);
|
||||
this.publish();
|
||||
}
|
||||
|
||||
@@ -307,30 +335,41 @@ export class VodMultiSourceHostService {
|
||||
return false;
|
||||
}
|
||||
|
||||
const session = this.discoveryToken;
|
||||
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.
|
||||
const controller = this.controller;
|
||||
|
||||
// Read the LIVE position, not the persisted one: the DB value lags by
|
||||
// up to 15 seconds and switching would visibly rewind.
|
||||
const resumeSeconds = Math.floor(this.controller.getResumeSeconds());
|
||||
const resumeSeconds = Math.floor(controller.getResumeSeconds());
|
||||
|
||||
const previous = this.controller.findSource(
|
||||
this.controller.activeSourceId() ?? ''
|
||||
const previous = controller.findSource(
|
||||
controller.activeSourceId() ?? ''
|
||||
);
|
||||
|
||||
const resolved = await this.resolver.resolve(candidate, {
|
||||
startTime: resumeSeconds,
|
||||
});
|
||||
if (!this.isCurrentSwitch(session, attempt)) {
|
||||
// Superseded mid-flight — dropping the result is the whole point.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!resolved) {
|
||||
// Mark it tried without making it active: a source we cannot even
|
||||
// build a URL for must not be offered again by failover, but it
|
||||
// never started playing either.
|
||||
this.controller.markTried(candidate.id);
|
||||
controller.markTried(candidate.id);
|
||||
this.publish();
|
||||
return false;
|
||||
}
|
||||
|
||||
this.controller.updateSource(resolved.candidate);
|
||||
controller.updateSource(resolved.candidate);
|
||||
this._previousSourceId.set(previous?.id ?? null);
|
||||
this.controller.setActiveSource(candidate.id);
|
||||
this.controller.setResumeSeconds(resumeSeconds);
|
||||
controller.setActiveSource(candidate.id);
|
||||
controller.setResumeSeconds(resumeSeconds);
|
||||
this.bindings.startPlayback(resolved.playback);
|
||||
|
||||
this._lastSwitch.set({
|
||||
|
||||
Reference in new issue
Block a user