mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
Merge remote-tracking branch 'origin/master' into claude/channel-history-playback-check-0b3bd9
# Conflicts: # libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts
This commit is contained in:
commit
02ed4e589c
378 files changed
+16812
-1098
No files matched your search
@@ -7,8 +7,11 @@ import { SettingsStore, EpgSourceSettingsService } from '@iptvnator/services';
|
||||
import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES } from '@iptvnator/shared/interfaces';
|
||||
import { EpgProgressService } from './epg-progress.service';
|
||||
|
||||
/** Spec-only double: capability flags are read-only getters on the real class. */
|
||||
type WritablePartial<T> = { -readonly [K in keyof T]?: T[K] };
|
||||
|
||||
describe('EpgProgressService', () => {
|
||||
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
||||
let epgBridge: WritablePartial<EpgRuntimeBridgeService>;
|
||||
let sources: { waitForReconciliation: jest.Mock };
|
||||
let settingsStore: {
|
||||
getSettings: jest.Mock;
|
||||
|
||||
@@ -2,9 +2,12 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service';
|
||||
|
||||
/** Spec-only double: capability flags are read-only getters on the real class. */
|
||||
type WritablePartial<T> = { -readonly [K in keyof T]?: T[K] };
|
||||
|
||||
describe('EpgRuntimeBridgeService', () => {
|
||||
let service: EpgRuntimeBridgeService;
|
||||
let runtimeCapabilities: Partial<RuntimeCapabilitiesService>;
|
||||
let runtimeCapabilities: WritablePartial<RuntimeCapabilitiesService>;
|
||||
const originalElectron = window.electron;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -7,12 +7,16 @@ import {
|
||||
PlaylistsService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service';
|
||||
import { EpgService } from './epg.service';
|
||||
|
||||
/** Spec-only double: capability flags are read-only getters on the real class. */
|
||||
type WritablePartial<T> = { -readonly [K in keyof T]?: T[K] };
|
||||
|
||||
describe('EpgService', () => {
|
||||
let service: EpgService;
|
||||
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
||||
let epgBridge: WritablePartial<EpgRuntimeBridgeService>;
|
||||
let snackBar: { open: jest.Mock };
|
||||
let settingsStore: {
|
||||
loadSettings: jest.Mock;
|
||||
@@ -164,7 +168,7 @@ describe('EpgService', () => {
|
||||
new Promise((resolve) => {
|
||||
complete = resolve;
|
||||
}),
|
||||
} as typeof window.electron;
|
||||
} as Partial<typeof window.electron> as typeof window.electron;
|
||||
try {
|
||||
const sources = TestBed.inject(EpgSourceSettingsService);
|
||||
const reconciliation = sources.synchronize([
|
||||
@@ -457,16 +461,7 @@ describe('EpgService', () => {
|
||||
|
||||
it('deduplicates concurrent scoped current program lookups for the same source scope', async () => {
|
||||
epgBridge.supportsProgramLookup = true;
|
||||
let resolvePrograms:
|
||||
| ((
|
||||
programs: {
|
||||
channel: string;
|
||||
start: string;
|
||||
stop: string;
|
||||
title: string;
|
||||
}[]
|
||||
) => void)
|
||||
| undefined;
|
||||
let resolvePrograms: ((programs: EpgProgram[]) => void) | undefined;
|
||||
epgBridge.getChannelPrograms = jest.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
@@ -495,6 +490,8 @@ describe('EpgService', () => {
|
||||
start: '2026-05-23T10:00:00.000Z',
|
||||
stop: '2026-05-23T11:00:00.000Z',
|
||||
title: 'Playlist Guide Bulletin',
|
||||
desc: null,
|
||||
category: null,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -928,7 +925,7 @@ describe('EpgService', () => {
|
||||
epgBridge.supportsProgramLookup = true;
|
||||
epgBridge.supportsCurrentProgramBatch = true;
|
||||
const batchResolvers: Array<
|
||||
(programs: Record<string, unknown>) => void
|
||||
(programs: Record<string, EpgProgram | null>) => void
|
||||
> = [];
|
||||
epgBridge.getCurrentProgramsBatch = jest.fn(
|
||||
() =>
|
||||
@@ -957,6 +954,8 @@ describe('EpgService', () => {
|
||||
start: '2026-05-23T10:00:00.000Z',
|
||||
stop: '2026-05-23T11:00:00.000Z',
|
||||
title: 'Playlist Guide Bulletin',
|
||||
desc: null,
|
||||
category: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -981,7 +980,7 @@ describe('EpgService', () => {
|
||||
epgBridge.supportsProgramLookup = true;
|
||||
epgBridge.supportsCurrentProgramBatch = true;
|
||||
let resolveBatch:
|
||||
((programs: Record<string, unknown>) => void) | undefined;
|
||||
((programs: Record<string, EpgProgram | null>) => void) | undefined;
|
||||
epgBridge.getCurrentProgramsBatch = jest.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
@@ -1010,12 +1009,16 @@ describe('EpgService', () => {
|
||||
start: '2026-05-23T10:00:00.000Z',
|
||||
stop: '2026-05-23T11:00:00.000Z',
|
||||
title: 'Playlist Guide Bulletin',
|
||||
desc: null,
|
||||
category: null,
|
||||
},
|
||||
'guide-sports': {
|
||||
channel: 'guide-sports',
|
||||
start: '2026-05-23T10:00:00.000Z',
|
||||
stop: '2026-05-23T11:00:00.000Z',
|
||||
title: 'Playlist Sports Bulletin',
|
||||
desc: null,
|
||||
category: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
@@ -127,7 +127,7 @@ describe('playlistReducers', () => {
|
||||
stalkerTimeslot: 3,
|
||||
stalkerAccountInfo: {
|
||||
login: 'subscriber',
|
||||
status: 'active',
|
||||
status: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -153,7 +153,7 @@ describe('playlistReducers', () => {
|
||||
stalkerTimeslot: 3,
|
||||
stalkerAccountInfo: {
|
||||
login: 'subscriber',
|
||||
status: 'active',
|
||||
status: 1,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -158,7 +158,7 @@ describe('HLS playback evidence', () => {
|
||||
networkDetails: {
|
||||
responseURL: `https://provider.example/xhr?token=${secret}`,
|
||||
responseText: secret,
|
||||
},
|
||||
} satisfies Partial<XMLHttpRequest> as XMLHttpRequest,
|
||||
})
|
||||
);
|
||||
const serialized = JSON.stringify(evidence);
|
||||
|
||||
+35
-29
@@ -12,6 +12,14 @@ import {
|
||||
import { PlaylistType } from '@iptvnator/playlist/shared/ui';
|
||||
import { AddPlaylistDialogComponent } from './add-playlist-dialog.component';
|
||||
|
||||
type ChildSignalKey =
|
||||
| 'urlUpload'
|
||||
| 'fileUpload'
|
||||
| 'textImport'
|
||||
| 'xtreamImport'
|
||||
| 'stalkerImport'
|
||||
| 'autoImport';
|
||||
|
||||
describe('AddPlaylistDialogComponent', () => {
|
||||
let component: AddPlaylistDialogComponent;
|
||||
let dataService: { sendIpcEvent: jest.Mock };
|
||||
@@ -67,8 +75,20 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Replaces a read-only `viewChild` signal with a stub that resolves to a
|
||||
* minimal structural double of the child component, or to `undefined`
|
||||
* while the child is not rendered yet.
|
||||
*/
|
||||
function stubChild(key: ChildSignalKey, child: object | undefined): void {
|
||||
Object.defineProperty(component, key, {
|
||||
value: jest.fn(() => child),
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
it('sends a trimmed custom title for URL playlists', () => {
|
||||
(component as { urlUpload: jest.Mock }).urlUpload = jest.fn(() => ({
|
||||
stubChild('urlUpload', {
|
||||
form: {
|
||||
getRawValue: () => ({
|
||||
playlistName: ' My Playlist ',
|
||||
@@ -76,7 +96,7 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
playlistUrl: ' https://example.com/list.m3u ',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
component.submitUrlPlaylist();
|
||||
|
||||
@@ -92,7 +112,7 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
});
|
||||
|
||||
it('omits the title when the optional name is blank', () => {
|
||||
(component as { urlUpload: jest.Mock }).urlUpload = jest.fn(() => ({
|
||||
stubChild('urlUpload', {
|
||||
form: {
|
||||
getRawValue: () => ({
|
||||
playlistName: ' ',
|
||||
@@ -100,7 +120,7 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
playlistUrl: 'https://example.com/list.m3u',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
component.submitUrlPlaylist();
|
||||
|
||||
@@ -167,10 +187,7 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
'clears the current $type import surface',
|
||||
({ type, childAccessor, clearMethod }) => {
|
||||
const clear = jest.fn();
|
||||
(component as unknown as Record<string, jest.Mock>)[childAccessor] =
|
||||
jest.fn(() => ({
|
||||
[clearMethod]: clear,
|
||||
}));
|
||||
stubChild(childAccessor, { [clearMethod]: clear });
|
||||
selectType(type);
|
||||
|
||||
component.clearCurrentForm();
|
||||
@@ -180,10 +197,10 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
);
|
||||
|
||||
it('disables clear when a file upload has no selection', () => {
|
||||
(component as { fileUpload: jest.Mock }).fileUpload = jest.fn(() => ({
|
||||
stubChild('fileUpload', {
|
||||
isImporting: () => false,
|
||||
selectedFile: () => null,
|
||||
}));
|
||||
});
|
||||
selectType('file');
|
||||
|
||||
expect(component.isClearDisabled()).toBeTruthy();
|
||||
@@ -260,9 +277,7 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
|
||||
it('prefills the xtream form once the child exists and clears the pending candidate', () => {
|
||||
const patchValue = jest.fn();
|
||||
(component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn(
|
||||
() => ({ form: { patchValue } })
|
||||
);
|
||||
stubChild('xtreamImport', { form: { patchValue } });
|
||||
|
||||
component.onCandidateSelected({
|
||||
kind: 'xtream',
|
||||
@@ -288,9 +303,7 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
|
||||
it('drops the candidate when the user switches to another method first', () => {
|
||||
const patchValue = jest.fn();
|
||||
(component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn(
|
||||
() => ({ form: { patchValue } })
|
||||
);
|
||||
stubChild('xtreamImport', { form: { patchValue } });
|
||||
|
||||
component.onCandidateSelected({
|
||||
kind: 'xtream',
|
||||
@@ -309,9 +322,7 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
});
|
||||
|
||||
it('keeps the candidate pending while the target form does not exist yet', () => {
|
||||
(component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn(
|
||||
() => undefined
|
||||
);
|
||||
stubChild('xtreamImport', undefined);
|
||||
const patchValue = jest.fn();
|
||||
|
||||
component.onCandidateSelected({
|
||||
@@ -322,9 +333,7 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
applyPrefill();
|
||||
|
||||
// Child appears on a later change-detection pass.
|
||||
(component as { xtreamImport: jest.Mock }).xtreamImport = jest.fn(
|
||||
() => ({ form: { patchValue } })
|
||||
);
|
||||
stubChild('xtreamImport', { form: { patchValue } });
|
||||
applyPrefill();
|
||||
|
||||
expect(patchValue).toHaveBeenCalledWith(
|
||||
@@ -334,8 +343,7 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
|
||||
it('prefills the stalker form including identity fields', () => {
|
||||
const patchValue = jest.fn();
|
||||
(component as { stalkerImport: jest.Mock }).stalkerImport =
|
||||
jest.fn(() => ({ form: { patchValue } }));
|
||||
stubChild('stalkerImport', { form: { patchValue } });
|
||||
|
||||
component.onCandidateSelected({
|
||||
kind: 'stalker',
|
||||
@@ -369,9 +377,9 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
|
||||
it('prefills the URL form for an m3u-url candidate', () => {
|
||||
const patchValue = jest.fn();
|
||||
(component as { urlUpload: jest.Mock }).urlUpload = jest.fn(() => ({
|
||||
stubChild('urlUpload', {
|
||||
form: { patchValue },
|
||||
}));
|
||||
});
|
||||
|
||||
component.onCandidateSelected({
|
||||
kind: 'm3u-url',
|
||||
@@ -389,9 +397,7 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
|
||||
it('prefills the raw-text form for an m3u-text candidate', () => {
|
||||
const patchValue = jest.fn();
|
||||
(component as { textImport: jest.Mock }).textImport = jest.fn(
|
||||
() => ({ textForm: { patchValue } })
|
||||
);
|
||||
stubChild('textImport', { textForm: { patchValue } });
|
||||
|
||||
component.onCandidateSelected({
|
||||
kind: 'm3u-text',
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
+4
-1
@@ -11,7 +11,10 @@ import { M3uEpgGuideSourceService } from './m3u-epg-guide-source.service';
|
||||
|
||||
function makeChannel(
|
||||
id: string,
|
||||
overrides: Partial<Channel> & { tvgId?: string; group?: string } = {}
|
||||
overrides: Omit<Partial<Channel>, 'group'> & {
|
||||
tvgId?: string;
|
||||
group?: string;
|
||||
} = {}
|
||||
): Channel {
|
||||
return {
|
||||
id,
|
||||
|
||||
+12
-8
@@ -162,13 +162,16 @@ describe('VideoPlayerComponent', () => {
|
||||
// Mirrors the real capability check: every
|
||||
// remote-control bridge method must be present.
|
||||
get supportsRemoteControl() {
|
||||
const bridge = window.electron as
|
||||
Record<string, unknown> | undefined;
|
||||
return [
|
||||
'updateRemoteControlStatus',
|
||||
'onChannelChange',
|
||||
'onRemoteControlCommand',
|
||||
].every(
|
||||
const bridge:
|
||||
Partial<typeof window.electron> | undefined =
|
||||
window.electron;
|
||||
return (
|
||||
[
|
||||
'updateRemoteControlStatus',
|
||||
'onChannelChange',
|
||||
'onRemoteControlCommand',
|
||||
] as const
|
||||
).every(
|
||||
(method) =>
|
||||
typeof bridge?.[method] === 'function'
|
||||
);
|
||||
@@ -582,7 +585,7 @@ describe('VideoPlayerComponent', () => {
|
||||
const updateRemoteControlStatus = jest.fn();
|
||||
window.electron = {
|
||||
updateRemoteControlStatus,
|
||||
} as typeof window.electron;
|
||||
} as unknown as typeof window.electron;
|
||||
|
||||
fixture = TestBed.createComponent(VideoPlayerComponent);
|
||||
component = fixture.componentInstance;
|
||||
@@ -687,6 +690,7 @@ describe('VideoPlayerComponent', () => {
|
||||
streamUrl: sampleChannel.url,
|
||||
startedAt: '2026-08-08T00:00:00.000Z',
|
||||
updatedAt: '2026-08-08T00:00:00.000Z',
|
||||
canClose: true,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components';
|
||||
import {
|
||||
DataService,
|
||||
ParentalLockService,
|
||||
PlaylistsService,
|
||||
RecordingsService,
|
||||
RuntimeCapabilitiesService,
|
||||
@@ -243,6 +244,7 @@ export class VideoPlayerComponent
|
||||
private readonly router = inject(Router);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
private readonly storage = inject(StorageMap);
|
||||
private readonly store = inject(Store);
|
||||
private readonly epgService = inject(EpgService);
|
||||
@@ -1477,8 +1479,19 @@ export class VideoPlayerComponent
|
||||
)
|
||||
)
|
||||
.subscribe((channel) => {
|
||||
// The number indexes the full list; a channel of a locked
|
||||
// group is not zapped to (the enforcement service would
|
||||
// reset it anyway, after playback had started).
|
||||
const playlistId = this.activePlaylistMeta()?._id;
|
||||
if (
|
||||
channel &&
|
||||
!(
|
||||
playlistId &&
|
||||
this.parentalLock.isM3uGroupLocked(
|
||||
playlistId,
|
||||
channel.group?.title ?? ''
|
||||
)
|
||||
) &&
|
||||
(!this.isLivePlayerFullscreen() ||
|
||||
this.keepsInlinePlayer(channel))
|
||||
) {
|
||||
|
||||
@@ -451,7 +451,11 @@ describe('PlaylistRefreshActionService', () => {
|
||||
const originalSetItem = Storage.prototype.setItem;
|
||||
const setItemSpy = jest
|
||||
.spyOn(Storage.prototype, 'setItem')
|
||||
.mockImplementation(function (key: string, value: string) {
|
||||
.mockImplementation(function (
|
||||
this: Storage,
|
||||
key: string,
|
||||
value: string
|
||||
) {
|
||||
executionOrder.push('setItem');
|
||||
return originalSetItem.call(this, key, value);
|
||||
});
|
||||
|
||||
@@ -29,7 +29,6 @@ function createPlaylist(
|
||||
overrides: Partial<PlaylistMeta> & { _id: string }
|
||||
): PlaylistMeta {
|
||||
return {
|
||||
_id: overrides._id,
|
||||
title: overrides.title ?? overrides.filename ?? overrides._id,
|
||||
filename: overrides.filename,
|
||||
count: overrides.count ?? 0,
|
||||
|
||||
+6
-1
@@ -73,6 +73,7 @@ describe('PlaylistInfoComponent', () => {
|
||||
autoRefresh: false,
|
||||
url: 'https://example.com/playlist.m3u',
|
||||
} as Playlist & { id: string };
|
||||
const metaBase = { count: 1, importDate: '2026-04-01', autoRefresh: false };
|
||||
|
||||
beforeEach(async () => {
|
||||
playlistsService = {
|
||||
@@ -213,6 +214,7 @@ describe('PlaylistInfoComponent', () => {
|
||||
createComponent();
|
||||
|
||||
const updatedPlaylist = {
|
||||
...metaBase,
|
||||
_id: 'playlist-1',
|
||||
title: 'Updated Xtream',
|
||||
serverUrl: 'http://new.example:8080',
|
||||
@@ -251,6 +253,7 @@ describe('PlaylistInfoComponent', () => {
|
||||
createComponent();
|
||||
|
||||
await component.saveChanges({
|
||||
...metaBase,
|
||||
_id: 'playlist-1',
|
||||
title: 'Updated Xtream',
|
||||
serverUrl:
|
||||
@@ -262,6 +265,7 @@ describe('PlaylistInfoComponent', () => {
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: {
|
||||
...metaBase,
|
||||
_id: 'playlist-1',
|
||||
title: 'Updated Xtream',
|
||||
serverUrl: 'http://new.example:8080/live',
|
||||
@@ -289,6 +293,7 @@ describe('PlaylistInfoComponent', () => {
|
||||
createComponent();
|
||||
|
||||
await component.saveChanges({
|
||||
...metaBase,
|
||||
_id: 'playlist-1',
|
||||
title: 'Updated Xtream',
|
||||
serverUrl:
|
||||
@@ -314,7 +319,7 @@ describe('PlaylistInfoComponent', () => {
|
||||
window.electron = {
|
||||
saveFileDialog: jest.fn().mockResolvedValue('/tmp/export.m3u8'),
|
||||
writeFile: jest.fn().mockResolvedValue({ success: true }),
|
||||
} as typeof window.electron;
|
||||
} as Partial<typeof window.electron> as typeof window.electron;
|
||||
createComponent();
|
||||
|
||||
await component.exportPlaylist();
|
||||
|
||||
@@ -491,7 +491,11 @@ describe('RecentPlaylistsComponent busy state', () => {
|
||||
const originalSetItem = Storage.prototype.setItem;
|
||||
const setItemSpy = jest
|
||||
.spyOn(Storage.prototype, 'setItem')
|
||||
.mockImplementation(function (key: string, value: string) {
|
||||
.mockImplementation(function (
|
||||
this: Storage,
|
||||
key: string,
|
||||
value: string
|
||||
) {
|
||||
executionOrder.push('setItem');
|
||||
return originalSetItem.call(this, key, value);
|
||||
});
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
@@ -22,11 +22,11 @@ function createPlaylist(
|
||||
overrides: Partial<PlaylistMeta> & { _id: string }
|
||||
): PlaylistMeta {
|
||||
return {
|
||||
_id: overrides._id,
|
||||
title: overrides.title ?? overrides.filename ?? overrides._id,
|
||||
count: overrides.count ?? 0,
|
||||
importDate:
|
||||
overrides.importDate ?? new Date('2026-04-05T10:00:00.000Z').toISOString(),
|
||||
overrides.importDate ??
|
||||
new Date('2026-04-05T10:00:00.000Z').toISOString(),
|
||||
autoRefresh: overrides.autoRefresh ?? false,
|
||||
...overrides,
|
||||
} as PlaylistMeta;
|
||||
@@ -392,24 +392,25 @@ describe('PlaylistContextFacade', () => {
|
||||
playlistId: xtreamA._id,
|
||||
section: 'recently-added',
|
||||
});
|
||||
expect(JSON.parse(localStorage.getItem(LAST_SECTION_STORAGE_KEY) ?? '{}'))
|
||||
.toEqual({
|
||||
playlists: {
|
||||
[m3uA._id]: {
|
||||
provider: 'playlists',
|
||||
section: 'favorites',
|
||||
updatedAt: expect.any(Number),
|
||||
},
|
||||
[xtreamA._id]: {
|
||||
provider: 'xtreams',
|
||||
section: 'recently-added',
|
||||
updatedAt: expect.any(Number),
|
||||
},
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem(LAST_SECTION_STORAGE_KEY) ?? '{}')
|
||||
).toEqual({
|
||||
playlists: {
|
||||
[m3uA._id]: {
|
||||
provider: 'playlists',
|
||||
section: 'favorites',
|
||||
updatedAt: expect.any(Number),
|
||||
},
|
||||
providers: {
|
||||
playlists: 'favorites',
|
||||
xtreams: 'recently-added',
|
||||
[xtreamA._id]: {
|
||||
provider: 'xtreams',
|
||||
section: 'recently-added',
|
||||
updatedAt: expect.any(Number),
|
||||
},
|
||||
});
|
||||
},
|
||||
providers: {
|
||||
playlists: 'favorites',
|
||||
xtreams: 'recently-added',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
@@ -381,10 +381,10 @@ describe('DownloadLibraryNavigationService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
it.each<readonly [string, Partial<StalkerPortalItem>]>([
|
||||
['is_series flag', { is_series: '1' }],
|
||||
['embedded episode list', { series: [1, 2] }],
|
||||
] as const)(
|
||||
])(
|
||||
'opens a recovered Stalker episode with an %s through the VOD-series detail route',
|
||||
async (_label, vodSeriesMetadata) => {
|
||||
playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST));
|
||||
|
||||
@@ -510,9 +510,9 @@ describe('DownloadQueueComponent', () => {
|
||||
expect(row(3).querySelector('.download-queue__progress')).toBeNull();
|
||||
expect(
|
||||
Array.from(
|
||||
fixture.nativeElement.querySelectorAll<HTMLElement>(
|
||||
'.download-queue__progress'
|
||||
)
|
||||
(
|
||||
fixture.nativeElement as HTMLElement
|
||||
).querySelectorAll<HTMLElement>('.download-queue__progress')
|
||||
).map((bar) => bar.getAttribute('aria-label'))
|
||||
).toEqual([
|
||||
'Download progress for Download 1',
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
PORTAL_SHELL_ACTIONS,
|
||||
PortalCollectionContextService,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import type { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import type { Playlist, XtreamCategory } from '@iptvnator/shared/interfaces';
|
||||
import { DialogService } from '@iptvnator/ui/components';
|
||||
import { BehaviorSubject, type Observable, Subject } from 'rxjs';
|
||||
import { DownloadLibraryNavigationService } from './download-library-navigation.service';
|
||||
@@ -47,6 +47,8 @@ interface ConfirmConfig {
|
||||
}
|
||||
|
||||
interface ExpectedDownloadsComponent {
|
||||
readonly activeCount: () => number;
|
||||
readonly categories: () => readonly XtreamCategory[];
|
||||
readonly model: () => {
|
||||
readonly active: readonly { readonly item: DownloadItem }[];
|
||||
readonly activeCount: number;
|
||||
@@ -780,7 +782,9 @@ describe('DownloadsComponent', () => {
|
||||
downloads.set([item]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const attention = fixture.nativeElement.querySelector<HTMLElement>(
|
||||
const attention = (
|
||||
fixture.nativeElement as HTMLElement
|
||||
).querySelector<HTMLElement>(
|
||||
'[data-test-id="downloads-attention-section"]'
|
||||
);
|
||||
const recover = attention?.querySelector<HTMLButtonElement>(
|
||||
|
||||
+8
-2
@@ -12,7 +12,10 @@ import {
|
||||
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
|
||||
import type { WorkspaceNavigationTarget } from '@iptvnator/portal/shared/util';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import type { DownloadActionResult } from '../download-actions';
|
||||
import type {
|
||||
DownloadActionResult,
|
||||
DownloadItemAction,
|
||||
} from '../download-actions';
|
||||
import { DownloadLibraryNavigationService } from '../download-library-navigation.service';
|
||||
import { DownloadManagerActionsService } from '../download-manager-actions.service';
|
||||
import { DownloadOfflineDetailComponent } from './download-offline-detail.component';
|
||||
@@ -67,7 +70,10 @@ interface DownloadsFake {
|
||||
|
||||
interface ActionsFake {
|
||||
readonly pendingIds: ReturnType<typeof signal<ReadonlySet<number>>>;
|
||||
readonly run: jest.Mock<Promise<DownloadActionResult>, [unknown]>;
|
||||
readonly run: jest.Mock<
|
||||
Promise<DownloadActionResult>,
|
||||
[DownloadItemAction]
|
||||
>;
|
||||
readonly showActionError: jest.Mock<void, []>;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
+18
-3
@@ -135,10 +135,15 @@ describe('StreamResolverService EPG display offset', () => {
|
||||
TestBed.inject(PlaylistsService).getPlaylistById = jest.fn(() =>
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
title: 'Stalker Portal',
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
lastUsage: '2026-01-01T00:00:00.000Z',
|
||||
count: 0,
|
||||
autoRefresh: false,
|
||||
portalUrl: 'https://stalker.example.com',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
} satisfies Playlist)
|
||||
);
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const entry = (id: string, name: string, startOffsetMin: number) => ({
|
||||
@@ -192,10 +197,15 @@ describe('StreamResolverService EPG display offset', () => {
|
||||
TestBed.inject(PlaylistsService).getPlaylistById = jest.fn(() =>
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
title: 'Stalker Portal',
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
lastUsage: '2026-01-01T00:00:00.000Z',
|
||||
count: 0,
|
||||
autoRefresh: false,
|
||||
portalUrl: 'https://stalker.example.com',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
} satisfies Playlist)
|
||||
);
|
||||
const nowMs = Date.now();
|
||||
const entry = (id: string, name: string, startOffsetMin: number) => ({
|
||||
@@ -322,10 +332,15 @@ describe('StreamResolverService EPG display offset', () => {
|
||||
epgOffsetMinutes = 0;
|
||||
return of({
|
||||
_id: 'xtream-1',
|
||||
title: 'Xtream Playlist',
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
lastUsage: '2026-01-01T00:00:00.000Z',
|
||||
count: 0,
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://xtream.example.com',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
} satisfies Partial<Playlist>);
|
||||
} satisfies Playlist);
|
||||
});
|
||||
xtreamApi.getFullEpg.mockResolvedValue([
|
||||
listing('Really on air', -75, 60),
|
||||
|
||||
@@ -33,7 +33,11 @@ describe('StreamResolverService', () => {
|
||||
ensureToken: jest.Mock;
|
||||
makeAuthenticatedRequest: jest.Mock;
|
||||
};
|
||||
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
||||
let epgBridge: {
|
||||
-readonly [
|
||||
K in keyof EpgRuntimeBridgeService
|
||||
]?: EpgRuntimeBridgeService[K];
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
playlistsService = {
|
||||
|
||||
@@ -710,7 +710,7 @@ export class StreamResolverService {
|
||||
// epg_channel_id column but is not declared on the TS interface.
|
||||
const epgKey = (
|
||||
item as unknown as Record<string, string | undefined | null>
|
||||
).epgChannelId?.trim();
|
||||
)['epgChannelId']?.trim();
|
||||
if (this.supportsProgramLookup && epgKey) {
|
||||
const uploaded = await this.epgBridge
|
||||
.getChannelPrograms(epgKey)
|
||||
@@ -995,7 +995,7 @@ export class StreamResolverService {
|
||||
string,
|
||||
string | undefined | null
|
||||
>
|
||||
).epgChannelId?.trim();
|
||||
)['epgChannelId']?.trim();
|
||||
if (this.supportsProgramLookup && epgChannelKey) {
|
||||
currentItem = await this.findCurrentInXmltv(
|
||||
epgChannelKey,
|
||||
|
||||
+7
-1
@@ -126,7 +126,7 @@ describe('UnifiedFavoritesDataService', () => {
|
||||
dbRemoveRecentItemsBatch: jest.fn(),
|
||||
};
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: electronApi as Window['electron'],
|
||||
value: electronApi as unknown as Window['electron'],
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
@@ -177,11 +177,17 @@ describe('UnifiedFavoritesDataService', () => {
|
||||
{
|
||||
_id: 'm3u-1',
|
||||
title: 'M3U List',
|
||||
count: 0,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
favorites: ['https://example.com/2.m3u8', 'channel-1'],
|
||||
},
|
||||
{
|
||||
_id: 'stalker-1',
|
||||
title: 'Stalker List',
|
||||
count: 0,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
favorites: stalkerFavorites,
|
||||
},
|
||||
|
||||
+4
-1
@@ -45,6 +45,9 @@ describe('UnifiedRecentDataService', () => {
|
||||
const playlistMeta = {
|
||||
_id: 'm3u-1',
|
||||
title: 'M3U List',
|
||||
count: 0,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
recentlyViewed: [
|
||||
{
|
||||
source: 'm3u',
|
||||
@@ -225,7 +228,7 @@ describe('UnifiedRecentDataService', () => {
|
||||
|
||||
expect(playlistsService.addM3uRecentlyViewed).toHaveBeenCalledWith(
|
||||
'm3u-1',
|
||||
expect.objectContaining<M3uRecentlyViewedItem>({
|
||||
expect.objectContaining<Partial<M3uRecentlyViewedItem>>({
|
||||
source: 'm3u',
|
||||
url: 'https://example.com/1.m3u8',
|
||||
channel_id: 'channel-1',
|
||||
|
||||
+41
-2
@@ -1,5 +1,23 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import { VodSourceDiscoveryService } from './vod-source-discovery.service';
|
||||
|
||||
const withholdsEverything = signal(false);
|
||||
|
||||
function createService(): VodSourceDiscoveryService {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: ParentalLockService,
|
||||
useValue: { withholdsEverything },
|
||||
},
|
||||
],
|
||||
});
|
||||
return TestBed.inject(VodSourceDiscoveryService);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovery talks to a foreign playlist, and Xtream carries the account in the
|
||||
* URL — so anything it fails on has to go through the redacting logger before
|
||||
@@ -17,7 +35,7 @@ describe('VodSourceDiscoveryService — failure logging', () => {
|
||||
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {
|
||||
/* captured */
|
||||
});
|
||||
service = new VodSourceDiscoveryService();
|
||||
service = createService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -85,7 +103,7 @@ describe('VodSourceDiscoveryService — candidate mapping', () => {
|
||||
row(4, undefined),
|
||||
]),
|
||||
};
|
||||
const service = new VodSourceDiscoveryService();
|
||||
const service = createService();
|
||||
|
||||
const result = await service.discover({
|
||||
title: 'Dune',
|
||||
@@ -111,3 +129,24 @@ describe('VodSourceDiscoveryService — candidate mapping', () => {
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
describe('VodSourceDiscoveryService — parental lock', () => {
|
||||
afterEach(() => {
|
||||
withholdsEverything.set(false);
|
||||
delete (window as { electron?: unknown }).electron;
|
||||
});
|
||||
|
||||
it('asks the worker nothing while the lock withholds everything', async () => {
|
||||
const dbFindTitleSources = jest.fn().mockResolvedValue([]);
|
||||
(window as { electron?: unknown }).electron = { dbFindTitleSources };
|
||||
withholdsEverything.set(true);
|
||||
|
||||
const result = await createService().discover({
|
||||
title: 'Dune',
|
||||
currentPlaylistId: 'playlist-0',
|
||||
});
|
||||
|
||||
expect(result.sources).toEqual([]);
|
||||
expect(dbFindTitleSources).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+11
-2
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import {
|
||||
unambiguousCategoryLanguage,
|
||||
type VodSourceCandidate,
|
||||
@@ -43,6 +44,7 @@ export interface VodSourceDiscoveryResult {
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VodSourceDiscoveryService {
|
||||
private readonly logger = createLogger('VodSourceDiscovery');
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
|
||||
get isAvailable(): boolean {
|
||||
return (
|
||||
@@ -59,7 +61,14 @@ export class VodSourceDiscoveryService {
|
||||
matchKind: 'title-year',
|
||||
};
|
||||
|
||||
if (!this.isAvailable || !request.title?.trim()) {
|
||||
// The worker withholds locked rows through its own lock state and
|
||||
// index; while the lock withholds everything (store unreadable, or
|
||||
// a bridge without the worker filter) neither can be trusted.
|
||||
if (
|
||||
!this.isAvailable ||
|
||||
!request.title?.trim() ||
|
||||
this.parentalLock.withholdsEverything()
|
||||
) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ const active: SourceHealthResult = {
|
||||
};
|
||||
const playlist = (id: string, host = id): PlaylistMeta => ({
|
||||
_id: id,
|
||||
title: id,
|
||||
count: 0,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
url: `https://${host}.test/list`,
|
||||
});
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
@@ -49,7 +49,8 @@ export class CategoryViewComponent {
|
||||
container.querySelectorAll('[data-category-id]')
|
||||
) as HTMLElement[];
|
||||
const selected = candidates.find(
|
||||
(el) => el.dataset.categoryId === String(selectedCategory)
|
||||
(el) =>
|
||||
el.dataset['categoryId'] === String(selectedCategory)
|
||||
);
|
||||
if (!selected) {
|
||||
return;
|
||||
|
||||
+2
-2
@@ -208,7 +208,7 @@ describe('UnifiedCollectionPageComponent', () => {
|
||||
params: {},
|
||||
},
|
||||
paramMap: workspaceParamMap$.asObservable(),
|
||||
} as ActivatedRoute;
|
||||
} as unknown as ActivatedRoute;
|
||||
|
||||
route = {
|
||||
snapshot: {
|
||||
@@ -222,7 +222,7 @@ describe('UnifiedCollectionPageComponent', () => {
|
||||
paramMap: routeParamMap$.asObservable(),
|
||||
queryParamMap: routeQueryParamMap$.asObservable(),
|
||||
pathFromRoot: [],
|
||||
} as ActivatedRoute & {
|
||||
} as unknown as ActivatedRoute & {
|
||||
snapshot: {
|
||||
paramMap: ReturnType<typeof convertToParamMap>;
|
||||
queryParamMap: ReturnType<typeof convertToParamMap>;
|
||||
|
||||
+1
@@ -607,6 +607,7 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
code: 'network-error',
|
||||
player: 'videojs',
|
||||
source: 'hls',
|
||||
sourceUrl: 'https://example.com/xtream.m3u8',
|
||||
container: '',
|
||||
mimeType: '',
|
||||
videoCodecs: [],
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('LivePanelsController', () => {
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
TestBed.flushEffects();
|
||||
await new Promise((resolve) => queueMicrotask(resolve));
|
||||
await new Promise<void>((resolve) => queueMicrotask(() => resolve()));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -4,7 +4,15 @@ import {
|
||||
runInInjectionContext,
|
||||
} from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import {
|
||||
ActivatedRoute,
|
||||
ActivatedRouteSnapshot,
|
||||
convertToParamMap,
|
||||
} from '@angular/router';
|
||||
|
||||
type WritableSnapshot = {
|
||||
-readonly [K in keyof ActivatedRouteSnapshot]: ActivatedRouteSnapshot[K];
|
||||
};
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import {
|
||||
extractPortalPlaylistId,
|
||||
@@ -36,7 +44,7 @@ describe('portal-route.utils', () => {
|
||||
layout: 'workspace',
|
||||
},
|
||||
},
|
||||
} as ActivatedRoute;
|
||||
} as unknown as ActivatedRoute;
|
||||
const childRoute = {
|
||||
snapshot: {
|
||||
data: {},
|
||||
@@ -257,10 +265,11 @@ describe('portal-route.utils', () => {
|
||||
|
||||
expect(playlistId()).toBe('playlist-1');
|
||||
|
||||
route.snapshot.paramMap = convertToParamMap({
|
||||
const snapshot = route.snapshot as WritableSnapshot;
|
||||
snapshot.paramMap = convertToParamMap({
|
||||
id: 'playlist-2',
|
||||
});
|
||||
route.snapshot.params = {
|
||||
snapshot.params = {
|
||||
id: 'playlist-2',
|
||||
};
|
||||
paramMap$.next(
|
||||
@@ -296,7 +305,7 @@ describe('portal-route.utils', () => {
|
||||
},
|
||||
},
|
||||
paramMap: parentParamMap$.asObservable(),
|
||||
} as ActivatedRoute;
|
||||
} as unknown as ActivatedRoute;
|
||||
const route = {
|
||||
snapshot: {
|
||||
data: {},
|
||||
@@ -316,10 +325,11 @@ describe('portal-route.utils', () => {
|
||||
|
||||
expect(playlistId()).toBe('playlist-1');
|
||||
|
||||
(parentRoute.snapshot as ActivatedRoute['snapshot']).paramMap =
|
||||
convertToParamMap({
|
||||
(parentRoute.snapshot as WritableSnapshot).paramMap = convertToParamMap(
|
||||
{
|
||||
id: 'playlist-2',
|
||||
});
|
||||
}
|
||||
);
|
||||
(parentRoute.snapshot as ActivatedRoute['snapshot']).params = {
|
||||
id: 'playlist-2',
|
||||
};
|
||||
|
||||
@@ -105,7 +105,9 @@ describe('createVodWatchedToggle', () => {
|
||||
applyPosition: (next) => position.set(next),
|
||||
playingNow,
|
||||
notify: (kind) => feedback.push(kind),
|
||||
onPersisted: (playlistId) => persisted.push(playlistId),
|
||||
onPersisted: (playlistId) => {
|
||||
persisted.push(playlistId);
|
||||
},
|
||||
logger,
|
||||
});
|
||||
const target = (stillCurrent = () => true) => ({
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Dependency-free entry for the DI tokens the web app's root shell and the
|
||||
// eager Xtream data layer provide or inject. The main barrel would put every
|
||||
// portal helper that lazy routes use onto the renderer's initial path.
|
||||
export * from './lib/portal-external-playback';
|
||||
export * from './lib/portal-playback-positions';
|
||||
export * from './lib/portal-player';
|
||||
export * from './lib/portal-shell-actions';
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
PlaylistUpdateState,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
STALKER_MAG_USER_AGENT,
|
||||
STALKER_STREAM_USER_AGENT,
|
||||
@@ -16,8 +19,8 @@ function createPlaylist(overrides: Partial<PlaylistMeta> = {}): PlaylistMeta {
|
||||
url: '',
|
||||
importDate: '',
|
||||
filePath: '',
|
||||
updateDate: '',
|
||||
updateState: '',
|
||||
updateDate: 0,
|
||||
updateState: PlaylistUpdateState.NOT_UPDATED,
|
||||
position: 0,
|
||||
autoRefresh: false,
|
||||
favorites: [],
|
||||
|
||||
@@ -25,7 +25,7 @@ const MISCLASSIFIED = {
|
||||
portalUrl: 'http://ministra.example/server/load.php',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
isFullStalkerPortal: false,
|
||||
} as PlaylistMeta;
|
||||
} as PlaylistMeta & { portalUrl: string };
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
|
||||
@@ -470,9 +470,9 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
expect(payload.params).not.toHaveProperty('device_id2');
|
||||
expect(payload.params).not.toHaveProperty('signature');
|
||||
expect(payload.params).not.toHaveProperty('signature2');
|
||||
expect(JSON.parse(String(payload.params.metrics))).not.toHaveProperty(
|
||||
'sn'
|
||||
);
|
||||
expect(
|
||||
JSON.parse(String(payload.params['metrics']))
|
||||
).not.toHaveProperty('sn');
|
||||
});
|
||||
|
||||
it('sends provided SN, device IDs, and signatures exactly in get_profile', async () => {
|
||||
@@ -505,7 +505,7 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
signature2: 'SIGNATURE-2',
|
||||
})
|
||||
);
|
||||
expect(JSON.parse(String(payload.params.metrics))).toEqual(
|
||||
expect(JSON.parse(String(payload.params['metrics']))).toEqual(
|
||||
expect.objectContaining({
|
||||
sn: 'CUSTOMSN123',
|
||||
})
|
||||
|
||||
@@ -112,7 +112,9 @@ describe('stalker-vod.utils regressions', () => {
|
||||
cmd: '/media/file_42.mpg',
|
||||
})
|
||||
);
|
||||
expect(state.vodDetailsItem?.data.id).toBe('42');
|
||||
expect(state.vodDetailsItem?.data).toEqual(
|
||||
expect.objectContaining({ id: '42' })
|
||||
);
|
||||
});
|
||||
|
||||
it('favorite toggle uses completion callback path without delayed state update', () => {
|
||||
|
||||
@@ -8,8 +8,10 @@ import { DataService, PlaylistsService } from '@iptvnator/services';
|
||||
import { StalkerSessionService } from './stalker-session.service';
|
||||
import { StalkerStore } from './stalker.store';
|
||||
|
||||
type StalkerStoreMember = keyof InstanceType<typeof StalkerStore>;
|
||||
|
||||
describe('StalkerStore API compatibility smoke', () => {
|
||||
let store: StalkerStore;
|
||||
let store: InstanceType<typeof StalkerStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -25,7 +27,6 @@ describe('StalkerStore API compatibility smoke', () => {
|
||||
{
|
||||
provide: StalkerSessionService,
|
||||
useValue: {
|
||||
ensureToken: jest.fn(),
|
||||
makeAuthenticatedRequest: jest.fn(),
|
||||
ensureToken: jest.fn().mockResolvedValue({
|
||||
token: null,
|
||||
@@ -75,7 +76,7 @@ describe('StalkerStore API compatibility smoke', () => {
|
||||
});
|
||||
|
||||
it('exposes compatibility state signals', () => {
|
||||
const expectedSignals = [
|
||||
const expectedSignals: StalkerStoreMember[] = [
|
||||
'selectedContentType',
|
||||
'selectedCategoryId',
|
||||
'selectedVodId',
|
||||
@@ -102,7 +103,7 @@ describe('StalkerStore API compatibility smoke', () => {
|
||||
});
|
||||
|
||||
it('exposes compatibility computed selectors', () => {
|
||||
const expectedComputed = [
|
||||
const expectedComputed: StalkerStoreMember[] = [
|
||||
// getTotalPages was removed with catalog pagination — the grid
|
||||
// appends portal pages and pages have no UI representation left.
|
||||
'hasMoreContent',
|
||||
@@ -126,7 +127,7 @@ describe('StalkerStore API compatibility smoke', () => {
|
||||
});
|
||||
|
||||
it('exposes compatibility methods and internal resources', () => {
|
||||
const expectedMethods = [
|
||||
const expectedMethods: StalkerStoreMember[] = [
|
||||
'setSelectedContentType',
|
||||
'setSelectedCategory',
|
||||
'setSelectedSerialId',
|
||||
|
||||
+189
-28
@@ -2,7 +2,7 @@ import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { DataService, ParentalLockService } from '@iptvnator/services';
|
||||
import {
|
||||
CONNECTIVITY_GUARD_RESET,
|
||||
PlaylistMeta,
|
||||
@@ -34,6 +34,7 @@ const PLAYLIST = {
|
||||
} as PlaylistMeta;
|
||||
|
||||
const TestContentStore = signalStore(
|
||||
{ protectedState: false },
|
||||
withState({
|
||||
currentPlaylist: undefined as PlaylistMeta | undefined,
|
||||
selectedContentType: 'vod' as 'vod' | 'series' | 'itv' | 'radio',
|
||||
@@ -58,6 +59,11 @@ const TestContentStore = signalStore(
|
||||
withStalkerContent()
|
||||
);
|
||||
|
||||
/** Request payload the content feature sends through `sendIpcEvent`. */
|
||||
interface StalkerIpcRequest {
|
||||
params?: { action?: string; p?: number; search?: string };
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
@@ -136,18 +142,34 @@ function createItvCacheMock(
|
||||
describe('withStalkerContent failure states', () => {
|
||||
let store: InstanceType<typeof TestContentStore>;
|
||||
let dataService: {
|
||||
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
|
||||
sendIpcEvent: jest.Mock<
|
||||
Promise<unknown>,
|
||||
[event: string, request: StalkerIpcRequest]
|
||||
>;
|
||||
};
|
||||
let parentalLock: {
|
||||
active: jest.Mock<boolean, []>;
|
||||
version: ReturnType<typeof signal<number>>;
|
||||
lockedStalkerIds: jest.Mock<string[], [string, string]>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
dataService = {
|
||||
sendIpcEvent: jest.fn(),
|
||||
};
|
||||
parentalLock = {
|
||||
active: jest.fn(() => false),
|
||||
version: signal(0),
|
||||
lockedStalkerIds: jest.fn(
|
||||
(_playlistId: string, _type: string): string[] => []
|
||||
),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TestContentStore,
|
||||
{ provide: DataService, useValue: dataService },
|
||||
{ provide: ParentalLockService, useValue: parentalLock },
|
||||
{
|
||||
provide: StalkerItvCacheService,
|
||||
useValue: createItvCacheMock(),
|
||||
@@ -176,11 +198,10 @@ describe('withStalkerContent failure states', () => {
|
||||
it('keeps ITV pages unfiltered while independent local searches change', async () => {
|
||||
const pending =
|
||||
createDeferred<ReturnType<typeof createContentResponse>>();
|
||||
dataService.sendIpcEvent.mockImplementation(
|
||||
(_event, request: { params: { action: string } }) =>
|
||||
request.params.action === 'get_genres'
|
||||
? Promise.resolve({ js: [] })
|
||||
: pending.promise
|
||||
dataService.sendIpcEvent.mockImplementation((_event, request) =>
|
||||
request.params?.action === 'get_genres'
|
||||
? Promise.resolve({ js: [] })
|
||||
: pending.promise
|
||||
);
|
||||
store.setSelectedContentType('itv');
|
||||
store.setCategories('itv', [
|
||||
@@ -192,15 +213,10 @@ describe('withStalkerContent failure states', () => {
|
||||
await flushResources();
|
||||
const orderedCalls = () =>
|
||||
dataService.sendIpcEvent.mock.calls.filter(
|
||||
(call) =>
|
||||
(call[1] as { params: { action: string } }).params
|
||||
.action === 'get_ordered_list'
|
||||
(call) => call[1].params?.action === 'get_ordered_list'
|
||||
);
|
||||
await waitForCondition(() => orderedCalls().length > 0);
|
||||
expect(
|
||||
(orderedCalls()[0][1] as { params: { search?: string } }).params
|
||||
.search
|
||||
).toBeUndefined();
|
||||
expect(orderedCalls()[0][1].params?.search).toBeUndefined();
|
||||
patchState(store, { searchPhrase: 'changed' });
|
||||
await flushResources();
|
||||
pending.resolve(createContentResponse('Panel match'));
|
||||
@@ -590,6 +606,149 @@ describe('withStalkerContent failure states', () => {
|
||||
expect(store.totalCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('pages past a VOD page made only of parental-locked rows', async () => {
|
||||
parentalLock.active.mockReturnValue(true);
|
||||
parentalLock.lockedStalkerIds.mockReturnValue(['9']);
|
||||
dataService.sendIpcEvent.mockImplementation(
|
||||
(_event: unknown, payload: { params?: { p?: number } }) => {
|
||||
const page = Number(payload.params?.p ?? 1);
|
||||
// Page 1: one visible film. Page 2: locked rows only. Page 3:
|
||||
// the visible film paging must still reach.
|
||||
const data =
|
||||
page === 1
|
||||
? [{ id: 'movie-1', name: 'One', category_id: '5' }]
|
||||
: page === 2
|
||||
? [
|
||||
{ id: 'adult-1', name: 'A', category_id: '9' },
|
||||
{ id: 'adult-2', name: 'B', category_id: '9' },
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: 'movie-3',
|
||||
name: 'Three',
|
||||
category_id: '5',
|
||||
},
|
||||
];
|
||||
return Promise.resolve({ js: { data, total_items: 4 } });
|
||||
}
|
||||
);
|
||||
|
||||
store.setSelectedContentType('vod');
|
||||
store.setCategories('vod', [
|
||||
{ category_id: '5', category_name: 'Action' },
|
||||
{ category_id: '9', category_name: 'Adult' },
|
||||
]);
|
||||
store.setSelectedCategory('*');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
void store.isPaginatedContentLoading();
|
||||
|
||||
await waitForCondition(() => store.getPaginatedContent().length === 1);
|
||||
expect(store.hasMoreContent()).toBe(true);
|
||||
|
||||
// The append lands on the fully withheld page; the loader must ask
|
||||
// for the next one by itself instead of ending the list.
|
||||
store.setPage(1);
|
||||
await waitForCondition(
|
||||
() => store.getPaginatedContent().length === 2,
|
||||
60
|
||||
);
|
||||
|
||||
expect(store.getPaginatedContent().map((item) => item.id)).toEqual([
|
||||
'movie-1',
|
||||
'movie-3',
|
||||
]);
|
||||
// Both withheld ids are subtracted from the portal's total.
|
||||
expect(store.totalCount()).toBe(2);
|
||||
expect(store.hasMoreContent()).toBe(false);
|
||||
});
|
||||
|
||||
it('drops accumulated rows of a newly locked genre and restarts from page 1', async () => {
|
||||
dataService.sendIpcEvent.mockImplementation(
|
||||
(_event: unknown, payload: { params?: { p?: number } }) => {
|
||||
const page = Number(payload.params?.p ?? 1);
|
||||
const data =
|
||||
page === 1
|
||||
? [
|
||||
{ id: 'movie-1', name: 'One', category_id: '5' },
|
||||
{ id: 'adult-1', name: 'A', category_id: '9' },
|
||||
]
|
||||
: [{ id: 'movie-2', name: 'Two', category_id: '5' }];
|
||||
return Promise.resolve({ js: { data, total_items: 3 } });
|
||||
}
|
||||
);
|
||||
|
||||
store.setSelectedContentType('vod');
|
||||
store.setCategories('vod', [
|
||||
{ category_id: '5', category_name: 'Action' },
|
||||
{ category_id: '9', category_name: 'Adult' },
|
||||
]);
|
||||
store.setSelectedCategory('*');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
void store.isPaginatedContentLoading();
|
||||
|
||||
await waitForCondition(() => store.getPaginatedContent().length === 2);
|
||||
store.setPage(1);
|
||||
await waitForCondition(() => store.getPaginatedContent().length === 3);
|
||||
|
||||
// Lock now: the genre-9 row loaded on page 1 must leave the screen
|
||||
// and the list must be rebuilt from page 1 under the new lock state.
|
||||
parentalLock.active.mockReturnValue(true);
|
||||
parentalLock.lockedStalkerIds.mockReturnValue(['9']);
|
||||
parentalLock.version.set(1);
|
||||
|
||||
await waitForCondition(
|
||||
() =>
|
||||
store.page() === 0 &&
|
||||
!store.isPaginatedContentLoading() &&
|
||||
store
|
||||
.getPaginatedContent()
|
||||
.every((item) => item.category_id !== '9'),
|
||||
60
|
||||
);
|
||||
|
||||
expect(store.getPaginatedContent().map((item) => item.id)).toEqual([
|
||||
'movie-1',
|
||||
]);
|
||||
expect(store.totalCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('takes page-one rows of a newly locked genre off screen before the reload answers', async () => {
|
||||
let hangReload = false;
|
||||
dataService.sendIpcEvent.mockImplementation(() =>
|
||||
hangReload
|
||||
? new Promise(() => undefined)
|
||||
: Promise.resolve({
|
||||
js: {
|
||||
data: [
|
||||
{ id: 'movie-1', name: 'One', category_id: '5' },
|
||||
{ id: 'adult-1', name: 'A', category_id: '9' },
|
||||
],
|
||||
total_items: 2,
|
||||
},
|
||||
})
|
||||
);
|
||||
store.setSelectedContentType('vod');
|
||||
store.setCategories('vod', [
|
||||
{ category_id: '5', category_name: 'Action' },
|
||||
{ category_id: '9', category_name: 'Adult' },
|
||||
]);
|
||||
store.setSelectedCategory('*');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
void store.isPaginatedContentLoading();
|
||||
await waitForCondition(() => store.getPaginatedContent().length === 2);
|
||||
|
||||
// Lock now while the replacement request hangs.
|
||||
hangReload = true;
|
||||
parentalLock.active.mockReturnValue(true);
|
||||
parentalLock.lockedStalkerIds.mockReturnValue(['9']);
|
||||
parentalLock.version.set(1);
|
||||
|
||||
// Page 1 blanks the grid synchronously before its request, so the
|
||||
// locked row is gone even though the reload never answers.
|
||||
await waitForCondition(() => store.getPaginatedContent().length === 0);
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps accumulated pages when an append fails and retries the same page', async () => {
|
||||
let failPageTwo = true;
|
||||
dataService.sendIpcEvent.mockImplementation(
|
||||
@@ -810,7 +969,10 @@ describe('withStalkerContent full ITV channel list cache', () => {
|
||||
|
||||
let store: InstanceType<typeof TestContentStore>;
|
||||
let dataService: {
|
||||
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
|
||||
sendIpcEvent: jest.Mock<
|
||||
Promise<unknown>,
|
||||
[event: string, request: StalkerIpcRequest]
|
||||
>;
|
||||
};
|
||||
let itvCache: ReturnType<typeof createItvCacheMock>;
|
||||
|
||||
@@ -1041,19 +1203,18 @@ describe('withStalkerContent full ITV channel list cache', () => {
|
||||
|
||||
it('keeps censored pagination after a delayed cache replays the current page', async () => {
|
||||
setup(null);
|
||||
dataService.sendIpcEvent.mockImplementation(
|
||||
(_event, payload: { params: { p: number } }) =>
|
||||
Promise.resolve({
|
||||
js: {
|
||||
data: [
|
||||
{
|
||||
id: String(payload.params.p),
|
||||
name: `Hidden ${payload.params.p}`,
|
||||
},
|
||||
],
|
||||
total_items: 3,
|
||||
},
|
||||
})
|
||||
dataService.sendIpcEvent.mockImplementation((_event, payload) =>
|
||||
Promise.resolve({
|
||||
js: {
|
||||
data: [
|
||||
{
|
||||
id: String(payload.params?.p),
|
||||
name: `Hidden ${payload.params?.p}`,
|
||||
},
|
||||
],
|
||||
total_items: 3,
|
||||
},
|
||||
})
|
||||
);
|
||||
enterItvCategory('1099');
|
||||
await waitForCondition(() => store.itvChannels().length === 1);
|
||||
|
||||
+220
-20
@@ -9,7 +9,13 @@ import {
|
||||
} from '@ngrx/signals';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { DataService, resetHostConnectivityGuard } from '@iptvnator/services';
|
||||
import {
|
||||
DataService,
|
||||
ParentalLockService,
|
||||
resetHostConnectivityGuard,
|
||||
} from '@iptvnator/services';
|
||||
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
StalkerCategoryItem,
|
||||
StalkerContentItem,
|
||||
@@ -31,8 +37,29 @@ import {
|
||||
filterItvChannelsByGenre,
|
||||
toStalkerContentItem,
|
||||
toStalkerItvChannel,
|
||||
stalkerWithheldRowKey,
|
||||
withoutWithheldStalkerItems,
|
||||
} from '../utils';
|
||||
|
||||
/**
|
||||
* Genre ids of the given section that the parental lock currently withholds
|
||||
* for this portal; empty while unlocked or off.
|
||||
*/
|
||||
function withheldStalkerCategoryIds(
|
||||
parentalLock: ParentalLockService,
|
||||
playlist: PlaylistMeta | undefined,
|
||||
contentType: StalkerContentType
|
||||
): ReadonlySet<string> {
|
||||
const playlistId = playlist?._id;
|
||||
if (!playlistId || !parentalLock.active()) {
|
||||
return new Set();
|
||||
}
|
||||
if (parentalLock.withholdsEverything?.()) {
|
||||
return ALL_CATEGORIES_WITHHELD;
|
||||
}
|
||||
return new Set(parentalLock.lockedStalkerIds(playlistId, contentType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Content/categories/channels feature state.
|
||||
*/
|
||||
@@ -250,7 +277,8 @@ export function withStalkerContent() {
|
||||
stalkerSession = inject(StalkerSessionService),
|
||||
portalRepair = inject(StalkerPortalRepairService),
|
||||
translateService = inject(TranslateService),
|
||||
itvCache = inject(StalkerItvCacheService)
|
||||
itvCache = inject(StalkerItvCacheService),
|
||||
parentalLock = inject(ParentalLockService)
|
||||
) => {
|
||||
const storeContext = store as typeof store &
|
||||
StalkerContentResourceStoreContract;
|
||||
@@ -261,6 +289,13 @@ export function withStalkerContent() {
|
||||
};
|
||||
|
||||
let lastLivePageKey = '';
|
||||
// Withheld (parental-locked) row ids seen while accumulating
|
||||
// the current list. A page that adds only withheld ids still
|
||||
// counts as progress, so paging continues past it; a page
|
||||
// adding nothing new — withheld or not — is a stalled portal.
|
||||
let withheldSeenKey = '';
|
||||
const withheldSeenIds = new Set<string>();
|
||||
let lastParentalLockVersion: number | null = null;
|
||||
return {
|
||||
categoryResource: resource({
|
||||
params: () => ({
|
||||
@@ -412,6 +447,10 @@ export function withStalkerContent() {
|
||||
(category) =>
|
||||
String(category.category_id) !== '*'
|
||||
).length,
|
||||
// Lock/unlock re-fires the loader: rows of a
|
||||
// locked genre are dropped at patch time, so the
|
||||
// list must be rebuilt when they become visible.
|
||||
parentalLockVersion: parentalLock.version(),
|
||||
}),
|
||||
loader: async ({
|
||||
params,
|
||||
@@ -457,15 +496,74 @@ export function withStalkerContent() {
|
||||
}
|
||||
|
||||
const categoryParam = params.category || '*';
|
||||
const withheldCategoryIds =
|
||||
withheldStalkerCategoryIds(
|
||||
parentalLock,
|
||||
playlist,
|
||||
params.contentType
|
||||
);
|
||||
// A lock flip while the list is past page 1 must
|
||||
// not append filtered rows onto pages that were
|
||||
// accumulated under the old lock state: drop the
|
||||
// withheld rows on screen now and restart from
|
||||
// page 1 so the list is rebuilt under the new one.
|
||||
if (
|
||||
lastParentalLockVersion !== null &&
|
||||
params.parentalLockVersion !==
|
||||
lastParentalLockVersion &&
|
||||
params.pageIndex > 1
|
||||
) {
|
||||
lastParentalLockVersion =
|
||||
params.parentalLockVersion;
|
||||
const retained = withoutWithheldStalkerItems(
|
||||
store.paginatedContent(),
|
||||
params.contentType,
|
||||
withheldCategoryIds
|
||||
);
|
||||
patchState(store, {
|
||||
paginatedContent: retained,
|
||||
...(params.contentType === 'itv'
|
||||
? {
|
||||
itvChannels:
|
||||
withoutWithheldStalkerItems(
|
||||
store.itvChannels(),
|
||||
'itv',
|
||||
withheldCategoryIds
|
||||
),
|
||||
}
|
||||
: params.contentType === 'radio'
|
||||
? {
|
||||
radioChannels:
|
||||
withoutWithheldStalkerItems(
|
||||
store.radioChannels(),
|
||||
'radio',
|
||||
withheldCategoryIds
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
(
|
||||
store as unknown as {
|
||||
setPage?: (page: number) => void;
|
||||
}
|
||||
).setPage?.(0);
|
||||
return retained;
|
||||
}
|
||||
lastParentalLockVersion =
|
||||
params.parentalLockVersion;
|
||||
|
||||
if (params.contentType === 'itv') {
|
||||
const cachedChannels =
|
||||
itvCache.getChannels(playlist);
|
||||
const channels =
|
||||
cachedChannels !== null
|
||||
? filterItvChannelsByGenre(
|
||||
cachedChannels,
|
||||
categoryParam
|
||||
? withoutWithheldStalkerItems(
|
||||
filterItvChannelsByGenre(
|
||||
cachedChannels,
|
||||
categoryParam
|
||||
),
|
||||
params.contentType,
|
||||
withheldCategoryIds
|
||||
)
|
||||
: null;
|
||||
// Serve from the cache only when it actually
|
||||
@@ -522,6 +620,8 @@ export function withStalkerContent() {
|
||||
params.pageIndex ===
|
||||
storeContext.page() + 1 &&
|
||||
paramsPlaylistKey === currentPlaylistKey &&
|
||||
params.parentalLockVersion ===
|
||||
parentalLock.version() &&
|
||||
// A legacy paged response must not overwrite
|
||||
// the full cached list that a re-fired
|
||||
// loader served in the meantime. Scoped
|
||||
@@ -609,12 +709,70 @@ export function withStalkerContent() {
|
||||
return [];
|
||||
}
|
||||
|
||||
const newItems = response.js.data.map((item) =>
|
||||
const rawItems = response.js.data.map((item) =>
|
||||
toStalkerContentItem(
|
||||
item,
|
||||
playlist.portalUrl ?? ''
|
||||
)
|
||||
);
|
||||
const newItems = withoutWithheldStalkerItems(
|
||||
rawItems,
|
||||
params.contentType,
|
||||
withheldCategoryIds
|
||||
);
|
||||
const listKey = JSON.stringify([
|
||||
paramsPlaylistKey,
|
||||
params.contentType,
|
||||
params.category,
|
||||
params.search,
|
||||
]);
|
||||
if (
|
||||
params.pageIndex === 1 ||
|
||||
withheldSeenKey !== listKey
|
||||
) {
|
||||
withheldSeenKey = listKey;
|
||||
withheldSeenIds.clear();
|
||||
}
|
||||
let newWithheldCount = 0;
|
||||
if (newItems.length < rawItems.length) {
|
||||
const kept = new Set(newItems);
|
||||
for (const item of rawItems) {
|
||||
if (kept.has(item)) {
|
||||
continue;
|
||||
}
|
||||
const id = stalkerWithheldRowKey(item);
|
||||
if (!withheldSeenIds.has(id)) {
|
||||
withheldSeenIds.add(id);
|
||||
newWithheldCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A page made only of withheld rows would
|
||||
// leave the list unchanged; request the next
|
||||
// one so unlocked rows further on still load.
|
||||
const skipWithheldPage = (hasMore: boolean) => {
|
||||
if (
|
||||
!hasMore ||
|
||||
newItems.length > 0 ||
|
||||
newWithheldCount === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
if (isCurrentRequest()) {
|
||||
// `page` belongs to the selection
|
||||
// feature; the facade composes
|
||||
// its setter ahead of this one.
|
||||
(
|
||||
store as unknown as {
|
||||
setPage?: (
|
||||
page: number
|
||||
) => void;
|
||||
}
|
||||
).setPage?.(params.pageIndex);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (
|
||||
params.contentType === 'itv' ||
|
||||
@@ -645,6 +803,16 @@ export function withStalkerContent() {
|
||||
const replay =
|
||||
livePageKey === lastLivePageKey;
|
||||
lastLivePageKey = livePageKey;
|
||||
const hasMoreChannels =
|
||||
rawItems.length > 0 &&
|
||||
(params.pageIndex === 1 ||
|
||||
replay ||
|
||||
newWithheldCount > 0 ||
|
||||
nextChannels.length >
|
||||
existingChannels.length) &&
|
||||
nextChannels.length +
|
||||
withheldSeenIds.size <
|
||||
(response.js.total_items ?? 0);
|
||||
patchState(store, {
|
||||
totalCount:
|
||||
response.js.total_items ?? 0,
|
||||
@@ -662,15 +830,9 @@ export function withStalkerContent() {
|
||||
},
|
||||
}
|
||||
: { radioChannels: nextChannels }),
|
||||
hasMoreChannels:
|
||||
channels.length > 0 &&
|
||||
(params.pageIndex === 1 ||
|
||||
replay ||
|
||||
nextChannels.length >
|
||||
existingChannels.length) &&
|
||||
nextChannels.length <
|
||||
(response.js.total_items ?? 0),
|
||||
hasMoreChannels,
|
||||
});
|
||||
skipWithheldPage(hasMoreChannels);
|
||||
} else {
|
||||
// VOD/series pages accumulate into one
|
||||
// continuous list for the infinite-scroll
|
||||
@@ -694,18 +856,30 @@ export function withStalkerContent() {
|
||||
// end on every scroll crossing.
|
||||
const appendStalled =
|
||||
params.pageIndex > 1 &&
|
||||
newWithheldCount === 0 &&
|
||||
nextContent.length <=
|
||||
previousContent.length;
|
||||
// Withheld rows count against the portal's
|
||||
// total, or the grid would keep asking for
|
||||
// pages the lock will never let it show.
|
||||
const totalCount = appendStalled
|
||||
? nextContent.length
|
||||
: Math.max(
|
||||
0,
|
||||
(response.js.total_items ?? 0) -
|
||||
withheldSeenIds.size
|
||||
);
|
||||
|
||||
patchState(store, {
|
||||
totalCount: appendStalled
|
||||
? nextContent.length
|
||||
: (response.js.total_items ?? 0),
|
||||
totalCount,
|
||||
paginatedContent: nextContent,
|
||||
contentError: null,
|
||||
appendError: null,
|
||||
hasMoreChannels: false,
|
||||
});
|
||||
skipWithheldPage(
|
||||
nextContent.length < totalCount
|
||||
);
|
||||
return nextContent;
|
||||
}
|
||||
|
||||
@@ -744,16 +918,40 @@ export function withStalkerContent() {
|
||||
const storeContext = store as typeof store &
|
||||
StalkerContentResourceStoreContract;
|
||||
const itvCache = inject(StalkerItvCacheService);
|
||||
const parentalLock = inject(ParentalLockService);
|
||||
|
||||
/**
|
||||
* The whole portal's ITV channel list (all categories) when
|
||||
* cached. `versionFor` establishes the reactive dependency so this
|
||||
* cached, minus the genres the parental lock withholds.
|
||||
* `versionFor` establishes the reactive dependency so this
|
||||
* recomputes when the list becomes ready or is refreshed.
|
||||
*/
|
||||
const itvFullChannelList = computed(() => {
|
||||
const playlist = storeContext.currentPlaylist();
|
||||
itvCache.versionFor(playlist);
|
||||
return itvCache.getChannels(playlist) ?? [];
|
||||
parentalLock.version();
|
||||
return withoutWithheldStalkerItems(
|
||||
itvCache.getChannels(playlist) ?? [],
|
||||
'itv',
|
||||
withheldStalkerCategoryIds(parentalLock, playlist, 'itv')
|
||||
);
|
||||
});
|
||||
/** The selected section's genres with the withheld ones removed. */
|
||||
const visibleCategoryResource = computed(() => {
|
||||
parentalLock.version();
|
||||
const contentType = storeContext.selectedContentType();
|
||||
const withheld = withheldStalkerCategoryIds(
|
||||
parentalLock,
|
||||
storeContext.currentPlaylist(),
|
||||
contentType
|
||||
);
|
||||
const categories = getCategoriesByType(store, contentType);
|
||||
return withheld.size === 0
|
||||
? categories
|
||||
: categories.filter(
|
||||
(category) =>
|
||||
!withheld.has(String(category.category_id))
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -909,7 +1107,9 @@ export function withStalkerContent() {
|
||||
storeContext.getContentResource.isLoading()
|
||||
),
|
||||
isPaginatedContentFailed: computed(() => store.contentError()),
|
||||
getCategoryResource: computed(() =>
|
||||
getCategoryResource: visibleCategoryResource,
|
||||
/** Every genre of the section, locked ones included (lock dialog). */
|
||||
getAllCategoriesForSelectedType: computed(() =>
|
||||
getCategoriesByType(
|
||||
store,
|
||||
storeContext.selectedContentType()
|
||||
|
||||
+1
-1
@@ -179,7 +179,7 @@ describe('withStalkerFavorites', () => {
|
||||
playlist: {
|
||||
_id: 'portal-1',
|
||||
favorites: [],
|
||||
} as PlaylistMeta,
|
||||
} as Partial<PlaylistMeta> as PlaylistMeta,
|
||||
})
|
||||
);
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
|
||||
+2
-3
@@ -1,7 +1,7 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { signalStore } from '@ngrx/signals';
|
||||
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { ElectronBridgeApi, PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerPortalRepairService } from '../../stalker-portal-repair.service';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { withStalkerPortal } from './with-stalker-portal.feature';
|
||||
@@ -48,7 +48,7 @@ describe('withStalkerPortal', () => {
|
||||
value: {
|
||||
dbCreatePlaylist,
|
||||
dbGetPlaylist,
|
||||
} as Window['electron'],
|
||||
} satisfies Partial<ElectronBridgeApi>,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
@@ -138,5 +138,4 @@ describe('withStalkerPortal', () => {
|
||||
expect(dbGetPlaylist).not.toHaveBeenCalled();
|
||||
expect(dbCreatePlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
});
|
||||
+1
-1
@@ -174,7 +174,7 @@ describe('withStalkerRecent', () => {
|
||||
playlist: {
|
||||
_id: 'portal-1',
|
||||
recentlyViewed: [],
|
||||
} as PlaylistMeta,
|
||||
} as Partial<PlaylistMeta> as PlaylistMeta,
|
||||
})
|
||||
);
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
|
||||
+1
@@ -30,6 +30,7 @@ const PLAYLIST = {
|
||||
} as PlaylistMeta;
|
||||
|
||||
const TestSeriesStore = signalStore(
|
||||
{ protectedState: false },
|
||||
withState({
|
||||
currentPlaylist: undefined as PlaylistMeta | undefined,
|
||||
}),
|
||||
|
||||
@@ -4,4 +4,5 @@ export * from './stalker-playback-command.utils';
|
||||
export * from './stalker-player-request.utils';
|
||||
export * from './stalker-request.utils';
|
||||
export * from './stalker-content-mappers';
|
||||
export * from './stalker-parental-lock.utils';
|
||||
export * from './stalker-sorting';
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
isStalkerItemWithheld,
|
||||
stalkerWithheldRowKey,
|
||||
withoutWithheldStalkerItems,
|
||||
} from './stalker-parental-lock.utils';
|
||||
import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces';
|
||||
|
||||
describe('stalker-parental-lock.utils', () => {
|
||||
const withheld = new Set(['7', '9']);
|
||||
|
||||
it('reads the genre from tv_genre_id for live and radio rows', () => {
|
||||
expect(isStalkerItemWithheld({ tv_genre_id: 7 }, 'itv', withheld)).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
isStalkerItemWithheld({ tv_genre_id: '9' }, 'radio', withheld)
|
||||
).toBe(true);
|
||||
expect(isStalkerItemWithheld({ tv_genre_id: 8 }, 'itv', withheld)).toBe(
|
||||
false
|
||||
);
|
||||
// A VOD row with a matching category_id is not a live row.
|
||||
expect(
|
||||
isStalkerItemWithheld({ category_id: '7' }, 'itv', withheld)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('reads category_id for VOD and series rows', () => {
|
||||
expect(
|
||||
isStalkerItemWithheld({ category_id: '7' }, 'vod', withheld)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isStalkerItemWithheld({ category_id: 9 }, 'series', withheld)
|
||||
).toBe(true);
|
||||
expect(isStalkerItemWithheld({}, 'vod', withheld)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the same array when nothing is withheld', () => {
|
||||
const items = [{ category_id: '7' }];
|
||||
expect(withoutWithheldStalkerItems(items, 'vod', new Set())).toBe(
|
||||
items
|
||||
);
|
||||
expect(withoutWithheldStalkerItems(items, 'vod', withheld)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStalkerItemWithheld in fail-closed mode', () => {
|
||||
it('withholds rows without a genre only while everything is withheld', () => {
|
||||
expect(isStalkerItemWithheld({}, 'itv', ALL_CATEGORIES_WITHHELD)).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
isStalkerItemWithheld(
|
||||
{ name: 'x' } as never,
|
||||
'vod',
|
||||
ALL_CATEGORIES_WITHHELD
|
||||
)
|
||||
).toBe(true);
|
||||
expect(isStalkerItemWithheld({}, 'itv', new Set(['9']))).toBe(false);
|
||||
expect(
|
||||
withoutWithheldStalkerItems(
|
||||
[{ tv_genre_id: '1' }, {}],
|
||||
'itv',
|
||||
ALL_CATEGORIES_WITHHELD
|
||||
)
|
||||
).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' })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces';
|
||||
import type { StalkerContentType } from '../stalker-store.contracts';
|
||||
|
||||
/** Minimal item shape the parental lock needs: the category a row belongs to. */
|
||||
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 ?? '')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an item belongs to one of the withheld categories. Live and radio
|
||||
* rows carry their genre in `tv_genre_id`; VOD and series rows carry
|
||||
* `category_id`. Only matters for portal-wide lists ("All" and search),
|
||||
* since a withheld category can never be selected on its own.
|
||||
*/
|
||||
export function isStalkerItemWithheld(
|
||||
item: StalkerLockableItem,
|
||||
contentType: StalkerContentType,
|
||||
withheldCategoryIds: ReadonlySet<string>
|
||||
): boolean {
|
||||
if (withheldCategoryIds.size === 0) {
|
||||
return false;
|
||||
}
|
||||
const categoryId =
|
||||
contentType === 'itv' || contentType === 'radio'
|
||||
? item.tv_genre_id
|
||||
: item.category_id;
|
||||
if (categoryId === undefined || categoryId === null) {
|
||||
// A row without a genre is visible under a normal lock set but not
|
||||
// in fail-closed mode: while the locks are unknown, "no genre" must
|
||||
// not become the one row the withheld catalog still shows.
|
||||
return withheldCategoryIds === ALL_CATEGORIES_WITHHELD;
|
||||
}
|
||||
return withheldCategoryIds.has(String(categoryId));
|
||||
}
|
||||
|
||||
export function withoutWithheldStalkerItems<T extends StalkerLockableItem>(
|
||||
items: T[],
|
||||
contentType: StalkerContentType,
|
||||
withheldCategoryIds: ReadonlySet<string>
|
||||
): T[] {
|
||||
if (withheldCategoryIds.size === 0) {
|
||||
return items;
|
||||
}
|
||||
return items.filter(
|
||||
(item) => !isStalkerItemWithheld(item, contentType, withheldCategoryIds)
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './lib/stalker-collection-detail.component';
|
||||
export * from './lib/stalker-catalog-detail/stalker-catalog-detail.component';
|
||||
export * from './lib/stalker-favorites-button/stalker-favorites-button.component';
|
||||
export * from './lib/stalker-catalog-facade.service';
|
||||
export * from './lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component';
|
||||
export * from './lib/stalker-feature.routes';
|
||||
export * from './lib/stalker-live-stream-layout/stalker-live-stream-layout.component';
|
||||
export * from './lib/stalker-search/stalker-search.component';
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
convertToParamMap,
|
||||
Router,
|
||||
RouterStateSnapshot,
|
||||
UrlTree,
|
||||
} from '@angular/router';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import { parentalLockStalkerCategoryGuard } from './parental-lock-category.guard';
|
||||
|
||||
function createRoute(playlistId: string, categoryId: string) {
|
||||
const parent = {
|
||||
paramMap: convertToParamMap({ id: playlistId }),
|
||||
} as ActivatedRouteSnapshot;
|
||||
const route = {
|
||||
paramMap: convertToParamMap({ categoryId }),
|
||||
} as ActivatedRouteSnapshot;
|
||||
Object.defineProperty(route, 'pathFromRoot', {
|
||||
value: [parent, route],
|
||||
});
|
||||
return route;
|
||||
}
|
||||
|
||||
describe('parentalLockStalkerCategoryGuard', () => {
|
||||
let parentalLock: {
|
||||
initialize: jest.Mock;
|
||||
active: jest.Mock;
|
||||
isStalkerCategoryLocked: jest.Mock;
|
||||
requestUnlock: jest.Mock;
|
||||
};
|
||||
let router: { createUrlTree: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
parentalLock = {
|
||||
initialize: jest.fn().mockResolvedValue(undefined),
|
||||
active: jest.fn(() => true),
|
||||
isStalkerCategoryLocked: jest.fn(() => false),
|
||||
requestUnlock: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
router = { createUrlTree: jest.fn(() => ({}) as UrlTree) };
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: ParentalLockService, useValue: parentalLock },
|
||||
{ provide: Router, useValue: router },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
function run(section: 'vod' | 'series', categoryId: string) {
|
||||
return TestBed.runInInjectionContext(() =>
|
||||
parentalLockStalkerCategoryGuard(section)(
|
||||
createRoute('portal-1', categoryId),
|
||||
{} as RouterStateSnapshot
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
it('passes an unlocked genre and an inactive lock', async () => {
|
||||
await expect(run('vod', '9')).resolves.toBe(true);
|
||||
parentalLock.active.mockReturnValue(false);
|
||||
parentalLock.isStalkerCategoryLocked.mockReturnValue(true);
|
||||
await expect(run('vod', '9')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('prompts for a locked genre and redirects to the section root on refusal', async () => {
|
||||
parentalLock.isStalkerCategoryLocked.mockReturnValue(true);
|
||||
|
||||
const result = await run('series', '9');
|
||||
|
||||
expect(parentalLock.isStalkerCategoryLocked).toHaveBeenCalledWith(
|
||||
'portal-1',
|
||||
'series',
|
||||
'9'
|
||||
);
|
||||
expect(router.createUrlTree).toHaveBeenCalledWith([
|
||||
'/workspace',
|
||||
'stalker',
|
||||
'portal-1',
|
||||
'series',
|
||||
]);
|
||||
expect(result).not.toBe(true);
|
||||
|
||||
parentalLock.requestUnlock.mockResolvedValue(true);
|
||||
await expect(run('series', '9')).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { inject } from '@angular/core';
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
CanActivateFn,
|
||||
Router,
|
||||
UrlTree,
|
||||
} from '@angular/router';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import { ParentalLockStalkerCategoryType } from '@iptvnator/shared/interfaces';
|
||||
|
||||
function findPlaylistId(route: ActivatedRouteSnapshot): string | null {
|
||||
for (const snapshot of route.pathFromRoot) {
|
||||
const id = snapshot.paramMap.get('id');
|
||||
if (id) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a parental-locked Stalker genre off the screen when reached by URL.
|
||||
* Stalker routes carry the portal's own genre id, which is what the lock
|
||||
* store keys on, so no lookup is needed. A locked genre prompts for the PIN;
|
||||
* a refusal redirects to the section root.
|
||||
*/
|
||||
export function parentalLockStalkerCategoryGuard(
|
||||
section: ParentalLockStalkerCategoryType
|
||||
): CanActivateFn {
|
||||
return async (route): Promise<boolean | UrlTree> => {
|
||||
const parentalLock = inject(ParentalLockService);
|
||||
const router = inject(Router);
|
||||
|
||||
await parentalLock.initialize();
|
||||
if (!parentalLock.active()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const playlistId = findPlaylistId(route);
|
||||
const categoryId = route.paramMap.get('categoryId');
|
||||
if (
|
||||
!playlistId ||
|
||||
!categoryId ||
|
||||
!parentalLock.isStalkerCategoryLocked(
|
||||
playlistId,
|
||||
section,
|
||||
categoryId
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (await parentalLock.requestUnlock()) {
|
||||
return true;
|
||||
}
|
||||
return router.createUrlTree([
|
||||
'/workspace',
|
||||
'stalker',
|
||||
playlistId,
|
||||
section,
|
||||
]);
|
||||
};
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export class StalkerActorRouteComponent {
|
||||
initialValue: this.route.snapshot.params,
|
||||
});
|
||||
private readonly personId = computed(() =>
|
||||
Number(this.routeParams().personId)
|
||||
Number(this.routeParams()['personId'])
|
||||
);
|
||||
|
||||
readonly profile = signal<ActorProfile | null>(null);
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
<h2 mat-dialog-title>{{ 'PARENTAL_LOCK.MANAGE_LOCKS' | translate }}</h2>
|
||||
|
||||
<mat-dialog-content>
|
||||
<p class="lock-dialog__description">
|
||||
{{ 'PARENTAL_LOCK.MANAGE_LOCKS_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
|
||||
<div class="header-actions">
|
||||
<div class="selection-info">
|
||||
{{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}:
|
||||
<strong>{{ lockedCount() }}</strong> / {{ categories().length }}
|
||||
</div>
|
||||
<div class="bulk-actions">
|
||||
@if (censoredCount() > 0) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-test-id="stalker-lock-censored"
|
||||
[disabled]="isSaving()"
|
||||
(click)="lockCensored()"
|
||||
>
|
||||
{{ 'PARENTAL_LOCK.LOCK_CENSORED' | translate }}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="isSaving() || lockedCount() === 0"
|
||||
(click)="unlockAll()"
|
||||
>
|
||||
{{ 'PARENTAL_LOCK.UNLOCK_ALL' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="search-inline">
|
||||
<mat-icon class="search-inline__icon">search</mat-icon>
|
||||
<input
|
||||
type="search"
|
||||
spellcheck="false"
|
||||
[value]="searchTerm()"
|
||||
(input)="searchTerm.set($any($event.target).value)"
|
||||
[placeholder]="'XTREAM.CATEGORY_MANAGEMENT.SEARCH_PLACEHOLDER' | translate"
|
||||
/>
|
||||
@if (searchTerm()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="search-inline__clear"
|
||||
(click)="clearSearch()"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</label>
|
||||
|
||||
<div class="categories-list">
|
||||
@for (category of filteredCategories(); track category.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="category-item"
|
||||
[class.category-item--locked]="category.locked"
|
||||
[attr.aria-pressed]="category.locked"
|
||||
[attr.data-test-id]="'stalker-category-lock-' + category.id"
|
||||
(click)="toggle(category)"
|
||||
>
|
||||
<mat-icon class="category-item__icon">{{
|
||||
category.locked ? 'lock' : 'lock_open'
|
||||
}}</mat-icon>
|
||||
<span class="category-name">{{ category.name }}</span>
|
||||
@if (category.censored) {
|
||||
<span
|
||||
class="censored-chip"
|
||||
[matTooltip]="'PARENTAL_LOCK.CENSORED_HINT' | translate"
|
||||
>18+</span
|
||||
>
|
||||
}
|
||||
</button>
|
||||
} @empty {
|
||||
<div class="empty-message">
|
||||
{{ 'XTREAM.CATEGORY_MANAGEMENT.NO_RESULTS' | translate }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button type="button" (click)="cancel()" [disabled]="isSaving()">
|
||||
{{ 'CLOSE' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
data-test-id="stalker-category-lock-save"
|
||||
(click)="save()"
|
||||
[disabled]="isSaving()"
|
||||
>
|
||||
@if (isSaving()) {
|
||||
<mat-spinner diameter="20" />
|
||||
} @else {
|
||||
{{ 'XTREAM.CATEGORY_MANAGEMENT.SAVE' | translate }}
|
||||
}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
.mat-mdc-dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lock-dialog__description {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.selection-info {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
strong {
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.bulk-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
button {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
min-height: 36px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--app-search-border, var(--mat-sys-outline-variant));
|
||||
background: var(--app-search-bg, var(--mat-sys-surface-container));
|
||||
cursor: text;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--mat-sys-primary);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--mat-sys-on-surface);
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
min-width: 0;
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__clear {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
padding: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.categories-list {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
min-height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--mat-sys-on-surface);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--mat-sys-surface-container);
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--mat-sys-surface-container);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
&--locked .category-item__icon {
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.category-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.censored-chip {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--mat-sys-error) 16%, transparent);
|
||||
color: var(--mat-sys-error);
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
:host ::ng-deep .mat-mdc-dialog-content {
|
||||
min-width: 440px;
|
||||
max-width: 600px;
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import {
|
||||
StalkerCategoryLockDialogComponent,
|
||||
StalkerCategoryLockDialogData,
|
||||
} from './stalker-category-lock-dialog.component';
|
||||
|
||||
describe('StalkerCategoryLockDialogComponent', () => {
|
||||
let fixture: ComponentFixture<StalkerCategoryLockDialogComponent>;
|
||||
let component: StalkerCategoryLockDialogComponent;
|
||||
const dialogRef = { close: jest.fn() };
|
||||
const parentalLock = {
|
||||
active: signal(false),
|
||||
lockedStalkerIds: jest.fn(() => ['2']),
|
||||
setStalkerLocks: jest.fn(),
|
||||
};
|
||||
const data: StalkerCategoryLockDialogData = {
|
||||
playlistId: 'stalker-1',
|
||||
contentType: 'itv',
|
||||
categories: [
|
||||
{ category_id: '1', category_name: 'News', censored: false },
|
||||
{ category_id: '2', category_name: 'Adult', censored: true },
|
||||
] as StalkerCategoryLockDialogData['categories'],
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
parentalLock.active.set(false);
|
||||
parentalLock.setStalkerLocks.mockResolvedValue(true);
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
StalkerCategoryLockDialogComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{ provide: MatDialogRef, useValue: dialogRef },
|
||||
{ provide: MAT_DIALOG_DATA, useValue: data },
|
||||
{ provide: ParentalLockService, useValue: parentalLock },
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
],
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(StalkerCategoryLockDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('seeds the draft from the stored locks', () => {
|
||||
expect(component.categories().map((c) => c.locked)).toEqual([
|
||||
false,
|
||||
true,
|
||||
]);
|
||||
expect(component.lockedCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('closes without saving when the session relocks while it is open', () => {
|
||||
expect(dialogRef.close).not.toHaveBeenCalled();
|
||||
|
||||
parentalLock.active.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(dialogRef.close).toHaveBeenCalledWith(false);
|
||||
expect(parentalLock.setStalkerLocks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogModule,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { StalkerCategoryItem } from '@iptvnator/portal/stalker/data-access';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import { ParentalLockStalkerCategoryType } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export interface StalkerCategoryLockDialogData {
|
||||
playlistId: string;
|
||||
contentType: ParentalLockStalkerCategoryType;
|
||||
/** Every genre of the section, the "All" pseudo-category excluded. */
|
||||
categories: StalkerCategoryItem[];
|
||||
}
|
||||
|
||||
interface LockableCategory {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly censored: boolean;
|
||||
readonly locked: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parental lock picker for a Stalker section's genres. Stalker has no
|
||||
* hide/show dialog (its genres are never persisted), so this is the only
|
||||
* category dialog for that portal type; it exists while the lock is on.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-stalker-category-lock-dialog',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './stalker-category-lock-dialog.component.html',
|
||||
styleUrl: './stalker-category-lock-dialog.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class StalkerCategoryLockDialogComponent {
|
||||
readonly data = inject<StalkerCategoryLockDialogData>(MAT_DIALOG_DATA);
|
||||
private readonly dialogRef = inject(
|
||||
MatDialogRef<StalkerCategoryLockDialogComponent, boolean>
|
||||
);
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly searchTerm = signal('');
|
||||
readonly isSaving = signal(false);
|
||||
readonly categories = signal<LockableCategory[]>(this.buildDraft());
|
||||
|
||||
readonly filteredCategories = computed(() => {
|
||||
const term = this.searchTerm().trim().toLowerCase();
|
||||
const categories = this.categories();
|
||||
return term
|
||||
? categories.filter((category) =>
|
||||
category.name.toLowerCase().includes(term)
|
||||
)
|
||||
: categories;
|
||||
});
|
||||
readonly lockedCount = computed(
|
||||
() => this.categories().filter((category) => category.locked).length
|
||||
);
|
||||
readonly censoredCount = computed(
|
||||
() => this.categories().filter((category) => category.censored).length
|
||||
);
|
||||
|
||||
constructor() {
|
||||
// Same relock rule as the Xtream editor: the dialog outliving the
|
||||
// unlocked session would keep the locked names and Save reachable.
|
||||
effect(() => {
|
||||
if (this.parentalLock.active()) {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private buildDraft(): LockableCategory[] {
|
||||
const locked = new Set(
|
||||
this.parentalLock.lockedStalkerIds(
|
||||
this.data.playlistId,
|
||||
this.data.contentType
|
||||
)
|
||||
);
|
||||
return this.data.categories.map((category) => {
|
||||
const id = String(category.category_id);
|
||||
return {
|
||||
id,
|
||||
name: category.category_name,
|
||||
censored: category.censored === true,
|
||||
locked: locked.has(id),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
clearSearch(): void {
|
||||
this.searchTerm.set('');
|
||||
}
|
||||
|
||||
toggle(category: LockableCategory): void {
|
||||
this.categories.update((categories) =>
|
||||
categories.map((current) =>
|
||||
current.id === category.id
|
||||
? { ...current, locked: !current.locked }
|
||||
: current
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Pre-checks the genres the portal itself flags as adult (`censored`). */
|
||||
lockCensored(): void {
|
||||
this.categories.update((categories) =>
|
||||
categories.map((current) =>
|
||||
current.censored ? { ...current, locked: true } : current
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
unlockAll(): void {
|
||||
this.categories.update((categories) =>
|
||||
categories.map((current) => ({ ...current, locked: false }))
|
||||
);
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
this.isSaving.set(true);
|
||||
try {
|
||||
const saved = await this.parentalLock.setStalkerLocks(
|
||||
this.data.playlistId,
|
||||
this.data.contentType,
|
||||
this.categories()
|
||||
.filter((category) => category.locked)
|
||||
.map((category) => category.id)
|
||||
);
|
||||
if (!saved) {
|
||||
this.snackBar.open(
|
||||
this.translate.instant('PARENTAL_LOCK.SAVE_FAILED'),
|
||||
this.translate.instant('CLOSE'),
|
||||
{ duration: 3000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.dialogRef.close(true);
|
||||
} finally {
|
||||
this.isSaving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
}
|
||||
@@ -411,16 +411,7 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
});
|
||||
|
||||
it('plays regular VOD collection details inline for embedded players instead of using the legacy play wrapper', async () => {
|
||||
const sourceItem = {
|
||||
id: '1701',
|
||||
title: 'Collection Movie',
|
||||
category_id: 'vod',
|
||||
cmd: '/media/file_1701.mpg',
|
||||
info: {
|
||||
name: 'Collection Movie',
|
||||
movie_image: 'movie.jpg',
|
||||
},
|
||||
};
|
||||
const sourceItem = createSourceItem();
|
||||
const playback: ResolvedPortalPlayback = {
|
||||
streamUrl: 'https://streams.example.test/movie.mp4',
|
||||
title: 'Collection Movie',
|
||||
@@ -488,7 +479,10 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
|
||||
fixture.componentRef.setInput('item', itemB);
|
||||
await settleDetail(fixture);
|
||||
pendingA.resolve({ streamUrl: 'https://streams.test/a.mp4' });
|
||||
pendingA.resolve({
|
||||
streamUrl: 'https://streams.test/a.mp4',
|
||||
title: 'Movie A',
|
||||
});
|
||||
await settleDetail(fixture);
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
|
||||
@@ -504,11 +498,15 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
fixture.componentInstance.onVodPlay(
|
||||
createStalkerVodItem(itemB.stalkerItem as never, itemB.playlistId)
|
||||
);
|
||||
pendingB.resolve({ streamUrl: 'https://streams.test/b.mp4' });
|
||||
pendingB.resolve({
|
||||
streamUrl: 'https://streams.test/b.mp4',
|
||||
title: 'Movie B',
|
||||
});
|
||||
await settleDetail(fixture);
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toEqual({
|
||||
streamUrl: 'https://streams.test/b.mp4',
|
||||
title: 'Movie B',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -570,23 +568,21 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
stalkerItem: { ...(item.stalkerItem as object) },
|
||||
});
|
||||
await settleDetail(fixture);
|
||||
pending.resolve({ streamUrl: 'https://streams.test/a.mp4' });
|
||||
pending.resolve({
|
||||
streamUrl: 'https://streams.test/a.mp4',
|
||||
title: 'Movie A',
|
||||
});
|
||||
await settleDetail(fixture);
|
||||
|
||||
expect(fixture.componentInstance.inlinePlayback()).toEqual({
|
||||
streamUrl: 'https://streams.test/a.mp4',
|
||||
title: 'Movie A',
|
||||
});
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks a collection movie watched under its owning playlist', async () => {
|
||||
const sourceItem = {
|
||||
id: '1701',
|
||||
title: 'Collection Movie',
|
||||
category_id: 'vod',
|
||||
cmd: '/media/file_1701.mpg',
|
||||
info: { name: 'Collection Movie', movie_image: 'movie.jpg' },
|
||||
};
|
||||
const sourceItem = createSourceItem();
|
||||
fixture.componentRef.setInput(
|
||||
'item',
|
||||
buildCollectionItem({
|
||||
@@ -627,13 +623,7 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
});
|
||||
|
||||
it('blocks the watched toggle while a collection Play is still resolving', async () => {
|
||||
const sourceItem = {
|
||||
id: '1701',
|
||||
title: 'Collection Movie',
|
||||
category_id: 'vod',
|
||||
cmd: '/media/file_1701.mpg',
|
||||
info: { name: 'Collection Movie', movie_image: 'movie.jpg' },
|
||||
};
|
||||
const sourceItem = createSourceItem();
|
||||
let resolve!: (value: ResolvedPortalPlayback) => void;
|
||||
stalkerStore.resolveVodPlayback.mockReturnValueOnce(
|
||||
new Promise<ResolvedPortalPlayback>((resolvePromise) => {
|
||||
@@ -675,13 +665,7 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
});
|
||||
|
||||
it('does not carry a pending collection start over to the next item', async () => {
|
||||
const sourceItem = {
|
||||
id: '1701',
|
||||
title: 'Collection Movie',
|
||||
category_id: 'vod',
|
||||
cmd: '/media/file_1701.mpg',
|
||||
info: { name: 'Collection Movie', movie_image: 'movie.jpg' },
|
||||
};
|
||||
const sourceItem = createSourceItem();
|
||||
let resolve!: (value: ResolvedPortalPlayback) => void;
|
||||
stalkerStore.resolveVodPlayback.mockReturnValueOnce(
|
||||
new Promise<ResolvedPortalPlayback>((resolvePromise) => {
|
||||
@@ -729,13 +713,7 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
});
|
||||
|
||||
it('mirrors an external player position into the collection row', async () => {
|
||||
const sourceItem = {
|
||||
id: '1701',
|
||||
title: 'Collection Movie',
|
||||
category_id: 'vod',
|
||||
cmd: '/media/file_1701.mpg',
|
||||
info: { name: 'Collection Movie', movie_image: 'movie.jpg' },
|
||||
};
|
||||
const sourceItem = createSourceItem();
|
||||
playbackPositions.getPlaybackPosition.mockResolvedValueOnce({
|
||||
playlistId: 'stalker-1',
|
||||
contentXtreamId: 1701,
|
||||
@@ -787,13 +765,7 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
resolveRead = resolve;
|
||||
})
|
||||
);
|
||||
const sourceItem = {
|
||||
id: '1701',
|
||||
title: 'Collection Movie',
|
||||
category_id: 'vod',
|
||||
cmd: '/media/file_1701.mpg',
|
||||
info: { name: 'Collection Movie', movie_image: 'movie.jpg' },
|
||||
};
|
||||
const sourceItem = createSourceItem();
|
||||
fixture.componentRef.setInput(
|
||||
'item',
|
||||
buildCollectionItem({
|
||||
@@ -834,9 +806,6 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
});
|
||||
|
||||
it('does not load VOD playback position when the playlist id is missing', async () => {
|
||||
const playlistsService = TestBed.inject(PlaylistsService) as {
|
||||
getPlaylistById: jest.Mock;
|
||||
};
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
...playlist,
|
||||
@@ -947,6 +916,27 @@ function buildVodSource(id: string, title: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Regular (non-series) Stalker VOD item as the collection grid hands it over. */
|
||||
function createSourceItem() {
|
||||
return {
|
||||
id: '1701',
|
||||
title: 'Collection Movie',
|
||||
category_id: 'vod',
|
||||
cmd: '/media/file_1701.mpg',
|
||||
info: {
|
||||
name: 'Collection Movie',
|
||||
movie_image: 'movie.jpg',
|
||||
description: '',
|
||||
actors: '',
|
||||
director: '',
|
||||
releasedate: '',
|
||||
genre: '',
|
||||
rating_imdb: '',
|
||||
rating_kinopoisk: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Route } from '@angular/router';
|
||||
import { parentalLockStalkerCategoryGuard } from './parental-lock-category.guard';
|
||||
import { PORTAL_CATALOG_DETAIL_COMPONENT } from '@iptvnator/portal/shared/util';
|
||||
import { StalkerCatalogDetailComponent } from './stalker-catalog-detail/stalker-catalog-detail.component';
|
||||
import { provideStalkerCatalogFacade } from './stalker-catalog-facade.service';
|
||||
@@ -77,6 +78,9 @@ export function createStalkerRoutes(): Route[] {
|
||||
},
|
||||
{
|
||||
path: ':categoryId',
|
||||
canActivate: [
|
||||
parentalLockStalkerCategoryGuard('vod'),
|
||||
],
|
||||
data: {
|
||||
api: 'stalker',
|
||||
contentType: 'vod',
|
||||
@@ -113,6 +117,9 @@ export function createStalkerRoutes(): Route[] {
|
||||
},
|
||||
{
|
||||
path: ':categoryId',
|
||||
canActivate: [
|
||||
parentalLockStalkerCategoryGuard('series'),
|
||||
],
|
||||
data: {
|
||||
api: 'stalker',
|
||||
contentType: 'series',
|
||||
|
||||
+15
-1
@@ -51,7 +51,21 @@ class StubStalkerSeriesViewComponent {
|
||||
}
|
||||
|
||||
const VOD_ITEM = createStalkerVodItem(
|
||||
{ id: '42', cmd: '/media/42', info: { name: 'Movie' } },
|
||||
{
|
||||
id: '42',
|
||||
cmd: '/media/42',
|
||||
info: {
|
||||
name: 'Movie',
|
||||
movie_image: '',
|
||||
description: '',
|
||||
actors: '',
|
||||
director: '',
|
||||
releasedate: '',
|
||||
genre: '',
|
||||
rating_imdb: '',
|
||||
rating_kinopoisk: '',
|
||||
},
|
||||
},
|
||||
'stalker-1'
|
||||
);
|
||||
|
||||
|
||||
+1
-4
@@ -20,10 +20,7 @@ describe('StalkerItvAllItemsComponent', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
StalkerItvAllItemsComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
imports: [StalkerItvAllItemsComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(StalkerItvAllItemsComponent);
|
||||
|
||||
+6
-6
@@ -18,21 +18,21 @@ const channel = (
|
||||
describe('StalkerLiveAutoOpen', () => {
|
||||
let autoOpen: StalkerLiveAutoOpen;
|
||||
let events: Subject<unknown>;
|
||||
const selectedCategoryId = signal<string | null>(null);
|
||||
const searchPhrase = signal('');
|
||||
const store = {
|
||||
currentPlaylist: signal<{ _id?: string } | null>({ _id: 'pl-3' }),
|
||||
selectedContentType: signal<string | null>('itv'),
|
||||
selectedCategoryId: signal<string | null>(null),
|
||||
selectedCategoryId,
|
||||
itvChannelsCategory: signal<string | null>(null),
|
||||
searchPhrase: signal(''),
|
||||
searchPhrase,
|
||||
itvFullChannelList: signal<StalkerItvChannel[]>([]),
|
||||
itvFullListActive: signal(false),
|
||||
itvFullListUnsupported: signal(false),
|
||||
preloadItvChannels: jest.fn(() => Promise.resolve()),
|
||||
setSearchPhrase: jest.fn((phrase: string) =>
|
||||
store.searchPhrase.set(phrase)
|
||||
),
|
||||
setSearchPhrase: jest.fn((phrase: string) => searchPhrase.set(phrase)),
|
||||
setSelectedCategory: jest.fn((category: string | null) =>
|
||||
store.selectedCategoryId.set(category)
|
||||
selectedCategoryId.set(category)
|
||||
),
|
||||
setPage: jest.fn(),
|
||||
};
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { isStalkerPlaybackRequestLockCurrent } from './stalker-live-lock-guard';
|
||||
|
||||
describe('isStalkerPlaybackRequestLockCurrent', () => {
|
||||
it('lets an unchanged lock version through', () => {
|
||||
expect(isStalkerPlaybackRequestLockCurrent(3, 3, true)).toBe(true);
|
||||
expect(isStalkerPlaybackRequestLockCurrent(3, 3, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('retires a request the session relocked over', () => {
|
||||
expect(isStalkerPlaybackRequestLockCurrent(3, 4, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('lets a request through when the change was an unlock', () => {
|
||||
expect(isStalkerPlaybackRequestLockCurrent(3, 4, false)).toBe(true);
|
||||
});
|
||||
});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Whether a playback request issued under parental-lock `requested` may
|
||||
* still complete now that the lock is at `current`. The embedded player
|
||||
* defers selecting the channel until its stream resolves, so the
|
||||
* enforcement service's clearing of the selection cannot retire the
|
||||
* request; a relock in between must. An UNLOCK in between is harmless — the
|
||||
* request completes.
|
||||
*/
|
||||
export function isStalkerPlaybackRequestLockCurrent(
|
||||
requested: number,
|
||||
current: number,
|
||||
active: boolean
|
||||
): boolean {
|
||||
return requested === current || !active;
|
||||
}
|
||||
+9
-8
@@ -22,25 +22,26 @@ describe('StalkerLiveNavigation', () => {
|
||||
const third = channel('3', 'sports');
|
||||
const rows = signal([first, second]);
|
||||
const loading = signal(false);
|
||||
const selectedCategoryId = signal<string | null>('news');
|
||||
const searchPhrase = signal('');
|
||||
const page = signal(0);
|
||||
const store = {
|
||||
currentPlaylist: signal({ _id: 'source-a' }),
|
||||
selectedContentType: signal('itv'),
|
||||
selectedCategoryId: signal<string | null>('news'),
|
||||
searchPhrase: signal(''),
|
||||
selectedCategoryId,
|
||||
searchPhrase,
|
||||
selectedItem: signal<StalkerItvChannel | null>(null),
|
||||
page: signal(0),
|
||||
page,
|
||||
hasMoreChannels: signal(false),
|
||||
getCategoryResource: signal([
|
||||
{ category_id: 'news' },
|
||||
{ category_id: 'sports' },
|
||||
]),
|
||||
setSearchPhrase: jest.fn((query: string) =>
|
||||
store.searchPhrase.set(query)
|
||||
),
|
||||
setSearchPhrase: jest.fn((query: string) => searchPhrase.set(query)),
|
||||
setSelectedCategory: jest.fn((category: string) =>
|
||||
store.selectedCategoryId.set(category)
|
||||
selectedCategoryId.set(category)
|
||||
),
|
||||
setPage: jest.fn((page: number) => store.page.set(page)),
|
||||
setPage: jest.fn((next: number) => page.set(next)),
|
||||
};
|
||||
const router = {
|
||||
url: '/workspace/stalker/source-a/itv?q=sport&keep=1',
|
||||
|
||||
+51
-38
@@ -4,10 +4,10 @@ import { By } from '@angular/platform-browser';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
|
||||
import type { PlaybackFallbackRequest } from '@iptvnator/playback/util';
|
||||
import {
|
||||
LIVE_EPG_PANEL_STATE_STORAGE_KEY,
|
||||
PORTAL_PLAYER,
|
||||
ResizableDirective,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
ACTIVE_EPG_FALLBACK_SIZE,
|
||||
@@ -16,15 +16,20 @@ import {
|
||||
import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg';
|
||||
import { AudioPlayerComponent } from '@iptvnator/ui/playback';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { ChannelListItemComponent } from '@iptvnator/ui/components';
|
||||
import {
|
||||
ChannelListItemComponent,
|
||||
ResizableDirective,
|
||||
} from '@iptvnator/ui/components';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import { of } from 'rxjs';
|
||||
import {
|
||||
ParentalLockService,
|
||||
PlaylistsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
ElectronBridgeApi,
|
||||
EpgItem,
|
||||
EpgProgram,
|
||||
ResolvedPortalPlayback,
|
||||
@@ -42,6 +47,11 @@ import {
|
||||
StubWebPlayerViewComponent,
|
||||
} from './stalker-live-stream-layout.spec-stubs';
|
||||
|
||||
/** Installs an intentionally partial Electron bridge double. */
|
||||
function installElectronBridge(bridge: Partial<ElectronBridgeApi>): void {
|
||||
window.electron = bridge as ElectronBridgeApi;
|
||||
}
|
||||
|
||||
describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
let fixture: ComponentFixture<StalkerLiveStreamLayoutComponent>;
|
||||
let component: StalkerLiveStreamLayoutComponent;
|
||||
@@ -158,7 +168,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
fetchChannelEpg: jest.fn(),
|
||||
ensureBulkItvEpg: jest.fn(),
|
||||
applyMappedItvEpg: jest.fn().mockResolvedValue(undefined),
|
||||
hasItvEpgMappingOverride: jest.fn(() => false),
|
||||
hasItvEpgMappingOverride: jest.fn((_id: string | number) => false),
|
||||
clearBulkItvEpgCache: jest.fn(() => {
|
||||
bulkItvEpgByChannel.set({});
|
||||
bulkItvEpgLoaded.set(false);
|
||||
@@ -190,13 +200,13 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
// 'list' into siblings.
|
||||
settingsStore.resolvedEpgViewMode.set('timeline');
|
||||
playlist.set({ _id: 'playlist-1', title: 'Demo Stalker' });
|
||||
window.electron = {
|
||||
installElectronBridge({
|
||||
platform: 'darwin',
|
||||
setUserAgent: jest.fn().mockResolvedValue(true),
|
||||
updateRemoteControlStatus: jest.fn(),
|
||||
onChannelChange: jest.fn(() => jest.fn()),
|
||||
onRemoteControlCommand: jest.fn(() => jest.fn()),
|
||||
} as typeof window.electron;
|
||||
});
|
||||
|
||||
fetchChannelEpg = stalkerStore.fetchChannelEpg;
|
||||
ensureBulkItvEpg = stalkerStore.ensureBulkItvEpg;
|
||||
@@ -283,6 +293,10 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
},
|
||||
{ provide: PlaylistsService, useValue: playlistService },
|
||||
{ provide: SettingsStore, useValue: settingsStore },
|
||||
{
|
||||
provide: ParentalLockService,
|
||||
useValue: { version: signal(0), active: signal(false) },
|
||||
},
|
||||
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
|
||||
{
|
||||
provide: TranslateService,
|
||||
@@ -439,11 +453,6 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
title: 'Alpha TV',
|
||||
isLive: true,
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 10001,
|
||||
contentType: 'live',
|
||||
},
|
||||
};
|
||||
const request: PlaybackFallbackRequest = {
|
||||
player: 'vlc',
|
||||
@@ -453,6 +462,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
code: 'network-error',
|
||||
player: 'html5',
|
||||
source: 'hls',
|
||||
sourceUrl: playback.streamUrl,
|
||||
container: '',
|
||||
mimeType: '',
|
||||
videoCodecs: [],
|
||||
@@ -604,9 +614,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
it('does not publish remote-control status when the bridge is incomplete', () => {
|
||||
fixture.destroy();
|
||||
const updateRemoteControlStatus = jest.fn();
|
||||
window.electron = {
|
||||
installElectronBridge({
|
||||
updateRemoteControlStatus,
|
||||
} as typeof window.electron;
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent);
|
||||
component = fixture.componentInstance;
|
||||
@@ -782,35 +792,38 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([true, false])('grows the render window for remote/numeric selection (cached=%s)', async (cached) => {
|
||||
const full = Array.from({ length: 250 }, (_, index) => ({
|
||||
id: `ch-${index}`,
|
||||
cmd: `ffrt4://itv/${index}`,
|
||||
name: `Channel ${index}`,
|
||||
o_name: `Channel ${index}`,
|
||||
logo: '',
|
||||
}));
|
||||
itvFullListActive.set(true);
|
||||
itvSelectedCategoryFromCache.set(cached);
|
||||
itvChannels.set(full);
|
||||
itvFullChannelList.set(cached ? full : []);
|
||||
searchPhrase.set('channel');
|
||||
fixture.detectChanges();
|
||||
it.each([true, false])(
|
||||
'grows the render window for remote/numeric selection (cached=%s)',
|
||||
async (cached) => {
|
||||
const full = Array.from({ length: 250 }, (_, index) => ({
|
||||
id: `ch-${index}`,
|
||||
cmd: `ffrt4://itv/${index}`,
|
||||
name: `Channel ${index}`,
|
||||
o_name: `Channel ${index}`,
|
||||
logo: '',
|
||||
}));
|
||||
itvFullListActive.set(true);
|
||||
itvSelectedCategoryFromCache.set(cached);
|
||||
itvChannels.set(full);
|
||||
itvFullChannelList.set(cached ? full : []);
|
||||
searchPhrase.set('channel');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.visibleChannels()).toHaveLength(100);
|
||||
expect(component.visibleChannels()).toHaveLength(100);
|
||||
|
||||
// Selecting channel index 150 (outside the 100-item window) must grow
|
||||
// the window so it is rendered and can be highlighted/scrolled to.
|
||||
await component.playChannel(full[150], false);
|
||||
fixture.detectChanges();
|
||||
// Selecting channel index 150 (outside the 100-item window) must grow
|
||||
// the window so it is rendered and can be highlighted/scrolled to.
|
||||
await component.playChannel(full[150], false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.visibleChannels().length).toBeGreaterThan(150);
|
||||
expect(
|
||||
component
|
||||
.visibleChannels()
|
||||
.some((channel) => channel.id === 'ch-150')
|
||||
).toBe(true);
|
||||
});
|
||||
expect(component.visibleChannels().length).toBeGreaterThan(150);
|
||||
expect(
|
||||
component
|
||||
.visibleChannels()
|
||||
.some((channel) => channel.id === 'ch-150')
|
||||
).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not clear cached channels when switching category in full-list mode', async () => {
|
||||
// Regression: the category-change reset effect used to setItvChannels([])
|
||||
|
||||
+32
-3
@@ -37,11 +37,13 @@ import {
|
||||
ResizableDirective,
|
||||
} from '@iptvnator/ui/components';
|
||||
import {
|
||||
ParentalLockService,
|
||||
PlaylistsService,
|
||||
RecordingsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { isStalkerPlaybackRequestLockCurrent } from './stalker-live-lock-guard';
|
||||
import {
|
||||
foldSearchText,
|
||||
buildStalkerEpgMappingKey,
|
||||
@@ -125,6 +127,8 @@ interface StalkerPlaybackResolutionOwner {
|
||||
readonly sourceId: string;
|
||||
readonly contentType: string;
|
||||
readonly channelId: string;
|
||||
/** `ParentalLockService.version` when the request was issued. */
|
||||
readonly lockVersion: number;
|
||||
}
|
||||
|
||||
/** Channels rendered per "page" when the full list is served from the cache. */
|
||||
@@ -181,6 +185,7 @@ export class StalkerLiveStreamLayoutComponent
|
||||
implements OnDestroy, FullscreenChannelPanelHost
|
||||
{
|
||||
readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
private readonly playlistService = inject(PlaylistsService);
|
||||
private readonly hostElement = inject(ElementRef<HTMLElement>);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
@@ -643,6 +648,22 @@ export class StalkerLiveStreamLayoutComponent
|
||||
30_000
|
||||
);
|
||||
|
||||
// The parental lock clears the store selection from outside this
|
||||
// layout when the playing channel's genre is withheld
|
||||
// (ParentalLockEnforcementService). The template mounts the player
|
||||
// only with a selection, so the playback held here is dropped too
|
||||
// instead of resurfacing with the next selection.
|
||||
effect(() => {
|
||||
if (this.stalkerStore.selectedItem()) {
|
||||
return;
|
||||
}
|
||||
untracked(() => {
|
||||
if (this.activePlayback()) {
|
||||
this.clearActivePlayback();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Load favorites for current playlist
|
||||
const playlistId = this.stalkerStore.currentPlaylist()?._id;
|
||||
if (playlistId) {
|
||||
@@ -914,6 +935,7 @@ export class StalkerLiveStreamLayoutComponent
|
||||
this.stalkerStore.currentPlaylist()?._id
|
||||
);
|
||||
const contentType = this.stalkerStore.selectedContentType();
|
||||
const lockVersion = this.parentalLock.version();
|
||||
const isRadioMode = this.isRadioMode();
|
||||
const deferSelection = !isRadioMode && this.usesEmbeddedPlayer();
|
||||
// Inline video retains its stream during resolution. Keep selection,
|
||||
@@ -931,10 +953,10 @@ export class StalkerLiveStreamLayoutComponent
|
||||
try {
|
||||
const playback = await this.resolvePlaybackForChannel(
|
||||
item,
|
||||
{ sourceId, contentType, channelId },
|
||||
{ sourceId, contentType, channelId, lockVersion },
|
||||
isRadioMode
|
||||
);
|
||||
const owner = { sourceId, contentType, channelId };
|
||||
const owner = { sourceId, contentType, channelId, lockVersion };
|
||||
if (
|
||||
!this.isPlaybackRequestCurrent(
|
||||
requestId,
|
||||
@@ -996,7 +1018,7 @@ export class StalkerLiveStreamLayoutComponent
|
||||
if (
|
||||
!this.isPlaybackRequestCurrent(
|
||||
requestId,
|
||||
{ sourceId, contentType, channelId },
|
||||
{ sourceId, contentType, channelId, lockVersion },
|
||||
expectedSelectedId
|
||||
)
|
||||
) {
|
||||
@@ -1046,6 +1068,13 @@ export class StalkerLiveStreamLayoutComponent
|
||||
): boolean {
|
||||
return (
|
||||
requestId === this.playbackRequestId &&
|
||||
// A relock while the stream resolved: the deferred selection
|
||||
// means the enforcement service had nothing to clear yet.
|
||||
isStalkerPlaybackRequestLockCurrent(
|
||||
owner.lockVersion,
|
||||
this.parentalLock.version(),
|
||||
this.parentalLock.active()
|
||||
) &&
|
||||
this.selectedChannelId() === expectedSelectedId &&
|
||||
normalizeStalkerEntityId(
|
||||
this.stalkerStore.currentPlaylist()?._id
|
||||
|
||||
+21
-5
@@ -197,9 +197,13 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
|
||||
expect(selectedItem()).toBe(channels[0]);
|
||||
const currentPlayback = {
|
||||
streamUrl: 'https://two.example/live.m3u8',
|
||||
title: 'Channel two',
|
||||
};
|
||||
current.resolve(currentPlayback);
|
||||
stale.resolve({ streamUrl: 'https://stale.example/live.m3u8' });
|
||||
stale.resolve({
|
||||
streamUrl: 'https://stale.example/live.m3u8',
|
||||
title: 'Channel one',
|
||||
});
|
||||
await Promise.all([staleSelection, currentSelection]);
|
||||
expect(selectedItem()).toBe(channels[1]);
|
||||
expect(component.isSelectedChannel(channels[1])).toBe(true);
|
||||
@@ -224,10 +228,16 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
|
||||
const requestA = component.playChannel(shared);
|
||||
playlist.set({ _id: 'playlist-b', title: 'Portal B' });
|
||||
const requestB = component.playChannel(shared);
|
||||
const playbackB = { streamUrl: 'https://b.example/shared.m3u8' };
|
||||
const playbackB = {
|
||||
streamUrl: 'https://b.example/shared.m3u8',
|
||||
title: 'Shared channel (Portal B)',
|
||||
};
|
||||
|
||||
sourceB.resolve(playbackB);
|
||||
sourceA.resolve({ streamUrl: 'https://a.example/shared.m3u8' });
|
||||
sourceA.resolve({
|
||||
streamUrl: 'https://a.example/shared.m3u8',
|
||||
title: 'Shared channel (Portal A)',
|
||||
});
|
||||
await Promise.all([requestA, requestB]);
|
||||
|
||||
expect(resolveItvPlayback).toHaveBeenCalledTimes(2);
|
||||
@@ -274,7 +284,10 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
|
||||
const request = component.playChannel(channels[0]);
|
||||
|
||||
selectedContentType.set('radio');
|
||||
pending.resolve({ streamUrl: 'https://stale.example/itv.m3u8' });
|
||||
pending.resolve({
|
||||
streamUrl: 'https://stale.example/itv.m3u8',
|
||||
title: 'Channel one',
|
||||
});
|
||||
await request;
|
||||
|
||||
expect(component.activePlayback()).toBeNull();
|
||||
@@ -312,7 +325,10 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
|
||||
resolveItvPlayback.mockReturnValueOnce(pending.promise);
|
||||
const request = component.playChannel(channels[0]);
|
||||
store.setSelectedItem(channels[1]);
|
||||
pending.resolve({ streamUrl: 'https://stale.example/live.m3u8' });
|
||||
pending.resolve({
|
||||
streamUrl: 'https://stale.example/live.m3u8',
|
||||
title: 'Channel one',
|
||||
});
|
||||
await request;
|
||||
expect(selectedItem()).toBe(channels[1]);
|
||||
expect(component.activePlayback()).toBeNull();
|
||||
|
||||
+18
-10
@@ -11,7 +11,7 @@ import {
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { ElectronBridgeApi, EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component';
|
||||
@@ -22,6 +22,11 @@ import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.c
|
||||
* which sits at the max-lines test budget; the template is overridden to
|
||||
* empty because these behaviors live entirely in constructor effects.
|
||||
*/
|
||||
/** Installs an intentionally partial Electron bridge double. */
|
||||
function installElectronBridge(bridge: Partial<ElectronBridgeApi>): void {
|
||||
window.electron = bridge as ElectronBridgeApi;
|
||||
}
|
||||
|
||||
describe('StalkerLiveStreamLayoutComponent remote status', () => {
|
||||
let fixture: ComponentFixture<StalkerLiveStreamLayoutComponent>;
|
||||
const originalElectron = window.electron;
|
||||
@@ -147,13 +152,13 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => {
|
||||
itvChannels.set(originalItvChannels);
|
||||
stalkerStore.selectedCategoryId.set('1001');
|
||||
stalkerStore.setSelectedItem.mockReset();
|
||||
window.electron = {
|
||||
installElectronBridge({
|
||||
platform: 'darwin',
|
||||
setUserAgent: jest.fn().mockResolvedValue(true),
|
||||
updateRemoteControlStatus,
|
||||
onChannelChange: jest.fn(() => jest.fn()),
|
||||
onRemoteControlCommand: jest.fn(() => jest.fn()),
|
||||
} as typeof window.electron;
|
||||
});
|
||||
|
||||
selectedContentType.set('itv');
|
||||
selectedItem.set(itvChannels()[0]);
|
||||
@@ -179,13 +184,16 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => {
|
||||
// Mirrors the real capability check: every
|
||||
// remote-control bridge method must be present.
|
||||
get supportsRemoteControl() {
|
||||
const bridge = window.electron as
|
||||
Record<string, unknown> | undefined;
|
||||
return [
|
||||
'updateRemoteControlStatus',
|
||||
'onChannelChange',
|
||||
'onRemoteControlCommand',
|
||||
].every(
|
||||
const bridge:
|
||||
Partial<ElectronBridgeApi> | undefined =
|
||||
window.electron;
|
||||
return (
|
||||
[
|
||||
'updateRemoteControlStatus',
|
||||
'onChannelChange',
|
||||
'onRemoteControlCommand',
|
||||
] as const
|
||||
).every(
|
||||
(method) =>
|
||||
typeof bridge?.[method] === 'function'
|
||||
);
|
||||
|
||||
+7
-1
@@ -117,8 +117,14 @@ export class StubPortalEmptyStateComponent {
|
||||
readonly message = input('');
|
||||
}
|
||||
|
||||
/** Accepts the layout's resizable bindings without touching storage or the DOM. */
|
||||
@Directive({
|
||||
selector: '[appResizable]',
|
||||
standalone: true,
|
||||
})
|
||||
export class StubResizableDirective {}
|
||||
export class StubResizableDirective {
|
||||
readonly minWidth = input<number>(200);
|
||||
readonly maxWidth = input<number>(600);
|
||||
readonly defaultWidth = input<number>(400);
|
||||
readonly storageKey = input<string>('');
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { isStalkerSearchRequestCurrent } from './stalker-search-request.util';
|
||||
|
||||
describe('isStalkerSearchRequestCurrent', () => {
|
||||
const key = {
|
||||
search: 'family',
|
||||
contentType: 'vod',
|
||||
page: 2,
|
||||
playlistId: 'stalker-1',
|
||||
parentalLockVersion: 3,
|
||||
};
|
||||
|
||||
it('accepts a response whose coordinates are unchanged', () => {
|
||||
expect(isStalkerSearchRequestCurrent(key, { ...key })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a response issued under an older parental lock version', () => {
|
||||
expect(
|
||||
isStalkerSearchRequestCurrent(key, {
|
||||
...key,
|
||||
parentalLockVersion: 4,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['search', { search: 'famil' }],
|
||||
['contentType', { contentType: 'series' }],
|
||||
['page', { page: 3 }],
|
||||
['playlistId', { playlistId: 'stalker-2' }],
|
||||
])('rejects a response whose %s moved on', (_field, change) => {
|
||||
expect(isStalkerSearchRequestCurrent(key, { ...key, ...change })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/** The coordinates a Stalker search page was requested under. */
|
||||
export interface StalkerSearchRequestKey {
|
||||
readonly search: string;
|
||||
readonly contentType: string;
|
||||
readonly page: number;
|
||||
readonly playlistId: string | null;
|
||||
/** `ParentalLockService.version` at request time. */
|
||||
readonly parentalLockVersion: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a search response may still be applied. Portal requests are not
|
||||
* aborted, so a page issued under an older state can finish after a newer
|
||||
* one; the parental lock version is part of the key because an older
|
||||
* response was filtered with the OLDER withheld set — applying it after a
|
||||
* relock would repopulate the grid with locked-category rows.
|
||||
*/
|
||||
export function isStalkerSearchRequestCurrent(
|
||||
requested: StalkerSearchRequestKey,
|
||||
current: StalkerSearchRequestKey
|
||||
): boolean {
|
||||
return (
|
||||
requested.search === current.search &&
|
||||
requested.contentType === current.contentType &&
|
||||
requested.page === current.page &&
|
||||
requested.playlistId === current.playlistId &&
|
||||
requested.parentalLockVersion === current.parentalLockVersion
|
||||
);
|
||||
}
|
||||
+133
-13
@@ -24,8 +24,15 @@ import {
|
||||
StalkerStore,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
import { DataService, PlaylistsService } from '@iptvnator/services';
|
||||
import { CONNECTIVITY_GUARD_RESET } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
DataService,
|
||||
ParentalLockService,
|
||||
PlaylistsService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
ALL_CATEGORIES_WITHHELD,
|
||||
CONNECTIVITY_GUARD_RESET,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerSearchComponent } from './stalker-search.component';
|
||||
|
||||
@@ -289,6 +296,37 @@ describe('StalkerSearchComponent playback session key', () => {
|
||||
describe('StalkerSearchComponent result paging', () => {
|
||||
let component: StalkerSearchComponent;
|
||||
let dataService: { sendIpcEvent: jest.Mock };
|
||||
let parentalLock: {
|
||||
active: jest.Mock<boolean, []>;
|
||||
version: ReturnType<typeof signal<number>>;
|
||||
lockedStalkerIds: jest.Mock<string[], [string, string]>;
|
||||
};
|
||||
let stalkerStoreMock: {
|
||||
selectedItem: ReturnType<typeof signal<unknown>>;
|
||||
setSelectedContentType: jest.Mock;
|
||||
setSelectedItem: jest.Mock;
|
||||
addToFavorites: jest.Mock;
|
||||
removeFromFavorites: jest.Mock;
|
||||
resolveVodPlayback: jest.Mock;
|
||||
};
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
TestBed.flushEffects();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
TestBed.flushEffects();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, attempts = 40) {
|
||||
for (let index = 0; index < attempts; index += 1) {
|
||||
if (predicate()) {
|
||||
return;
|
||||
}
|
||||
await flush();
|
||||
}
|
||||
throw new Error('Timed out waiting for the search resource');
|
||||
}
|
||||
const activePlaylist = signal({
|
||||
_id: 'playlist|one',
|
||||
title: 'Search portal',
|
||||
@@ -307,6 +345,21 @@ describe('StalkerSearchComponent result paging', () => {
|
||||
dataService = {
|
||||
sendIpcEvent: jest.fn().mockResolvedValue({ success: true }),
|
||||
};
|
||||
parentalLock = {
|
||||
active: jest.fn(() => false),
|
||||
version: signal(0),
|
||||
lockedStalkerIds: jest.fn(
|
||||
(_playlistId: string, _type: string): string[] => []
|
||||
),
|
||||
};
|
||||
stalkerStoreMock = {
|
||||
selectedItem: signal(null),
|
||||
setSelectedContentType: jest.fn(),
|
||||
setSelectedItem: jest.fn(),
|
||||
addToFavorites: jest.fn(),
|
||||
removeFromFavorites: jest.fn(),
|
||||
resolveVodPlayback: jest.fn(),
|
||||
};
|
||||
activePlaylist.set({
|
||||
_id: 'playlist|one',
|
||||
title: 'Search portal',
|
||||
@@ -336,17 +389,8 @@ describe('StalkerSearchComponent result paging', () => {
|
||||
provide: PlaylistsService,
|
||||
useValue: { getPortalFavorites: () => of([]) },
|
||||
},
|
||||
{
|
||||
provide: StalkerStore,
|
||||
useValue: {
|
||||
selectedItem: signal(null),
|
||||
setSelectedContentType: jest.fn(),
|
||||
setSelectedItem: jest.fn(),
|
||||
addToFavorites: jest.fn(),
|
||||
removeFromFavorites: jest.fn(),
|
||||
resolveVodPlayback: jest.fn(),
|
||||
},
|
||||
},
|
||||
{ provide: StalkerStore, useValue: stalkerStoreMock },
|
||||
{ provide: ParentalLockService, useValue: parentalLock },
|
||||
{ provide: StalkerSessionService, useValue: {} },
|
||||
{ provide: StalkerPortalRepairService, useValue: {} },
|
||||
{
|
||||
@@ -403,6 +447,82 @@ describe('StalkerSearchComponent result paging', () => {
|
||||
expect(component.searchHasMore()).toBe(false);
|
||||
});
|
||||
|
||||
it('advances past a page made only of parental-locked rows, but not past a repeated one', async () => {
|
||||
component.applySearchPageSuccess(1, searchItems('page1', 3), 10);
|
||||
const pageBefore = component.searchPage();
|
||||
|
||||
// Locked rows the list had not seen: schedule the next page.
|
||||
component.advancePastWithheldPage(pageBefore, 0, 2, () => true);
|
||||
await flushMicrotasks();
|
||||
expect(component.searchPage()).toBe(pageBefore + 1);
|
||||
|
||||
// A page with visible rows, or one adding no new withheld ids, or a
|
||||
// request that is no longer current: stay put.
|
||||
component.advancePastWithheldPage(pageBefore + 1, 1, 2, () => true);
|
||||
component.advancePastWithheldPage(pageBefore + 1, 0, 0, () => true);
|
||||
component.advancePastWithheldPage(pageBefore + 1, 0, 2, () => false);
|
||||
await flushMicrotasks();
|
||||
expect(component.searchPage()).toBe(pageBefore + 1);
|
||||
});
|
||||
|
||||
it('drops withheld rows on screen at relock time, page 1 included', () => {
|
||||
component.applySearchPageSuccess(
|
||||
1,
|
||||
[
|
||||
{ id: 'news-1', name: 'News', category_id: '5' },
|
||||
{ id: 'adult-1', name: 'Adult', category_id: '9' },
|
||||
],
|
||||
2
|
||||
);
|
||||
expect(component.searchResults()).toHaveLength(2);
|
||||
|
||||
component.applyRelockToResults(new Set(['9']), 'vod');
|
||||
|
||||
expect(component.searchResults().map((item) => item.id)).toEqual([
|
||||
'news-1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('closes an open detail whose genre became withheld on relock', () => {
|
||||
component.selectItem({ id: 'adult-9', name: 'A', category_id: '9' });
|
||||
expect(component.itemDetails()).not.toBeNull();
|
||||
|
||||
// Another genre locked: the detail stays.
|
||||
component.closeWithheldDetail(new Set(['5']));
|
||||
expect(component.itemDetails()).not.toBeNull();
|
||||
|
||||
component.closeWithheldDetail(new Set(['9']));
|
||||
expect(component.itemDetails()).toBeNull();
|
||||
expect(component.vodDetailsItem()).toBeNull();
|
||||
expect(stalkerStoreMock.setSelectedItem).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it('closes a genre-less detail on relock only in fail-closed mode', () => {
|
||||
component.selectItem({ id: 'no-genre', name: 'N' });
|
||||
expect(component.itemDetails()).not.toBeNull();
|
||||
|
||||
component.closeWithheldDetail(new Set(['9']));
|
||||
expect(component.itemDetails()).not.toBeNull();
|
||||
|
||||
component.closeWithheldDetail(ALL_CATEGORIES_WITHHELD);
|
||||
expect(component.itemDetails()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps paging past a page whose rows were all withheld by the parental lock', () => {
|
||||
component.applySearchPageSuccess(1, searchItems('page1', 3), 10);
|
||||
expect(component.searchHasMore()).toBe(true);
|
||||
|
||||
// The portal sent rows, every one of them locked: no visible growth,
|
||||
// but not the end of the results either.
|
||||
component.applySearchPageSuccess(2, [], 10, true);
|
||||
expect(component.searchResults()).toHaveLength(3);
|
||||
expect(component.searchHasMore()).toBe(true);
|
||||
|
||||
// An actually empty page still ends it.
|
||||
component.applySearchPageSuccess(3, [], 10, false);
|
||||
expect(component.searchHasMore()).toBe(false);
|
||||
});
|
||||
|
||||
it('stops paging when a total-backed append makes no progress', () => {
|
||||
component.applySearchPageSuccess(1, searchItems('page1', 3), 10);
|
||||
expect(component.searchHasMore()).toBe(true);
|
||||
|
||||
@@ -21,13 +21,17 @@ import {
|
||||
executeStalkerRequest,
|
||||
StalkerPortalRepairService,
|
||||
StalkerSessionService,
|
||||
stalkerWithheldRowKey,
|
||||
withoutWithheldStalkerItems,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
DataService,
|
||||
ParentalLockService,
|
||||
PlaylistsService,
|
||||
resetHostConnectivityGuard,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
ALL_CATEGORIES_WITHHELD,
|
||||
PlaybackPositionData,
|
||||
ResolvedPortalPlayback,
|
||||
StalkerPortalActions,
|
||||
@@ -66,6 +70,7 @@ import {
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
import { isStalkerSearchRequestCurrent } from './stalker-search-request.util';
|
||||
|
||||
interface StalkerFilter {
|
||||
key: StalkerSearchContentType;
|
||||
@@ -120,6 +125,16 @@ export class StalkerSearchComponent {
|
||||
private readonly activatedRoute = inject(ActivatedRoute);
|
||||
private readonly location = inject(Location);
|
||||
private readonly dataService = inject(DataService);
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
/** Lock version the accumulated results were built under. */
|
||||
private searchResultsLockVersion: number | null = null;
|
||||
/**
|
||||
* Withheld row ids seen for the current search identity. A page adding
|
||||
* only new withheld ids is still progress and is skipped automatically;
|
||||
* a page adding nothing new is the end of the results.
|
||||
*/
|
||||
private searchWithheldKey = '';
|
||||
private readonly searchWithheldIds = new Set<string>();
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private readonly playlistService = inject(PlaylistsService);
|
||||
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
|
||||
@@ -241,6 +256,9 @@ export class StalkerSearchComponent {
|
||||
page: this.searchPage(),
|
||||
playlistId: this.currentPlaylist()?._id ?? null,
|
||||
action: StalkerPortalActions.GetOrderedList,
|
||||
// Lock/unlock re-fires the search: withheld rows are dropped at
|
||||
// page time, so the results must be rebuilt when they change.
|
||||
parentalLockVersion: this.parentalLock.version(),
|
||||
}),
|
||||
loader: async ({ params }) => {
|
||||
if (params.search.length < 3) {
|
||||
@@ -260,6 +278,43 @@ export class StalkerSearchComponent {
|
||||
return [];
|
||||
}
|
||||
const contentType = params.contentType;
|
||||
// The dedicated search route has no category guard, so it filters
|
||||
// the portal's rows itself: a locked genre's title must not reach
|
||||
// the grid, its detail or playback through search.
|
||||
const withheldCategoryIds: ReadonlySet<string> =
|
||||
!this.parentalLock.active()
|
||||
? new Set<string>()
|
||||
: this.parentalLock.withholdsEverything?.()
|
||||
? ALL_CATEGORIES_WITHHELD
|
||||
: new Set(
|
||||
this.parentalLock.lockedStalkerIds(
|
||||
playlist._id,
|
||||
contentType
|
||||
)
|
||||
);
|
||||
const lockVersionChanged =
|
||||
this.searchResultsLockVersion !== null &&
|
||||
this.searchResultsLockVersion !== params.parentalLockVersion;
|
||||
this.searchResultsLockVersion = params.parentalLockVersion;
|
||||
if (lockVersionChanged) {
|
||||
this.applyRelockToResults(withheldCategoryIds, contentType);
|
||||
}
|
||||
if (lockVersionChanged && params.page > 1) {
|
||||
// A lock flip past page 1: rebuild from page 1 rather than
|
||||
// appending to pages accumulated under the old lock state.
|
||||
this.searchPage.set(1);
|
||||
return this.accumulatedSearchResults();
|
||||
}
|
||||
const withheldKey = JSON.stringify([
|
||||
params.playlistId,
|
||||
contentType,
|
||||
params.search,
|
||||
params.parentalLockVersion,
|
||||
]);
|
||||
if (params.page === 1 || this.searchWithheldKey !== withheldKey) {
|
||||
this.searchWithheldKey = withheldKey;
|
||||
this.searchWithheldIds.clear();
|
||||
}
|
||||
|
||||
// Mirror the catalog request shape: many Ministra portals
|
||||
// return an empty list for get_ordered_list without the
|
||||
@@ -278,13 +333,18 @@ export class StalkerSearchComponent {
|
||||
...(contentType === 'vod' ? { genre: '0' } : {}),
|
||||
};
|
||||
|
||||
// A stale response (term/filter/page/portal moved on while this
|
||||
// page was in flight) must not clobber the accumulated list.
|
||||
// A stale response (term/filter/page/portal — or the parental
|
||||
// lock — moved on while this page was in flight) must not clobber
|
||||
// the accumulated list: the request is not aborted, and a
|
||||
// pre-relock response was filtered with the pre-relock set.
|
||||
const isCurrent = (): boolean =>
|
||||
params.search === this.searchTerm() &&
|
||||
params.contentType === this.selectedFilterType() &&
|
||||
params.page === this.searchPage() &&
|
||||
params.playlistId === (this.currentPlaylist()?._id ?? null);
|
||||
isStalkerSearchRequestCurrent(params, {
|
||||
search: this.searchTerm(),
|
||||
contentType: this.selectedFilterType(),
|
||||
page: this.searchPage(),
|
||||
playlistId: this.currentPlaylist()?._id ?? null,
|
||||
parentalLockVersion: this.parentalLock.version(),
|
||||
});
|
||||
|
||||
try {
|
||||
// executeStalkerRequest owns the portal-mode decision (shared
|
||||
@@ -301,20 +361,53 @@ export class StalkerSearchComponent {
|
||||
playlist,
|
||||
requestParams
|
||||
);
|
||||
const items = (response.js?.data || []).map(
|
||||
const rawItems = (response.js?.data || []).map(
|
||||
(item: StalkerVodSource) =>
|
||||
this.processItemUrls(item, portalUrl)
|
||||
);
|
||||
|
||||
const items = withoutWithheldStalkerItems(
|
||||
rawItems,
|
||||
contentType,
|
||||
withheldCategoryIds
|
||||
);
|
||||
// Before the withheld-id bookkeeping: a stale page must not
|
||||
// pre-record ids into a set a newer relock request cleared,
|
||||
// or that request's page counts no new withheld rows and
|
||||
// stops paging short of later visible matches.
|
||||
if (!isCurrent()) {
|
||||
return items;
|
||||
}
|
||||
let newWithheldCount = 0;
|
||||
if (items.length < rawItems.length) {
|
||||
const kept = new Set(items);
|
||||
for (const item of rawItems) {
|
||||
const id = stalkerWithheldRowKey(item);
|
||||
if (
|
||||
!kept.has(item) &&
|
||||
!this.searchWithheldIds.has(id)
|
||||
) {
|
||||
this.searchWithheldIds.add(id);
|
||||
newWithheldCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.applySearchPageSuccess(
|
||||
const merged = this.applySearchPageSuccess(
|
||||
params.page,
|
||||
items,
|
||||
response.js?.total_items
|
||||
response.js?.total_items,
|
||||
// A page made only of withheld rows still is a page the
|
||||
// portal served; judge progress on what it sent.
|
||||
rawItems.length > 0 &&
|
||||
(items.length > 0 || newWithheldCount > 0)
|
||||
);
|
||||
this.advancePastWithheldPage(
|
||||
params.page,
|
||||
items.length,
|
||||
newWithheldCount,
|
||||
isCurrent
|
||||
);
|
||||
return merged;
|
||||
} catch (error) {
|
||||
this.logger.warn('Stalker search page failed', {
|
||||
page: params.page,
|
||||
@@ -343,7 +436,8 @@ export class StalkerSearchComponent {
|
||||
applySearchPageSuccess(
|
||||
page: number,
|
||||
items: StalkerVodSource[],
|
||||
totalItems: number | undefined
|
||||
totalItems: number | undefined,
|
||||
pageHadRows: boolean = items.length > 0
|
||||
): StalkerVodSource[] {
|
||||
const previous = page === 1 ? [] : this.accumulatedSearchResults();
|
||||
const merged =
|
||||
@@ -352,13 +446,16 @@ export class StalkerSearchComponent {
|
||||
// a reported total. Dedup after mid-list portal mutations can leave
|
||||
// the unique list permanently shorter than total_items, and a
|
||||
// repeated page dedupes to no growth; either way a no-progress
|
||||
// append is the practical end of the results.
|
||||
const madeProgress = page === 1 || merged.length > previous.length;
|
||||
// append is the practical end of the results. A page whose rows were
|
||||
// all withheld by the parental lock counts as progress too.
|
||||
const withheldRows = pageHadRows && items.length === 0;
|
||||
const madeProgress =
|
||||
page === 1 || merged.length > previous.length || withheldRows;
|
||||
this.searchHasMore.set(
|
||||
madeProgress &&
|
||||
(typeof totalItems === 'number' && totalItems >= 0
|
||||
? merged.length < totalItems
|
||||
: items.length > 0)
|
||||
: pageHadRows)
|
||||
);
|
||||
this.searchAppendError.set(false);
|
||||
this.accumulatedSearchResults.set(merged);
|
||||
@@ -605,6 +702,84 @@ export class StalkerSearchComponent {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The infinite scroll gives up after a few loads that add no height, so
|
||||
* a run of pages made only of parental-locked rows must advance by
|
||||
* itself until a visible row (or the real end) is reached. Only a page
|
||||
* that added withheld ids not seen before counts — a stalled portal
|
||||
* repeating the same locked rows must still end the loop.
|
||||
*/
|
||||
advancePastWithheldPage(
|
||||
page: number,
|
||||
visibleCount: number,
|
||||
newWithheldCount: number,
|
||||
isCurrent: () => boolean
|
||||
): void {
|
||||
if (
|
||||
visibleCount > 0 ||
|
||||
newWithheldCount === 0 ||
|
||||
!this.searchHasMore()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
if (isCurrent()) {
|
||||
this.searchPage.set(page + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the open detail when its genre is withheld by the parental
|
||||
* lock (Lock now, idle relock): the title, its playback actions and the
|
||||
* store's selected item must not outlive the list row.
|
||||
*/
|
||||
/**
|
||||
* A lock change reached the results on screen, which were read under
|
||||
* the old lock state: close an open detail of a now-withheld genre (the
|
||||
* list hiding it is not enough) and drop the withheld rows NOW, before
|
||||
* the replacement page is awaited — on page 1 too, or they stay
|
||||
* clickable while (or, if it hangs, after) that request is pending.
|
||||
*/
|
||||
applyRelockToResults(
|
||||
withheldCategoryIds: ReadonlySet<string>,
|
||||
contentType: Parameters<typeof withoutWithheldStalkerItems>[1]
|
||||
): void {
|
||||
this.closeWithheldDetail(withheldCategoryIds);
|
||||
this.accumulatedSearchResults.set(
|
||||
withoutWithheldStalkerItems(
|
||||
this.accumulatedSearchResults(),
|
||||
contentType,
|
||||
withheldCategoryIds
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
closeWithheldDetail(withheldCategoryIds: ReadonlySet<string>): void {
|
||||
const details = this.itemDetails();
|
||||
if (!details) {
|
||||
return;
|
||||
}
|
||||
// A detail without a genre is withheld only in fail-closed mode
|
||||
// (`ALL_CATEGORIES_WITHHELD`): "unknown genre" is not "no locked
|
||||
// genre" while the locks themselves are unknown.
|
||||
const categoryId = details.category_id;
|
||||
const withheld =
|
||||
categoryId === undefined || categoryId === null || categoryId === ''
|
||||
? withheldCategoryIds === ALL_CATEGORIES_WITHHELD
|
||||
: withheldCategoryIds.has(String(categoryId));
|
||||
if (!withheld) {
|
||||
return;
|
||||
}
|
||||
const cleared = clearStalkerDetailViewState();
|
||||
this.itemDetails.set(cleared.itemDetails);
|
||||
this.vodDetailsItem.set(cleared.vodDetailsItem);
|
||||
this.isSelectedVodFavorite.set(false);
|
||||
this.selectedVodPosition.set(null);
|
||||
this.closeInlinePlayer();
|
||||
this.stalkerStore.setSelectedItem(null);
|
||||
}
|
||||
|
||||
/** Leave the search page (e.g. back to the actor page that opened it) */
|
||||
goBack(): void {
|
||||
this.location.back();
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@ const ITEM = {
|
||||
rating_imdb: '8.4',
|
||||
rating_kinopoisk: '8.0',
|
||||
tmdb_id: 88001,
|
||||
tmdb_status: 'returning-series',
|
||||
tmdb_status: 'returning',
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -210,7 +210,7 @@ describe('createStalkerSeriesDownloadAdapter', () => {
|
||||
year: 2025,
|
||||
genres: ['Drama', 'Mystery'],
|
||||
rating: 8.4,
|
||||
status: 'returning-series',
|
||||
status: 'returning',
|
||||
posterUrl: SERIES_POSTER_URL,
|
||||
tmdbId: 88001,
|
||||
providerCategoryId: '18',
|
||||
|
||||
+5
-3
@@ -12,10 +12,12 @@ import {
|
||||
} from './stalker-series-resume';
|
||||
|
||||
function episode(
|
||||
overrides: Partial<StalkerMappedEpisode> & { id: number }
|
||||
overrides: Partial<Omit<StalkerMappedEpisode, 'id'>> & { id: number }
|
||||
): StalkerMappedEpisode {
|
||||
const { id, ...rest } = overrides;
|
||||
return {
|
||||
title: `Episode ${overrides.episode_num ?? 1}`,
|
||||
id: String(id),
|
||||
title: `Episode ${rest.episode_num ?? 1}`,
|
||||
container_extension: 'mpg',
|
||||
custom_sid: 'vod-series',
|
||||
added: '',
|
||||
@@ -23,7 +25,7 @@ function episode(
|
||||
episode_num: 1,
|
||||
direct_source: '',
|
||||
info: {},
|
||||
...overrides,
|
||||
...rest,
|
||||
} as StalkerMappedEpisode;
|
||||
}
|
||||
|
||||
|
||||
+8
-4
@@ -330,9 +330,10 @@ describe('StalkerSeriesViewComponent playback session key', () => {
|
||||
|
||||
pending.resolve({
|
||||
streamUrl: 'https://stale.example/old-command.mpg',
|
||||
title: 'Stale command',
|
||||
contentInfo: {
|
||||
playlistId: 'transport-playlist',
|
||||
contentXtreamId: episodeA.id,
|
||||
contentXtreamId: Number(episodeA.id),
|
||||
contentType: 'episode',
|
||||
},
|
||||
});
|
||||
@@ -676,9 +677,10 @@ describe('StalkerSeriesViewComponent playback session key', () => {
|
||||
setLazySeries('replacement-parent', 'replacement-provider-episode');
|
||||
pending.resolve({
|
||||
streamUrl: 'https://stale.example/old-episode.mpg',
|
||||
title: 'Stale episode',
|
||||
contentInfo: {
|
||||
playlistId: 'transport-playlist',
|
||||
contentXtreamId: episode.id,
|
||||
contentXtreamId: Number(episode.id),
|
||||
contentType: 'episode',
|
||||
},
|
||||
});
|
||||
@@ -709,18 +711,20 @@ describe('StalkerSeriesViewComponent playback session key', () => {
|
||||
fixture.componentInstance.onEpisodeClicked(newEpisode);
|
||||
const newestPlayback = {
|
||||
streamUrl: 'https://new.example/episode.mpg',
|
||||
title: 'Newest episode',
|
||||
contentInfo: {
|
||||
playlistId: 'transport-playlist',
|
||||
contentXtreamId: newEpisode.id,
|
||||
contentXtreamId: Number(newEpisode.id),
|
||||
contentType: 'episode' as const,
|
||||
},
|
||||
};
|
||||
newRequest.resolve(newestPlayback);
|
||||
oldRequest.resolve({
|
||||
streamUrl: 'https://old.example/episode.mpg',
|
||||
title: 'Old episode',
|
||||
contentInfo: {
|
||||
playlistId: 'transport-playlist',
|
||||
contentXtreamId: oldEpisode.id,
|
||||
contentXtreamId: Number(oldEpisode.id),
|
||||
contentType: 'episode',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,7 +6,10 @@ import type {
|
||||
PortalPlaybackPositions,
|
||||
PortalPlayer,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import type {
|
||||
PlaybackPositionData,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { StalkerVodPlaybackController } from './stalker-vod-playback-controller';
|
||||
|
||||
interface Deferred<T> {
|
||||
@@ -119,11 +122,14 @@ describe('StalkerVodPlaybackController', () => {
|
||||
|
||||
it('does not mount playback that resolves after the detail closes', async () => {
|
||||
const { controller, inlinePlayback } = createController();
|
||||
const pending = createDeferred<{ streamUrl: string }>();
|
||||
const pending = createDeferred<ResolvedPortalPlayback>();
|
||||
const playback = controller.startVodPlayback(() => pending.promise);
|
||||
|
||||
controller.closeInlinePlayer();
|
||||
pending.resolve({ streamUrl: 'https://stale.example/movie.mpg' });
|
||||
pending.resolve({
|
||||
streamUrl: 'https://stale.example/movie.mpg',
|
||||
title: 'Stale movie',
|
||||
});
|
||||
await playback;
|
||||
|
||||
expect(inlinePlayback()).toBeNull();
|
||||
@@ -131,14 +137,20 @@ describe('StalkerVodPlaybackController', () => {
|
||||
|
||||
it('keeps the newest VOD request when resolutions finish out of order', async () => {
|
||||
const { controller, inlinePlayback } = createController();
|
||||
const older = createDeferred<{ streamUrl: string }>();
|
||||
const newer = createDeferred<{ streamUrl: string }>();
|
||||
const older = createDeferred<ResolvedPortalPlayback>();
|
||||
const newer = createDeferred<ResolvedPortalPlayback>();
|
||||
const olderRequest = controller.startVodPlayback(() => older.promise);
|
||||
const newerRequest = controller.startVodPlayback(() => newer.promise);
|
||||
const newestPlayback = { streamUrl: 'https://new.example/movie.mpg' };
|
||||
const newestPlayback = {
|
||||
streamUrl: 'https://new.example/movie.mpg',
|
||||
title: 'Newest movie',
|
||||
};
|
||||
|
||||
newer.resolve(newestPlayback);
|
||||
older.resolve({ streamUrl: 'https://old.example/movie.mpg' });
|
||||
older.resolve({
|
||||
streamUrl: 'https://old.example/movie.mpg',
|
||||
title: 'Old movie',
|
||||
});
|
||||
await Promise.all([olderRequest, newerRequest]);
|
||||
|
||||
expect(inlinePlayback()).toBe(newestPlayback);
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"module": "preserve",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
+75
-3
@@ -1,3 +1,5 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import {
|
||||
credentials,
|
||||
ElectronXtreamDataSourceHarness,
|
||||
@@ -21,6 +23,7 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
|
||||
type: 'live' as const,
|
||||
xtream_id: 10,
|
||||
hidden: false,
|
||||
locked: false,
|
||||
};
|
||||
const dbContentItem = {
|
||||
id: 1,
|
||||
@@ -33,6 +36,48 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
|
||||
harness = setupElectronXtreamDataSource();
|
||||
});
|
||||
|
||||
describe('withholding everything', () => {
|
||||
it('serves no categories, content or search hits while the lock store withholds everything', async () => {
|
||||
const parentalLock = TestBed.inject(ParentalLockService);
|
||||
Object.defineProperty(parentalLock, 'withholdsEverything', {
|
||||
configurable: true,
|
||||
value: () => true,
|
||||
});
|
||||
harness.dbService.getXtreamImportStatus.mockResolvedValue(
|
||||
'completed'
|
||||
);
|
||||
harness.dbService.getXtreamCategories.mockResolvedValue([
|
||||
dbCategory,
|
||||
]);
|
||||
harness.dbService.getXtreamContent.mockResolvedValue([
|
||||
dbContentItem,
|
||||
]);
|
||||
|
||||
await expect(
|
||||
harness.dataSource.getCategories(
|
||||
playlistId,
|
||||
credentials,
|
||||
'live'
|
||||
)
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
harness.dataSource.getContent(playlistId, credentials, 'live')
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
harness.dataSource.searchContent(playlistId, 'news', ['live'])
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
harness.dataSource.getCachedCategories(playlistId, 'live')
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
harness.dataSource.getCachedContent(playlistId, 'live')
|
||||
).resolves.toEqual([]);
|
||||
expect(
|
||||
harness.dbService.getXtreamCategories
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCategories', () => {
|
||||
it('returns cached categories without calling the API when import is completed', async () => {
|
||||
harness.dbService.getXtreamImportStatus.mockResolvedValue(
|
||||
@@ -56,6 +101,26 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a complete cache whose every category the parental lock withholds', async () => {
|
||||
// The filtered read returns nothing, but rows exist: refetching
|
||||
// from the provider would be wasted work and, for content, a
|
||||
// full re-import.
|
||||
harness.dbService.getXtreamImportStatus.mockResolvedValue(
|
||||
'completed'
|
||||
);
|
||||
harness.dbService.getXtreamCategories.mockResolvedValue([]);
|
||||
harness.dbService.hasXtreamCategories.mockResolvedValue(true);
|
||||
|
||||
const result = await harness.dataSource.getCategories(
|
||||
playlistId,
|
||||
credentials,
|
||||
'live'
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(harness.apiService.getCategories).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches from the API and caches to DB when the cache is cold', async () => {
|
||||
const remoteCategories = [
|
||||
{ category_id: '10', category_name: 'News' },
|
||||
@@ -84,7 +149,9 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
|
||||
playlistId,
|
||||
remoteCategories,
|
||||
'movies',
|
||||
undefined
|
||||
undefined,
|
||||
// Parental lock: no locks stored for the playlist.
|
||||
[]
|
||||
);
|
||||
expect(result).toEqual([dbCategory]);
|
||||
expect(onPhaseChange.mock.calls).toEqual([
|
||||
@@ -174,7 +241,8 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
|
||||
playlistId,
|
||||
expect.any(Array),
|
||||
'live',
|
||||
[5]
|
||||
[5],
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -207,7 +275,11 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(
|
||||
harness.dataSource.getCategories(playlistId, credentials, 'live')
|
||||
harness.dataSource.getCategories(
|
||||
playlistId,
|
||||
credentials,
|
||||
'live'
|
||||
)
|
||||
).rejects.toThrow('portal unreachable');
|
||||
|
||||
await harness.dataSource.getCategories(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
DatabaseService,
|
||||
ParentalLockService,
|
||||
PlaybackPositionService,
|
||||
XtreamPendingRestoreService,
|
||||
XtreamImportStatus,
|
||||
@@ -46,6 +47,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
XtreamPendingRestoreService
|
||||
);
|
||||
private readonly apiService = inject(XtreamApiService);
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
private readonly categoryRequests = new Map<
|
||||
string,
|
||||
Promise<XtreamCategoryFromDb[]>
|
||||
@@ -147,8 +149,17 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
type: CategoryType,
|
||||
options?: XtreamOperationOptions
|
||||
): Promise<XtreamCategoryFromDb[]> {
|
||||
// Fail closed with the renderer: while the lock store is loading,
|
||||
// unreadable or its SQLite index not yet reconciled, the index may
|
||||
// still carry a stale `locked = false` stamp, so nothing is served.
|
||||
if (this.parentalLock.withholdsEverything?.()) {
|
||||
return [];
|
||||
}
|
||||
const dbType = mapCategoryTypeToDbType(type);
|
||||
const requestKey = `${playlistId}:${dbType}`;
|
||||
// The lock version keys the share: a read issued under an older lock
|
||||
// state (still unlocked, or locks since edited) answers with rows the
|
||||
// caller under the newer state must not be handed.
|
||||
const requestKey = `${playlistId}:${dbType}:${this.parentalLock.version()}`;
|
||||
const inFlightRequest = this.categoryRequests.get(requestKey);
|
||||
|
||||
if (inFlightRequest) {
|
||||
@@ -187,7 +198,14 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
playlistId,
|
||||
dbType
|
||||
);
|
||||
if (importStatus === 'completed' && cached.length > 0) {
|
||||
// The read is filtered by the parental lock, so an empty result is
|
||||
// not proof of a cold cache: a type whose every category is locked
|
||||
// is complete and must not be refetched from the provider.
|
||||
if (
|
||||
importStatus === 'completed' &&
|
||||
(cached.length > 0 ||
|
||||
(await this.dbService.hasXtreamCategories(playlistId, dbType)))
|
||||
) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
@@ -213,7 +231,10 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
playlistId,
|
||||
remoteData,
|
||||
dbType,
|
||||
hiddenCategoryXtreamIds
|
||||
hiddenCategoryXtreamIds,
|
||||
// Locks live in the renderer's store; the fresh rows get the
|
||||
// SQLite mirror stamped on insert so a refresh keeps them.
|
||||
this.parentalLock.lockedXtreamIds(playlistId, dbType)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -252,6 +273,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
playlistId: string,
|
||||
type: CategoryType
|
||||
): Promise<XtreamCategoryFromDb[]> {
|
||||
// Same fail-closed gate as the live reads: the warm-route hydration
|
||||
// reads the cache directly and must not publish rows a stale index
|
||||
// stamp still marks unlocked.
|
||||
if (this.parentalLock.withholdsEverything?.()) {
|
||||
return [];
|
||||
}
|
||||
return this.dbService.getXtreamCategories(
|
||||
playlistId,
|
||||
mapCategoryTypeToDbType(type)
|
||||
@@ -292,7 +319,13 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
onTotal?: (total: number) => void,
|
||||
options?: XtreamOperationOptions
|
||||
): Promise<XtreamContentItem[]> {
|
||||
const requestKey = `${playlistId}:${type}`;
|
||||
// Fail closed with the renderer: while the lock store is loading,
|
||||
// unreadable or its SQLite index not yet reconciled, the index may
|
||||
// still carry a stale `locked = false` stamp, so nothing is served.
|
||||
if (this.parentalLock.withholdsEverything?.()) {
|
||||
return [];
|
||||
}
|
||||
const requestKey = `${playlistId}:${type}:${this.parentalLock.version()}`;
|
||||
const inFlightRequest = this.contentRequests.get(requestKey);
|
||||
|
||||
if (inFlightRequest) {
|
||||
@@ -329,7 +362,13 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
// Fetch from DB directly — avoids a separate 'has' round-trip.
|
||||
// An empty result means the cache is cold; proceed to fetch from API.
|
||||
const cached = await this.dbService.getXtreamContent(playlistId, type);
|
||||
if (importStatus === 'completed' && cached.length > 0) {
|
||||
// Filtered by the parental lock like the category read: empty is not
|
||||
// cold while unfiltered rows exist.
|
||||
if (
|
||||
importStatus === 'completed' &&
|
||||
(cached.length > 0 ||
|
||||
(await this.dbService.hasXtreamContent(playlistId, type)))
|
||||
) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
@@ -370,6 +409,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
playlistId: string,
|
||||
type: StreamType
|
||||
): Promise<XtreamContentItem[]> {
|
||||
// Same fail-closed gate as the live reads: the warm-route hydration
|
||||
// reads the cache directly and must not publish rows a stale index
|
||||
// stamp still marks unlocked.
|
||||
if (this.parentalLock.withholdsEverything?.()) {
|
||||
return [];
|
||||
}
|
||||
return this.dbService.getXtreamContent(playlistId, type);
|
||||
}
|
||||
|
||||
@@ -403,6 +448,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
types: string[],
|
||||
excludeHidden?: boolean
|
||||
): Promise<XtreamContentItem[]> {
|
||||
// Fail closed with the renderer: while the lock store is loading,
|
||||
// unreadable or its SQLite index not yet reconciled, the index may
|
||||
// still carry a stale `locked = false` stamp, so nothing is served.
|
||||
if (this.parentalLock.withholdsEverything?.()) {
|
||||
return [];
|
||||
}
|
||||
return this.dbService.searchXtreamContent(
|
||||
playlistId,
|
||||
searchTerm,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import { PwaXtreamDataSource } from './pwa-xtream-data-source';
|
||||
import type { XtreamContentItem } from './xtream-data-source.interface';
|
||||
import {
|
||||
XtreamApiService,
|
||||
XtreamCredentials,
|
||||
} from '../services/xtream-api.service';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import { ParentalLockService, PlaylistsService } from '@iptvnator/services';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
describe('PwaXtreamDataSource', () => {
|
||||
@@ -17,6 +18,11 @@ describe('PwaXtreamDataSource', () => {
|
||||
getPlaylistById: jest.Mock;
|
||||
transformPlaylistMeta: jest.Mock;
|
||||
};
|
||||
let parentalLock: {
|
||||
active: jest.Mock<boolean, []>;
|
||||
lockedXtreamIds: jest.Mock<number[], [string, string]>;
|
||||
withholdsEverything: jest.Mock<boolean, []>;
|
||||
};
|
||||
|
||||
const credentials: XtreamCredentials = {
|
||||
serverUrl: 'http://localhost:3211',
|
||||
@@ -34,10 +40,18 @@ describe('PwaXtreamDataSource', () => {
|
||||
getPlaylistById: jest.fn(() => of(undefined)),
|
||||
transformPlaylistMeta: jest.fn(() => of(null)),
|
||||
};
|
||||
parentalLock = {
|
||||
active: jest.fn(() => false),
|
||||
lockedXtreamIds: jest.fn(
|
||||
(_playlistId: string, _type: string): number[] => []
|
||||
),
|
||||
withholdsEverything: jest.fn(() => false),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
PwaXtreamDataSource,
|
||||
{ provide: ParentalLockService, useValue: parentalLock },
|
||||
{
|
||||
provide: XtreamApiService,
|
||||
useValue: apiService,
|
||||
@@ -56,6 +70,77 @@ describe('PwaXtreamDataSource', () => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('withholds locked categories from catalog reads and search while the lock is active', async () => {
|
||||
apiService.getStreams.mockResolvedValue([
|
||||
{ stream_id: 1, name: 'Family film', category_id: '5' },
|
||||
{ stream_id: 2, name: 'Family after dark', category_id: '9' },
|
||||
]);
|
||||
parentalLock.active.mockReturnValue(true);
|
||||
parentalLock.lockedXtreamIds.mockImplementation((_id, type) =>
|
||||
type === 'movies' ? [9] : []
|
||||
);
|
||||
|
||||
const content = await dataSource.getContent(
|
||||
'playlist-1',
|
||||
credentials,
|
||||
'movie'
|
||||
);
|
||||
const found = await dataSource.searchContent('playlist-1', 'family', [
|
||||
'movie',
|
||||
]);
|
||||
|
||||
expect(content.map((item) => item.name)).toEqual(['Family film']);
|
||||
expect(found.map((item) => item.name)).toEqual(['Family film']);
|
||||
|
||||
parentalLock.active.mockReturnValue(false);
|
||||
const unlocked = await dataSource.searchContent(
|
||||
'playlist-1',
|
||||
'family',
|
||||
['movie']
|
||||
);
|
||||
expect(unlocked).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('matches a noncanonical provider category id against its numeric lock', async () => {
|
||||
apiService.getStreams.mockResolvedValue([
|
||||
{ stream_id: 1, name: 'Family film', category_id: '5' },
|
||||
{ stream_id: 2, name: 'Family after dark', category_id: '009' },
|
||||
]);
|
||||
parentalLock.active.mockReturnValue(true);
|
||||
parentalLock.lockedXtreamIds.mockImplementation((_id, type) =>
|
||||
type === 'movies' ? [9] : []
|
||||
);
|
||||
|
||||
const content = await dataSource.getContent(
|
||||
'playlist-1',
|
||||
credentials,
|
||||
'movie'
|
||||
);
|
||||
|
||||
expect(content.map((item) => item.name)).toEqual(['Family film']);
|
||||
});
|
||||
|
||||
it('withholds everything while the lock store cannot be read', async () => {
|
||||
apiService.getStreams.mockResolvedValue([
|
||||
{ stream_id: 1, name: 'Family film', category_id: '5' },
|
||||
]);
|
||||
parentalLock.active.mockReturnValue(true);
|
||||
parentalLock.withholdsEverything = jest.fn(() => true);
|
||||
|
||||
const content = await dataSource.getContent(
|
||||
'playlist-1',
|
||||
credentials,
|
||||
'movie'
|
||||
);
|
||||
const found = await dataSource.searchContent('playlist-1', 'family', [
|
||||
'movie',
|
||||
]);
|
||||
|
||||
expect(content).toEqual([]);
|
||||
expect(found).toEqual([]);
|
||||
expect(parentalLock.lockedXtreamIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports remote loading phases for API fetches but stays silent on cache hits', async () => {
|
||||
apiService.getStreams.mockResolvedValue([
|
||||
{ stream_id: 1, name: 'News' },
|
||||
@@ -281,17 +366,17 @@ describe('PwaXtreamDataSource', () => {
|
||||
'playlist-1',
|
||||
credentials,
|
||||
'live'
|
||||
)) as Array<Record<string, unknown>>;
|
||||
)) as XtreamContentItem[];
|
||||
const vod = (await dataSource.getContent(
|
||||
'playlist-1',
|
||||
credentials,
|
||||
'movie'
|
||||
)) as Array<Record<string, unknown>>;
|
||||
)) as XtreamContentItem[];
|
||||
const series = (await dataSource.getContent(
|
||||
'playlist-1',
|
||||
credentials,
|
||||
'series'
|
||||
)) as Array<Record<string, unknown>>;
|
||||
)) as XtreamContentItem[];
|
||||
|
||||
expect(live[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -399,7 +484,7 @@ describe('PwaXtreamDataSource', () => {
|
||||
'playlist-1',
|
||||
credentials,
|
||||
'movie'
|
||||
)) as Array<Record<string, unknown>>;
|
||||
)) as XtreamContentItem[];
|
||||
|
||||
expect(content).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -524,7 +609,7 @@ describe('PwaXtreamDataSource', () => {
|
||||
'playlist-1',
|
||||
credentials,
|
||||
'movie'
|
||||
)) as Array<Record<string, unknown>>;
|
||||
)) as XtreamContentItem[];
|
||||
|
||||
expect(content[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { inject, Injectable, Injector } from '@angular/core';
|
||||
import {
|
||||
ALL_CATEGORIES_WITHHELD,
|
||||
foldSearchText,
|
||||
ContentMetadataPatch,
|
||||
Playlist,
|
||||
@@ -10,14 +11,14 @@ import {
|
||||
XtreamSerieItem,
|
||||
XtreamVodStream,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util/logger';
|
||||
import {
|
||||
CategoryType,
|
||||
StreamType,
|
||||
XtreamApiService,
|
||||
XtreamCredentials,
|
||||
} from '../services/xtream-api.service';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import { ParentalLockService, PlaylistsService } from '@iptvnator/services';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
DbCategoryType,
|
||||
@@ -82,6 +83,7 @@ type StoredXtreamPlaylistData = Omit<XtreamPlaylistData, 'password'> & {
|
||||
export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
private readonly apiService = inject(XtreamApiService);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
private readonly logger = createLogger('PwaXtreamDataSource');
|
||||
private readonly contentTypes = ['live', 'movie', 'series'] as const;
|
||||
|
||||
@@ -332,7 +334,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
// Check in-memory cache first
|
||||
const cachedCategories = this.categoryCache.get(cacheKey);
|
||||
if (cachedCategories) {
|
||||
return cachedCategories;
|
||||
return this.withoutLockedCategories(
|
||||
playlistId,
|
||||
type,
|
||||
cachedCategories
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
@@ -345,17 +351,112 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
// Cache in memory
|
||||
this.categoryCache.set(cacheKey, categories);
|
||||
|
||||
return categories;
|
||||
return this.withoutLockedCategories(playlistId, type, categories);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parental lock (PWA): there is no SQLite worker to filter reads, so the
|
||||
* withheld categories are dropped here, at the same boundary the
|
||||
* Electron source reads them through.
|
||||
*/
|
||||
private withheldCategoryIds(
|
||||
playlistId: string,
|
||||
type: CategoryType | StreamType
|
||||
): ReadonlySet<string> {
|
||||
if (!this.parentalLock.active()) {
|
||||
return new Set();
|
||||
}
|
||||
if (this.parentalLock.withholdsEverything?.()) {
|
||||
return ALL_CATEGORIES_WITHHELD;
|
||||
}
|
||||
const dbType =
|
||||
type === 'vod' || type === 'movie'
|
||||
? 'movies'
|
||||
: type === 'series'
|
||||
? 'series'
|
||||
: 'live';
|
||||
return new Set(
|
||||
this.parentalLock
|
||||
.lockedXtreamIds(playlistId, dbType)
|
||||
.map((id) => String(id))
|
||||
);
|
||||
}
|
||||
|
||||
private withoutLockedCategories(
|
||||
playlistId: string,
|
||||
type: CategoryType,
|
||||
categories: XtreamCategory[]
|
||||
): XtreamCategory[] {
|
||||
const withheld = this.withheldCategoryIds(playlistId, type);
|
||||
return withheld.size === 0
|
||||
? categories
|
||||
: categories.filter(
|
||||
(category) =>
|
||||
!withheld.has(canonicalCategoryId(category.category_id))
|
||||
);
|
||||
}
|
||||
|
||||
private withoutLockedContent<T extends { category_id?: string | number }>(
|
||||
playlistId: string,
|
||||
type: StreamType,
|
||||
items: T[]
|
||||
): T[] {
|
||||
const withheld = this.withheldCategoryIds(playlistId, type);
|
||||
return withheld.size === 0
|
||||
? items
|
||||
: items.filter(
|
||||
(item) => !withheld.has(canonicalCategoryId(item.category_id))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw category list in the SQLite wire shape, locked ones included:
|
||||
* the lock editor's candidates. Hidden/shown is not tracked in the PWA,
|
||||
* so every row reports `hidden: false`. Reads the session cache and
|
||||
* falls back to the API with the stored credentials on a cold session.
|
||||
*/
|
||||
async getAllCategories(
|
||||
playlistId: string,
|
||||
type: DbCategoryType
|
||||
): Promise<XtreamCategoryFromDb[]> {
|
||||
void playlistId;
|
||||
void type;
|
||||
// PWA doesn't track hidden categories - return empty
|
||||
return [];
|
||||
const categoryType: CategoryType = type === 'movies' ? 'vod' : type;
|
||||
const cacheKey = `${playlistId}-${categoryType}-categories`;
|
||||
let categories = this.categoryCache.get(cacheKey);
|
||||
if (!categories) {
|
||||
const playlist = await this.getPlaylist(playlistId);
|
||||
if (!playlist) {
|
||||
return [];
|
||||
}
|
||||
categories = await this.apiService.getCategories(
|
||||
{
|
||||
serverUrl: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
},
|
||||
categoryType
|
||||
);
|
||||
this.categoryCache.set(cacheKey, categories);
|
||||
}
|
||||
const locked = new Set(
|
||||
this.parentalLock.lockedXtreamIds(playlistId, type)
|
||||
);
|
||||
const rows: XtreamCategoryFromDb[] = [];
|
||||
for (const category of categories) {
|
||||
const xtreamId = Number(category.category_id);
|
||||
if (!Number.isFinite(xtreamId)) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
id: xtreamId,
|
||||
name: category.category_name,
|
||||
playlist_id: playlistId,
|
||||
type,
|
||||
xtream_id: xtreamId,
|
||||
hidden: false,
|
||||
locked: locked.has(xtreamId),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
async getCachedCategories(
|
||||
@@ -417,8 +518,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
// Check in-memory cache first
|
||||
const cachedContent = this.contentCache.get(cacheKey);
|
||||
if (cachedContent) {
|
||||
return cachedContent as
|
||||
XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[];
|
||||
return this.withoutLockedContent(
|
||||
playlistId,
|
||||
type,
|
||||
cachedContent
|
||||
) as XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[];
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
@@ -445,7 +549,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
// Cache in memory
|
||||
this.contentCache.set(cacheKey, content);
|
||||
|
||||
return content;
|
||||
return this.withoutLockedContent(playlistId, type, content);
|
||||
}
|
||||
|
||||
async getCachedContent(
|
||||
@@ -553,7 +657,13 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
|
||||
for (const type of types) {
|
||||
const cacheKey = `${playlistId}-${type}-content`;
|
||||
const content = this.contentCache.get(cacheKey) || [];
|
||||
// Same lock boundary as the catalog read: a search must not
|
||||
// surface rows the category list withholds.
|
||||
const content = this.withoutLockedContent(
|
||||
playlistId,
|
||||
type as StreamType,
|
||||
this.contentCache.get(cacheKey) || []
|
||||
);
|
||||
|
||||
const filtered = content.filter((item) => {
|
||||
const title =
|
||||
@@ -1476,3 +1586,14 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
this.contentCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The lock store keys provider category ids as numbers (the lock editor
|
||||
* converts with `Number`), so a noncanonical provider id such as `"001"`
|
||||
* must be compared as `"1"`. Non-numeric and empty ids stay as they are.
|
||||
*/
|
||||
function canonicalCategoryId(id: string | number | null | undefined): string {
|
||||
const raw = String(id ?? '').trim();
|
||||
const numeric = Number(raw);
|
||||
return raw !== '' && Number.isFinite(numeric) ? String(numeric) : raw;
|
||||
}
|
||||
@@ -88,6 +88,8 @@ export interface XtreamCategoryFromDb {
|
||||
type: 'movies' | 'live' | 'series';
|
||||
xtream_id: number;
|
||||
hidden: boolean;
|
||||
/** Parental lock index; see ParentalLockService. */
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('EpgQueueService invalidation', () => {
|
||||
|
||||
type ServicePrivates = {
|
||||
fetchEpg: (
|
||||
credentials: typeof credentials,
|
||||
portalCredentials: typeof credentials,
|
||||
streamId: number
|
||||
) => Promise<void>;
|
||||
shouldFetch: (streamId: number) => boolean;
|
||||
@@ -42,7 +42,9 @@ describe('EpgQueueService invalidation', () => {
|
||||
provide: XtreamXmltvFallbackService,
|
||||
useValue: {
|
||||
getProgramsForChannel: jest.fn().mockResolvedValue([]),
|
||||
getCurrentProgramsBatch: jest.fn().mockResolvedValue({}),
|
||||
getCurrentProgramsBatch: jest
|
||||
.fn()
|
||||
.mockResolvedValue({}),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('EpgQueueService', () => {
|
||||
|
||||
type ServicePrivates = {
|
||||
fetchEpg: (
|
||||
credentials: typeof credentials,
|
||||
portalCredentials: typeof credentials,
|
||||
streamId: number
|
||||
) => Promise<void>;
|
||||
shouldFetch: (streamId: number) => boolean;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { EpgSourceSettingsService, SettingsStore } from '@iptvnator/services';
|
||||
import { XtreamApiService, XtreamCredentials } from './xtream-api.service';
|
||||
import { XtreamXmltvFallbackService } from './xtream-xmltv-fallback.service';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util/logger';
|
||||
|
||||
interface CacheEntry {
|
||||
data: EpgItem[];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util/logger';
|
||||
import {
|
||||
EpgItem,
|
||||
XtreamCategory,
|
||||
|
||||
@@ -223,7 +223,7 @@ describe('XtreamUrlService', () => {
|
||||
.mockResolvedValueOnce({ status: 206 });
|
||||
window.electron = {
|
||||
xtreamProbeUrl,
|
||||
} as typeof window.electron;
|
||||
} as unknown as typeof window.electron;
|
||||
|
||||
const firstUrl = await service.resolveCatchupUrl(
|
||||
'playlist-1',
|
||||
@@ -263,7 +263,7 @@ describe('XtreamUrlService', () => {
|
||||
}));
|
||||
window.electron = {
|
||||
xtreamProbeUrl,
|
||||
} as typeof window.electron;
|
||||
} as unknown as typeof window.electron;
|
||||
|
||||
const initialUrl = await service.resolveCatchupUrl(
|
||||
'playlist-format-refresh',
|
||||
@@ -306,7 +306,7 @@ describe('XtreamUrlService', () => {
|
||||
}));
|
||||
window.electron = {
|
||||
xtreamProbeUrl,
|
||||
} as typeof window.electron;
|
||||
} as unknown as typeof window.electron;
|
||||
|
||||
const catchupUrl = await service.resolveCatchupUrl(
|
||||
'playlist-hls',
|
||||
@@ -345,7 +345,7 @@ describe('XtreamUrlService', () => {
|
||||
}));
|
||||
window.electron = {
|
||||
xtreamProbeUrl,
|
||||
} as typeof window.electron;
|
||||
} as unknown as typeof window.electron;
|
||||
|
||||
const catchupUrl = await service.resolveCatchupUrl(
|
||||
'playlist-hls-only',
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ describe('XtreamXmltvFallbackService', () => {
|
||||
getChannelPrograms: jest.fn(),
|
||||
getCurrentProgramsBatch: jest.fn(),
|
||||
};
|
||||
(window as { electron?: Bridge }).electron = bridge;
|
||||
(window as { electron?: unknown }).electron = bridge;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
EpgProgram,
|
||||
epgProviderClockMs,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util/logger';
|
||||
import { EpgSourceSettingsService, SettingsStore } from '@iptvnator/services';
|
||||
|
||||
type ElectronEpgBridge = {
|
||||
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { PortalStatusType } from '../../xtream-state';
|
||||
import {
|
||||
createContentTestProviders,
|
||||
createContentTestStore,
|
||||
createDeferred,
|
||||
createPendingRestoreServiceMock,
|
||||
waitForCondition,
|
||||
} from './with-content.feature.spec-helpers';
|
||||
|
||||
jest.mock('@iptvnator/portal/shared/util', () => ({
|
||||
createLogger: () => ({
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
type ContentType = 'live' | 'movie' | 'series';
|
||||
|
||||
const TestContentStore = createContentTestStore(() =>
|
||||
Promise.resolve('active' as PortalStatusType)
|
||||
);
|
||||
|
||||
/**
|
||||
* Parental-lock reloads (`reloadCategories` / `reloadCachedContent`) fail
|
||||
* closed. Split from `with-content.feature.spec.ts`, which sits at the spec
|
||||
* line cap.
|
||||
*/
|
||||
describe('withContent parental-lock reloads', () => {
|
||||
let store: InstanceType<typeof TestContentStore>;
|
||||
let dataSource: {
|
||||
getCategories: jest.Mock;
|
||||
getCachedCategories: jest.Mock;
|
||||
getContent: jest.Mock;
|
||||
getCachedContent: jest.Mock;
|
||||
hasCategories: jest.Mock;
|
||||
hasContent: jest.Mock;
|
||||
restoreUserData: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
dataSource = {
|
||||
getCategories: jest.fn().mockResolvedValue([]),
|
||||
getCachedCategories: jest.fn().mockResolvedValue([]),
|
||||
getContent: jest.fn().mockResolvedValue([]),
|
||||
getCachedContent: jest.fn().mockResolvedValue([]),
|
||||
hasCategories: jest.fn().mockResolvedValue(true),
|
||||
hasContent: jest.fn().mockResolvedValue(false),
|
||||
restoreUserData: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: createContentTestProviders(TestContentStore, {
|
||||
dataSource,
|
||||
databaseService: {
|
||||
clearXtreamImportCache: jest.fn().mockResolvedValue(true),
|
||||
cancelOperation: jest.fn().mockResolvedValue(true),
|
||||
createOperationId: jest.fn(
|
||||
(prefix?: string) => `${prefix ?? 'db-op'}-1`
|
||||
),
|
||||
getXtreamImportStatus: jest
|
||||
.fn()
|
||||
.mockResolvedValue('completed'),
|
||||
setXtreamImportStatus: jest.fn().mockResolvedValue(true),
|
||||
supportsDbOperationCancellation: jest
|
||||
.fn()
|
||||
.mockReturnValue(true),
|
||||
},
|
||||
xtreamApiService: {
|
||||
cancelSession: jest.fn().mockResolvedValue(true),
|
||||
},
|
||||
pendingRestoreService: createPendingRestoreServiceMock(),
|
||||
dataService: {
|
||||
sendIpcEvent: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true }),
|
||||
},
|
||||
}),
|
||||
});
|
||||
store = TestBed.inject(TestContentStore);
|
||||
});
|
||||
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it('withholds every catalog list at once and drops reads whose guard says no', async () => {
|
||||
dataSource.getCategories.mockResolvedValue([{ category_id: 'x' }]);
|
||||
dataSource.getContent.mockResolvedValue([{ xtream_id: 1 }]);
|
||||
await store.initializeContent();
|
||||
expect(store.liveStreams()).toEqual([{ xtream_id: 1 }]);
|
||||
|
||||
store.withholdCatalog();
|
||||
expect(store.liveCategories()).toEqual([]);
|
||||
expect(store.vodStreams()).toEqual([]);
|
||||
expect(store.serialStreams()).toEqual([]);
|
||||
expect(store.contentLoadStateByType().live).toBe('ready');
|
||||
|
||||
await store.reloadCategories(() => false);
|
||||
await store.reloadCachedContent(() => false);
|
||||
expect(store.liveCategories()).toEqual([]);
|
||||
expect(store.liveStreams()).toEqual([]);
|
||||
|
||||
await store.reloadCachedContent();
|
||||
expect(store.liveStreams()).toEqual([{ xtream_id: 1 }]);
|
||||
});
|
||||
|
||||
it('withholds rows published by a hydration a relock overtook and reloads them afterwards', async () => {
|
||||
const live = createDeferred<unknown[]>();
|
||||
let calls = 0;
|
||||
dataSource.getContent.mockImplementation(() => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
// The hydration's live read is held open; the relock lands
|
||||
// while it is in flight.
|
||||
return live.promise;
|
||||
}
|
||||
// Reads 2-3 still belong to the hydration, later ones to the
|
||||
// deferred filtered reload.
|
||||
return Promise.resolve(
|
||||
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);
|
||||
|
||||
await store.reloadCachedContent();
|
||||
expect(calls).toBe(1);
|
||||
|
||||
live.resolve([{ xtream_id: 1 }]);
|
||||
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 }]);
|
||||
expect(store.isContentInitialized()).toBe(true);
|
||||
});
|
||||
|
||||
it("carries the deferred request's publish guard into the reload", async () => {
|
||||
const live = createDeferred<unknown[]>();
|
||||
let calls = 0;
|
||||
dataSource.getContent.mockImplementation(() => {
|
||||
calls += 1;
|
||||
return calls === 1
|
||||
? live.promise
|
||||
: Promise.resolve([{ xtream_id: calls }]);
|
||||
});
|
||||
const initialization = store.initializeContent();
|
||||
await waitForCondition(() => calls === 1);
|
||||
|
||||
let current = true;
|
||||
await store.reloadCachedContent(() => current);
|
||||
// The lock moved on again before the hydration settled.
|
||||
current = false;
|
||||
live.resolve([{ xtream_id: 1 }]);
|
||||
await initialization;
|
||||
|
||||
// The reload stops at its first guard check instead of publishing
|
||||
// the older rows; the superseding apply reads everything again.
|
||||
expect(calls).toBe(4);
|
||||
expect(store.liveStreams()).toEqual([]);
|
||||
expect(store.vodStreams()).toEqual([]);
|
||||
expect(store.serialStreams()).toEqual([]);
|
||||
});
|
||||
|
||||
it('suppresses a hydration publication as soon as the catalog is withheld', async () => {
|
||||
const live = createDeferred<unknown[]>();
|
||||
let calls = 0;
|
||||
let liveAfterHydrationPublish: unknown;
|
||||
dataSource.getContent.mockImplementation(() => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return live.promise;
|
||||
}
|
||||
if (calls === 2) {
|
||||
// The hydration published its live rows just before this
|
||||
// (vod) read: capture what it left on screen.
|
||||
liveAfterHydrationPublish = store.liveStreams();
|
||||
}
|
||||
return Promise.resolve([{ xtream_id: calls }]);
|
||||
});
|
||||
const initialization = store.initializeContent();
|
||||
await waitForCondition(() => calls === 1);
|
||||
|
||||
// Relock: the catalog is withheld BEFORE any reload has run, then
|
||||
// the hydration's pending (unlocked) live read resolves.
|
||||
store.withholdCatalog();
|
||||
live.resolve([{ xtream_id: 1 }]);
|
||||
await initialization;
|
||||
|
||||
expect(liveAfterHydrationPublish).toEqual([]);
|
||||
// The deferred reload refilled the list with fresh rows.
|
||||
expect(store.liveStreams()).not.toEqual([{ xtream_id: 1 }]);
|
||||
expect(store.liveStreams()).not.toEqual([]);
|
||||
});
|
||||
|
||||
it('empties the category lists when their reload fails', async () => {
|
||||
dataSource.getCategories.mockResolvedValue([{ category_id: 'x' }]);
|
||||
await store.reloadCategories();
|
||||
expect(store.liveCategories()).toEqual([{ category_id: 'x' }]);
|
||||
|
||||
dataSource.getCategories.mockRejectedValue(new Error('db'));
|
||||
await store.reloadCategories();
|
||||
|
||||
expect(store.liveCategories()).toEqual([]);
|
||||
expect(store.vodCategories()).toEqual([]);
|
||||
expect(store.serialCategories()).toEqual([]);
|
||||
});
|
||||
|
||||
it('empties a type whose cached reload fails and lets the others reload', async () => {
|
||||
dataSource.getContent.mockResolvedValue([{ xtream_id: 1 }]);
|
||||
await store.initializeContent();
|
||||
expect(store.contentLoadStateByType()).toEqual({
|
||||
live: 'ready',
|
||||
vod: 'ready',
|
||||
series: 'ready',
|
||||
});
|
||||
|
||||
dataSource.getContent.mockImplementation(
|
||||
(_playlistId: string, _credentials: unknown, type: ContentType) =>
|
||||
type === 'movie'
|
||||
? Promise.reject(new Error('db'))
|
||||
: Promise.resolve([{ xtream_id: 2 }])
|
||||
);
|
||||
await store.reloadCachedContent();
|
||||
|
||||
expect(store.vodStreams()).toEqual([]);
|
||||
expect(store.liveStreams()).toEqual([{ xtream_id: 2 }]);
|
||||
expect(store.serialStreams()).toEqual([{ xtream_id: 2 }]);
|
||||
expect(store.contentLoadStateByType()).toEqual({
|
||||
live: 'ready',
|
||||
vod: 'idle',
|
||||
series: 'ready',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops reloads whose playlist was switched away while they read', async () => {
|
||||
dataSource.getCategories.mockResolvedValue([{ category_id: 'a' }]);
|
||||
dataSource.getContent.mockResolvedValue([{ xtream_id: 1 }]);
|
||||
await store.initializeContent();
|
||||
|
||||
const categories = createDeferred<unknown[]>();
|
||||
const content = createDeferred<unknown[]>();
|
||||
dataSource.getCategories.mockReturnValue(categories.promise);
|
||||
dataSource.getContent.mockImplementation(
|
||||
(_playlistId: string, _credentials: unknown, type: ContentType) =>
|
||||
type === 'live'
|
||||
? content.promise
|
||||
: Promise.reject(new Error('db'))
|
||||
);
|
||||
const reloadingCategories = store.reloadCategories();
|
||||
const reloadingContent = store.reloadCachedContent();
|
||||
store.switchPlaylist('playlist-b');
|
||||
categories.resolve([{ category_id: 'stale' }]);
|
||||
content.resolve([{ xtream_id: 99 }]);
|
||||
await reloadingCategories;
|
||||
await reloadingContent;
|
||||
|
||||
expect(store.liveCategories()).toEqual([{ category_id: 'a' }]);
|
||||
expect(store.liveStreams()).toEqual([{ xtream_id: 1 }]);
|
||||
expect(store.contentLoadStateByType()).toEqual({
|
||||
live: 'ready',
|
||||
vod: 'ready',
|
||||
series: 'ready',
|
||||
});
|
||||
});
|
||||
});
|
||||
Loaded 100 of 378 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user