Merge pull request #1004 from 4gray/agent/pwa-electron-runtime-consolidated

refactor(runtime): consolidate PWA and Electron capability gates
This commit is contained in:
4gray authored and GitHub committed 2026-05-22 15:18:23 +03:00
commit 4d28c962ad
53 files changed
+1979 -256

No files matched your search

@@ -12,7 +12,6 @@ import {
} from './electron-test-fixtures';
import {
fetchXtreamEpgFixture,
XtreamEpgFixture,
XtreamNormalizedEpgListing,
} from './portal-mock-fixtures';
@@ -87,7 +86,7 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
});
const currentDateKey = formatDateKeyInZone(Date.now(), timeZone);
const currentDayTitles = visibleTitlesForCurrentDate(
const currentDayTitles = titlesForDate(
fixture.fullEpg,
currentDateKey,
timeZone
@@ -184,22 +183,6 @@ function titlesForDate(
.map((listing) => listing.title);
}
function visibleTitlesForCurrentDate(
listings: XtreamNormalizedEpgListing[],
currentDateKey: string,
timeZone: string
): string[] {
const nowSeconds = Math.floor(Date.now() / 1000);
return listings
.filter(
(listing) =>
formatDateKeyInZone(listing.startTimestamp * 1000, timeZone) ===
currentDateKey && listing.stopTimestamp >= nowSeconds
)
.map((listing) => listing.title);
}
function getFirstFutureDateKey(
listings: XtreamNormalizedEpgListing[],
currentDateKey: string,
@@ -44,24 +44,34 @@ describe('buildSettingsSectionNavItems', () => {
return ids;
}
it('exposes desktop-only items only when isDesktop is true', () => {
const desktopItems = buildSettingsSectionNavItems(true);
const pwaItems = buildSettingsSectionNavItems(false);
it('exposes feature-specific items only when their runtime capabilities are supported', () => {
const supportedItems = buildSettingsSectionNavItems({
supportsEpg: true,
supportsRemoteControl: true,
});
const unsupportedItems = buildSettingsSectionNavItems({
supportsEpg: false,
supportsRemoteControl: false,
});
expect(desktopItems.map((item) => item.id)).toEqual(
expect(supportedItems.map((item) => item.id)).toEqual(
expect.arrayContaining(['epg', 'remote-control'])
);
expect(pwaItems.find((item) => item.id === 'epg')?.visible).toBe(
false
);
expect(
pwaItems.find((item) => item.id === 'remote-control')?.visible
unsupportedItems.find((item) => item.id === 'epg')?.visible
).toBe(false);
expect(
unsupportedItems.find((item) => item.id === 'remote-control')
?.visible
).toBe(false);
});
it('every nav id matches an existing section template id (regression: remote-control nav no longer maps to the Nx lib name)', () => {
const navIds = new Set(
buildSettingsSectionNavItems(true).map((item) => item.id)
buildSettingsSectionNavItems({
supportsEpg: true,
supportsRemoteControl: true,
}).map((item) => item.id)
);
const templateIds = collectSectionTemplateIds();
+11 -5
View File
@@ -85,9 +85,15 @@ export const SETTINGS_EMBEDDED_PLAYER_OPTIONS: SettingsPlayerOption[] = [
},
];
export function buildSettingsSectionNavItems(
isDesktop: boolean
): SettingsSection[] {
export interface SettingsSectionVisibility {
supportsEpg: boolean;
supportsRemoteControl: boolean;
}
export function buildSettingsSectionNavItems({
supportsEpg,
supportsRemoteControl,
}: SettingsSectionVisibility): SettingsSection[] {
return [
{
id: 'general',
@@ -105,7 +111,7 @@ export function buildSettingsSectionNavItems(
id: 'epg',
label: 'SETTINGS.NAV_EPG',
icon: 'calendar_month',
visible: isDesktop,
visible: supportsEpg,
},
{
// Must match the section's HTML id (`remote-control`) so the
@@ -116,7 +122,7 @@ export function buildSettingsSectionNavItems(
id: 'remote-control',
label: 'SETTINGS.NAV_REMOTE',
icon: 'smartphone',
visible: isDesktop,
visible: supportsRemoteControl,
},
{
id: 'backup',
@@ -69,7 +69,7 @@
</div>
</div>
@if (isDesktop() && isExternalPlayerSelected()) {
@if (supportsManagedExternalPlayers() && isExternalPlayerSelected()) {
<div
class="setting-item"
data-test-id="external-player-double-click-setting"
@@ -148,7 +148,7 @@
</div>
}
@if (isDesktop() && form().value.player === 'mpv') {
@if (supportsExternalPlayerPathSettings() && form().value.player === 'mpv') {
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}</h4>
@@ -220,7 +220,7 @@
</div>
}
@if (isDesktop() && form().value.player === 'vlc') {
@if (supportsExternalPlayerPathSettings() && form().value.player === 'vlc') {
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}</h4>
@@ -58,9 +58,10 @@ describe('SettingsPlaybackSectionComponent', () => {
fixture.componentRef.setInput('streamFormatEnum', StreamFormat);
});
it('hides the external-player double-click option outside desktop builds', () => {
it('hides the external-player double-click option when managed external players are unsupported', () => {
fixture.componentRef.setInput('form', createForm(VideoPlayer.MPV));
fixture.componentRef.setInput('isDesktop', false);
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsManagedExternalPlayers', false);
fixture.detectChanges();
expect(
@@ -75,6 +76,7 @@ describe('SettingsPlaybackSectionComponent', () => {
it('hides the external-player double-click option for embedded players', () => {
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.detectChanges();
expect(
@@ -85,10 +87,11 @@ describe('SettingsPlaybackSectionComponent', () => {
});
it.each([VideoPlayer.MPV, VideoPlayer.VLC])(
'labels the double-click option as external-player behavior on desktop for %s',
'labels the double-click option as external-player behavior when managed players are supported for %s',
(player) => {
fixture.componentRef.setInput('form', createForm(player));
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.detectChanges();
expect(
@@ -106,6 +109,7 @@ describe('SettingsPlaybackSectionComponent', () => {
const form = createForm();
fixture.componentRef.setInput('form', form);
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.detectChanges();
expect(
@@ -124,6 +128,28 @@ describe('SettingsPlaybackSectionComponent', () => {
).not.toBeNull();
});
it('keeps the double-click option visible when path settings are unavailable', () => {
fixture.componentRef.setInput('form', createForm(VideoPlayer.MPV));
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.componentRef.setInput('supportsExternalPlayerPathSettings', false);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="external-player-double-click-setting"]'
)
).not.toBeNull();
expect(fixture.nativeElement.textContent).not.toContain(
MPV_PATH_DESCRIPTION
);
expect(
fixture.nativeElement.querySelector(
'[data-test-id="mpv-compatible-player-tip"]'
)
).toBeNull();
});
it('shows the recording folder setting only in desktop builds', () => {
fixture.componentRef.setInput('isDesktop', true);
fixture.detectChanges();
@@ -147,6 +173,7 @@ describe('SettingsPlaybackSectionComponent', () => {
it('shows MPV bundle guidance and the IINA executable tip for desktop MPV playback', () => {
fixture.componentRef.setInput('form', createForm(VideoPlayer.MPV));
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsExternalPlayerPathSettings', true);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
@@ -162,9 +189,11 @@ describe('SettingsPlaybackSectionComponent', () => {
);
});
it('hides MPV path guidance and the IINA executable tip outside desktop builds', () => {
it('hides MPV path guidance and the IINA executable tip when path settings are unsupported', () => {
fixture.componentRef.setInput('form', createForm(VideoPlayer.MPV));
fixture.componentRef.setInput('isDesktop', false);
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.componentRef.setInput('supportsExternalPlayerPathSettings', false);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain(
@@ -183,6 +212,7 @@ describe('SettingsPlaybackSectionComponent', () => {
it('shows VLC bundle guidance without the IINA tip for desktop VLC playback', () => {
fixture.componentRef.setInput('form', createForm(VideoPlayer.VLC));
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsExternalPlayerPathSettings', true);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
@@ -198,9 +228,11 @@ describe('SettingsPlaybackSectionComponent', () => {
);
});
it('hides VLC path guidance outside desktop builds', () => {
it('hides VLC path guidance when path settings are unsupported', () => {
fixture.componentRef.setInput('form', createForm(VideoPlayer.VLC));
fixture.componentRef.setInput('isDesktop', false);
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.componentRef.setInput('supportsExternalPlayerPathSettings', false);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain(
@@ -215,6 +247,7 @@ describe('SettingsPlaybackSectionComponent', () => {
it('does not show external-player path guidance for embedded players', () => {
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsExternalPlayerPathSettings', true);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain(
@@ -233,6 +266,7 @@ describe('SettingsPlaybackSectionComponent', () => {
it('shows MPV command-line arguments only when MPV is selected', () => {
fixture.componentRef.setInput('form', createForm(VideoPlayer.MPV));
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsExternalPlayerPathSettings', true);
fixture.detectChanges();
expect(
@@ -258,6 +292,7 @@ describe('SettingsPlaybackSectionComponent', () => {
it('shows VLC command-line arguments only when VLC is selected', () => {
fixture.componentRef.setInput('form', createForm(VideoPlayer.VLC));
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('supportsExternalPlayerPathSettings', true);
fixture.detectChanges();
expect(
@@ -45,6 +45,8 @@ export class SettingsPlaybackSectionComponent {
readonly players = input.required<SettingsPlayerOption[]>();
readonly streamFormatEnum = input.required<typeof StreamFormat>();
readonly isDesktop = input(false);
readonly supportsManagedExternalPlayers = input(false);
readonly supportsExternalPlayerPathSettings = input(false);
readonly selectRecordingFolder = output<void>();
isExternalPlayerSelected(): boolean {
@@ -48,10 +48,12 @@
[players]="players()"
[streamFormatEnum]="streamFormatEnum"
[isDesktop]="isDesktop"
[supportsManagedExternalPlayers]="supportsManagedExternalPlayers"
[supportsExternalPlayerPathSettings]="supportsExternalPlayerPathSettings"
(selectRecordingFolder)="selectRecordingFolder()"
/>
@if (isDesktop) {
@if (supportsEpg) {
<app-settings-epg-section
[form]="settingsForm"
[activeSection]="activeSection()"
@@ -63,7 +65,9 @@
(refreshAllEpg)="refreshAllEpg()"
(clearEpgData)="clearEpgData()"
/>
}
@if (supportsRemoteControl) {
<app-settings-remote-control-section
[form]="settingsForm"
[activeSection]="activeSection()"
@@ -250,10 +250,16 @@ describe('SettingsComponent', () => {
staleUrls: [],
}),
clearEpgData: jest.fn().mockResolvedValue({ success: true }),
fetchEpg: jest.fn().mockResolvedValue({ success: true }),
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
getAppVersion: jest.fn().mockResolvedValue('1.0.0'),
getChannelPrograms: jest.fn().mockResolvedValue([]),
getEpgChannelsByRange: jest.fn().mockResolvedValue([]),
getLocalIpAddresses: jest.fn().mockResolvedValue([]),
openInMpv: jest.fn(),
openInVlc: jest.fn(),
platform: 'linux',
searchEpgPrograms: jest.fn().mockResolvedValue([]),
saveFileDialog: jest.fn().mockResolvedValue('/tmp/backup.json'),
setMpvPlayerPath: jest.fn().mockResolvedValue(undefined),
setVlcPlayerPath: jest.fn().mockResolvedValue(undefined),
@@ -472,8 +478,100 @@ describe('SettingsComponent', () => {
).toBe(true);
});
it('hides external player path settings when the Electron bridge is incomplete', async () => {
fixture.destroy();
window.electron = {
getAppVersion: jest.fn().mockResolvedValue('1.0.0'),
platform: 'linux',
updateSettings: jest.fn().mockResolvedValue(undefined),
} as unknown as typeof window.electron;
const partialBridgeFixture =
TestBed.createComponent(SettingsComponent);
const partialBridgeComponent =
partialBridgeFixture.componentInstance;
partialBridgeComponent.checkAppVersion = jest.fn();
partialBridgeComponent.fetchLocalIpAddresses = jest
.fn()
.mockResolvedValue(undefined);
partialBridgeFixture.detectChanges();
expect(partialBridgeComponent.isDesktop).toBe(true);
expect(
partialBridgeComponent.supportsManagedExternalPlayers
).toBe(false);
expect(
partialBridgeComponent.supportsExternalPlayerPathSettings
).toBe(false);
expect(partialBridgeComponent.supportsEpg).toBe(false);
expect(partialBridgeComponent.supportsRemoteControl).toBe(false);
expect(
partialBridgeComponent.settingsForm.get('epgUrl')
).toBeNull();
expect(
partialBridgeComponent.sectionNav.find(
(section) => section.id === 'epg'
)
).toBeUndefined();
expect(
partialBridgeComponent.sectionNav.find(
(section) => section.id === 'remote-control'
)
).toBeUndefined();
expect(
partialBridgeComponent
.players()
.some((player) => player.id === VideoPlayer.MPV)
).toBe(false);
expect(
partialBridgeComponent
.players()
.some((player) => player.id === VideoPlayer.VLC)
).toBe(false);
});
it('keeps external player choices when launch support exists without path settings', () => {
fixture.destroy();
window.electron = {
getAppVersion: jest.fn().mockResolvedValue('1.0.0'),
openInMpv: jest.fn(),
openInVlc: jest.fn(),
platform: 'linux',
updateSettings: jest.fn().mockResolvedValue(undefined),
} as unknown as typeof window.electron;
const launchOnlyBridgeFixture =
TestBed.createComponent(SettingsComponent);
const launchOnlyBridgeComponent =
launchOnlyBridgeFixture.componentInstance;
launchOnlyBridgeComponent.checkAppVersion = jest.fn();
launchOnlyBridgeComponent.fetchLocalIpAddresses = jest
.fn()
.mockResolvedValue(undefined);
launchOnlyBridgeFixture.detectChanges();
expect(
launchOnlyBridgeComponent.supportsManagedExternalPlayers
).toBe(true);
expect(
launchOnlyBridgeComponent.supportsExternalPlayerPathSettings
).toBe(false);
expect(
launchOnlyBridgeComponent
.players()
.some((player) => player.id === VideoPlayer.MPV)
).toBe(true);
expect(
launchOnlyBridgeComponent
.players()
.some((player) => player.id === VideoPlayer.VLC)
).toBe(true);
});
it('does not block settings initialization while embedded mpv support is pending', async () => {
let resolveSupport: (value: EmbeddedMpvSupport) => void;
let resolveSupport:
| ((value: EmbeddedMpvSupport) => void)
| undefined;
window.electron = {
...window.electron,
getEmbeddedMpvSupport: jest.fn(
@@ -495,8 +593,11 @@ describe('SettingsComponent', () => {
).toBe(false);
if (!resolveSupport) {
throw new Error('Expected embedded MPV support resolver');
throw new Error(
'Expected embedded MPV support probe to start'
);
}
resolveSupport({
supported: true,
platform: 'darwin',
@@ -705,6 +806,12 @@ describe('SettingsComponent', () => {
.mockResolvedValue(undefined);
browserFixture.detectChanges();
expect(browserComponent.isDesktop).toBe(false);
expect(browserComponent.isPwa).toBe(true);
expect(browserComponent.supportsEpg).toBe(false);
expect(browserComponent.supportsRemoteControl).toBe(false);
expect(browserComponent.settingsForm.get('epgUrl')).toBeNull();
mockStore.overrideSelector(selectAllPlaylistsMeta, [
createPlaylistMeta({ _id: 'browser-m3u' }),
]);
@@ -845,6 +952,67 @@ describe('SettingsComponent', () => {
expect(component.isExportingData()).toBe(false);
});
it('falls back to browser backup download when desktop file-save preload is incomplete', async () => {
fixture.destroy();
const saveFileDialog = jest.fn().mockResolvedValue('/tmp/backup.json');
window.electron = {
platform: 'linux',
saveFileDialog,
} as unknown as typeof window.electron;
const createObjectURL = jest.fn().mockReturnValue('blob:backup');
const revokeObjectURL = jest.fn();
const originalCreateObjectURL = window.URL.createObjectURL;
const originalRevokeObjectURL = window.URL.revokeObjectURL;
Object.defineProperty(window.URL, 'createObjectURL', {
configurable: true,
value: createObjectURL,
});
Object.defineProperty(window.URL, 'revokeObjectURL', {
configurable: true,
value: revokeObjectURL,
});
const clickSpy = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation();
let partialFileSaveFixture: ComponentFixture<SettingsComponent> | null =
null;
try {
partialFileSaveFixture = TestBed.createComponent(SettingsComponent);
const partialFileSaveComponent =
partialFileSaveFixture.componentInstance;
partialFileSaveComponent.checkAppVersion = jest.fn();
partialFileSaveComponent.fetchLocalIpAddresses = jest
.fn()
.mockResolvedValue(undefined);
partialFileSaveFixture.detectChanges();
expect(partialFileSaveComponent.isDesktop).toBe(true);
expect(partialFileSaveComponent.supportsDesktopFileSave).toBe(
false
);
await partialFileSaveComponent.exportData();
expect(saveFileDialog).not.toHaveBeenCalled();
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
expect(clickSpy).toHaveBeenCalled();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:backup');
} finally {
partialFileSaveFixture?.destroy();
clickSpy.mockRestore();
Object.defineProperty(window.URL, 'createObjectURL', {
configurable: true,
value: originalCreateObjectURL,
});
Object.defineProperty(window.URL, 'revokeObjectURL', {
configurable: true,
value: originalRevokeObjectURL,
});
}
});
it('shows a failure snackbar and skips refresh when clearing EPG data rejects', async () => {
(window.electron.clearEpgData as jest.Mock).mockRejectedValueOnce(
new Error('boom')
+38 -15
View File
@@ -43,6 +43,7 @@ import {
PlaylistBackupImportSummary,
PlaylistBackupService,
PlaylistsService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import {
EmbeddedMpvSupport,
@@ -121,6 +122,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
private matDialog = inject(MatDialog);
private playlistBackupService = inject(PlaylistBackupService);
private readonly databaseService = inject(DatabaseService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly dialogData = inject<{ isDialog: boolean } | null>(
MAT_DIALOG_DATA,
{ optional: true }
@@ -134,13 +136,20 @@ export class SettingsComponent implements OnInit, OnDestroy {
readonly streamFormatEnum = StreamFormat;
/** Flag that indicates whether the app runs in electron environment */
readonly isDesktop = !!window.electron;
readonly isDesktop = this.runtime.isElectron;
readonly supportsDesktopFileSave = this.runtime.supportsDesktopFileSave;
readonly supportsEpg = this.runtime.supportsEpg;
readonly supportsManagedExternalPlayers =
this.runtime.supportsManagedExternalPlayers;
readonly supportsExternalPlayerPathSettings =
this.runtime.supportsExternalPlayerPathSettings;
readonly supportsRemoteControl = this.runtime.supportsRemoteControl;
readonly embeddedMpvSupport = signal<EmbeddedMpvSupport | null>(null);
readonly supportsEmbeddedMpv = computed(
() => this.isDesktop && !!this.embeddedMpvSupport()?.supported
);
isPwa = this.dataService.getAppEnvironment() === 'pwa';
readonly isPwa = this.runtime.isPwa;
private readonly settingsCtx = inject(SettingsContextService);
readonly activeSection = this.settingsCtx.activeSection;
@@ -154,13 +163,15 @@ export class SettingsComponent implements OnInit, OnDestroy {
},
]
: []),
...SETTINGS_OS_PLAYER_OPTIONS,
...(this.supportsManagedExternalPlayers
? SETTINGS_OS_PLAYER_OPTIONS
: []),
]);
/** Player options */
readonly players = computed(() => [
...SETTINGS_EMBEDDED_PLAYER_OPTIONS,
...(this.isDesktop ? this.osPlayers() : []),
...this.osPlayers(),
]);
/** Current version of the app */
@@ -180,7 +191,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
/** Settings form object */
settingsForm = this.formBuilder.group({
player: [VideoPlayer.VideoJs],
...(this.isDesktop
...(this.supportsEpg
? { epgUrl: new FormArray<FormControl<string | null>>([]) }
: {}),
streamFormat: StreamFormat.M3u8StreamFormat,
@@ -209,7 +220,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
],
recordingFolder: '',
coverSize: 'medium' as CoverSize,
...(this.isDesktop ? { preferUploadedEpgOverXtream: false } : {}),
...(this.supportsEpg
? { preferUploadedEpgOverXtream: false }
: {}),
});
/** Form array with epg sources */
@@ -227,7 +240,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
private settingsStore = inject(SettingsStore);
readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems(
this.isDesktop
{
supportsEpg: this.supportsEpg,
supportsRemoteControl: this.supportsRemoteControl,
}
);
readonly playlistDeleteSummary = computed<SettingsPlaylistDeleteSummary>(
@@ -335,7 +351,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
* Fetches local IP addresses for remote control URL display
*/
async fetchLocalIpAddresses(): Promise<void> {
if (window.electron?.getLocalIpAddresses) {
if (
this.supportsRemoteControl &&
window.electron?.getLocalIpAddresses
) {
const addresses = await window.electron.getLocalIpAddresses();
this.localIpAddresses.set(addresses);
}
@@ -359,7 +378,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
const currentSettings = this.settingsStore.getSettings();
this.settingsForm.patchValue(currentSettings);
if (this.isDesktop && currentSettings.epgUrl) {
if (this.supportsEpg && currentSettings.epgUrl) {
this.epgUrl.clear();
this.setEpgUrls(currentSettings.epgUrl);
}
@@ -479,7 +498,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
if (window.electron) {
window.electron.updateSettings(settings);
}
if (this.supportsExternalPlayerPathSettings && window.electron) {
window.electron.setMpvPlayerPath(settings.mpvPlayerPath);
window.electron.setVlcPlayerPath(settings.vlcPlayerPath);
}
@@ -545,7 +566,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
*/
applyChangedSettings(): void {
this.settingsForm.markAsPristine();
if (this.isDesktop) {
if (this.supportsEpg) {
let epgUrls = this.settingsForm.value.epgUrl;
if (epgUrls) {
if (!Array.isArray(epgUrls)) {
@@ -590,7 +611,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
* intends when clicking "Refresh".
*/
refreshEpg(url: string): void {
if (!url || !window.electron?.forceFetchEpg) return;
if (!this.supportsEpg || !url) {
return;
}
void window.electron.forceFetchEpg(url);
}
@@ -600,11 +623,11 @@ export class SettingsComponent implements OnInit, OnDestroy {
* gets visible per-URL feedback.
*/
refreshAllEpg(): void {
if (!window.electron?.forceFetchEpg) return;
if (!this.supportsEpg) return;
const urls = (this.epgUrl.value as string[])
.map((url) => url?.trim())
.filter((url): url is string => Boolean(url));
urls.forEach((url) => window.electron.forceFetchEpg(url));
urls.forEach((url) => void window.electron.forceFetchEpg(url));
}
/**
@@ -637,7 +660,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
),
onConfirm: async (): Promise<void> => {
if (
!window.electron?.clearEpgData ||
!this.supportsEpg ||
this.isClearingEpgData()
) {
return;
@@ -679,7 +702,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
try {
const backup = await this.playlistBackupService.exportBackup();
if (this.isDesktop && window.electron?.saveFileDialog) {
if (this.supportsDesktopFileSave && window.electron) {
const savePath = await window.electron.saveFileDialog(
backup.defaultFileName,
[
+23 -4
View File
@@ -68,8 +68,23 @@ Renderer code that needs to branch by runtime should use
`RuntimeCapabilitiesService` from `@iptvnator/services` instead of adding new
direct `window.electron` or `DataService.getAppEnvironment()` checks. Keep
feature decisions expressed as capabilities such as `supportsEpg`,
`supportsSqlite`, `supportsDownloads`, or `supportsManagedExternalPlayers` so
PWA and Electron behavior stays auditable from one shared boundary.
`supportsSqlite`, `supportsXtreamSqliteDataSource`, `supportsDownloads`, or
`supportsManagedExternalPlayers` so PWA and Electron behavior stays auditable
from one shared boundary. `supportsSqlite` requires the complete playlist
storage preload API surface used by `PlaylistsService`, `supportsDownloads`
requires the complete downloads preload API surface used by `DownloadsService`,
`supportsEpg` requires the Electron EPG preload methods used by the shared EPG
panels (`fetchEpg`, `getChannelPrograms`, `checkEpgFreshness`,
`forceFetchEpg`, `clearEpgData`, `getEpgChannelsByRange`, and
`searchEpgPrograms`), `supportsPlaylistRefresh` requires the native playlist
refresh/cancel/progress bridge, `supportsXtreamSectionNavigation` is available
in PWA and in Electron when either the SQLite Xtream data source or the Xtream
API transport is available, `supportsDesktopFileSave` requires both
`saveFileDialog` and `writeFile`, and
`supportsManagedExternalPlayers` requires the MPV and VLC preload launch and
path-setting methods (`openInMpv`, `openInVlc`, `setMpvPlayerPath`, and
`setVlcPlayerPath`); a partial Electron bridge must not expose desktop-only
actions in the PWA/shared UI.
## Runtime Limitations
@@ -109,8 +124,12 @@ certificate authorities, configure Node with `NODE_EXTRA_CA_CERTS`.
## PWA Portal User Data
Xtream favorites and recently viewed items use the browser-side
`PwaXtreamDataSource` when Electron DB preload APIs are unavailable. The PWA
stores this user activity and sidecar state in localStorage:
`PwaXtreamDataSource` when Electron DB preload APIs are unavailable. The
`XTREAM_DATA_SOURCE` provider chooses the Electron SQLite-backed source only
when `RuntimeCapabilitiesService.supportsXtreamSqliteDataSource` is true; a
browser PWA or partial preload bridge must fall back to the PWA source and run
the browser cleanup hook on playlist deletion. The PWA stores this user activity
and sidecar state in localStorage:
- `xtream-collection-items`
- `xtream-favorites`
+7
View File
@@ -97,6 +97,13 @@ Type definitions:
## Renderer Integrations
All renderer integrations resolve the Electron remote-control bridge through
`RuntimeCapabilitiesService.supportsRemoteControl`. A partial Electron bridge
is treated as unsupported unless it exposes all remote-control methods:
`updateRemoteControlStatus`, `onChannelChange`, and
`onRemoteControlCommand`. This keeps PWA/self-hosted builds and partial test
bridges from accidentally activating desktop-only remote-control behavior.
## Shared helpers
- File: `libs/portal/shared/util/src/lib/remote-channel-navigation.ts`
@@ -0,0 +1,101 @@
import { TestBed } from '@angular/core/testing';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
EpgImportProgress,
EpgProgressService,
} from './epg-progress.service';
describe('EpgProgressService', () => {
let runtimeCapabilities: { supportsEpg: boolean };
const originalElectron = window.electron;
beforeEach(() => {
runtimeCapabilities = { supportsEpg: false };
});
afterEach(() => {
window.electron = originalElectron;
TestBed.resetTestingModule();
jest.restoreAllMocks();
});
function configureService(): EpgProgressService {
TestBed.configureTestingModule({
providers: [
EpgProgressService,
{
provide: RuntimeCapabilitiesService,
useValue: runtimeCapabilities,
},
],
});
return TestBed.inject(EpgProgressService);
}
it('does not subscribe to Electron progress events when runtime EPG support is disabled', () => {
const onEpgProgress = jest.fn();
window.electron = {
...window.electron,
onEpgProgress,
} as unknown as typeof window.electron;
configureService();
expect(onEpgProgress).not.toHaveBeenCalled();
});
it('does not force retry when runtime EPG support is disabled', () => {
const forceFetchEpg = jest.fn();
window.electron = {
...window.electron,
forceFetchEpg,
} as unknown as typeof window.electron;
const service = configureService();
service.retry('https://example.com/epg.xml');
expect(forceFetchEpg).not.toHaveBeenCalled();
});
it('forces retry through the Electron bridge when runtime EPG support is enabled', () => {
const forceFetchEpg = jest.fn();
window.electron = {
...window.electron,
forceFetchEpg,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpg = true;
const service = configureService();
service.retry('https://example.com/epg.xml');
expect(forceFetchEpg).toHaveBeenCalledWith(
'https://example.com/epg.xml'
);
});
it('updates imports from Electron progress events when runtime EPG support is enabled', () => {
let listener: ((progress: EpgImportProgress) => void) | undefined;
window.electron = {
...window.electron,
onEpgProgress: jest.fn((callback) => {
listener = callback;
}),
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpg = true;
const service = configureService();
listener?.({
url: 'https://example.com/epg.xml',
status: 'loading',
});
expect(window.electron?.onEpgProgress).toHaveBeenCalledTimes(1);
expect(service.imports()).toEqual([
{
url: 'https://example.com/epg.xml',
status: 'loading',
},
]);
});
});
@@ -1,4 +1,5 @@
import { Injectable, computed, signal } from '@angular/core';
import { Injectable, computed, inject, signal } from '@angular/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
export interface EpgImportProgress {
url: string;
@@ -10,6 +11,7 @@ export interface EpgImportProgress {
@Injectable({ providedIn: 'root' })
export class EpgProgressService {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly importsMap = signal<Map<string, EpgImportProgress>>(
new Map()
);
@@ -48,7 +50,10 @@ export class EpgProgressService {
// Clear the errored row so the backend's subsequent 'queued' event
// reappears cleanly rather than updating an existing error row.
this.removeImport(url);
void window.electron?.forceFetchEpg?.(url);
if (!this.runtime.supportsEpg) {
return;
}
void window.electron.forceFetchEpg(url);
}
private initializeListener(): void {
@@ -57,7 +62,7 @@ export class EpgProgressService {
}
this.initialized = true;
if (window.electron?.onEpgProgress) {
if (this.runtime.supportsEpg && window.electron?.onEpgProgress) {
window.electron.onEpgProgress((data) => {
this.updateProgress(data);
});
@@ -2,15 +2,24 @@ import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { firstValueFrom } from 'rxjs';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { EpgService } from './epg.service';
describe('EpgService', () => {
let service: EpgService;
let runtimeCapabilities: { supportsEpg: boolean };
const originalElectron = window.electron;
beforeEach(() => {
runtimeCapabilities = { supportsEpg: false };
TestBed.configureTestingModule({
providers: [
EpgService,
{
provide: RuntimeCapabilitiesService,
useValue: runtimeCapabilities,
},
{
provide: MatSnackBar,
useValue: {
@@ -29,6 +38,42 @@ describe('EpgService', () => {
service = TestBed.inject(EpgService);
});
afterEach(() => {
window.electron = originalElectron;
});
it('does not fetch EPG when runtime EPG support is disabled', () => {
const fetchEpg = jest.fn();
window.electron = {
...window.electron,
fetchEpg,
} as unknown as typeof window.electron;
service.fetchEpg(['https://example.com/epg.xml']);
expect(fetchEpg).not.toHaveBeenCalled();
});
it('fetches EPG through the Electron bridge when runtime EPG support is enabled', () => {
const fetchEpg = jest.fn().mockResolvedValue({ success: true });
window.electron = {
...window.electron,
fetchEpg,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpg = true;
service.fetchEpg([
'https://example.com/epg.xml',
'',
'https://example.com/other.xml',
]);
expect(fetchEpg).toHaveBeenCalledWith([
'https://example.com/epg.xml',
'https://example.com/other.xml',
]);
});
it('returns an empty batch result when the desktop bridge is unavailable', async () => {
const result = await firstValueFrom(
service.getCurrentProgramsForChannels(['channel-1'])
+7 -7
View File
@@ -8,6 +8,7 @@ import {
EpgChannelMetadata,
EpgProgram,
} from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { normalizeEpgPrograms } from './epg-program-normalization.util';
interface CachedProgram {
@@ -29,6 +30,7 @@ const debugEpgService = createDevLogger('EpgService');
export class EpgService {
private snackBar = inject(MatSnackBar);
private translate = inject(TranslateService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private epgAvailable = new BehaviorSubject<boolean>(false);
private currentEpgPrograms = new BehaviorSubject<EpgProgram[]>([]);
@@ -37,8 +39,6 @@ export class EpgService {
private programCache = new Map<string, CachedProgram>();
private readonly CACHE_TTL = 60000; // 60 seconds
private readonly isDesktop = !!window.electron;
readonly epgAvailable$ = this.epgAvailable.asObservable();
readonly currentEpgPrograms$ = this.currentEpgPrograms.asObservable();
@@ -46,7 +46,7 @@ export class EpgService {
* Fetches EPG from the given URLs
*/
fetchEpg(urls: string[]): void {
if (!this.isDesktop) return;
if (!this.runtime.supportsEpg) return;
// Filter out empty URLs and send all URLs at once
const validUrls = urls.filter((url) => url?.trim());
@@ -76,7 +76,7 @@ export class EpgService {
* Gets EPG programs for a specific channel
*/
getChannelPrograms(channelId: string): void {
if (!this.isDesktop) return;
if (!this.runtime.supportsEpg) return;
debugEpgService('Fetching EPG for channel ID:', channelId);
from(window.electron.getChannelPrograms(channelId))
@@ -116,7 +116,7 @@ export class EpgService {
getCurrentProgramForChannel(
channelId: string
): Observable<EpgProgram | null> {
if (!this.isDesktop || !channelId) {
if (!this.runtime.supportsEpg || !channelId) {
return of(null);
}
@@ -184,7 +184,7 @@ export class EpgService {
getCurrentProgramsForChannels(
channelIds: string[]
): Observable<Map<string, EpgProgram | null>> {
if (!this.isDesktop) {
if (!this.runtime.supportsEpg) {
return of(new Map());
}
@@ -263,7 +263,7 @@ export class EpgService {
getChannelMetadataForChannels(
channelIds: string[]
): Observable<Map<string, EpgChannelMetadata | null>> {
if (!this.isDesktop) {
if (!this.runtime.supportsEpg) {
return of(new Map());
}
@@ -286,6 +286,9 @@ describe('VideoPlayerComponent', () => {
beforeEach(async () => {
window.electron = {
platform: 'darwin',
updateRemoteControlStatus: jest.fn(),
onChannelChange: jest.fn(() => jest.fn()),
onRemoteControlCommand: jest.fn(() => jest.fn()),
} as typeof window.electron;
syncStoreState(null);
@@ -464,6 +467,21 @@ describe('VideoPlayerComponent', () => {
expect(headerContext.action()).toBeNull();
});
it('does not publish remote-control status when the bridge is incomplete', () => {
fixture.destroy();
const updateRemoteControlStatus = jest.fn();
window.electron = {
updateRemoteControlStatus,
} as typeof window.electron;
fixture = TestBed.createComponent(VideoPlayerComponent);
component = fixture.componentInstance;
syncStoreState(sampleChannel);
fixture.detectChanges();
expect(updateRemoteControlStatus).not.toHaveBeenCalled();
});
it('opens MPV fallback with the active channel headers preserved', () => {
syncStoreState({
...sampleChannel,
@@ -383,8 +383,9 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
this.registerHeaderShortcut();
// Setup remote control channel change listener (Electron only)
if (this.isDesktop && window.electron?.onChannelChange) {
const unsubscribe = window.electron.onChannelChange(
const remoteControl = this.remoteControlBridge;
if (remoteControl?.onChannelChange) {
const unsubscribe = remoteControl.onChannelChange(
(data: { direction: 'up' | 'down' }) => {
this.handleRemoteChannelChange(data.direction);
}
@@ -393,8 +394,8 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
this.unsubscribeRemoteChannelChange = unsubscribe;
}
}
if (this.isDesktop && window.electron?.onRemoteControlCommand) {
const unsubscribe = window.electron.onRemoteControlCommand(
if (remoteControl?.onRemoteControlCommand) {
const unsubscribe = remoteControl.onRemoteControlCommand(
(command) => {
this.handleRemoteControlCommand(command);
}
@@ -409,7 +410,8 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
this.store.select(selectActive),
this.store.select(selectCurrentEpgProgram).pipe(startWith(null)),
]).subscribe(([channels, activeChannel, epgProgram]) => {
if (!window.electron?.updateRemoteControlStatus || !activeChannel) {
const remoteControl = this.remoteControlBridge;
if (!remoteControl?.updateRemoteControlStatus || !activeChannel) {
return;
}
@@ -421,7 +423,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
(channel) => channel.url === activeChannel.url
);
window.electron.updateRemoteControlStatus({
remoteControl.updateRemoteControlStatus({
portal: 'm3u',
isLiveView: true,
channelName: activeChannel.name ?? activeChannel.tvg?.name,
@@ -805,8 +807,9 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
}
localStorage.setItem('volume', String(clamped));
if (window.electron?.updateRemoteControlStatus) {
window.electron.updateRemoteControlStatus({
const remoteControl = this.remoteControlBridge;
if (remoteControl?.updateRemoteControlStatus) {
remoteControl.updateRemoteControlStatus({
portal: 'm3u',
isLiveView: true,
supportsVolume: true,
@@ -816,6 +819,10 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
}
}
private get remoteControlBridge(): Window['electron'] | undefined {
return this.runtime.supportsRemoteControl ? window.electron : undefined;
}
shouldShowInlinePlayer(channel: Channel | null | undefined): boolean {
if (!channel) {
return false;
@@ -6,6 +6,7 @@ import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { DialogService } from '@iptvnator/ui/components';
import {
DataService,
DatabaseService,
DbOperationEvent,
PlaybackPositionService,
@@ -13,7 +14,11 @@ import {
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state';
import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
PLAYLIST_UPDATE,
Playlist,
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PlaylistRefreshActionService } from './playlist-refresh-action.service';
@@ -58,6 +63,9 @@ describe('PlaylistRefreshActionService', () => {
deleteXtreamPlaylistContent: jest.Mock;
updateXtreamPlaylistDetails: jest.Mock;
};
let dataService: {
sendIpcEvent: jest.Mock;
};
let dialogService: {
openConfirmDialog: jest.Mock;
};
@@ -77,7 +85,8 @@ describe('PlaylistRefreshActionService', () => {
getAllPlaybackPositions: jest.Mock;
};
let runtime: {
isElectron: boolean;
supportsPlaylistRefresh: boolean;
supportsXtreamSqliteDataSource: boolean;
};
let routeProvider: ReturnType<
typeof signal<'playlists' | 'xtreams' | null>
@@ -112,6 +121,9 @@ describe('PlaylistRefreshActionService', () => {
}),
updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(true),
};
dataService = {
sendIpcEvent: jest.fn(),
};
dialogService = {
openConfirmDialog: jest.fn(),
};
@@ -131,7 +143,8 @@ describe('PlaylistRefreshActionService', () => {
getAllPlaybackPositions: jest.fn().mockResolvedValue([]),
};
runtime = {
isElectron: true,
supportsPlaylistRefresh: true,
supportsXtreamSqliteDataSource: true,
};
routeProvider = signal<'playlists' | 'xtreams' | null>('xtreams');
resolvedPlaylistId = signal<string | null>(null);
@@ -165,6 +178,10 @@ describe('PlaylistRefreshActionService', () => {
provide: DatabaseService,
useValue: databaseService,
},
{
provide: DataService,
useValue: dataService,
},
{
provide: PlaylistRefreshService,
useValue: playlistRefreshService,
@@ -195,8 +212,8 @@ describe('PlaylistRefreshActionService', () => {
localStorage.clear();
});
it('treats file-backed M3U playlists as refreshable in Electron', () => {
runtime.isElectron = true;
it('treats file-backed M3U playlists as refreshable when the refresh bridge is available', () => {
runtime.supportsPlaylistRefresh = true;
expect(
service.canRefresh(
@@ -210,8 +227,8 @@ describe('PlaylistRefreshActionService', () => {
).toBe(true);
});
it('does not expose filesystem refresh outside Electron', () => {
runtime.isElectron = false;
it('does not expose file-backed M3U refresh without the refresh bridge', () => {
runtime.supportsPlaylistRefresh = false;
expect(
service.canRefresh(
@@ -225,6 +242,55 @@ describe('PlaylistRefreshActionService', () => {
).toBe(false);
});
it('treats URL-backed M3U playlists as refreshable without the refresh bridge', () => {
runtime.supportsPlaylistRefresh = false;
expect(
service.canRefresh(
createPlaylistMeta({
serverUrl: undefined,
username: undefined,
password: undefined,
url: 'https://example.com/playlist.m3u',
})
)
).toBe(true);
});
it('uses the browser URL refresh path when the refresh bridge is unavailable', () => {
runtime.supportsPlaylistRefresh = false;
const playlist = createPlaylistMeta({
_id: 'playlist-url',
title: 'URL playlist',
serverUrl: undefined,
username: undefined,
password: undefined,
url: 'https://example.com/playlist.m3u',
});
service.refresh(playlist);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
PLAYLIST_UPDATE,
{
id: 'playlist-url',
title: 'URL playlist',
url: 'https://example.com/playlist.m3u',
}
);
expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled();
});
it('treats Xtream playlists as refreshable only when the SQLite data source is available', () => {
runtime.supportsXtreamSqliteDataSource = true;
expect(service.canRefresh(createPlaylistMeta())).toBe(true);
runtime.supportsXtreamSqliteDataSource = false;
expect(service.canRefresh(createPlaylistMeta())).toBe(false);
});
it('stores Xtream restore data before updating playlist meta and navigating', async () => {
const item = createPlaylistMeta();
const executionOrder: string[] = [];
@@ -5,6 +5,7 @@ import { TranslateService } from '@ngx-translate/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { DialogService } from '@iptvnator/ui/components';
import {
DataService,
DatabaseService,
type DbOperationEvent,
isDbAbortError,
@@ -14,7 +15,7 @@ import {
XtreamPendingRestoreService,
} from '@iptvnator/services';
import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { PLAYLIST_UPDATE, PlaylistMeta } from '@iptvnator/shared/interfaces';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
export interface XtreamRefreshPreparationState {
@@ -33,6 +34,7 @@ export class PlaylistRefreshActionService {
private readonly snackBar = inject(MatSnackBar);
private readonly dialogService = inject(DialogService);
private readonly databaseService = inject(DatabaseService);
private readonly dataService = inject(DataService);
private readonly playbackPositionService = inject(PlaybackPositionService);
private readonly playlistRefreshService = inject(PlaylistRefreshService);
private readonly runtime = inject(RuntimeCapabilitiesService);
@@ -48,11 +50,19 @@ export class PlaylistRefreshActionService {
readonly refreshPreparation = this.refreshPreparationState.asReadonly();
canRefresh(playlist: PlaylistMeta | null): boolean {
if (!playlist || !this.runtime.isElectron) {
if (!playlist) {
return false;
}
return Boolean(playlist.serverUrl || playlist.url || playlist.filePath);
if (playlist.serverUrl) {
return this.runtime.supportsXtreamSqliteDataSource;
}
if (playlist.url) {
return true;
}
return this.runtime.supportsPlaylistRefresh && Boolean(playlist.filePath);
}
refresh(playlist: PlaylistMeta): void {
@@ -60,10 +70,19 @@ export class PlaylistRefreshActionService {
return;
}
if (playlist.serverUrl) {
if (playlist.serverUrl && this.runtime.supportsXtreamSqliteDataSource) {
this.refreshXtream(playlist);
} else if (playlist.url || playlist.filePath) {
} else if (
this.runtime.supportsPlaylistRefresh &&
(playlist.url || playlist.filePath)
) {
void this.refreshM3u(playlist);
} else if (playlist.url) {
this.dataService.sendIpcEvent(PLAYLIST_UPDATE, {
id: playlist._id,
title: playlist.title,
url: playlist.url,
});
}
}
@@ -167,7 +167,7 @@
<mat-icon>close</mat-icon>
</button>
}
@if (item.url || item.filePath) {
@if (item.url || (item.filePath && supportsPlaylistRefresh)) {
<button
mat-icon-button
class="refresh-btn"
@@ -188,7 +188,7 @@
}
</button>
}
@if (item.serverUrl && isElectron) {
@if (item.serverUrl && supportsXtreamSqliteDataSource) {
<button
mat-icon-button
class="refresh-btn"
@@ -14,12 +14,14 @@ describe('PlaylistItemComponent', () => {
let component: PlaylistItemComponent;
let fixture: ComponentFixture<PlaylistItemComponent>;
let runtime: {
isElectron: boolean;
supportsPlaylistRefresh: boolean;
supportsXtreamSqliteDataSource: boolean;
};
beforeEach(waitForAsync(() => {
runtime = {
isElectron: true,
supportsPlaylistRefresh: true,
supportsXtreamSqliteDataSource: true,
};
TestBed.configureTestingModule({
@@ -91,6 +93,7 @@ describe('PlaylistItemComponent', () => {
it('renders a refresh action for file-backed M3U playlists', () => {
fixture.destroy();
runtime.supportsPlaylistRefresh = true;
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
@@ -108,9 +111,53 @@ describe('PlaylistItemComponent', () => {
expect(nativeElement.querySelector('.refresh-btn')).not.toBeNull();
});
it('renders the Xtream refresh action only when Electron capabilities are available', () => {
it('hides file-backed M3U refresh without the refresh bridge', () => {
fixture.destroy();
runtime.isElectron = true;
runtime.supportsPlaylistRefresh = false;
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
title: 'Local Source',
_id: 'local-source',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
filePath: '/tmp/local-source.m3u',
};
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector(
'.refresh-btn'
)
).toBeNull();
});
it('keeps URL-backed M3U refresh visible without the refresh bridge', () => {
fixture.destroy();
runtime.supportsPlaylistRefresh = false;
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
title: 'Remote Source',
_id: 'remote-source',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
url: 'https://example.com/playlist.m3u',
};
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector(
'.refresh-btn'
)
).not.toBeNull();
});
it('renders the Xtream refresh action only when the SQLite data source is available', () => {
fixture.destroy();
runtime.supportsXtreamSqliteDataSource = true;
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
@@ -132,7 +179,7 @@ describe('PlaylistItemComponent', () => {
).not.toBeNull();
fixture.destroy();
runtime.isElectron = false;
runtime.supportsXtreamSqliteDataSource = false;
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
@@ -67,7 +67,9 @@ export class PlaylistItemComponent implements OnInit {
{ initialValue: null }
);
readonly isElectron = this.runtime.isElectron;
readonly supportsPlaylistRefresh = this.runtime.supportsPlaylistRefresh;
readonly supportsXtreamSqliteDataSource =
this.runtime.supportsXtreamSqliteDataSource;
readonly currentLocale = computed(() => {
this.languageTick();
return normalizeDateLocale(
@@ -87,7 +87,11 @@ describe('RecentPlaylistsComponent busy state', () => {
cancelRefresh: jest.Mock;
refreshPlaylist: jest.Mock;
};
let runtimeIsElectron: boolean;
let runtime: {
isElectron: boolean;
supportsPlaylistRefresh: boolean;
supportsXtreamSqliteDataSource: boolean;
};
let router: {
navigate: jest.Mock;
};
@@ -95,13 +99,8 @@ describe('RecentPlaylistsComponent busy state', () => {
open: jest.Mock;
};
let store: MockStore;
const originalElectron = window.electron;
beforeEach(async () => {
window.electron = {
platform: 'darwin',
} as typeof window.electron;
databaseService = {
cancelOperation: jest.fn().mockResolvedValue(true),
createOperationId: jest.fn((prefix: string) => `${prefix}-op`),
@@ -128,7 +127,11 @@ describe('RecentPlaylistsComponent busy state', () => {
items: [],
}),
};
runtimeIsElectron = true;
runtime = {
isElectron: true,
supportsPlaylistRefresh: true,
supportsXtreamSqliteDataSource: true,
};
router = {
navigate: jest.fn(),
};
@@ -177,11 +180,7 @@ describe('RecentPlaylistsComponent busy state', () => {
},
{
provide: RuntimeCapabilitiesService,
useValue: {
get isElectron() {
return runtimeIsElectron;
},
},
useValue: runtime,
},
{
provide: PlaylistContextFacade,
@@ -236,7 +235,6 @@ describe('RecentPlaylistsComponent busy state', () => {
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
window.electron = originalElectron;
});
it('tracks delete progress and clears the busy row after completion', async () => {
@@ -479,7 +477,27 @@ describe('RecentPlaylistsComponent busy state', () => {
});
it('uses the legacy IPC refresh flow for non-Xtream playlists', () => {
runtimeIsElectron = false;
runtime.supportsPlaylistRefresh = false;
const item = createPlaylistMeta({
_id: 'playlist-m3u-1',
serverUrl: undefined,
username: undefined,
password: undefined,
filePath: undefined,
url: 'https://example.com/test.m3u',
});
component.refreshPlaylist(item);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(PLAYLIST_UPDATE, {
id: item._id,
title: item.title,
url: item.url,
});
});
it('does not use legacy IPC refresh for file-backed playlists without the refresh bridge', () => {
runtime.supportsPlaylistRefresh = false;
const item = createPlaylistMeta({
_id: 'playlist-m3u-1',
serverUrl: undefined,
@@ -490,19 +508,16 @@ describe('RecentPlaylistsComponent busy state', () => {
component.refreshPlaylist(item);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(PLAYLIST_UPDATE, {
id: item._id,
title: item.title,
filePath: item.filePath,
});
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled();
});
it('re-evaluates Electron availability when refreshing local M3U playlists', async () => {
runtimeIsElectron = false;
it('re-evaluates refresh bridge availability when refreshing local M3U playlists', async () => {
runtime.supportsPlaylistRefresh = false;
const lateComponent = TestBed.createComponent(
RecentPlaylistsComponent
).componentInstance;
runtimeIsElectron = true;
runtime.supportsPlaylistRefresh = true;
const item = createPlaylistMeta({
_id: 'playlist-m3u-2',
serverUrl: undefined,
@@ -101,6 +101,14 @@ export class RecentPlaylistsComponent {
return this.runtime.isElectron;
}
get supportsPlaylistRefresh(): boolean {
return this.runtime.supportsPlaylistRefresh;
}
get supportsXtreamSqliteDataSource(): boolean {
return this.runtime.supportsXtreamSqliteDataSource;
}
readonly allPlaylistsLoaded = this.store.selectSignal(
selectPlaylistsLoadingFlag
);
@@ -291,17 +299,20 @@ export class RecentPlaylistsComponent {
return;
}
if (item.serverUrl) {
if (item.serverUrl && this.supportsXtreamSqliteDataSource) {
// For Xtream playlists, delete and re-import
this.refreshXtreamPlaylist(item);
} else if (this.runtime.isElectron && (item.url || item.filePath)) {
} else if (
this.supportsPlaylistRefresh &&
(item.url || item.filePath)
) {
void this.refreshM3uPlaylist(item);
} else {
// For M3U playlists, use existing refresh logic
} else if (item.url) {
// Browser/PWA URL refresh uses the PWA data service path.
this.dataService.sendIpcEvent(PLAYLIST_UPDATE, {
id: item._id,
title: item.title,
...(item.url ? { url: item.url } : { filePath: item.filePath }),
url: item.url,
});
}
}
@@ -17,8 +17,19 @@ describe('UnifiedFavoritesDataService', () => {
let service: UnifiedFavoritesDataService;
let electronApi: {
dbAddFavorite: jest.Mock;
dbAddRecentItem: jest.Mock;
dbClearPlaylistRecentItems: jest.Mock;
dbClearRecentlyViewed: jest.Mock;
dbGetAllGlobalFavorites: jest.Mock;
dbGetContentByXtreamId: jest.Mock;
dbGetFavorites: jest.Mock;
dbGetGlobalRecentlyAdded: jest.Mock;
dbGetRecentItems: jest.Mock;
dbGetRecentlyViewed: jest.Mock;
dbReorderGlobalFavorites: jest.Mock;
dbRemoveFavorite: jest.Mock;
dbRemoveRecentItem: jest.Mock;
dbRemoveRecentItemsBatch: jest.Mock;
};
let databaseService: {
getAllGlobalFavorites: jest.Mock;
@@ -99,8 +110,21 @@ describe('UnifiedFavoritesDataService', () => {
beforeEach(() => {
electronApi = {
dbAddFavorite: jest.fn().mockResolvedValue({ success: true }),
dbAddRecentItem: jest.fn(),
dbClearPlaylistRecentItems: jest.fn(),
dbClearRecentlyViewed: jest.fn(),
dbGetAllGlobalFavorites: jest.fn(),
dbGetContentByXtreamId: jest.fn(),
dbGetFavorites: jest.fn(),
dbGetGlobalRecentlyAdded: jest.fn(),
dbGetRecentItems: jest.fn(),
dbGetRecentlyViewed: jest.fn(),
dbReorderGlobalFavorites: jest.fn().mockResolvedValue({
success: true,
}),
dbRemoveFavorite: jest.fn().mockResolvedValue(undefined),
dbRemoveRecentItem: jest.fn(),
dbRemoveRecentItemsBatch: jest.fn(),
};
Object.defineProperty(window, 'electron', {
value: electronApi as Window['electron'],
@@ -522,6 +546,31 @@ describe('UnifiedFavoritesDataService', () => {
expect(electronApi.dbAddFavorite).not.toHaveBeenCalled();
});
it('uses the active Xtream data source when the Electron bridge lacks activity storage methods', async () => {
Object.defineProperty(window, 'electron', {
value: {} as Window['electron'],
configurable: true,
});
await service.removeFavorite({
uid: 'xtream::xtream-1::movie:101',
name: 'Partial Bridge Movie',
contentType: 'movie',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
posterUrl: 'movie.png',
xtreamId: 101,
contentId: 1010,
} satisfies UnifiedCollectionItem);
expect(xtreamDataSource.removeFavorite).toHaveBeenCalledWith(
1010,
'xtream-1'
);
expect(electronApi.dbRemoveFavorite).not.toHaveBeenCalled();
});
it('adds Stalker favorites through portal favorites', async () => {
await service.addFavorite({
uid: 'stalker::stalker-1::101',
@@ -3,7 +3,11 @@ import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { firstValueFrom, map } from 'rxjs';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import {
DatabaseService,
PlaylistsService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import {
Channel,
extractStalkerItemId,
@@ -47,6 +51,7 @@ export class UnifiedFavoritesDataService {
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly translate = inject(TranslateService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
async getFavorites(
@@ -90,12 +95,13 @@ export class UnifiedFavoritesDataService {
await this.setM3uFavorites(item.playlistId, filtered);
break;
}
case 'xtream':
case 'xtream': {
if (item.contentId == null) {
return;
}
if (window.electron) {
await window.electron.dbRemoveFavorite(
const electron = this.electronActivityBridge;
if (electron) {
await electron.dbRemoveFavorite(
item.contentId,
item.playlistId
);
@@ -106,6 +112,7 @@ export class UnifiedFavoritesDataService {
item.playlistId
);
break;
}
case 'stalker': {
const sourceItemId = item.uid.split('::')[2];
await firstValueFrom(
@@ -157,7 +164,8 @@ export class UnifiedFavoritesDataService {
return;
}
if (!window.electron) {
const electron = this.electronActivityBridge;
if (!electron) {
await this.xtreamDataSource.addFavorite(
contentId,
item.playlistId,
@@ -166,7 +174,7 @@ export class UnifiedFavoritesDataService {
return;
}
await window.electron.dbAddFavorite(
await electron.dbAddFavorite(
contentId,
item.playlistId,
item.posterUrl ?? item.logo ?? undefined
@@ -287,21 +295,22 @@ export class UnifiedFavoritesDataService {
}
const xtreamUpdates = this.buildXtreamPositionUpdates(items);
const electron = this.electronActivityBridge;
if (
options?.scope === 'playlist' &&
options.playlistId &&
options.portalType === 'xtream'
) {
if (xtreamUpdates.length > 0 && window.electron) {
await window.electron.dbReorderGlobalFavorites(xtreamUpdates);
if (xtreamUpdates.length > 0 && electron) {
await electron.dbReorderGlobalFavorites(xtreamUpdates);
}
return;
}
await Promise.all([
xtreamUpdates.length > 0 && window.electron
? window.electron.dbReorderGlobalFavorites(xtreamUpdates)
xtreamUpdates.length > 0 && electron
? electron.dbReorderGlobalFavorites(xtreamUpdates)
: Promise.resolve(),
this.saveOrder(items.map((item) => item.uid)),
]);
@@ -356,7 +365,7 @@ export class UnifiedFavoritesDataService {
private async clearXtreamFavorites(
items: UnifiedCollectionItem[]
): Promise<void> {
const electron = window.electron;
const electron = this.electronActivityBridge;
if (!electron) {
await Promise.all(
items
@@ -514,7 +523,7 @@ export class UnifiedFavoritesDataService {
}
private async getXtreamAllFavorites(): Promise<UnifiedCollectionItem[]> {
if (!window.electron) {
if (!this.electronActivityBridge) {
const allMeta = await this.getAllMeta();
const results: UnifiedCollectionItem[] = [];
for (const meta of allMeta.filter(
@@ -527,7 +536,6 @@ export class UnifiedFavoritesDataService {
return results;
}
if (!window.electron?.dbGetAllGlobalFavorites) return [];
try {
const rows =
(await this.dbService.getAllGlobalFavorites()) as XtreamFavoriteRow[];
@@ -542,7 +550,7 @@ export class UnifiedFavoritesDataService {
): Promise<UnifiedCollectionItem[]> {
try {
const meta = await this.getPlaylistMeta(playlistId);
if (!window.electron) {
if (!this.electronActivityBridge) {
const rows =
await this.xtreamDataSource.getFavorites(playlistId);
return rows.map((row) =>
@@ -568,7 +576,7 @@ export class UnifiedFavoritesDataService {
return null;
}
if (!window.electron) {
if (!this.electronActivityBridge) {
const content = await this.xtreamDataSource.getContentByXtreamId(
item.xtreamId,
item.playlistId,
@@ -721,9 +729,10 @@ export class UnifiedFavoritesDataService {
}
private async getSavedOrder(): Promise<string[]> {
if (!window.electron?.dbGetAppState) return [];
const electron = window.electron;
if (!this.runtime.supportsAppStateStorage || !electron) return [];
try {
const raw = await window.electron.dbGetAppState(
const raw = await electron.dbGetAppState(
GLOBAL_FAVORITES_ORDER_KEY
);
return raw ? (JSON.parse(raw) as string[]) : [];
@@ -733,9 +742,10 @@ export class UnifiedFavoritesDataService {
}
private async saveOrder(uidOrder: string[]): Promise<void> {
if (!window.electron?.dbSetAppState) return;
const electron = window.electron;
if (!this.runtime.supportsAppStateStorage || !electron) return;
try {
await window.electron.dbSetAppState(
await electron.dbSetAppState(
GLOBAL_FAVORITES_ORDER_KEY,
JSON.stringify(uidOrder)
);
@@ -845,4 +855,10 @@ export class UnifiedFavoritesDataService {
return extractStalkerItemId(favorite);
}
private get electronActivityBridge(): Window['electron'] | undefined {
return this.runtime.supportsPortalActivityStorage
? window.electron
: undefined;
}
}
@@ -94,9 +94,27 @@ describe('UnifiedRecentDataService', () => {
},
];
const createPortalActivityElectronApi = (): Window['electron'] =>
({
dbGetRecentlyViewed: jest.fn(),
dbClearRecentlyViewed: jest.fn(),
dbGetAllGlobalFavorites: jest.fn(),
dbGetGlobalRecentlyAdded: jest.fn(),
dbAddFavorite: jest.fn(),
dbRemoveFavorite: jest.fn(),
dbGetFavorites: jest.fn(),
dbReorderGlobalFavorites: jest.fn(),
dbGetRecentItems: jest.fn(),
dbAddRecentItem: jest.fn(),
dbClearPlaylistRecentItems: jest.fn(),
dbRemoveRecentItem: jest.fn(),
dbRemoveRecentItemsBatch: jest.fn(),
dbGetContentByXtreamId: jest.fn(),
}) as unknown as Window['electron'];
beforeEach(() => {
Object.defineProperty(window, 'electron', {
value: {} as Window['electron'],
value: createPortalActivityElectronApi(),
configurable: true,
});
store = {
@@ -295,6 +313,30 @@ describe('UnifiedRecentDataService', () => {
);
});
it('uses the active Xtream data source when the Electron bridge lacks activity storage methods', async () => {
Object.defineProperty(window, 'electron', {
value: {} as Window['electron'],
configurable: true,
});
await service.removeRecentItem({
uid: 'xtream::xtream-1::movie:290',
name: 'Partial Bridge Movie',
contentType: 'movie',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
xtreamId: 290,
contentId: 444,
} satisfies UnifiedCollectionItem);
expect(xtreamDataSource.removeRecentItem).toHaveBeenCalledWith(
444,
'xtream-1'
);
expect(dbService.removeRecentItem).not.toHaveBeenCalled();
});
it('builds distinct Xtream recent UIDs when live and series share an xtream id', async () => {
store.select.mockReturnValue(
of([
@@ -2,7 +2,11 @@ import { inject, Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { firstValueFrom, map } from 'rxjs';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import {
DatabaseService,
PlaylistsService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import {
Channel,
extractStalkerItemId,
@@ -40,6 +44,7 @@ export class UnifiedRecentDataService {
private readonly store = inject(Store);
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
async getRecentItems(
@@ -60,15 +65,15 @@ export class UnifiedRecentDataService {
return;
}
if (!window.electron) {
await this.xtreamDataSource.removeRecentItem(
if (this.hasPortalActivityStorage) {
await this.dbService.removeRecentItem(
item.contentId,
item.playlistId
);
return;
}
await this.dbService.removeRecentItem(
await this.xtreamDataSource.removeRecentItem(
item.contentId,
item.playlistId
);
@@ -139,7 +144,7 @@ export class UnifiedRecentDataService {
const tasks: Promise<unknown>[] = [];
if (xtreamBatch.length > 0) {
if (window.electron) {
if (this.hasPortalActivityStorage) {
tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch));
} else {
tasks.push(
@@ -177,7 +182,7 @@ export class UnifiedRecentDataService {
playlistId?: string
): Promise<void> {
if (scope === 'playlist' && playlistId) {
if (window.electron) {
if (this.hasPortalActivityStorage) {
await this.dbService.clearPlaylistRecentItems(playlistId);
} else {
await this.xtreamDataSource.clearRecentItems(playlistId);
@@ -189,7 +194,7 @@ export class UnifiedRecentDataService {
return;
}
if (window.electron) {
if (this.hasPortalActivityStorage) {
await this.dbService.clearGlobalRecentlyViewed();
} else {
const allMeta = await this.getAllMeta();
@@ -264,7 +269,7 @@ export class UnifiedRecentDataService {
: null);
if (contentId != null) {
if (window.electron) {
if (this.hasPortalActivityStorage) {
await this.dbService.addRecentItem(
contentId,
item.playlistId
@@ -349,8 +354,12 @@ export class UnifiedRecentDataService {
return this.getM3uPlaylistRecent(playlistId);
}
private get hasPortalActivityStorage(): boolean {
return this.runtime.supportsPortalActivityStorage;
}
private async getXtreamGlobalRecent(): Promise<UnifiedCollectionItem[]> {
if (!window.electron) {
if (!this.hasPortalActivityStorage) {
const allMeta = await this.getAllMeta();
const results: UnifiedCollectionItem[] = [];
for (const meta of allMeta.filter(
@@ -393,7 +402,7 @@ export class UnifiedRecentDataService {
): Promise<UnifiedCollectionItem[]> {
try {
const meta = await this.getPlaylistMeta(playlistId);
if (!window.electron) {
if (!this.hasPortalActivityStorage) {
const rows =
await this.xtreamDataSource.getRecentItems(playlistId);
return rows.map((row) =>
@@ -434,7 +443,7 @@ export class UnifiedRecentDataService {
return null;
}
if (!window.electron) {
if (!this.hasPortalActivityStorage) {
const content = await this.xtreamDataSource.getContentByXtreamId(
item.xtreamId,
item.playlistId,
@@ -1,7 +1,7 @@
import { buildPortalRailLinks } from './portal-rail-links';
describe('buildPortalRailLinks', () => {
it('builds Xtream links with scoped tooltip labels on Electron', () => {
it('builds Xtream links with scoped tooltip labels when downloads are supported', () => {
const links = buildPortalRailLinks({
provider: 'xtreams',
playlistId: 'xtream-1',
@@ -0,0 +1,129 @@
import { TestBed } from '@angular/core/testing';
import { signalStore } from '@ngrx/signals';
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
import { PlaylistMeta, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
import { StalkerSessionService } from '../../stalker-session.service';
import { withStalkerPortal } from './with-stalker-portal.feature';
jest.mock('@iptvnator/portal/shared/util', () => ({
createLogger: () => ({
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
}),
}));
const TestPortalStore = signalStore(withStalkerPortal());
const PLAYLIST = {
_id: 'stalker-1',
title: 'Demo Stalker',
count: 0,
autoRefresh: false,
importDate: '2026-05-22T00:00:00.000Z',
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
macAddress: '00:1A:79:00:00:01',
isFullStalkerPortal: false,
} as PlaylistMeta;
describe('withStalkerPortal', () => {
let store: InstanceType<typeof TestPortalStore>;
let dbCreatePlaylist: jest.Mock;
let dbGetPlaylist: jest.Mock;
let runtime: {
supportsStalkerPlaylistSqliteSync: boolean;
};
let stalkerSession: {
ensureToken: jest.Mock;
setActiveWatchdogPlaylist: jest.Mock;
};
beforeEach(() => {
dbCreatePlaylist = jest.fn().mockResolvedValue(undefined);
dbGetPlaylist = jest.fn().mockResolvedValue(null);
Object.defineProperty(window, 'electron', {
value: {
dbCreatePlaylist,
dbGetPlaylist,
} as Window['electron'],
configurable: true,
});
runtime = {
supportsStalkerPlaylistSqliteSync: true,
};
stalkerSession = {
ensureToken: jest.fn(),
setActiveWatchdogPlaylist: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
TestPortalStore,
{
provide: DataService,
useValue: {
sendIpcEvent: jest.fn(),
},
},
{
provide: RuntimeCapabilitiesService,
useValue: runtime,
},
{
provide: StalkerSessionService,
useValue: stalkerSession,
},
],
});
store = TestBed.inject(TestPortalStore);
});
it('creates the SQLite playlist row when the runtime exposes the required bridge methods', async () => {
await store.setCurrentPlaylist(PLAYLIST);
expect(dbGetPlaylist).toHaveBeenCalledWith('stalker-1');
expect(dbCreatePlaylist).toHaveBeenCalledWith({
id: 'stalker-1',
name: 'Demo Stalker',
macAddress: '00:1A:79:00:00:01',
url: 'http://demo.example/stalker_portal/server/load.php',
type: 'stalker',
});
expect(stalkerSession.setActiveWatchdogPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
macAddress: '00:1A:79:00:00:01',
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
})
);
});
it('does not touch SQLite when the Electron bridge is partial', async () => {
runtime.supportsStalkerPlaylistSqliteSync = false;
await store.setCurrentPlaylist(PLAYLIST);
expect(dbGetPlaylist).not.toHaveBeenCalled();
expect(dbCreatePlaylist).not.toHaveBeenCalled();
});
it('sends Stalker requests through DataService without requiring the SQLite bridge', async () => {
const dataService = TestBed.inject(DataService) as unknown as {
sendIpcEvent: jest.Mock;
};
dataService.sendIpcEvent.mockResolvedValue({ js: { data: [] } });
await store.makeStalkerRequest(PLAYLIST, { action: 'get_profile' });
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
STALKER_REQUEST,
expect.objectContaining({
macAddress: '00:1A:79:00:00:01',
params: { action: 'get_profile' },
url: 'http://demo.example/stalker_portal/server/load.php',
})
);
});
});
@@ -8,12 +8,12 @@ import {
} from '@ngrx/signals';
import { PlaylistMeta, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService } from '@iptvnator/services';
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
import { StalkerSessionService } from '../../stalker-session.service';
import { toStalkerSessionPlaylist } from '../utils';
type StalkerPortalWindow = Window & {
electron?: {
electron: {
dbCreatePlaylist: (playlist: {
id: string;
name: string;
@@ -81,8 +81,8 @@ export function withStalkerPortal() {
withMethods(
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
stalkerSession = inject(StalkerSessionService),
runtime = inject(RuntimeCapabilitiesService)
) => ({
async setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
stalkerSession.setActiveWatchdogPlaylist(
@@ -96,7 +96,7 @@ export function withStalkerPortal() {
// Only sync if this is actually a Stalker playlist (has macAddress and portalUrl)
if (
playlist &&
dataService.isElectron &&
runtime.supportsStalkerPlaylistSqliteSync &&
playlist._id &&
playlist.macAddress &&
playlist.portalUrl
@@ -104,9 +104,6 @@ export function withStalkerPortal() {
try {
const electronApi = (window as StalkerPortalWindow)
.electron;
if (!electronApi) {
return;
}
const playlistId = String(playlist._id);
// Check if playlist exists in SQLite
@@ -243,6 +243,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
beforeEach(async () => {
window.electron = {
platform: 'darwin',
updateRemoteControlStatus: jest.fn(),
onChannelChange: jest.fn(() => jest.fn()),
onRemoteControlCommand: jest.fn(() => jest.fn()),
} as typeof window.electron;
fetchChannelEpg = stalkerStore.fetchChannelEpg;
@@ -505,6 +508,20 @@ describe('StalkerLiveStreamLayoutComponent', () => {
expect(finallySpy).not.toHaveBeenCalled();
});
it('does not publish remote-control status when the bridge is incomplete', () => {
fixture.destroy();
const updateRemoteControlStatus = jest.fn();
window.electron = {
updateRemoteControlStatus,
} as typeof window.electron;
fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect(updateRemoteControlStatus).not.toHaveBeenCalled();
});
it('starts external playback from remote channel navigation when double-click opening is enabled', async () => {
settingsStore.openStreamOnDoubleClick.set(true);
portalPlayer.isEmbeddedPlayer.mockReturnValue(false);
@@ -335,7 +335,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
});
effect(() => {
if (!window.electron?.updateRemoteControlStatus) {
const remoteControl = this.remoteControlBridge;
if (!remoteControl?.updateRemoteControlStatus) {
return;
}
@@ -344,7 +345,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
const channels = this.visibleChannels();
if (selectedType !== 'itv' || !selectedItem?.id) {
window.electron.updateRemoteControlStatus({
remoteControl.updateRemoteControlStatus({
portal: 'stalker',
isLiveView: false,
supportsVolume: false,
@@ -357,7 +358,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
);
const currentProgram = this.currentProgram();
window.electron.updateRemoteControlStatus({
remoteControl.updateRemoteControlStatus({
portal: 'stalker',
isLiveView: true,
channelName: selectedItem.o_name || selectedItem.name,
@@ -369,8 +370,9 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
});
});
if (window.electron?.onChannelChange) {
const unsubscribe = window.electron.onChannelChange(
const remoteControl = this.remoteControlBridge;
if (remoteControl?.onChannelChange) {
const unsubscribe = remoteControl.onChannelChange(
(data: { direction: 'up' | 'down' }) => {
this.handleRemoteChannelChange(data.direction);
}
@@ -379,8 +381,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.unsubscribeRemoteChannelChange = unsubscribe;
}
}
if (window.electron?.onRemoteControlCommand) {
const unsubscribe = window.electron.onRemoteControlCommand(
if (remoteControl?.onRemoteControlCommand) {
const unsubscribe = remoteControl.onRemoteControlCommand(
(command) => {
this.handleRemoteControlCommand(command);
}
@@ -799,6 +801,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
);
}
private get remoteControlBridge(): Window['electron'] | undefined {
return this.runtime.supportsRemoteControl ? window.electron : undefined;
}
private handleRemoteChannelChange(direction: 'up' | 'down'): void {
this.handleAdjacentChannelChange(direction);
}
@@ -0,0 +1,91 @@
import { TestBed } from '@angular/core/testing';
import {
PLAYLIST_DELETE_CLEANUP,
PlaylistDeleteCleanup,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import {
ElectronXtreamDataSource,
PwaXtreamDataSource,
provideXtreamDataSource,
} from './index';
import {
IXtreamDataSource,
XTREAM_DATA_SOURCE,
} from './xtream-data-source.interface';
describe('provideXtreamDataSource', () => {
let electronSource: IXtreamDataSource;
let pwaSource: IXtreamDataSource;
let runtime: {
supportsXtreamSqliteDataSource: boolean;
};
function configure(supportsXtreamSqliteDataSource: boolean): void {
electronSource = {
deletePlaylist: jest.fn().mockResolvedValue(undefined),
} as Partial<IXtreamDataSource> as IXtreamDataSource;
pwaSource = {
deletePlaylist: jest.fn().mockResolvedValue(undefined),
} as Partial<IXtreamDataSource> as IXtreamDataSource;
runtime = {
supportsXtreamSqliteDataSource,
};
TestBed.configureTestingModule({
providers: [
...provideXtreamDataSource(),
{
provide: RuntimeCapabilitiesService,
useValue: runtime,
},
{
provide: ElectronXtreamDataSource,
useValue: electronSource,
},
{
provide: PwaXtreamDataSource,
useValue: pwaSource,
},
],
});
}
afterEach(() => TestBed.resetTestingModule());
it('uses the Electron data source only when the Xtream SQLite capability is available', () => {
configure(true);
expect(TestBed.inject(XTREAM_DATA_SOURCE)).toBe(electronSource);
});
it('falls back to the PWA data source when the Electron bridge lacks Xtream SQLite methods', () => {
configure(false);
expect(TestBed.inject(XTREAM_DATA_SOURCE)).toBe(pwaSource);
});
it('skips browser sidecar cleanup for SQLite-backed Xtream storage', async () => {
configure(true);
const [cleanup] = TestBed.inject(
PLAYLIST_DELETE_CLEANUP
) as PlaylistDeleteCleanup[];
await cleanup('playlist-1');
expect(electronSource.deletePlaylist).not.toHaveBeenCalled();
expect(pwaSource.deletePlaylist).not.toHaveBeenCalled();
});
it('runs browser sidecar cleanup when SQLite-backed Xtream storage is unavailable', async () => {
configure(false);
const [cleanup] = TestBed.inject(
PLAYLIST_DELETE_CLEANUP
) as PlaylistDeleteCleanup[];
await cleanup('playlist-1');
expect(pwaSource.deletePlaylist).toHaveBeenCalledWith('playlist-1');
expect(electronSource.deletePlaylist).not.toHaveBeenCalled();
});
});
@@ -23,7 +23,7 @@ export { PwaXtreamDataSource } from './pwa-xtream-data-source';
export function xtreamDataSourceFactory(): IXtreamDataSource {
const runtime = inject(RuntimeCapabilitiesService);
if (runtime.isElectron) {
if (runtime.supportsXtreamSqliteDataSource) {
return inject(ElectronXtreamDataSource);
}
@@ -50,7 +50,7 @@ export function provideXtreamDataSource(): Provider[] {
const runtime = inject(RuntimeCapabilitiesService);
return (playlistId: string) => {
if (runtime.isElectron) {
if (runtime.supportsXtreamSqliteDataSource) {
// Electron playlist deletion must use DatabaseService so
// SQLite content and sidecars are cleaned together; do
// not invoke this cleanup path through PlaylistsService.
@@ -1,10 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { signalStore, withState } from '@ngrx/signals';
import {
DataService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { EpgItem } from '@iptvnator/shared/interfaces';
import { XtreamApiService } from '../../services/xtream-api.service';
import { XtreamXmltvFallbackService } from '../../services/xtream-xmltv-fallback.service';
@@ -48,7 +44,6 @@ function buildProgram(
interface TestStoreSetup {
appEnvironment?: 'electron' | 'pwa';
isElectron?: boolean;
selectedItem: { xtream_id: number; epg_channel_id?: string | null };
preferUploaded?: boolean;
}
@@ -82,18 +77,6 @@ function configureStore(setup: TestStoreSetup) {
TestBed.configureTestingModule({
providers: [
TestEpgStore,
{
provide: DataService,
useValue: {
getAppEnvironment: jest.fn(
() => setup.appEnvironment ?? 'electron'
),
isElectron: setup.isElectron ?? true,
supportsEpg:
setup.appEnvironment !== 'pwa' &&
(setup.isElectron ?? true),
},
},
{
provide: RuntimeCapabilitiesService,
useValue: {
@@ -183,7 +166,6 @@ describe('withEpg', () => {
it('does not load Xtream or XMLTV EPG in browser/PWA mode', async () => {
const { store, xtreamApiService, fallbackService } = configureStore({
appEnvironment: 'pwa',
isElectron: false,
selectedItem: { xtream_id: 101, epg_channel_id: 'rtl.de' },
});
@@ -8,7 +8,6 @@ import {
} from '@ngrx/signals';
import { EpgItem } from '@iptvnator/shared/interfaces';
import {
DataService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
@@ -84,7 +83,6 @@ export function withEpg() {
withMethods((store) => {
const apiService = inject(XtreamApiService);
const dataService = inject(DataService);
const fallbackService = inject(XtreamXmltvFallbackService);
const runtime = inject(RuntimeCapabilitiesService);
const settingsStore = inject(SettingsStore);
@@ -116,13 +114,9 @@ export function withEpg() {
credentials: XtreamCredentials,
xtreamId: number
): Promise<EpgItem[]> =>
dataService.isElectron
? apiService.getFullEpg(credentials, xtreamId, {
suppressErrorLog: true,
})
: apiService.getShortEpg(credentials, xtreamId, 10, {
suppressErrorLog: true,
});
apiService.getFullEpg(credentials, xtreamId, {
suppressErrorLog: true,
});
return {
/**
@@ -182,6 +182,8 @@ describe('LiveStreamLayoutComponent', () => {
window.electron = {
updateRemoteControlStatus: jest.fn(),
onChannelChange: jest.fn(() => jest.fn()),
onRemoteControlCommand: jest.fn(() => jest.fn()),
} as typeof window.electron;
xtreamStore.constructStreamUrl.mockClear();
@@ -236,6 +238,13 @@ describe('LiveStreamLayoutComponent', () => {
get isElectron() {
return Boolean(window.electron);
},
get supportsRemoteControl() {
return Boolean(
window.electron?.updateRemoteControlStatus &&
window.electron.onChannelChange &&
window.electron.onRemoteControlCommand
);
},
},
},
{ provide: SettingsStore, useValue: settingsStore },
@@ -487,6 +496,20 @@ describe('LiveStreamLayoutComponent', () => {
);
});
it('does not publish remote-control status when the bridge is incomplete', () => {
fixture.destroy();
const updateRemoteControlStatus = jest.fn();
window.electron = {
updateRemoteControlStatus,
} as typeof window.electron;
fixture = TestBed.createComponent(LiveStreamLayoutComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect(updateRemoteControlStatus).not.toHaveBeenCalled();
});
it('resolves a catchup url for archived program activation', async () => {
portalPlayer.isEmbeddedPlayer.mockReturnValue(false);
@@ -267,7 +267,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
});
effect(() => {
if (!window.electron?.updateRemoteControlStatus) {
const remoteControl = this.remoteControlBridge;
if (!remoteControl?.updateRemoteControlStatus) {
return;
}
@@ -277,7 +278,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
const currentProgram = this.currentEpgItem();
if (selectedContentType !== 'live' || !selectedItem?.xtream_id) {
window.electron.updateRemoteControlStatus({
remoteControl.updateRemoteControlStatus({
portal: 'xtream',
isLiveView: false,
supportsVolume: false,
@@ -290,7 +291,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
Number(item.xtream_id) === Number(selectedItem.xtream_id)
);
window.electron.updateRemoteControlStatus({
remoteControl.updateRemoteControlStatus({
portal: 'xtream',
isLiveView: true,
channelName: selectedItem.title ?? selectedItem.name,
@@ -304,8 +305,9 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
}
ngOnInit() {
if (window.electron?.onChannelChange) {
const unsubscribe = window.electron.onChannelChange(
const remoteControl = this.remoteControlBridge;
if (remoteControl?.onChannelChange) {
const unsubscribe = remoteControl.onChannelChange(
(data: { direction: 'up' | 'down' }) => {
this.handleRemoteChannelChange(data.direction);
}
@@ -314,8 +316,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
this.unsubscribeRemoteChannelChange = unsubscribe;
}
}
if (window.electron?.onRemoteControlCommand) {
const unsubscribe = window.electron.onRemoteControlCommand(
if (remoteControl?.onRemoteControlCommand) {
const unsubscribe = remoteControl.onRemoteControlCommand(
(command) => {
this.handleRemoteControlCommand(command);
}
@@ -556,6 +558,10 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
};
}
private get remoteControlBridge(): Window['electron'] | undefined {
return this.runtime.supportsRemoteControl ? window.electron : undefined;
}
private getProgramTimestampSeconds(
dateValue: string,
unixTimestampValue?: number | string | null
@@ -26,7 +26,8 @@ describe('PlaylistDeleteActionService', () => {
deletePlaylist: jest.Mock;
};
let runtime: {
isElectron: boolean;
supportsSqlite: boolean;
supportsXtreamSqliteDataSource: boolean;
};
let injector: EnvironmentInjector;
@@ -41,7 +42,8 @@ describe('PlaylistDeleteActionService', () => {
deletePlaylist: jest.fn(() => of({ success: true })),
};
runtime = {
isElectron: false,
supportsSqlite: false,
supportsXtreamSqliteDataSource: false,
};
injector = createEnvironmentInjector(
@@ -76,8 +78,8 @@ describe('PlaylistDeleteActionService', () => {
expect(databaseService.deletePlaylist).not.toHaveBeenCalled();
});
it('deletes Electron Xtream playlists through DatabaseService with progress options', async () => {
runtime.isElectron = true;
it('deletes SQLite-backed Xtream playlists through DatabaseService with progress options', async () => {
runtime.supportsXtreamSqliteDataSource = true;
const onEvent = jest.fn();
const service = createService();
@@ -98,8 +100,8 @@ describe('PlaylistDeleteActionService', () => {
expect(playlistsService.deletePlaylist).not.toHaveBeenCalled();
});
it('deletes Electron non-Xtream playlists without progress options', async () => {
runtime.isElectron = true;
it('deletes SQLite-backed non-Xtream playlists without progress options', async () => {
runtime.supportsSqlite = true;
const service = createService();
await expect(
@@ -115,4 +117,17 @@ describe('PlaylistDeleteActionService', () => {
undefined
);
});
it('uses browser playlist storage when an Xtream playlist lacks Xtream SQLite support', async () => {
runtime.supportsSqlite = true;
runtime.supportsXtreamSqliteDataSource = false;
const service = createService();
await expect(service.deletePlaylist(playlist)).resolves.toBe(true);
expect(playlistsService.deletePlaylist).toHaveBeenCalledWith(
'playlist-1'
);
expect(databaseService.deletePlaylist).not.toHaveBeenCalled();
});
});
@@ -27,7 +27,11 @@ export class PlaylistDeleteActionService {
playlist: PlaylistMeta,
options: PlaylistDeleteActionOptions = {}
): Promise<boolean> {
if (this.runtime.isElectron) {
const supportsElectronDelete = playlist.serverUrl
? this.runtime.supportsXtreamSqliteDataSource
: this.runtime.supportsSqlite;
if (supportsElectronDelete) {
return this.deletePlaylistInElectron(playlist, options);
}
@@ -22,34 +22,100 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.isMacOS).toBe(false);
expect(service.supportsEpg).toBe(false);
expect(service.supportsSqlite).toBe(false);
expect(service.supportsXtreamSqliteDataSource).toBe(false);
expect(service.supportsDownloads).toBe(false);
expect(service.supportsPortalActivityStorage).toBe(false);
expect(service.supportsAppStateStorage).toBe(false);
expect(service.supportsStalkerPlaylistSqliteSync).toBe(false);
expect(service.supportsPlaylistRefresh).toBe(false);
expect(service.supportsManagedExternalPlayers).toBe(false);
expect(service.supportsExternalPlayerPathSettings).toBe(false);
expect(service.supportsEmbeddedMpv).toBe(false);
expect(service.supportsDesktopFileSave).toBe(false);
expect(service.supportsRemoteControl).toBe(false);
expect(service.supportsXtreamSectionNavigation).toBe(false);
expect(service.supportsXtreamSectionNavigation).toBe(true);
});
it('reports Electron capabilities from the available preload bridge methods', () => {
testWindow.electron = {
platform: 'darwin',
dbDeleteAllPlaylists: jest.fn(),
dbDeletePlaylist: jest.fn(),
dbGetAppPlaylist: jest.fn(),
dbGetAppPlaylists: jest.fn(),
dbUpsertAppPlaylist: jest.fn(),
dbUpsertAppPlaylists: jest.fn(),
dbGetAppState: jest.fn(),
dbSetAppState: jest.fn(),
dbGetRecentlyViewed: jest.fn(),
dbClearRecentlyViewed: jest.fn(),
dbGetAllGlobalFavorites: jest.fn(),
dbGetGlobalRecentlyAdded: jest.fn(),
dbAddFavorite: jest.fn(),
dbRemoveRecentItem: jest.fn(),
dbRemoveFavorite: jest.fn(),
dbGetFavorites: jest.fn(),
dbReorderGlobalFavorites: jest.fn(),
dbGetRecentItems: jest.fn(),
dbAddRecentItem: jest.fn(),
dbClearPlaylistRecentItems: jest.fn(),
dbRemoveRecentItemsBatch: jest.fn(),
dbGetContentByXtreamId: jest.fn(),
dbGetPlaylist: jest.fn(),
dbCreatePlaylist: jest.fn(),
dbUpdatePlaylist: jest.fn(),
dbHasCategories: jest.fn(),
dbGetCategories: jest.fn(),
dbSaveCategories: jest.fn(),
dbGetAllCategories: jest.fn(),
dbUpdateCategoryVisibility: jest.fn(),
dbHasContent: jest.fn(),
dbGetContent: jest.fn(),
dbSaveContent: jest.fn(),
dbSearchContent: jest.fn(),
dbIsFavorite: jest.fn(),
dbSavePlaybackPosition: jest.fn(),
dbGetPlaybackPosition: jest.fn(),
dbGetSeriesPlaybackPositions: jest.fn(),
dbGetRecentPlaybackPositions: jest.fn(),
dbGetAllPlaybackPositions: jest.fn(),
dbClearAllPlaybackPositions: jest.fn(),
dbClearPlaybackPosition: jest.fn(),
dbDeleteXtreamContent: jest.fn(),
dbRestoreXtreamUserData: jest.fn(),
downloadsStart: jest.fn(),
downloadsCancel: jest.fn(),
downloadsRetry: jest.fn(),
downloadsRemove: jest.fn(),
downloadsGetList: jest.fn(),
downloadsGet: jest.fn(),
downloadsGetDefaultFolder: jest.fn(),
downloadsSelectFolder: jest.fn(),
downloadsRevealFile: jest.fn(),
downloadsPlayFile: jest.fn(),
downloadsClearCompleted: jest.fn(),
onDownloadsUpdate: jest.fn(),
refreshPlaylist: jest.fn(),
cancelPlaylistRefresh: jest.fn(),
onPlaylistRefreshEvent: jest.fn(),
openInMpv: jest.fn(),
openInVlc: jest.fn(),
setMpvPlayerPath: jest.fn(),
setVlcPlayerPath: jest.fn(),
prepareEmbeddedMpv: jest.fn(),
saveFileDialog: jest.fn(),
writeFile: jest.fn(),
updateRemoteControlStatus: jest.fn(),
onChannelChange: jest.fn(),
onRemoteControlCommand: jest.fn(),
xtreamRequest: jest.fn(),
fetchEpg: jest.fn(),
getChannelPrograms: jest.fn(),
checkEpgFreshness: jest.fn(),
forceFetchEpg: jest.fn(),
clearEpgData: jest.fn(),
getEpgChannelsByRange: jest.fn(),
searchEpgPrograms: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
@@ -61,9 +127,14 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.isMacOS).toBe(true);
expect(service.supportsEpg).toBe(true);
expect(service.supportsSqlite).toBe(true);
expect(service.supportsXtreamSqliteDataSource).toBe(true);
expect(service.supportsDownloads).toBe(true);
expect(service.supportsPortalActivityStorage).toBe(true);
expect(service.supportsAppStateStorage).toBe(true);
expect(service.supportsStalkerPlaylistSqliteSync).toBe(true);
expect(service.supportsPlaylistRefresh).toBe(true);
expect(service.supportsManagedExternalPlayers).toBe(true);
expect(service.supportsExternalPlayerPathSettings).toBe(true);
expect(service.supportsEmbeddedMpv).toBe(true);
expect(service.supportsDesktopFileSave).toBe(true);
expect(service.supportsRemoteControl).toBe(true);
@@ -80,14 +151,20 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.environment).toBe('electron');
expect(service.platform).toBeUndefined();
expect(service.isMacOS).toBe(false);
expect(service.supportsEpg).toBe(true);
expect(service.supportsEpg).toBe(false);
expect(service.supportsSqlite).toBe(false);
expect(service.supportsXtreamSqliteDataSource).toBe(false);
expect(service.supportsDownloads).toBe(false);
expect(service.supportsPortalActivityStorage).toBe(false);
expect(service.supportsAppStateStorage).toBe(false);
expect(service.supportsStalkerPlaylistSqliteSync).toBe(false);
expect(service.supportsPlaylistRefresh).toBe(false);
expect(service.supportsManagedExternalPlayers).toBe(false);
expect(service.supportsExternalPlayerPathSettings).toBe(false);
expect(service.supportsEmbeddedMpv).toBe(false);
expect(service.supportsDesktopFileSave).toBe(false);
expect(service.supportsRemoteControl).toBe(false);
expect(service.supportsXtreamSectionNavigation).toBe(true);
expect(service.supportsXtreamSectionNavigation).toBe(false);
});
it('reads the bridge dynamically so tests and late preload setup stay accurate', () => {
@@ -101,4 +178,256 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.isElectron).toBe(true);
expect(service.environment).toBe('electron');
});
it('keeps the Xtream SQLite data source disabled when only generic SQLite methods exist', () => {
testWindow.electron = createPlaylistStorageBridge();
const service = new RuntimeCapabilitiesService();
expect(service.supportsSqlite).toBe(true);
expect(service.supportsAppStateStorage).toBe(true);
expect(service.supportsXtreamSqliteDataSource).toBe(false);
});
it('checks app state storage without requiring full playlist SQLite support', () => {
testWindow.electron = {
dbGetAppState: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsAppStateStorage).toBe(false);
testWindow.electron = {
dbGetAppState: jest.fn(),
dbSetAppState: jest.fn(),
};
expect(service.supportsAppStateStorage).toBe(true);
expect(service.supportsSqlite).toBe(false);
});
it('checks Stalker playlist SQLite sync without requiring the full Xtream data source', () => {
testWindow.electron = {
dbGetPlaylist: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsStalkerPlaylistSqliteSync).toBe(false);
testWindow.electron = {
dbGetPlaylist: jest.fn(),
dbCreatePlaylist: jest.fn(),
};
expect(service.supportsStalkerPlaylistSqliteSync).toBe(true);
expect(service.supportsXtreamSqliteDataSource).toBe(false);
});
it('decouples external player launch support from path-setting support', () => {
testWindow.electron = {
openInMpv: jest.fn(),
openInVlc: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsManagedExternalPlayers).toBe(true);
expect(service.supportsExternalPlayerPathSettings).toBe(false);
testWindow.electron = {
openInMpv: jest.fn(),
openInVlc: jest.fn(),
setMpvPlayerPath: jest.fn(),
setVlcPlayerPath: jest.fn(),
};
expect(service.supportsManagedExternalPlayers).toBe(true);
expect(service.supportsExternalPlayerPathSettings).toBe(true);
});
it('requires the complete EPG preload surface', () => {
testWindow.electron = {
fetchEpg: jest.fn(),
getChannelPrograms: jest.fn(),
checkEpgFreshness: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsEpg).toBe(false);
testWindow.electron = {
fetchEpg: jest.fn(),
getChannelPrograms: jest.fn(),
checkEpgFreshness: jest.fn(),
forceFetchEpg: jest.fn(),
clearEpgData: jest.fn(),
getEpgChannelsByRange: jest.fn(),
searchEpgPrograms: jest.fn(),
};
expect(service.supportsEpg).toBe(true);
});
it('requires the complete downloads preload surface', () => {
testWindow.electron = {
downloadsGetList: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsDownloads).toBe(false);
testWindow.electron = {
downloadsStart: jest.fn(),
downloadsCancel: jest.fn(),
downloadsRetry: jest.fn(),
downloadsRemove: jest.fn(),
downloadsGetList: jest.fn(),
downloadsGet: jest.fn(),
downloadsGetDefaultFolder: jest.fn(),
downloadsSelectFolder: jest.fn(),
downloadsRevealFile: jest.fn(),
downloadsPlayFile: jest.fn(),
downloadsClearCompleted: jest.fn(),
onDownloadsUpdate: jest.fn(),
};
expect(service.supportsDownloads).toBe(true);
});
it('requires both desktop file-save preload methods', () => {
testWindow.electron = {
saveFileDialog: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsDesktopFileSave).toBe(false);
testWindow.electron = {
saveFileDialog: jest.fn(),
writeFile: jest.fn(),
};
expect(service.supportsDesktopFileSave).toBe(true);
});
it('requires the complete playlist refresh preload surface', () => {
testWindow.electron = {
refreshPlaylist: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsPlaylistRefresh).toBe(false);
testWindow.electron = {
refreshPlaylist: jest.fn(),
cancelPlaylistRefresh: jest.fn(),
onPlaylistRefreshEvent: jest.fn(),
};
expect(service.supportsPlaylistRefresh).toBe(true);
});
it('requires the complete playlist storage SQLite preload surface', () => {
testWindow.electron = {
dbGetAppPlaylists: jest.fn(),
dbUpsertAppPlaylist: jest.fn(),
dbGetAppState: jest.fn(),
dbSetAppState: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsSqlite).toBe(false);
testWindow.electron = createPlaylistStorageBridge();
expect(service.supportsSqlite).toBe(true);
});
it('supports Xtream section navigation in Electron when Xtream API transport is available', () => {
testWindow.electron = {
xtreamRequest: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsXtreamSqliteDataSource).toBe(false);
expect(service.supportsXtreamSectionNavigation).toBe(true);
});
it('supports Xtream section navigation in Electron when only the SQLite data source is available', () => {
testWindow.electron = createXtreamSqliteBridge();
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsXtreamSqliteDataSource).toBe(true);
expect(service.supportsXtreamSectionNavigation).toBe(true);
});
});
function createPlaylistStorageBridge(): Record<string, jest.Mock> {
return {
dbDeleteAllPlaylists: jest.fn(),
dbDeletePlaylist: jest.fn(),
dbGetAppPlaylist: jest.fn(),
dbGetAppPlaylists: jest.fn(),
dbGetAppState: jest.fn(),
dbSetAppState: jest.fn(),
dbUpsertAppPlaylist: jest.fn(),
dbUpsertAppPlaylists: jest.fn(),
};
}
function createXtreamSqliteBridge(): Record<string, jest.Mock> {
return {
dbAddFavorite: jest.fn(),
dbAddRecentItem: jest.fn(),
dbClearAllPlaybackPositions: jest.fn(),
dbClearPlaybackPosition: jest.fn(),
dbClearPlaylistRecentItems: jest.fn(),
dbCreatePlaylist: jest.fn(),
dbDeletePlaylist: jest.fn(),
dbDeleteXtreamContent: jest.fn(),
dbGetAllCategories: jest.fn(),
dbGetAllPlaybackPositions: jest.fn(),
dbGetAppState: jest.fn(),
dbGetCategories: jest.fn(),
dbGetContent: jest.fn(),
dbGetContentByXtreamId: jest.fn(),
dbGetFavorites: jest.fn(),
dbGetPlaylist: jest.fn(),
dbGetPlaybackPosition: jest.fn(),
dbGetRecentItems: jest.fn(),
dbGetRecentPlaybackPositions: jest.fn(),
dbGetSeriesPlaybackPositions: jest.fn(),
dbHasCategories: jest.fn(),
dbHasContent: jest.fn(),
dbIsFavorite: jest.fn(),
dbRemoveFavorite: jest.fn(),
dbRemoveRecentItem: jest.fn(),
dbRestoreXtreamUserData: jest.fn(),
dbSaveCategories: jest.fn(),
dbSaveContent: jest.fn(),
dbSavePlaybackPosition: jest.fn(),
dbSearchContent: jest.fn(),
dbSetAppState: jest.fn(),
dbUpdateCategoryVisibility: jest.fn(),
dbUpdatePlaylist: jest.fn(),
};
}
@@ -32,34 +32,134 @@ export class RuntimeCapabilitiesService {
}
get supportsEpg(): boolean {
return this.isElectron;
return [
'fetchEpg',
'getChannelPrograms',
'checkEpgFreshness',
'forceFetchEpg',
'clearEpgData',
'getEpgChannelsByRange',
'searchEpgPrograms',
].every((methodName) => this.hasElectronMethod(methodName));
}
get supportsSqlite(): boolean {
return (
this.hasElectronMethod('dbGetAppPlaylists') &&
this.hasElectronMethod('dbUpsertAppPlaylist') &&
this.hasElectronMethod('dbGetAppState') &&
this.hasElectronMethod('dbSetAppState')
);
return [
'dbDeleteAllPlaylists',
'dbDeletePlaylist',
'dbGetAppPlaylist',
'dbGetAppPlaylists',
'dbGetAppState',
'dbSetAppState',
'dbUpsertAppPlaylist',
'dbUpsertAppPlaylists',
].every((methodName) => this.hasElectronMethod(methodName));
}
get supportsXtreamSqliteDataSource(): boolean {
return [
'dbGetPlaylist',
'dbCreatePlaylist',
'dbUpdatePlaylist',
'dbDeletePlaylist',
'dbHasCategories',
'dbGetCategories',
'dbSaveCategories',
'dbGetAllCategories',
'dbUpdateCategoryVisibility',
'dbHasContent',
'dbGetContent',
'dbSaveContent',
'dbGetAppState',
'dbSetAppState',
'dbSearchContent',
'dbGetFavorites',
'dbAddFavorite',
'dbRemoveFavorite',
'dbIsFavorite',
'dbGetRecentItems',
'dbAddRecentItem',
'dbRemoveRecentItem',
'dbClearPlaylistRecentItems',
'dbGetContentByXtreamId',
'dbSavePlaybackPosition',
'dbGetPlaybackPosition',
'dbGetSeriesPlaybackPositions',
'dbGetRecentPlaybackPositions',
'dbGetAllPlaybackPositions',
'dbClearAllPlaybackPositions',
'dbClearPlaybackPosition',
'dbDeleteXtreamContent',
'dbRestoreXtreamUserData',
].every((methodName) => this.hasElectronMethod(methodName));
}
get supportsDownloads(): boolean {
return this.hasElectronMethod('downloadsGetList');
return [
'downloadsStart',
'downloadsCancel',
'downloadsRetry',
'downloadsRemove',
'downloadsGetList',
'downloadsGet',
'downloadsGetDefaultFolder',
'downloadsSelectFolder',
'downloadsRevealFile',
'downloadsPlayFile',
'downloadsClearCompleted',
'onDownloadsUpdate',
].every((methodName) => this.hasElectronMethod(methodName));
}
get supportsPortalActivityStorage(): boolean {
return (
this.hasElectronMethod('dbGetRecentlyViewed') &&
this.hasElectronMethod('dbGetAllGlobalFavorites') &&
this.hasElectronMethod('dbGetGlobalRecentlyAdded') &&
this.hasElectronMethod('dbRemoveRecentItem') &&
this.hasElectronMethod('dbRemoveFavorite')
return [
'dbGetRecentlyViewed',
'dbClearRecentlyViewed',
'dbGetAllGlobalFavorites',
'dbGetGlobalRecentlyAdded',
'dbAddFavorite',
'dbRemoveFavorite',
'dbGetFavorites',
'dbReorderGlobalFavorites',
'dbGetRecentItems',
'dbAddRecentItem',
'dbClearPlaylistRecentItems',
'dbRemoveRecentItem',
'dbRemoveRecentItemsBatch',
'dbGetContentByXtreamId',
].every((methodName) => this.hasElectronMethod(methodName));
}
get supportsAppStateStorage(): boolean {
return ['dbGetAppState', 'dbSetAppState'].every((methodName) =>
this.hasElectronMethod(methodName)
);
}
get supportsStalkerPlaylistSqliteSync(): boolean {
return ['dbGetPlaylist', 'dbCreatePlaylist'].every((methodName) =>
this.hasElectronMethod(methodName)
);
}
get supportsPlaylistRefresh(): boolean {
return [
'refreshPlaylist',
'cancelPlaylistRefresh',
'onPlaylistRefreshEvent',
].every((methodName) => this.hasElectronMethod(methodName));
}
get supportsManagedExternalPlayers(): boolean {
return this.isElectron;
return ['openInMpv', 'openInVlc'].every((methodName) =>
this.hasElectronMethod(methodName)
);
}
get supportsExternalPlayerPathSettings(): boolean {
return ['setMpvPlayerPath', 'setVlcPlayerPath'].every((methodName) =>
this.hasElectronMethod(methodName)
);
}
get supportsEmbeddedMpv(): boolean {
@@ -82,7 +182,11 @@ export class RuntimeCapabilitiesService {
}
get supportsXtreamSectionNavigation(): boolean {
return this.isElectron;
return (
this.isPwa ||
this.supportsXtreamSqliteDataSource ||
this.hasElectronMethod('xtreamRequest')
);
}
private hasElectronMethod(methodName: string): boolean {
@@ -12,7 +12,11 @@ import { BehaviorSubject, firstValueFrom, of, Subject } from 'rxjs';
import { EpgService } from '@iptvnator/epg/data-access';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state';
import { PlaylistsService, SettingsStore } from '@iptvnator/services';
import {
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { Channel, PlaylistMeta } from '@iptvnator/shared/interfaces';
import { ChannelListContainerComponent } from './channel-list-container.component';
@@ -62,11 +66,15 @@ describe('ChannelListContainerComponent', () => {
let dispatch: jest.Mock;
let activePlaylistSignal: ReturnType<typeof signal<PlaylistMeta | null>>;
let favoriteChannelIds$: BehaviorSubject<string[]>;
let runtimeCapabilities: { supportsEpg: boolean };
let storageGet: jest.Mock;
beforeEach(async () => {
const routerEvents$ = new Subject<NavigationEnd>();
dispatch = jest.fn();
favoriteChannelIds$ = new BehaviorSubject<string[]>([]);
runtimeCapabilities = { supportsEpg: true };
storageGet = jest.fn().mockReturnValue(of({}));
activePlaylistSignal = signal<PlaylistMeta | null>({
_id: 'playlist-1',
title: 'Playlist One',
@@ -124,9 +132,13 @@ describe('ChannelListContainerComponent', () => {
{
provide: StorageMap,
useValue: {
get: jest.fn().mockReturnValue(of({})),
get: storageGet,
},
},
{
provide: RuntimeCapabilitiesService,
useValue: runtimeCapabilities,
},
{
provide: Store,
useValue: {
@@ -181,6 +193,30 @@ describe('ChannelListContainerComponent', () => {
);
});
it('does not enable EPG rows when runtime EPG support is unavailable', () => {
runtimeCapabilities.supportsEpg = false;
storageGet.mockReturnValue(
of({ epgUrl: ['https://example.com/epg.xml'] })
);
fixture.detectChanges();
expect(fixture.componentInstance.shouldShowEpg()).toBe(false);
expect(storageGet).not.toHaveBeenCalled();
});
it('enables EPG rows when runtime EPG support and an EPG URL are available', () => {
runtimeCapabilities.supportsEpg = true;
storageGet.mockReturnValue(
of({ epgUrl: ['https://example.com/epg.xml'] })
);
fixture.detectChanges();
expect(storageGet).toHaveBeenCalled();
expect(fixture.componentInstance.shouldShowEpg()).toBe(true);
});
it('dispatches playlist meta updates when hidden group titles change', () => {
fixture.componentInstance.onHiddenGroupTitlesChanged([
'Movies',
@@ -40,7 +40,11 @@ import {
firstValueFrom,
map,
} from 'rxjs';
import { PlaylistsService, SettingsStore } from '@iptvnator/services';
import {
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import {
Channel,
EpgProgram,
@@ -109,6 +113,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
/** Map of channel ID to current EPG program */
@@ -287,8 +292,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
ngOnInit(): void {
// Check if EPG should be shown (only in Electron with configured EPG URL)
const isElectron = !!window['electron'];
if (isElectron) {
if (this.runtime.supportsEpg) {
this.storage
.get(STORE_KEY.Settings)
.subscribe((settings: unknown) => {
@@ -0,0 +1,82 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { EpgProgressService } from '@iptvnator/epg/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { EpgSourceStatusComponent } from './epg-source-status.component';
describe('EpgSourceStatusComponent', () => {
let fixture: ComponentFixture<EpgSourceStatusComponent>;
let component: EpgSourceStatusComponent;
let runtimeCapabilities: { supportsEpg: boolean };
const imports = signal([]);
const originalElectron = window.electron;
beforeEach(async () => {
runtimeCapabilities = { supportsEpg: false };
imports.set([]);
await TestBed.configureTestingModule({
imports: [EpgSourceStatusComponent, TranslateModule.forRoot()],
providers: [
{
provide: EpgProgressService,
useValue: { imports },
},
{
provide: RuntimeCapabilitiesService,
useValue: runtimeCapabilities,
},
],
}).compileComponents();
});
afterEach(() => {
window.electron = originalElectron;
fixture?.destroy();
});
function createComponent(url = 'https://example.com/epg.xml'): void {
fixture = TestBed.createComponent(EpgSourceStatusComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('url', url);
}
it('does not check source freshness when runtime EPG support is disabled', async () => {
const checkEpgFreshness = jest.fn().mockResolvedValue({
freshUrls: ['https://example.com/epg.xml'],
staleUrls: [],
});
window.electron = {
...window.electron,
checkEpgFreshness,
} as unknown as typeof window.electron;
createComponent();
fixture.detectChanges();
await fixture.whenStable();
expect(checkEpgFreshness).not.toHaveBeenCalled();
expect(component.status()).toBe('unknown');
});
it('loads source freshness when runtime EPG support is enabled', async () => {
const url = 'https://example.com/epg.xml';
const checkEpgFreshness = jest.fn().mockResolvedValue({
freshUrls: [url],
staleUrls: [],
});
window.electron = {
...window.electron,
checkEpgFreshness,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpg = true;
createComponent(url);
fixture.detectChanges();
await fixture.whenStable();
expect(checkEpgFreshness).toHaveBeenCalledWith([url], 12);
expect(component.status()).toBe('fresh');
});
});
@@ -12,6 +12,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgProgressService } from '@iptvnator/epg/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
type BadgeStatus =
| 'loading'
@@ -37,6 +38,7 @@ export class EpgSourceStatusComponent implements OnInit {
readonly url = input.required<string>();
private readonly epgProgress = inject(EpgProgressService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly freshnessLoaded = signal(false);
private readonly isFresh = signal(false);
@@ -78,7 +80,7 @@ export class EpgSourceStatusComponent implements OnInit {
}
async ngOnInit(): Promise<void> {
if (!window.electron?.checkEpgFreshness) {
if (!this.runtime.supportsEpg) {
return;
}
const url = this.url();
@@ -1,4 +1,14 @@
import { isSelectedEpgDayToday } from './multi-epg-container.component';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { OverlayRef } from '@angular/cdk/overlay';
import { MatDialog } from '@angular/material/dialog';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
MultiEpgContainerComponent,
isSelectedEpgDayToday,
} from './multi-epg-container.component';
import { COMPONENT_OVERLAY_REF } from './overlay-ref.token';
describe('isSelectedEpgDayToday', () => {
it('returns true only when the selected EPG day is the actual current day', () => {
@@ -9,3 +19,135 @@ describe('isSelectedEpgDayToday', () => {
expect(isSelectedEpgDayToday('20260522', now)).toBe(false);
});
});
describe('MultiEpgContainerComponent runtime gates', () => {
let fixture: ComponentFixture<MultiEpgContainerComponent>;
let component: MultiEpgContainerComponent;
let runtimeCapabilities: { supportsEpg: boolean };
const originalElectron = window.electron;
beforeEach(async () => {
runtimeCapabilities = { supportsEpg: false };
await TestBed.configureTestingModule({
imports: [MultiEpgContainerComponent],
providers: [
{ provide: MatDialog, useValue: { open: jest.fn() } },
{
provide: COMPONENT_OVERLAY_REF,
useValue: { detach: jest.fn() },
},
{
provide: OverlayRef,
useValue: { detach: jest.fn() },
},
{
provide: RuntimeCapabilitiesService,
useValue: runtimeCapabilities,
},
{
provide: TranslateService,
useValue: {
currentLang: 'en',
defaultLang: 'en',
onLangChange: of(null),
},
},
],
})
.overrideComponent(MultiEpgContainerComponent, {
set: { template: '' },
})
.compileComponents();
fixture = TestBed.createComponent(MultiEpgContainerComponent);
component = fixture.componentInstance;
});
afterEach(() => {
window.electron = originalElectron;
fixture.destroy();
jest.restoreAllMocks();
jest.useRealTimers();
});
it('does not request EPG channel ranges when runtime EPG support is disabled', async () => {
const getEpgChannelsByRange = jest.fn().mockResolvedValue([]);
window.electron = {
...window.electron,
getEpgChannelsByRange,
} as unknown as typeof window.electron;
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
await component.requestPrograms();
expect(getEpgChannelsByRange).not.toHaveBeenCalled();
expect(component.isLoading()).toBe(false);
});
it('requests EPG channel ranges when runtime EPG support is enabled', async () => {
const getEpgChannelsByRange = jest.fn().mockResolvedValue([
{
channel_id: 'channel-1',
display_name: 'Channel One',
programs: [],
},
]);
window.electron = {
...window.electron,
getEpgChannelsByRange,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpg = true;
await component.requestPrograms();
expect(getEpgChannelsByRange).toHaveBeenCalledWith(0, 20);
expect(component.isLoading()).toBe(false);
});
it('does not search EPG programs when runtime EPG support is disabled', () => {
jest.useFakeTimers();
const searchEpgPrograms = jest.fn().mockResolvedValue([]);
window.electron = {
...window.electron,
searchEpgPrograms,
} as unknown as typeof window.electron;
component.onProgramSearchInput({
target: { value: 'news' },
} as unknown as Event);
jest.advanceTimersByTime(600);
expect(searchEpgPrograms).not.toHaveBeenCalled();
expect(component.isSearchingPrograms()).toBe(false);
expect(component.programSearchResults()).toEqual([]);
});
it('searches EPG programs when runtime EPG support is enabled', async () => {
jest.useFakeTimers();
const results = [
{
channelId: 'channel-1',
start: '2026-05-22T10:00:00.000Z',
stop: '2026-05-22T11:00:00.000Z',
title: 'News',
},
];
const searchEpgPrograms = jest.fn().mockResolvedValue(results);
window.electron = {
...window.electron,
searchEpgPrograms,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpg = true;
component.onProgramSearchInput({
target: { value: 'news' },
} as unknown as Event);
jest.advanceTimersByTime(500);
await Promise.resolve();
expect(searchEpgPrograms).toHaveBeenCalledWith('news', 20);
expect(component.programSearchResults()).toEqual(results);
expect(component.isSearchingPrograms()).toBe(false);
});
});
@@ -29,6 +29,7 @@ import {
EpgChannelWithPrograms,
EpgProgram,
} from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { EpgItemDescriptionComponent } from '../epg-list/epg-item-description/epg-item-description.component';
import { COMPONENT_OVERLAY_REF } from './overlay-ref.token';
@@ -181,6 +182,7 @@ export class MultiEpgContainerComponent
private readonly dialog = inject(MatDialog);
private readonly overlayRef = inject<OverlayRef>(COMPONENT_OVERLAY_REF);
private readonly runtime = inject(RuntimeCapabilitiesService);
ngOnInit() {
// Update current time line every minute
@@ -267,8 +269,8 @@ export class MultiEpgContainerComponent
}
async requestPrograms(): Promise<void> {
if (!window.electron) {
console.warn('Multi-EPG not available: Electron not detected');
if (!this.runtime.supportsEpg) {
console.warn('Multi-EPG not available in this runtime');
return;
}
@@ -460,6 +462,12 @@ export class MultiEpgContainerComponent
return;
}
if (!this.runtime.supportsEpg) {
this.programSearchResults.set([]);
this.isSearchingPrograms.set(false);
return;
}
// Debounce search by 500ms - show spinner only when search actually starts
this.searchDebounceTimer = setTimeout(async () => {
this.isSearchingPrograms.set(true);
@@ -159,10 +159,19 @@ describe('DashboardDataService', () => {
Object.defineProperty(window, 'electron', {
value: {
dbGetRecentlyViewed: jest.fn(),
dbClearRecentlyViewed: jest.fn(),
dbGetAllGlobalFavorites: jest.fn(),
dbGetGlobalRecentlyAdded: jest.fn(),
dbAddFavorite: jest.fn(),
dbRemoveRecentItem: jest.fn(),
dbRemoveFavorite: jest.fn(),
dbGetFavorites: jest.fn(),
dbReorderGlobalFavorites: jest.fn(),
dbGetRecentItems: jest.fn(),
dbAddRecentItem: jest.fn(),
dbClearPlaylistRecentItems: jest.fn(),
dbRemoveRecentItemsBatch: jest.fn(),
dbGetContentByXtreamId: jest.fn(),
} as unknown as Window['electron'],
configurable: true,
});
@@ -29,7 +29,7 @@ export interface CommandBuilderContext {
hasActivePlaylist: boolean;
hasXtreamPlaylists: boolean;
canRefreshPlaylist: boolean;
isElectron: boolean;
supportsDownloads: boolean;
showDashboard: boolean;
translate: TranslateFn;
router: Router;
@@ -139,8 +139,13 @@ export function getPlaylistCommandDefinitions(
export function getGlobalCommandDefinitions(
ctx: CommandBuilderContext
): WorkspaceCommandContribution[] {
const { route, hasXtreamPlaylists, isElectron, showDashboard, actions } =
ctx;
const {
route,
hasXtreamPlaylists,
supportsDownloads,
showDashboard,
actions,
} = ctx;
return [
{
@@ -185,7 +190,7 @@ export function getGlobalCommandDefinitions(
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.OPEN_DOWNLOADS_DESCRIPTION',
priority: 40,
visible: isElectron && route.kind !== 'downloads',
visible: supportsDownloads && route.kind !== 'downloads',
run: () => actions.openDownloadsShortcut(),
},
{
@@ -18,6 +18,7 @@ import {
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
DownloadsService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
@@ -129,6 +130,7 @@ describe('WorkspaceShellFacade', () => {
typeof signal<PlaylistSignalMeta | null>
>;
let playlistsSignal: ReturnType<typeof signal<PlaylistSignalMeta[]>>;
let downloadsActiveCountSignal: ReturnType<typeof signal<number>>;
let refreshPreparationSignal: ReturnType<
typeof signal<XtreamRefreshPreparationState | null>
>;
@@ -142,6 +144,7 @@ describe('WorkspaceShellFacade', () => {
let runtime: {
isElectron: boolean;
isMacOS: boolean;
supportsDownloads: boolean;
};
beforeEach(() => {
@@ -149,6 +152,7 @@ describe('WorkspaceShellFacade', () => {
runtime = {
isElectron: true,
isMacOS: true,
supportsDownloads: true,
};
activePlaylistSignal = signal({
@@ -161,6 +165,7 @@ describe('WorkspaceShellFacade', () => {
{ _id: 'pl-1', serverUrl: 'http://example.com' },
{ _id: 'pl-2', macAddress: '00:11:22:33' },
]);
downloadsActiveCountSignal = signal(0);
refreshPreparationSignal = signal<XtreamRefreshPreparationState | null>(
null
);
@@ -273,6 +278,12 @@ describe('WorkspaceShellFacade', () => {
provide: PlaylistsService,
useValue: playlistsService,
},
{
provide: DownloadsService,
useValue: {
activeCount: downloadsActiveCountSignal,
},
},
{
provide: MatDialog,
useValue: {
@@ -685,6 +696,19 @@ describe('WorkspaceShellFacade', () => {
expect(commands.every((command) => command.enabled)).toBe(true);
});
it('hides the downloads command when downloads are unsupported', () => {
runtime.supportsDownloads = false;
activePlaylistSignal.set(null);
playlistsSignal.set([]);
facade.currentUrl.set('/workspace/dashboard');
const commands = facade.commandPaletteCommands();
expect(commands.map((command) => command.id)).not.toContain(
'open-downloads'
);
});
it('includes M3U navigation, playlist actions, and Multi-EPG on playlist routes', () => {
const headerContext = TestBed.inject(WorkspaceHeaderContextService);
@@ -100,7 +100,7 @@ export class WorkspaceShellFacade {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly downloadsService = inject(DownloadsService);
readonly hasActiveDownloads = computed(
() => this.isElectron && this.downloadsService.activeCount() > 0
() => this.supportsDownloads && this.downloadsService.activeCount() > 0
);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
@@ -146,6 +146,11 @@ export class WorkspaceShellFacade {
get isMacOS(): boolean {
return this.runtime.isMacOS;
}
get supportsDownloads(): boolean {
return this.runtime.supportsDownloads;
}
readonly currentUrl = signal(this.router.url);
readonly currentRoute = computed(() =>
parseWorkspaceShellRoute(this.currentUrl())
@@ -437,7 +442,7 @@ export class WorkspaceShellFacade {
buildPortalRailLinks({
provider: context.provider,
playlistId: context.playlistId,
supportsDownloads: this.isElectron,
supportsDownloads: this.supportsDownloads,
workspace: true,
}).primary,
context.provider,
@@ -456,7 +461,7 @@ export class WorkspaceShellFacade {
buildPortalRailLinks({
provider: context.provider,
playlistId: context.playlistId,
supportsDownloads: this.isElectron,
supportsDownloads: this.supportsDownloads,
workspace: true,
}).secondary.filter((link) => link.section !== 'downloads'),
context.provider,
@@ -801,7 +806,7 @@ export class WorkspaceShellFacade {
(playlist) => !!playlist.serverUrl
),
canRefreshPlaylist: this.canRefreshPlaylist(),
isElectron: this.isElectron,
supportsDownloads: this.supportsDownloads,
showDashboard: this.showDashboard(),
translate: (key, params) => this.translateText(key, params),
router: this.router,