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 68a103de4..f5a81c699 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 @@ -95,6 +95,32 @@ describe('title-sources.operations', () => { expect(compiledQuery(fts.all).sql).toContain('LIMIT'); }); + it('can still find a short non-ASCII title', async () => { + // SQLite's LOWER() and GLOB classes are ASCII-only, so folding + // "Он" to "он" never happened and the film stayed invisible in + // the Sources chip entirely. + const scan = createDbMock([]); + await findTitleSources(scan.db, { title: 'Он' }); + const scanQuery = compiledQuery(scan.all); + + expect(scanQuery.sql).toContain('instr'); + // Both the folded and the as-typed form, since neither alone + // matches a title stored in the other case. + expect(scanQuery.params).toContain('он'); + expect(scanQuery.params).toContain('Он'); + }); + + it('keeps the word boundary for ASCII tokens', async () => { + // The looser substring test must not leak into the ASCII path, + // where it would let "it" match "Titanic". + const scan = createDbMock([]); + await findTitleSources(scan.db, { title: 'It' }); + const scanQuery = compiledQuery(scan.all); + + expect(scanQuery.sql).toContain('GLOB ?'); + expect(scanQuery.sql).not.toContain('instr'); + }); + it('requires every token of an all-short multiword title', async () => { // "I Am" reaches the scan too — no token survives the trigram // minimum. Matching on the first token alone returns every catalog 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 11c86e4c9..a8b0c51b9 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 @@ -116,21 +116,48 @@ function excludePlaylistClause( * ("IT (2017) 1080p"). * * Still a necessary-not-sufficient filter — `normalizeTitleKeys` below is what - * confirms a match. Like the `LIKE` it replaces, it compares ASCII-lowercased - * text, so a non-ASCII short title is no better and no worse served than before. + * confirms a match. */ -function scanCandidateQuery(base: string, excludePlaylist: SQL) { +/** + * One token's presence test. + * + * SQLite's `LOWER()` and GLOB character classes are ASCII-only, so a Cyrillic + * or Greek token cannot be folded or word-bounded in stock SQLite — matching + * "он" against a stored "Он" would simply fail, and the film stayed invisible + * in the Sources chip. + * + * ASCII tokens keep the word-boundary GLOB, which is what stops "it" matching + * "Titanic". A non-ASCII token falls back to a substring test tried against + * both the folded and the as-typed form: that covers a title stored in the + * same case as the request or in lower case, which is the realistic pair, and + * it deliberately does NOT claim full Unicode case folding. It is looser than + * the ASCII branch, and the normalized confirmation afterwards is what makes + * that safe — a looser filter here costs transfer, never a wrong match. + */ +function tokenPredicate(token: string, rawToken: string): SQL { + // Normalized, so an ASCII token holds letters and digits only — nothing + // GLOB would read as a metacharacter. + if (/^[a-z0-9]+$/.test(token)) { + return sql`' ' || LOWER(c.title) || ' ' GLOB ${`*[^a-z0-9]${token}[^a-z0-9]*`}`; + } + + return sql`(instr(LOWER(c.title), ${token}) > 0 OR instr(c.title, ${rawToken}) > 0)`; +} + +function scanCandidateQuery( + base: string, + rawTitle: string, + excludePlaylist: SQL +) { // EVERY token has to appear, not just the first. This path is reached // whenever no token survives FTS's minimum length, which includes // multiword titles like "I Am" — matching on "i" alone would return most // of the catalog and hand it all to the TypeScript pass to throw away. const tokens = base.split(' ').filter(Boolean); - // Normalized, so they hold letters, digits and spaces only — nothing GLOB - // would read as a metacharacter. + const rawTokens = rawTitle.split(/\s+/).filter(Boolean); const wordMatches = sql.join( - tokens.map( - (token) => - sql`' ' || LOWER(c.title) || ' ' GLOB ${`*[^a-z0-9]${token}[^a-z0-9]*`}` + tokens.map((token, index) => + tokenPredicate(token, rawTokens[index] ?? token) ), sql` AND ` ); @@ -224,7 +251,7 @@ export async function findTitleSources( ftsCandidateQuery(matchQuery, excludePlaylist) )) as TitleSourceRow[]) : ((await db.all( - scanCandidateQuery(wanted.base, excludePlaylist) + scanCandidateQuery(wanted.base, rawTitle, excludePlaylist) )) as TitleSourceRow[]); } catch { // Malformed FTS query for exotic titles — no sources, not a crash diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index ecc88d12a..6e4c5d9f3 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -478,6 +478,17 @@ session first. It REPLACES what is playing — with MPV or VLC and instance reuse off, the backend would otherwise spawn a second detached player, leaving both sources running and Stop owning only the newer one. +## Short titles and Unicode + +SQLite's `LOWER()` and GLOB character classes are ASCII-only, so a short +non-ASCII title could not be folded or word-bounded and simply never matched — +the film stayed absent from the chip. ASCII tokens keep the word-boundary GLOB +(what stops "it" matching "Titanic"); a non-ASCII token falls back to a +substring test against both the folded and the as-typed form. That covers a +title stored in the same case as the request or in lower case, and deliberately +does not claim full Unicode case folding. It is looser, and the normalized +confirmation afterwards is what makes looser safe. + ## Claims about the present `isActive` means "the source a switch or Play would use" — selection, not diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts index cc0fb39fa..1fbded3cc 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts @@ -30,6 +30,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => { const addRecentItem = jest.fn(); const activeSession = signal(null); const closeSession = jest.fn().mockResolvedValue(undefined); + const openResolvedPlayback = jest.fn(); const activeSource = signal(null); function sessionFor(playlistId: string, contentXtreamId: number) { @@ -75,7 +76,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => { provide: PORTAL_PLAYER, useValue: { isEmbeddedPlayer: jest.fn().mockReturnValue(false), - openResolvedPlayback: jest.fn(), + openResolvedPlayback, }, }, { @@ -161,6 +162,44 @@ describe('VodDetailsPlaybackService — external session ownership', () => { expect(closeSession).toHaveBeenCalled(); }); + it('launches only the newest source when two switches overlap', async () => { + activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID)); + // One shared promise: both calls see the same running session, so + // both await the same close rather than each making its own. + let releaseClose: (() => void) | undefined; + const closing = new Promise((resolve) => { + releaseClose = () => resolve(); + }); + closeSession.mockReturnValue(closing); + // These spies are module-level and never cleared between cases. + openResolvedPlayback.mockClear(); + + const first = service.startResolvedPlayback({ + streamUrl: 'https://example.com/one.mkv', + title: 'Example Movie', + }); + const second = service.startResolvedPlayback({ + streamUrl: 'https://example.com/two.mkv', + title: 'Example Movie', + }); + + releaseClose?.(); + await Promise.all([first, second]); + + // Both saw the same running session and awaited its close. Launching + // both afterwards is how two detached players appear again, with the + // older one holding a source the user has already moved on from. + expect(openResolvedPlayback).toHaveBeenCalledTimes(1); + expect(openResolvedPlayback).toHaveBeenCalledWith( + expect.objectContaining({ + streamUrl: 'https://example.com/two.mkv', + }), + true + ); + + closeSession.mockResolvedValue(undefined); + }); + it('records a source started through multi-source as recently viewed', () => { // Playing an alternative from the picker, or letting a pin decide the // primary Play, is still watching the movie — it belongs in Recently diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index ddc49057e..f18d8d432 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -342,9 +342,14 @@ export class VodDetailsPlaybackService { * template already renders through `@if`, so the player component and its * engine survive and simply re-seek to `playback.startTime`. */ + /** Bumped by every start; only the newest may launch after its close. */ + private startGeneration = 0; + async startResolvedPlayback( playback: ResolvedPortalPlayback ): Promise { + const generation = ++this.startGeneration; + // A switch REPLACES what is playing. With MPV or VLC and instance // reuse off, the backend spawns a second detached player otherwise — // both sources keep running and Stop owns only the newer one. @@ -353,6 +358,14 @@ export class VodDetailsPlaybackService { await this.externalPlayback.closeSession(running); } + // Closing is a round-trip, and a second pick across it would otherwise + // reach this line too: both would have seen the same session, closed + // it once, and then launched independently — two detached players + // again, the older one holding a source the user has moved on from. + if (generation !== this.startGeneration) { + return; + } + // Same movie, different source: still a view. this.addToRecentlyViewed(); this.startPlayback(playback); 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 e250e537c..8a89de854 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 @@ -1,93 +1,34 @@ -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, + setupVodMultiSourceHost, } from './vod-multi-source-host.fixtures'; describe('VodMultiSourceHostService — stale resolutions', () => { - let service: VodMultiSourceHostService; - - const movie = signal(null); - - // Whatever is on screen; the pin path distinguishes it from selection. - - const playbackLive = signal(false); - 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); - } + const harness = setupVodMultiSourceHost(); + const { + discovery, + movie, + pins, + probes, + resolver, + startPlayback, + vodAutoFailover, + } = harness; + const loadMovie = harness.loadMovie; + const rowFor = harness.rowFor; + let service = harness.service; /** 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, playbackLive }) - ); + service = harness.reset(); }); it('drops a switch once the movie identity goes null mid-navigation', async () => { @@ -111,6 +52,37 @@ describe('VodMultiSourceHostService — stale resolutions', () => { expect(rowFor(ALT_TWO.id)?.isActive).toBe(false); }); + it('lets a source picked during the pinned lookup win', 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]); + + const lookup = createDeferred(); + let insideLookup = false; + const pinnedPlay = service.playPinnedSource(() => { + insideLookup = true; + return lookup.promise; + }); + while (!insideLookup) { + await Promise.resolve(); + } + + // The user picks a different source while the pinned resume position + // is still being read. + await service.play(ALT_THREE.id); + startPlayback.mockClear(); + lookup.resolve(0); + + // The older pinned attempt must not finish last and replace it. + await expect(pinnedPlay).resolves.toBe('superseded'); + expect(startPlayback).not.toHaveBeenCalled(); + expect(rowFor(ALT_THREE.id)?.isActive).toBe(true); + }); + it('tells a superseded pinned play apart from an unusable pin', async () => { pins.get.mockResolvedValue({ matchKey: 'title:the matrix:1999', 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 09b484f81..cfcda59d1 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 @@ -246,12 +246,17 @@ export class VodMultiSourceHostService { resumeFor?: (source: VodSourceCandidate) => Promise ): Promise { const session = this.sessionToken; + // Claim a switch generation up front. The discovery wait and the + // resume lookup are both awaits, and a source the user picks across + // either one has to win — otherwise this older attempt finishes last + // and replaces what they just chose. + const attempt = ++this.switchToken; return startPinnedSource({ controller: this.controller, loadInFlight: this.loadInFlight, pinnedSourceId: () => this.pendingPinnedSourceId(), resumeFor, - isCurrent: () => session === this.sessionToken, + isCurrent: () => this.isCurrentSwitch(session, attempt), play: (sourceId) => this.runPlay(sourceId), }); } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.spec.ts index 9b6bc1169..a7ca448d6 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.spec.ts @@ -103,6 +103,21 @@ describe('createPrimaryActionPosition', () => { expect(api.position()?.positionSeconds).toBe(4200); }); + it('claims nothing while the pinned copy’s row is still loading', () => { + const { api } = setup( + [source(), ALT], + position(2538), + () => new Promise(() => undefined) + ); + TestBed.tick(); + + // The route copy's "Resume 42:18" would be a label for a stream this + // button is not going to start — and a click across this window looks + // the pinned position up separately and begins somewhere else. + expect(api.position()).toBeNull(); + expect(api.hasPosition()).toBe(false); + }); + it('reads Play for a pinned copy that was never watched', async () => { const { api } = setup( [source(), ALT], diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.ts index ad4cbf841..5a99dddaa 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.ts @@ -104,10 +104,19 @@ export function createPrimaryActionPosition( const position = computed(() => { const pinned = foreignPin(); - if (!pinned || !pinnedLoadedFor()) { + if (!pinned) { return deps.routePosition(); } + // The pin names a copy whose row has not been read yet. Showing the + // ROUTE copy's `Resume 42:18` here would be a label for a stream this + // button is not going to start — and a click across this window looks + // the pinned position up separately and begins somewhere else. Say + // nothing until the answer is in; empty beats wrong. + if (!pinnedLoadedFor()) { + return null; + } + // What just played beats what was stored before it did. const live = deps.livePosition(); const isPinnedCopy = diff --git a/libs/services/src/lib/stream-probe.service.spec.ts b/libs/services/src/lib/stream-probe.service.spec.ts index 0fa4810e8..2d177ba8d 100644 --- a/libs/services/src/lib/stream-probe.service.spec.ts +++ b/libs/services/src/lib/stream-probe.service.spec.ts @@ -56,7 +56,9 @@ describe('StreamProbeService', () => { const probe = jest .fn() .mockResolvedValueOnce({ status: 200, url: 'http://x/y.mkv' }) - .mockResolvedValueOnce({ status: 403, url: 'http://x/y.mkv' }); + // 404, not 403: this test is about cache keying, and a 403 would + // now earn a ranged-GET retry and muddy the call count. + .mockResolvedValueOnce({ status: 404, url: 'http://x/y.mkv' }); const service = withBridge(probe as ProbeFn); const first = await service.probe('http://x/y.mkv', 'HEAD', { @@ -100,7 +102,8 @@ describe('StreamProbeService', () => { ); }); - it('maps a refusal to fail', async () => { + it('maps a refusal to fail once the GET retry agrees', async () => { + // 403 earns a retry, but a 403 to the ranged GET too is a real answer. const service = withBridge(ok(403)); await expect(service.probe('http://x/y.mkv')).resolves.toEqual( @@ -118,11 +121,13 @@ describe('StreamProbeService', () => { ); }); - it.each([405, 501])( + it.each([400, 403, 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. + // 400 and 403 are what a WAF returns for an unexpected HEAD, and + // treating those as dead ranks a working source below worse ones. const probe = jest .fn() .mockResolvedValueOnce({ status, url: 'u' }) @@ -148,8 +153,9 @@ describe('StreamProbeService', () => { } ); - it('does not retry a plain refusal', async () => { - const probe = ok(403); + it('does not retry a status that names the resource, not the method', async () => { + // 404 is about this URL; retrying it with another verb proves nothing. + const probe = ok(404); const service = withBridge(probe); await service.probe('http://x/y.mkv'); diff --git a/libs/services/src/lib/stream-probe.service.ts b/libs/services/src/lib/stream-probe.service.ts index af4980a4a..fd8ecc8f9 100644 --- a/libs/services/src/lib/stream-probe.service.ts +++ b/libs/services/src/lib/stream-probe.service.ts @@ -170,9 +170,23 @@ 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". */ +/** + * Statuses that may mean "not this method" rather than "not this resource". + * + * 405/501 say so outright. 403 and 400 are the ones a WAF or a stream server + * returns for a HEAD it does not expect while serving the same URL over GET + * perfectly well — and calling that source unavailable is a confident lie that + * also ranks it below worse ones. The retry is bounded (one ranged GET), so + * being generous here costs a request, while being strict costs a working + * source. + */ function refusesHeadRequests(httpStatus: number): boolean { - return httpStatus === 405 || httpStatus === 501; + return ( + httpStatus === 400 || + httpStatus === 403 || + httpStatus === 405 || + httpStatus === 501 + ); } function toProbeStatus(httpStatus: number): VodSourceProbeResult['status'] {