From 78a5a7af7418bfd857be1b406a7eaf7ac23630e0 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 03:25:38 +0300 Subject: [PATCH 01/25] refactor(settings): use runtime capabilities for platform state --- apps/web/src/app/settings/settings.component.spec.ts | 4 ++++ apps/web/src/app/settings/settings.component.ts | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 2cd48a890..006f003c7 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -705,6 +705,10 @@ describe('SettingsComponent', () => { .mockResolvedValue(undefined); browserFixture.detectChanges(); + expect(browserComponent.isDesktop).toBe(false); + expect(browserComponent.isPwa).toBe(true); + expect(browserComponent.settingsForm.get('epgUrl')).toBeNull(); + mockStore.overrideSelector(selectAllPlaylistsMeta, [ createPlaylistMeta({ _id: 'browser-m3u' }), ]); diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 5c229ac67..010f6cd93 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,13 @@ 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 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; From 096a8bb80332d31a613545f629667f39736b43de Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 03:27:48 +0300 Subject: [PATCH 02/25] refactor(epg): use runtime capability for desktop guards --- .../data-access/src/lib/epg.service.spec.ts | 25 +++++++++++++++++++ libs/epg/data-access/src/lib/epg.service.ts | 14 +++++------ 2 files changed, 32 insertions(+), 7 deletions(-) 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..cbdfeb941 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,22 @@ 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('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()); } From f9742976343dfde5c4d0a47f0c5dc816982e9274 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 03:32:16 +0300 Subject: [PATCH 03/25] refactor(epg): gate progress bridge by runtime capability --- .../src/lib/epg-progress.service.spec.ts | 85 +++++++++++++++++++ .../src/lib/epg-progress.service.ts | 9 +- 2 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 libs/epg/data-access/src/lib/epg-progress.service.spec.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..77b60de45 --- /dev/null +++ b/libs/epg/data-access/src/lib/epg-progress.service.spec.ts @@ -0,0 +1,85 @@ +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('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..a3307c4d9 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,6 +50,9 @@ 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); + if (!this.runtime.supportsEpg) { + return; + } void window.electron?.forceFetchEpg?.(url); } @@ -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); }); From 030c6ef89d5aa4872be57d9a64e0e557c601d46c Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 03:35:08 +0300 Subject: [PATCH 04/25] refactor(epg): gate source freshness by runtime capability --- .../epg-source-status.component.spec.ts | 82 +++++++++++++++++++ .../epg-source-status.component.ts | 4 +- 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.spec.ts diff --git a/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.spec.ts b/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.spec.ts new file mode 100644 index 000000000..552917aed --- /dev/null +++ b/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.spec.ts @@ -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; + 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'); + }); +}); diff --git a/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.ts b/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.ts index 7356a5502..a33f019e6 100644 --- a/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.ts +++ b/libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.ts @@ -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(); 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 { - if (!window.electron?.checkEpgFreshness) { + if (!this.runtime.supportsEpg || !window.electron?.checkEpgFreshness) { return; } const url = this.url(); From 54d29f22394d7a431c1c963bb7b60a789a480048 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 03:37:47 +0300 Subject: [PATCH 05/25] refactor(epg): gate multi epg queries by runtime capability --- .../multi-epg-container.component.spec.ts | 96 ++++++++++++++++++- .../multi-epg-container.component.ts | 12 ++- 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts index addd6cb0c..4232a97e1 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts @@ -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,87 @@ describe('isSelectedEpgDayToday', () => { expect(isSelectedEpgDayToday('20260522', now)).toBe(false); }); }); + +describe('MultiEpgContainerComponent runtime gates', () => { + let fixture: ComponentFixture; + 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('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([]); + }); +}); diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts index 3923f4822..1f0e2f3eb 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts @@ -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(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 { - 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); From 03586d934afa29693c795d661f2feac71273bf5c Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 03:41:10 +0300 Subject: [PATCH 06/25] refactor(channels): gate list epg by runtime capability --- .../channel-list-container.component.spec.ts | 26 +++++++++++++++++-- .../channel-list-container.component.ts | 10 ++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts index b7bc00147..d2f1a1d3e 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts @@ -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>; let favoriteChannelIds$: BehaviorSubject; + let runtimeCapabilities: { supportsEpg: boolean }; + let storageGet: jest.Mock; beforeEach(async () => { const routerEvents$ = new Subject(); dispatch = jest.fn(); favoriteChannelIds$ = new BehaviorSubject([]); + runtimeCapabilities = { supportsEpg: true }; + storageGet = jest.fn().mockReturnValue(of({})); activePlaylistSignal = signal({ _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,16 @@ 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('dispatches playlist meta updates when hidden group titles change', () => { fixture.componentInstance.onHiddenGroupTitlesChanged([ 'Movies', diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index 0586b32f9..8eb489b43 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -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) => { From 790dbb70626b6f9a501cf58f573d0eb0f7d95197 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 03:48:09 +0300 Subject: [PATCH 07/25] refactor(portal): gate activity storage by runtime capability --- .../unified-favorites-data.service.spec.ts | 49 +++++++++++++++++ .../unified-favorites-data.service.ts | 52 ++++++++++++------- .../unified-recent-data.service.spec.ts | 44 +++++++++++++++- .../collection/unified-recent-data.service.ts | 31 +++++++---- .../lib/runtime-capabilities.service.spec.ts | 9 ++++ .../src/lib/runtime-capabilities.service.ts | 23 +++++--- .../src/lib/dashboard-data.service.spec.ts | 9 ++++ 7 files changed, 179 insertions(+), 38 deletions(-) diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts index 79f3a90d8..744f9c8e9 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts @@ -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', diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts index 2ab10870e..1db243c50 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts @@ -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 { - 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 { - 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 { 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,9 @@ export class UnifiedFavoritesDataService { } private async getSavedOrder(): Promise { - if (!window.electron?.dbGetAppState) return []; + if (!this.runtime.hasElectronMethod('dbGetAppState')) return []; try { - const raw = await window.electron.dbGetAppState( + const raw = await window.electron?.dbGetAppState( GLOBAL_FAVORITES_ORDER_KEY ); return raw ? (JSON.parse(raw) as string[]) : []; @@ -733,9 +741,9 @@ export class UnifiedFavoritesDataService { } private async saveOrder(uidOrder: string[]): Promise { - if (!window.electron?.dbSetAppState) return; + if (!this.runtime.hasElectronMethod('dbSetAppState')) return; try { - await window.electron.dbSetAppState( + await window.electron?.dbSetAppState( GLOBAL_FAVORITES_ORDER_KEY, JSON.stringify(uidOrder) ); @@ -845,4 +853,10 @@ export class UnifiedFavoritesDataService { return extractStalkerItemId(favorite); } + + private get electronActivityBridge(): Window['electron'] | undefined { + return this.runtime.supportsPortalActivityStorage + ? window.electron + : undefined; + } } diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts index 017b3fc8a..72037c787 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts @@ -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([ diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts index cb743c8e4..3da7dedd2 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts @@ -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[] = []; 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 { 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 { - 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 { 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, diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index a9d8ec726..206751d0d 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -39,10 +39,19 @@ describe('RuntimeCapabilitiesService', () => { 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(), downloadsGetList: jest.fn(), prepareEmbeddedMpv: jest.fn(), saveFileDialog: jest.fn(), diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index ec6d9bcca..d1676ffc1 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -49,13 +49,22 @@ export class RuntimeCapabilitiesService { } 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 supportsManagedExternalPlayers(): boolean { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 11907b848..d5cc41009 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -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, }); From 465b1b5559e5f4bbcae8e492dfddf5e4ce70feb1 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 03:51:01 +0300 Subject: [PATCH 08/25] refactor(stalker): gate portal sqlite sync by runtime capability --- .../with-stalker-portal.feature.spec.ts | 131 ++++++++++++++++++ .../features/with-stalker-portal.feature.ts | 18 ++- 2 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.spec.ts diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.spec.ts new file mode 100644 index 000000000..b35b5fc81 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.spec.ts @@ -0,0 +1,131 @@ +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; + let dbCreatePlaylist: jest.Mock; + let dbGetPlaylist: jest.Mock; + let runtime: { + hasElectronMethod: jest.Mock; + }; + 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 = { + hasElectronMethod: jest.fn(() => 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.hasElectronMethod.mockImplementation( + (methodName) => methodName === 'dbGetPlaylist' + ); + + 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', + }) + ); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.ts index 9a42485d2..e77e61460 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-portal.feature.ts @@ -8,20 +8,20 @@ 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?: { - dbCreatePlaylist: (playlist: { + dbCreatePlaylist?: (playlist: { id: string; name: string; macAddress: string; url: string; type: 'stalker'; }) => Promise; - dbGetPlaylist: (playlistId: string) => Promise; + dbGetPlaylist?: (playlistId: string) => Promise; }; }; @@ -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,8 @@ export function withStalkerPortal() { // Only sync if this is actually a Stalker playlist (has macAddress and portalUrl) if ( playlist && - dataService.isElectron && + runtime.hasElectronMethod('dbGetPlaylist') && + runtime.hasElectronMethod('dbCreatePlaylist') && playlist._id && playlist.macAddress && playlist.portalUrl @@ -104,7 +105,10 @@ export function withStalkerPortal() { try { const electronApi = (window as StalkerPortalWindow) .electron; - if (!electronApi) { + if ( + !electronApi?.dbGetPlaylist || + !electronApi.dbCreatePlaylist + ) { return; } From 5cf7ab93589399af2dddaad788f3165553f3af73 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 03:53:09 +0300 Subject: [PATCH 09/25] refactor(xtream): use epg runtime capability only --- .../stores/features/with-epg.feature.spec.ts | 20 +------------------ .../lib/stores/features/with-epg.feature.ts | 12 +++-------- 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts index f01456a67..82c8b31db 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts @@ -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' }, }); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts index 4161a613e..7461af037 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts @@ -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 => - dataService.isElectron - ? apiService.getFullEpg(credentials, xtreamId, { - suppressErrorLog: true, - }) - : apiService.getShortEpg(credentials, xtreamId, 10, { - suppressErrorLog: true, - }); + apiService.getFullEpg(credentials, xtreamId, { + suppressErrorLog: true, + }); return { /** From 8cfc40f59b596632522a5e49a284226470e80d17 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 03:57:54 +0300 Subject: [PATCH 10/25] refactor(xtream): gate data source by sqlite capability --- docs/architecture/pwa-self-hosted.md | 13 ++- .../src/lib/data-sources/index.spec.ts | 91 +++++++++++++++++++ .../data-access/src/lib/data-sources/index.ts | 4 +- .../lib/runtime-capabilities.service.spec.ts | 40 ++++++++ .../src/lib/runtime-capabilities.service.ts | 38 ++++++++ 5 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 libs/portal/xtream/data-access/src/lib/data-sources/index.spec.ts diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index f479dc04f..3121766e1 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -68,8 +68,9 @@ 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. ## Runtime Limitations @@ -109,8 +110,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/libs/portal/xtream/data-access/src/lib/data-sources/index.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/index.spec.ts new file mode 100644 index 000000000..09226800f --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/data-sources/index.spec.ts @@ -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 as IXtreamDataSource; + pwaSource = { + deletePlaylist: jest.fn().mockResolvedValue(undefined), + } as Partial 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(); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/index.ts b/libs/portal/xtream/data-access/src/lib/data-sources/index.ts index 9abd9febb..4644a4d43 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/index.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/index.ts @@ -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. diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index 206751d0d..db2067c97 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -22,6 +22,7 @@ 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.supportsManagedExternalPlayers).toBe(false); @@ -52,6 +53,29 @@ describe('RuntimeCapabilitiesService', () => { dbClearPlaylistRecentItems: jest.fn(), dbRemoveRecentItemsBatch: jest.fn(), dbGetContentByXtreamId: jest.fn(), + dbGetPlaylist: jest.fn(), + dbCreatePlaylist: jest.fn(), + dbUpdatePlaylist: jest.fn(), + dbDeletePlaylist: 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(), downloadsGetList: jest.fn(), prepareEmbeddedMpv: jest.fn(), saveFileDialog: jest.fn(), @@ -70,6 +94,7 @@ 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.supportsManagedExternalPlayers).toBe(true); @@ -91,6 +116,7 @@ describe('RuntimeCapabilitiesService', () => { expect(service.isMacOS).toBe(false); expect(service.supportsEpg).toBe(true); expect(service.supportsSqlite).toBe(false); + expect(service.supportsXtreamSqliteDataSource).toBe(false); expect(service.supportsDownloads).toBe(false); expect(service.supportsPortalActivityStorage).toBe(false); expect(service.supportsEmbeddedMpv).toBe(false); @@ -110,4 +136,18 @@ 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 = { + dbGetAppPlaylists: jest.fn(), + dbUpsertAppPlaylist: jest.fn(), + dbGetAppState: jest.fn(), + dbSetAppState: jest.fn(), + }; + + const service = new RuntimeCapabilitiesService(); + + expect(service.supportsSqlite).toBe(true); + expect(service.supportsXtreamSqliteDataSource).toBe(false); + }); }); diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index d1676ffc1..80d12546d 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -44,6 +44,44 @@ export class RuntimeCapabilitiesService { ); } + 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'); } From 3e398f1d1a49540479e3be4e27e3506c59a7ab57 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 04:03:54 +0300 Subject: [PATCH 11/25] refactor(remote-control): gate live bridge by capability --- docs/architecture/remote-control.md | 7 ++++++ .../video-player.component.spec.ts | 18 +++++++++++++++ .../video-player/video-player.component.ts | 23 ++++++++++++------- ...alker-live-stream-layout.component.spec.ts | 17 ++++++++++++++ .../stalker-live-stream-layout.component.ts | 20 ++++++++++------ .../live-stream-layout.component.spec.ts | 16 +++++++++++++ .../live-stream-layout.component.ts | 20 ++++++++++------ 7 files changed, 99 insertions(+), 22 deletions(-) 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/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/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index eea6577ed..8fdb120df 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -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); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index ed454b032..713b8a6c2 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -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); } diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index ded053d9f..ab16c4e13 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -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(); @@ -487,6 +489,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); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index 91ac35165..f4e0b6a6a 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -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 From 109aea0794474bc022f7073659b963779974da0d Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 04:07:56 +0300 Subject: [PATCH 12/25] refactor(runtime): require external player bridge methods --- docs/architecture/pwa-self-hosted.md | 4 +++- .../lib/runtime-capabilities.service.spec.ts | 21 +++++++++++++++++++ .../src/lib/runtime-capabilities.service.ts | 4 +++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 3121766e1..e3ab98344 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -70,7 +70,9 @@ direct `window.electron` or `DataService.getAppEnvironment()` checks. Keep feature decisions expressed as capabilities such as `supportsEpg`, `supportsSqlite`, `supportsXtreamSqliteDataSource`, `supportsDownloads`, or `supportsManagedExternalPlayers` so PWA and Electron behavior stays auditable -from one shared boundary. +from one shared boundary. `supportsManagedExternalPlayers` requires the MPV and +VLC preload launch methods (`openInMpv` and `openInVlc`); a partial Electron +bridge must not expose managed external-player actions in the PWA/shared UI. ## Runtime Limitations diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index db2067c97..65392cc91 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -77,6 +77,8 @@ describe('RuntimeCapabilitiesService', () => { dbDeleteXtreamContent: jest.fn(), dbRestoreXtreamUserData: jest.fn(), downloadsGetList: jest.fn(), + openInMpv: jest.fn(), + openInVlc: jest.fn(), prepareEmbeddedMpv: jest.fn(), saveFileDialog: jest.fn(), writeFile: jest.fn(), @@ -119,6 +121,7 @@ describe('RuntimeCapabilitiesService', () => { expect(service.supportsXtreamSqliteDataSource).toBe(false); expect(service.supportsDownloads).toBe(false); expect(service.supportsPortalActivityStorage).toBe(false); + expect(service.supportsManagedExternalPlayers).toBe(false); expect(service.supportsEmbeddedMpv).toBe(false); expect(service.supportsDesktopFileSave).toBe(false); expect(service.supportsRemoteControl).toBe(false); @@ -150,4 +153,22 @@ describe('RuntimeCapabilitiesService', () => { expect(service.supportsSqlite).toBe(true); expect(service.supportsXtreamSqliteDataSource).toBe(false); }); + + it('requires both managed external player launch methods', () => { + testWindow.electron = { + openInMpv: jest.fn(), + }; + + const service = new RuntimeCapabilitiesService(); + + expect(service.isElectron).toBe(true); + expect(service.supportsManagedExternalPlayers).toBe(false); + + testWindow.electron = { + openInMpv: jest.fn(), + openInVlc: jest.fn(), + }; + + expect(service.supportsManagedExternalPlayers).toBe(true); + }); }); diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index 80d12546d..84b6bff5d 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -106,7 +106,9 @@ export class RuntimeCapabilitiesService { } get supportsManagedExternalPlayers(): boolean { - return this.isElectron; + return ['openInMpv', 'openInVlc'].every((methodName) => + this.hasElectronMethod(methodName) + ); } get supportsEmbeddedMpv(): boolean { From 9a5620bf95b097e4b0e8dee373600fd10e69395d Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 04:11:28 +0300 Subject: [PATCH 13/25] refactor(runtime): require downloads bridge surface --- docs/architecture/pwa-self-hosted.md | 8 ++-- .../lib/runtime-capabilities.service.spec.ts | 39 +++++++++++++++++++ .../src/lib/runtime-capabilities.service.ts | 15 ++++++- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index e3ab98344..c9aba55c7 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -70,9 +70,11 @@ direct `window.electron` or `DataService.getAppEnvironment()` checks. Keep feature decisions expressed as capabilities such as `supportsEpg`, `supportsSqlite`, `supportsXtreamSqliteDataSource`, `supportsDownloads`, or `supportsManagedExternalPlayers` so PWA and Electron behavior stays auditable -from one shared boundary. `supportsManagedExternalPlayers` requires the MPV and -VLC preload launch methods (`openInMpv` and `openInVlc`); a partial Electron -bridge must not expose managed external-player actions in the PWA/shared UI. +from one shared boundary. `supportsDownloads` requires the complete downloads +preload API surface used by `DownloadsService`, and +`supportsManagedExternalPlayers` requires the MPV and VLC preload launch methods +(`openInMpv` and `openInVlc`); a partial Electron bridge must not expose +desktop-only actions in the PWA/shared UI. ## Runtime Limitations diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index 65392cc91..75ab37d2d 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -76,7 +76,18 @@ describe('RuntimeCapabilitiesService', () => { 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(), openInMpv: jest.fn(), openInVlc: jest.fn(), prepareEmbeddedMpv: jest.fn(), @@ -171,4 +182,32 @@ describe('RuntimeCapabilitiesService', () => { expect(service.supportsManagedExternalPlayers).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); + }); }); diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index 84b6bff5d..c55e8aaa9 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -83,7 +83,20 @@ export class RuntimeCapabilitiesService { } 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 { From 4603bd83d516094daa88cd716b4d458b0e6a30e5 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 04:19:05 +0300 Subject: [PATCH 14/25] refactor(runtime): gate playlist refresh by capability --- docs/architecture/pwa-self-hosted.md | 3 +- .../playlist-refresh-action.service.spec.ts | 24 ++++++-- .../lib/playlist-refresh-action.service.ts | 18 ++++-- .../playlist-item.component.html | 4 +- .../playlist-item.component.spec.ts | 57 ++++++++++++++++-- .../playlist-item/playlist-item.component.ts | 4 +- .../recent-playlists.component.spec.ts | 59 ++++++++++++------- .../recent-playlists.component.ts | 21 +++++-- .../lib/runtime-capabilities.service.spec.ts | 25 ++++++++ .../src/lib/runtime-capabilities.service.ts | 8 +++ 10 files changed, 177 insertions(+), 46 deletions(-) diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index c9aba55c7..612c98168 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -71,7 +71,8 @@ feature decisions expressed as capabilities such as `supportsEpg`, `supportsSqlite`, `supportsXtreamSqliteDataSource`, `supportsDownloads`, or `supportsManagedExternalPlayers` so PWA and Electron behavior stays auditable from one shared boundary. `supportsDownloads` requires the complete downloads -preload API surface used by `DownloadsService`, and +preload API surface used by `DownloadsService`, `supportsPlaylistRefresh` +requires the native playlist refresh/cancel/progress bridge, and `supportsManagedExternalPlayers` requires the MPV and VLC preload launch methods (`openInMpv` and `openInVlc`); a partial Electron bridge must not expose desktop-only actions in the PWA/shared UI. 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..7a92a7c94 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 @@ -77,7 +77,8 @@ describe('PlaylistRefreshActionService', () => { getAllPlaybackPositions: jest.Mock; }; let runtime: { - isElectron: boolean; + supportsPlaylistRefresh: boolean; + supportsXtreamSqliteDataSource: boolean; }; let routeProvider: ReturnType< typeof signal<'playlists' | 'xtreams' | null> @@ -131,7 +132,8 @@ describe('PlaylistRefreshActionService', () => { getAllPlaybackPositions: jest.fn().mockResolvedValue([]), }; runtime = { - isElectron: true, + supportsPlaylistRefresh: true, + supportsXtreamSqliteDataSource: true, }; routeProvider = signal<'playlists' | 'xtreams' | null>('xtreams'); resolvedPlaylistId = signal(null); @@ -195,8 +197,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 +212,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 +227,16 @@ describe('PlaylistRefreshActionService', () => { ).toBe(false); }); + 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..aa5a5b099 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 @@ -48,11 +48,18 @@ 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; + } + + return ( + this.runtime.supportsPlaylistRefresh && + Boolean(playlist.url || playlist.filePath) + ); } refresh(playlist: PlaylistMeta): void { @@ -60,9 +67,12 @@ 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); } } 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)) {