mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
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:
1 parent
b5be8b2773
commit
5c141b24b8
5 files changed
+121
-20
No files matched your search
@@ -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
|
||||
|
||||
+9
@@ -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,
|
||||
}),
|
||||
() => ({
|
||||
|
||||
+48
-13
@@ -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 {
|
||||
|
||||
+43
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user