fix(settings): discard stale hidden-category lookups and key withheld Stalker rows by their real identity

- A hidden-category lookup that lands after a later playback (same
  provider id, another playlist) no longer overwrites the newer channel's
  category; resolutions are generation- and playlist-checked.
- Withheld Stalker rows are keyed by id, stream_id, movie_id, series_id
  or the row's cmd/name, so id-less rows no longer collapse onto one key
  and stall paging past locked pages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 12:07:29 +02:00
1 parent 4bd4bf2fc2
commit 870a2ec7fd
7 files changed
+82 -4

No files matched your search

+7 -1
View File
@@ -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
@@ -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;
@@ -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' })
);
});
});
@@ -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 ?? '')}`;
}
/**
@@ -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)
@@ -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(
@@ -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<unknown[]>((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(