diff --git a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts index 000d74894..6d2fb6506 100644 --- a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts @@ -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, diff --git a/apps/web/src/app/settings/settings-options.spec.ts b/apps/web/src/app/settings/settings-options.spec.ts index 7ac76cf97..856d0566d 100644 --- a/apps/web/src/app/settings/settings-options.spec.ts +++ b/apps/web/src/app/settings/settings-options.spec.ts @@ -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(); diff --git a/apps/web/src/app/settings/settings-options.ts b/apps/web/src/app/settings/settings-options.ts index 93e8bb84d..a2275a994 100644 --- a/apps/web/src/app/settings/settings-options.ts +++ b/apps/web/src/app/settings/settings-options.ts @@ -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', diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html index b3a26a9d7..8b3e15080 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.html +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -69,7 +69,7 @@ - @if (isDesktop() && isExternalPlayerSelected()) { + @if (supportsManagedExternalPlayers() && isExternalPlayerSelected()) {
} - @if (isDesktop() && form().value.player === 'mpv') { + @if (supportsExternalPlayerPathSettings() && form().value.player === 'mpv') {

{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}

@@ -220,7 +220,7 @@
} - @if (isDesktop() && form().value.player === 'vlc') { + @if (supportsExternalPlayerPathSettings() && form().value.player === 'vlc') {

{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}

diff --git a/apps/web/src/app/settings/settings-playback-section.component.spec.ts b/apps/web/src/app/settings/settings-playback-section.component.spec.ts index 0658fa3ec..822a7280c 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.spec.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.spec.ts @@ -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( diff --git a/apps/web/src/app/settings/settings-playback-section.component.ts b/apps/web/src/app/settings/settings-playback-section.component.ts index f5def7a1e..a0d6a6531 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.ts @@ -45,6 +45,8 @@ export class SettingsPlaybackSectionComponent { readonly players = input.required(); readonly streamFormatEnum = input.required(); readonly isDesktop = input(false); + readonly supportsManagedExternalPlayers = input(false); + readonly supportsExternalPlayerPathSettings = input(false); readonly selectRecordingFolder = output(); isExternalPlayerSelected(): boolean { diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 314e151e4..440c65a18 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -48,10 +48,12 @@ [players]="players()" [streamFormatEnum]="streamFormatEnum" [isDesktop]="isDesktop" + [supportsManagedExternalPlayers]="supportsManagedExternalPlayers" + [supportsExternalPlayerPathSettings]="supportsExternalPlayerPathSettings" (selectRecordingFolder)="selectRecordingFolder()" /> - @if (isDesktop) { + @if (supportsEpg) { + } + @if (supportsRemoteControl) { { 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 | 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') diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 5c229ac67..8f763d291 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -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(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>([]) } : {}), 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( @@ -335,7 +351,10 @@ export class SettingsComponent implements OnInit, OnDestroy { * Fetches local IP addresses for remote control URL display */ async fetchLocalIpAddresses(): Promise { - 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 => { 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, [ diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index f479dc04f..a1268e694 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -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` diff --git a/docs/architecture/remote-control.md b/docs/architecture/remote-control.md index 72d5a4e1a..313985185 100644 --- a/docs/architecture/remote-control.md +++ b/docs/architecture/remote-control.md @@ -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` diff --git a/libs/epg/data-access/src/lib/epg-progress.service.spec.ts b/libs/epg/data-access/src/lib/epg-progress.service.spec.ts new file mode 100644 index 000000000..746d0a4f4 --- /dev/null +++ b/libs/epg/data-access/src/lib/epg-progress.service.spec.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', + }, + ]); + }); +}); diff --git a/libs/epg/data-access/src/lib/epg-progress.service.ts b/libs/epg/data-access/src/lib/epg-progress.service.ts index 277bca07c..6c19f88e2 100644 --- a/libs/epg/data-access/src/lib/epg-progress.service.ts +++ b/libs/epg/data-access/src/lib/epg-progress.service.ts @@ -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>( 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); }); diff --git a/libs/epg/data-access/src/lib/epg.service.spec.ts b/libs/epg/data-access/src/lib/epg.service.spec.ts index c5cab5637..661c0df3c 100644 --- a/libs/epg/data-access/src/lib/epg.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg.service.spec.ts @@ -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']) diff --git a/libs/epg/data-access/src/lib/epg.service.ts b/libs/epg/data-access/src/lib/epg.service.ts index c71c6739e..c3a857fde 100644 --- a/libs/epg/data-access/src/lib/epg.service.ts +++ b/libs/epg/data-access/src/lib/epg.service.ts @@ -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(false); private currentEpgPrograms = new BehaviorSubject([]); @@ -37,8 +39,6 @@ export class EpgService { private programCache = new Map(); 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 { - if (!this.isDesktop || !channelId) { + if (!this.runtime.supportsEpg || !channelId) { return of(null); } @@ -184,7 +184,7 @@ export class EpgService { getCurrentProgramsForChannels( channelIds: string[] ): Observable> { - if (!this.isDesktop) { + if (!this.runtime.supportsEpg) { return of(new Map()); } @@ -263,7 +263,7 @@ export class EpgService { getChannelMetadataForChannels( channelIds: string[] ): Observable> { - if (!this.isDesktop) { + if (!this.runtime.supportsEpg) { return of(new Map()); } diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index ec1996b4b..f9bca304e 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -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, diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 9e407777a..25279dc08 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -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; diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts index 1e5821d0a..02584545e 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts @@ -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(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[] = []; diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts index 1d9e113fe..03c800bd9 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts @@ -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, + }); } } diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html index b9856fc0d..371a490e2 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html @@ -167,7 +167,7 @@ close } - @if (item.url || item.filePath) { + @if (item.url || (item.filePath && supportsPlaylistRefresh)) {