fix(settings): resolve hidden live categories before relocking playback and reload categories in the deferred hydration path

- The Xtream live layout resolves a playing channel's category through
  the unfiltered rows when the visible list lacks it (search can play a
  hidden category's channel); until that lookup lands the category is
  unknown and a relock stops the channel.
- A relock that overtakes the initial hydration now withholds the
  category publications too and reloads categories with the content.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 08:54:38 +02:00
1 parent b5be8b2773
commit 5c141b24b8
5 files changed
+121 -20

No files matched your search

+8 -4
View File
@@ -171,16 +171,20 @@ on either side.
the INITIAL hydration (the content is not initialized yet, so the reload
has nothing to re-read) sets a deferred-reload flag instead: the
hydration then publishes empty lists in place of the rows it read under
the previous lock state, and the filtered reload runs as soon as the
hydration settles, on every path that marks the content initialized.
the previous lock state (categories included), and the filtered reload
of categories and content runs as soon as the hydration settles, on
every path that marks the content initialized.
Both reloads fail closed: a category reload that
rejects empties the three category lists, and a per-type content reload
that rejects empties that type and sets it back to `idle` so the next
visit loads it again (filtered) — rows read under the previous lock state
are never kept. Live playback is stopped by the layout itself:
`LiveStreamLayoutComponent` keeps the playing channel's provider category
id (mapped from the SQLite row id on Electron) and drops `activePlayback`
on a `version` change that locks it, because the player is gated on that
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`
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.
- **Stalker:** the same service checks the selected genre through
@@ -121,6 +121,13 @@ describe('withContent parental-lock reloads', () => {
calls <= 3 ? [{ xtream_id: 1 }] : [{ xtream_id: 2 }]
);
});
let categoryCalls = 0;
dataSource.getCategories.mockImplementation(async () => {
categoryCalls += 1;
return categoryCalls <= 3
? [{ category_id: 'old' }]
: [{ category_id: 'new' }];
});
const initialization = store.initializeContent();
await waitForCondition(() => calls === 1);
@@ -131,6 +138,8 @@ describe('withContent parental-lock reloads', () => {
await initialization;
expect(calls).toBe(6);
expect(categoryCalls).toBe(6);
expect(store.liveCategories()).toEqual([{ category_id: 'new' }]);
expect(store.liveStreams()).toEqual([{ xtream_id: 2 }]);
expect(store.vodStreams()).toEqual([{ xtream_id: 2 }]);
expect(store.serialStreams()).toEqual([{ xtream_id: 2 }]);
@@ -231,6 +231,7 @@ export function withContent() {
return;
}
reloadAfterInitialization = false;
await methods.reloadCategories();
await methods.reloadCachedContent();
};
const dataService = inject(DataService);
@@ -648,6 +649,9 @@ export function withContent() {
// Read before the lock changed; the deferred reload
// below publishes the filtered rows instead.
for (const key of [
'liveCategories',
'vodCategories',
'serialCategories',
'liveStreams',
'vodStreams',
'serialStreams',
@@ -1168,10 +1172,16 @@ export function withContent() {
RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_CATEGORIES,
() =>
patchState(store, {
liveCategories: live,
vodCategories: vod,
liveCategories: reloadAfterInitialization
? []
: live,
vodCategories: reloadAfterInitialization
? []
: vod,
vodCategoriesPlaylistId: ctx.playlistId,
serialCategories: series,
serialCategories: reloadAfterInitialization
? []
: series,
isLoadingCategories: false,
}),
() => ({
@@ -95,6 +95,7 @@ import {
} from '../portal-channels-list/portal-channels-list.component';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import {
DatabaseService,
ParentalLockService,
RecordingsService,
RuntimeCapabilitiesService,
@@ -170,6 +171,7 @@ export class LiveStreamLayoutComponent
private readonly settingsStore = inject(SettingsStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly parentalLock = inject(ParentalLockService);
private readonly databaseService = inject(DatabaseService);
private readonly liveAutoOpenState = inject(LiveStreamAutoOpenStateService);
readonly categories = this.xtreamStore.getCategoriesBySelectedType;
@@ -419,8 +421,12 @@ export class LiveStreamLayoutComponent
);
readonly activePlayback = signal<ResolvedPortalPlayback | null>(null);
private readonly activeLiveItemId = signal<number | null>(null);
/** Provider category id of the playing channel, for the parental lock. */
private activeLiveProviderCategoryId: number | null = null;
/**
* Provider category id of the playing channel, for the parental lock.
* `null`: nothing playing (or no category); `'unknown'`: Electron has
* not resolved it yet (or cannot) — a relock then stops the channel.
*/
private activeLiveProviderCategoryId: number | null | 'unknown' = null;
readonly playbackSessionKey = computed(() => {
const sourceId = this.xtreamStore.currentPlaylist()?.id;
const contentId = this.activeLiveItemId();
@@ -454,15 +460,20 @@ export class LiveStreamLayoutComponent
untracked(() => {
const categoryId = this.activeLiveProviderCategoryId;
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (
categoryId === null ||
!playlistId ||
!this.parentalLock.isXtreamCategoryLocked(
playlistId,
'live',
categoryId
)
) {
if (categoryId === null || !playlistId) {
return;
}
// Unresolved category: fail closed while the lock is active
// rather than keep a possibly locked stream running.
const withheld =
categoryId === 'unknown'
? this.parentalLock.active()
: this.parentalLock.isXtreamCategoryLocked(
playlistId,
'live',
categoryId
);
if (!withheld) {
return;
}
this.playbackRequestId += 1;
@@ -851,7 +862,7 @@ export class LiveStreamLayoutComponent
*/
private resolveLiveProviderCategoryId(
item: XtreamLiveChannelItem
): number | null {
): number | null | 'unknown' {
const categoryId = Number(item.category_id);
if (!Number.isFinite(categoryId)) {
return null;
@@ -864,7 +875,31 @@ export class LiveStreamLayoutComponent
Number((candidate as { id?: number }).id) === categoryId
) as { xtream_id?: number } | undefined;
const providerId = Number(category?.xtream_id);
return Number.isFinite(providerId) ? providerId : null;
if (Number.isFinite(providerId)) {
return providerId;
}
// Not in the visible list — a hidden category (search can play its
// channels). Resolve through the unfiltered rows; until that lands
// the id is unknown and a relock stops the channel.
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (playlistId) {
void this.databaseService
.getAllXtreamCategories(playlistId, 'live')
.then((rows) => {
if (this.activeLiveItemId() !== item.xtream_id) {
return;
}
const row = rows.find(
(candidate) => Number(candidate.id) === categoryId
);
const resolved = Number(row?.xtream_id);
if (Number.isFinite(resolved)) {
this.activeLiveProviderCategoryId = resolved;
}
})
.catch(() => undefined);
}
return 'unknown';
}
private selectLiveItemCategory(item: XtreamLiveChannelItem): void {
@@ -24,6 +24,7 @@ import {
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
import { GridListComponent } from '@iptvnator/portal/shared/ui';
import {
DatabaseService,
ParentalLockService,
RuntimeCapabilitiesService,
SettingsStore,
@@ -51,8 +52,12 @@ describe('LiveStreamLayoutComponent parental lock', () => {
const emptyList = signal<unknown[]>([]);
const parentalLock = {
version: signal(0),
active: signal(false),
isXtreamCategoryLocked: jest.fn(() => false),
};
const databaseService = {
getAllXtreamCategories: jest.fn(async () => [] as unknown[]),
};
const runtime = { supportsXtreamSqliteDataSource: false };
const xtreamStore = {
getCategoriesBySelectedType: signal([
@@ -82,8 +87,11 @@ describe('LiveStreamLayoutComponent parental lock', () => {
beforeEach(async () => {
parentalLock.version.set(0);
parentalLock.active.set(false);
parentalLock.isXtreamCategoryLocked.mockReset();
parentalLock.isXtreamCategoryLocked.mockReturnValue(false);
databaseService.getAllXtreamCategories.mockReset();
databaseService.getAllXtreamCategories.mockResolvedValue([]);
runtime.supportsXtreamSqliteDataSource = false;
await TestBed.configureTestingModule({
@@ -130,6 +138,7 @@ describe('LiveStreamLayoutComponent parental lock', () => {
},
},
{ provide: RuntimeCapabilitiesService, useValue: runtime },
{ provide: DatabaseService, useValue: databaseService },
{
provide: SettingsStore,
useValue: {
@@ -188,6 +197,7 @@ describe('LiveStreamLayoutComponent parental lock', () => {
afterEach(() => fixture.destroy());
function lock(categoryId: number): void {
parentalLock.active.set(true);
parentalLock.isXtreamCategoryLocked.mockImplementation(
(_playlistId: string, _type: string, id: number) =>
id === categoryId
@@ -224,6 +234,39 @@ describe('LiveStreamLayoutComponent parental lock', () => {
expect(component.activePlayback()).not.toBeNull();
});
it('resolves a hidden category through the unfiltered rows before judging it', async () => {
runtime.supportsXtreamSqliteDataSource = true;
// Row 99 is hidden: absent from liveCategories, present in the
// unfiltered table with provider id 9.
databaseService.getAllXtreamCategories.mockResolvedValue([
{ id: 99, xtream_id: 9 },
]);
component.playLive({ ...sampleChannel, category_id: 99 });
await fixture.whenStable();
expect(databaseService.getAllXtreamCategories).toHaveBeenCalledWith(
'playlist-1',
'live'
);
lock(7);
expect(component.activePlayback()).not.toBeNull();
lock(9);
expect(component.activePlayback()).toBeNull();
});
it('stops a channel whose category is still unresolved when the lock activates', () => {
runtime.supportsXtreamSqliteDataSource = true;
databaseService.getAllXtreamCategories.mockReturnValue(
new Promise(() => undefined)
);
component.playLive({ ...sampleChannel, category_id: 99 });
lock(7);
expect(component.activePlayback()).toBeNull();
});
it('maps the SQLite category row id to the provider id it is locked by', () => {
runtime.supportsXtreamSqliteDataSource = true;
component.playLive({ ...sampleChannel, category_id: 41 });