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 02b9ea14c..dafd9b592 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 @@ -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 () => { 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 0f0a899fc..4cea96c69 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 @@ -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 []; 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 new file mode 100644 index 000000000..2ce2c73c5 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts @@ -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(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 { + 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>(); + 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>(); + 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>(); + 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'); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.fixtures.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.fixtures.ts new file mode 100644 index 000000000..50d0b71a7 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.fixtures.ts @@ -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() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} 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 109c40c39..0abdcb409 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 @@ -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() { - let resolve!: (value: T) => void; - const promise = new Promise((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; 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 9e9d17ae8..5185c34cf 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 @@ -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(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({