mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
Merge pull request #1004 from 4gray/agent/pwa-electron-runtime-consolidated
refactor(runtime): consolidate PWA and Electron capability gates
This commit is contained in:
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();
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
[
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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"
|
||||
|
||||
+52
-5
@@ -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 = {
|
||||
|
||||
+3
-1
@@ -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(
|
||||
|
||||
+37
-22
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+49
@@ -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',
|
||||
|
||||
+35
-19
@@ -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;
|
||||
}
|
||||
}
|
||||
+43
-1
@@ -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',
|
||||
|
||||
+129
@@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
+5
-8
@@ -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
|
||||
|
||||
+17
@@ -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);
|
||||
|
||||
+13
-7
@@ -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 {
|
||||
/**
|
||||
|
||||
+23
@@ -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);
|
||||
|
||||
|
||||
+13
-7
@@ -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 {
|
||||
|
||||
+38
-2
@@ -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',
|
||||
|
||||
+7
-3
@@ -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,
|
||||
});
|
||||
|
||||
+9
-4
@@ -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(),
|
||||
},
|
||||
{
|
||||
|
||||
+24
@@ -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);
|
||||
|
||||
|
||||
+9
-4
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user