diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 6cf6698bc..c9d1aff43 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -195,7 +195,8 @@ on either side. id (mapped from the SQLite row id on Electron — through the visible category list, else through the unfiltered rows, since search can play a channel of a HIDDEN category; until that lookup lands the id is unknown - and a relock stops the channel) and drops `activePlayback` on a `version` + and a relock stops the channel, and a lookup that lands after a later + playback — same provider id, other playlist — is discarded) and drops `activePlayback` on a `version` change that locks it, because the player is gated on that signal, not on the store selection — which an ordinary category switch also clears while the channel keeps playing. @@ -230,6 +231,11 @@ on either side. passing as unlocked. Electron routes carry SQLite row ids, so the Xtream guard maps them through the unfiltered category read; Stalker routes already carry the genre id. +- **Stalker withheld-row bookkeeping:** the paging logic that advances past + a page made only of withheld rows keys those rows by + `stalkerWithheldRowKey` (`id`, else `stream_id`/`movie_id`/`series_id`, + else the row's `cmd`/`name`), never by a shared `''`, so a page of new + locked rows is not mistaken for a stalled portal. - **Stalker search staleness:** portal requests are not aborted, so a search page issued before a relock can finish after it, filtered with the pre-relock withheld set; `isStalkerSearchRequestCurrent` keys the diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts index a8abe6e55..ea8dd602e 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts @@ -37,6 +37,7 @@ import { filterItvChannelsByGenre, toStalkerContentItem, toStalkerItvChannel, + stalkerWithheldRowKey, withoutWithheldStalkerItems, } from '../utils'; @@ -739,7 +740,7 @@ export function withStalkerContent() { if (kept.has(item)) { continue; } - const id = String(item.id ?? ''); + const id = stalkerWithheldRowKey(item); if (!withheldSeenIds.has(id)) { withheldSeenIds.add(id); newWithheldCount += 1; diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-parental-lock.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-parental-lock.utils.spec.ts index 36eceaa72..0e34d2456 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-parental-lock.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-parental-lock.utils.spec.ts @@ -1,5 +1,6 @@ import { isStalkerItemWithheld, + stalkerWithheldRowKey, withoutWithheldStalkerItems, } from './stalker-parental-lock.utils'; import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces'; @@ -64,3 +65,15 @@ describe('isStalkerItemWithheld in fail-closed mode', () => { ).toEqual([]); }); }); + +describe('stalkerWithheldRowKey', () => { + it('keys rows by whichever id field they carry, never by a shared empty string', () => { + expect(stalkerWithheldRowKey({ id: 5 })).toBe('id:5'); + expect(stalkerWithheldRowKey({ stream_id: '6' })).toBe('id:6'); + expect(stalkerWithheldRowKey({ movie_id: 7 })).toBe('id:7'); + expect(stalkerWithheldRowKey({ series_id: 8 })).toBe('id:8'); + expect(stalkerWithheldRowKey({ cmd: 'ffmpeg http://a' })).not.toBe( + stalkerWithheldRowKey({ cmd: 'ffmpeg http://b' }) + ); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-parental-lock.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-parental-lock.utils.ts index 5e0069cf4..6a65d18f0 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-parental-lock.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-parental-lock.utils.ts @@ -5,6 +5,27 @@ import type { StalkerContentType } from '../stalker-store.contracts'; export interface StalkerLockableItem { tv_genre_id?: string | number; category_id?: string | number; + id?: string | number; + stream_id?: string | number; + movie_id?: string | number; + series_id?: string | number; + cmd?: string; + name?: string; +} + +/** + * The key the paging bookkeeping records a withheld row under. Stalker rows + * carry their id in one of several fields; keying every id-less row under + * `''` would count only the first of them as new, and a page of new locked + * rows would then look like a stalled portal and end paging short of later + * visible matches. + */ +export function stalkerWithheldRowKey(item: StalkerLockableItem): string { + const id = item.id ?? item.stream_id ?? item.movie_id ?? item.series_id; + if (id !== undefined && id !== null && String(id) !== '') { + return `id:${String(id)}`; + } + return `row:${String(item.cmd ?? item.name ?? '')}`; } /** diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index 83dcc9e9d..3406ae9df 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -21,6 +21,7 @@ import { executeStalkerRequest, StalkerPortalRepairService, StalkerSessionService, + stalkerWithheldRowKey, withoutWithheldStalkerItems, } from '@iptvnator/portal/stalker/data-access'; import { @@ -389,7 +390,7 @@ export class StalkerSearchComponent { if (items.length < rawItems.length) { const kept = new Set(items); for (const item of rawItems) { - const id = String(item.id ?? ''); + const id = stalkerWithheldRowKey(item); if ( !kept.has(item) && !this.searchWithheldIds.has(id) diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index 92602bace..b24e9986c 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -427,6 +427,8 @@ export class LiveStreamLayoutComponent * not resolved it yet (or cannot) — a relock then stops the channel. */ private activeLiveProviderCategoryId: number | null | 'unknown' = null; + /** Bumped per resolution so a slow hidden-category lookup cannot land on a later playback. */ + private liveCategoryLookupGeneration = 0; readonly playbackSessionKey = computed(() => { const sourceId = this.xtreamStore.currentPlaylist()?.id; const contentId = this.activeLiveItemId(); @@ -863,6 +865,9 @@ export class LiveStreamLayoutComponent private resolveLiveProviderCategoryId( item: XtreamLiveChannelItem ): number | null | 'unknown' { + // Every resolution retires the lookups before it, including one for + // a same-id channel of another playlist that resolved synchronously. + const generation = ++this.liveCategoryLookupGeneration; const categoryId = Number(item.category_id); if (!Number.isFinite(categoryId)) { return null; @@ -886,7 +891,11 @@ export class LiveStreamLayoutComponent void this.databaseService .getAllXtreamCategories(playlistId, 'live') .then((rows) => { - if (this.activeLiveItemId() !== item.xtream_id) { + if ( + generation !== this.liveCategoryLookupGeneration || + this.xtreamStore.currentPlaylist()?.id !== playlistId || + this.activeLiveItemId() !== item.xtream_id + ) { return; } const row = rows.find( diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.parental-lock.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.parental-lock.spec.ts index f878b60f3..69d0f19ab 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.parental-lock.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.parental-lock.spec.ts @@ -255,6 +255,33 @@ describe('LiveStreamLayoutComponent parental lock', () => { expect(component.activePlayback()).toBeNull(); }); + it('ignores a hidden-category lookup that lands after a later playback', async () => { + runtime.supportsXtreamSqliteDataSource = true; + let resolveLookup: (rows: unknown[]) => void = () => undefined; + databaseService.getAllXtreamCategories.mockReturnValueOnce( + new Promise((resolve) => (resolveLookup = resolve)) + ); + // Playlist 1: hidden category 99, lookup in flight. + component.playLive({ ...sampleChannel, category_id: 99 }); + + // Playlist 2: a channel with the same provider id whose category is + // visible (row 41 → provider 7) resolves synchronously. + xtreamStore.currentPlaylist.set({ ...playlist, id: 'playlist-2' }); + fixture.detectChanges(); + component.playLive({ ...sampleChannel, category_id: 41 }); + expect(component.activePlayback()).not.toBeNull(); + + // The stale lookup answers for playlist 1 with provider 9. + resolveLookup([{ id: 99, xtream_id: 9 }]); + await fixture.whenStable(); + + lock(9); + expect(component.activePlayback()).not.toBeNull(); + lock(7); + expect(component.activePlayback()).toBeNull(); + xtreamStore.currentPlaylist.set(playlist); + }); + it('stops a channel whose category is still unresolved when the lock activates', () => { runtime.supportsXtreamSqliteDataSource = true; databaseService.getAllXtreamCategories.mockReturnValue(