fix(runtime): address consolidated review feedback

This commit is contained in:
4gray committed 2026-05-22 14:02:56 +03:00
1 parent 582422c512
commit ed8680116c
17 files changed
+293 -35

No files matched your search

@@ -48,7 +48,7 @@
[players]="players()"
[streamFormatEnum]="streamFormatEnum"
[isDesktop]="isDesktop"
[supportsManagedExternalPlayers]="supportsManagedExternalPlayers"
[supportsManagedExternalPlayers]="supportsExternalPlayerPathSettings"
(selectRecordingFolder)="selectRecordingFolder()"
/>
@@ -934,10 +934,11 @@ describe('SettingsComponent', () => {
const clickSpy = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation();
let partialFileSaveFixture: ComponentFixture<SettingsComponent> | null =
null;
try {
const partialFileSaveFixture =
TestBed.createComponent(SettingsComponent);
partialFileSaveFixture = TestBed.createComponent(SettingsComponent);
const partialFileSaveComponent =
partialFileSaveFixture.componentInstance;
partialFileSaveComponent.checkAppVersion = jest.fn();
@@ -958,6 +959,7 @@ describe('SettingsComponent', () => {
expect(clickSpy).toHaveBeenCalled();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:backup');
} finally {
partialFileSaveFixture?.destroy();
clickSpy.mockRestore();
Object.defineProperty(window.URL, 'createObjectURL', {
configurable: true,
@@ -612,7 +612,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
if (!this.supportsEpg || !url) {
return;
}
void window.electron!.forceFetchEpg!(url);
void window.electron.forceFetchEpg(url);
}
/**
@@ -625,7 +625,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
const urls = (this.epgUrl.value as string[])
.map((url) => url?.trim())
.filter((url): url is string => Boolean(url));
urls.forEach((url) => void window.electron!.forceFetchEpg!(url));
urls.forEach((url) => void window.electron.forceFetchEpg(url));
}
/**
@@ -666,7 +666,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.isClearingEpgData.set(true);
try {
const result = await window.electron!.clearEpgData!();
const result = await window.electron.clearEpgData();
if (result && result.success === false) {
throw new Error('Clear EPG returned success=false');
}
@@ -58,6 +58,22 @@ describe('EpgProgressService', () => {
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 = {
@@ -53,7 +53,7 @@ export class EpgProgressService {
if (!this.runtime.supportsEpg) {
return;
}
void window.electron?.forceFetchEpg?.(url);
void window.electron.forceFetchEpg(url);
}
private initializeListener(): void {
@@ -54,6 +54,26 @@ describe('EpgService', () => {
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'])
@@ -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;
};
@@ -113,6 +121,9 @@ describe('PlaylistRefreshActionService', () => {
}),
updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(true),
};
dataService = {
sendIpcEvent: jest.fn(),
};
dialogService = {
openConfirmDialog: jest.fn(),
};
@@ -167,6 +178,10 @@ describe('PlaylistRefreshActionService', () => {
provide: DatabaseService,
useValue: databaseService,
},
{
provide: DataService,
useValue: dataService,
},
{
provide: PlaylistRefreshService,
useValue: playlistRefreshService,
@@ -227,6 +242,45 @@ 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;
@@ -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);
@@ -56,10 +58,11 @@ export class PlaylistRefreshActionService {
return this.runtime.supportsXtreamSqliteDataSource;
}
return (
this.runtime.supportsPlaylistRefresh &&
Boolean(playlist.url || playlist.filePath)
);
if (playlist.url) {
return true;
}
return this.runtime.supportsPlaylistRefresh && Boolean(playlist.filePath);
}
refresh(playlist: PlaylistMeta): void {
@@ -74,6 +77,12 @@ export class PlaylistRefreshActionService {
(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,
});
}
}
@@ -729,7 +729,7 @@ export class UnifiedFavoritesDataService {
}
private async getSavedOrder(): Promise<string[]> {
if (!this.runtime.hasElectronMethod('dbGetAppState')) return [];
if (!this.runtime.supportsAppStateStorage) return [];
try {
const raw = await window.electron?.dbGetAppState(
GLOBAL_FAVORITES_ORDER_KEY
@@ -741,7 +741,7 @@ export class UnifiedFavoritesDataService {
}
private async saveOrder(uidOrder: string[]): Promise<void> {
if (!this.runtime.hasElectronMethod('dbSetAppState')) return;
if (!this.runtime.supportsAppStateStorage) return;
try {
await window.electron?.dbSetAppState(
GLOBAL_FAVORITES_ORDER_KEY,
@@ -32,7 +32,7 @@ describe('withStalkerPortal', () => {
let dbCreatePlaylist: jest.Mock;
let dbGetPlaylist: jest.Mock;
let runtime: {
hasElectronMethod: jest.Mock<boolean, [string]>;
supportsStalkerPlaylistSqliteSync: boolean;
};
let stalkerSession: {
ensureToken: jest.Mock;
@@ -51,7 +51,7 @@ describe('withStalkerPortal', () => {
});
runtime = {
hasElectronMethod: jest.fn(() => true),
supportsStalkerPlaylistSqliteSync: true,
};
stalkerSession = {
ensureToken: jest.fn(),
@@ -101,9 +101,7 @@ describe('withStalkerPortal', () => {
});
it('does not touch SQLite when the Electron bridge is partial', async () => {
runtime.hasElectronMethod.mockImplementation(
(methodName) => methodName === 'dbGetPlaylist'
);
runtime.supportsStalkerPlaylistSqliteSync = false;
await store.setCurrentPlaylist(PLAYLIST);
@@ -13,15 +13,15 @@ import { StalkerSessionService } from '../../stalker-session.service';
import { toStalkerSessionPlaylist } from '../utils';
type StalkerPortalWindow = Window & {
electron?: {
dbCreatePlaylist?: (playlist: {
electron: {
dbCreatePlaylist: (playlist: {
id: string;
name: string;
macAddress: string;
url: string;
type: 'stalker';
}) => Promise<unknown>;
dbGetPlaylist?: (playlistId: string) => Promise<unknown>;
dbGetPlaylist: (playlistId: string) => Promise<unknown>;
};
};
@@ -96,8 +96,7 @@ export function withStalkerPortal() {
// Only sync if this is actually a Stalker playlist (has macAddress and portalUrl)
if (
playlist &&
runtime.hasElectronMethod('dbGetPlaylist') &&
runtime.hasElectronMethod('dbCreatePlaylist') &&
runtime.supportsStalkerPlaylistSqliteSync &&
playlist._id &&
playlist.macAddress &&
playlist.portalUrl
@@ -105,12 +104,6 @@ export function withStalkerPortal() {
try {
const electronApi = (window as StalkerPortalWindow)
.electron;
if (
!electronApi?.dbGetPlaylist ||
!electronApi.dbCreatePlaylist
) {
return;
}
const playlistId = String(playlist._id);
// Check if playlist exists in SQLite
@@ -25,6 +25,8 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsXtreamSqliteDataSource).toBe(false);
expect(service.supportsDownloads).toBe(false);
expect(service.supportsPortalActivityStorage).toBe(false);
expect(service.supportsAppStateStorage).toBe(false);
expect(service.supportsStalkerPlaylistSqliteSync).toBe(false);
expect(service.supportsPlaylistRefresh).toBe(false);
expect(service.supportsManagedExternalPlayers).toBe(false);
expect(service.supportsExternalPlayerPathSettings).toBe(false);
@@ -128,6 +130,8 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsXtreamSqliteDataSource).toBe(true);
expect(service.supportsDownloads).toBe(true);
expect(service.supportsPortalActivityStorage).toBe(true);
expect(service.supportsAppStateStorage).toBe(true);
expect(service.supportsStalkerPlaylistSqliteSync).toBe(true);
expect(service.supportsPlaylistRefresh).toBe(true);
expect(service.supportsManagedExternalPlayers).toBe(true);
expect(service.supportsExternalPlayerPathSettings).toBe(true);
@@ -152,6 +156,8 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsXtreamSqliteDataSource).toBe(false);
expect(service.supportsDownloads).toBe(false);
expect(service.supportsPortalActivityStorage).toBe(false);
expect(service.supportsAppStateStorage).toBe(false);
expect(service.supportsStalkerPlaylistSqliteSync).toBe(false);
expect(service.supportsPlaylistRefresh).toBe(false);
expect(service.supportsManagedExternalPlayers).toBe(false);
expect(service.supportsExternalPlayerPathSettings).toBe(false);
@@ -179,6 +185,45 @@ describe('RuntimeCapabilitiesService', () => {
const service = new RuntimeCapabilitiesService();
expect(service.supportsSqlite).toBe(true);
expect(service.supportsAppStateStorage).toBe(true);
expect(service.supportsXtreamSqliteDataSource).toBe(false);
});
it('checks app state storage without requiring full playlist SQLite support', () => {
testWindow.electron = {
dbGetAppState: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsAppStateStorage).toBe(false);
testWindow.electron = {
dbGetAppState: jest.fn(),
dbSetAppState: jest.fn(),
};
expect(service.supportsAppStateStorage).toBe(true);
expect(service.supportsSqlite).toBe(false);
});
it('checks Stalker playlist SQLite sync without requiring the full Xtream data source', () => {
testWindow.electron = {
dbGetPlaylist: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsStalkerPlaylistSqliteSync).toBe(false);
testWindow.electron = {
dbGetPlaylist: jest.fn(),
dbCreatePlaylist: jest.fn(),
};
expect(service.supportsStalkerPlaylistSqliteSync).toBe(true);
expect(service.supportsXtreamSqliteDataSource).toBe(false);
});
@@ -324,6 +369,16 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsXtreamSqliteDataSource).toBe(false);
expect(service.supportsXtreamSectionNavigation).toBe(true);
});
it('supports Xtream section navigation in Electron when only the SQLite data source is available', () => {
testWindow.electron = createXtreamSqliteBridge();
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsXtreamSqliteDataSource).toBe(true);
expect(service.supportsXtreamSectionNavigation).toBe(true);
});
});
function createPlaylistStorageBridge(): Record<string, jest.Mock> {
@@ -338,3 +393,41 @@ function createPlaylistStorageBridge(): Record<string, jest.Mock> {
dbUpsertAppPlaylists: jest.fn(),
};
}
function createXtreamSqliteBridge(): Record<string, jest.Mock> {
return {
dbAddFavorite: jest.fn(),
dbAddRecentItem: jest.fn(),
dbClearAllPlaybackPositions: jest.fn(),
dbClearPlaybackPosition: jest.fn(),
dbClearPlaylistRecentItems: jest.fn(),
dbCreatePlaylist: jest.fn(),
dbDeletePlaylist: jest.fn(),
dbDeleteXtreamContent: jest.fn(),
dbGetAllCategories: jest.fn(),
dbGetAllPlaybackPositions: jest.fn(),
dbGetAppState: jest.fn(),
dbGetCategories: jest.fn(),
dbGetContent: jest.fn(),
dbGetContentByXtreamId: jest.fn(),
dbGetFavorites: jest.fn(),
dbGetPlaylist: jest.fn(),
dbGetPlaybackPosition: jest.fn(),
dbGetRecentItems: jest.fn(),
dbGetRecentPlaybackPositions: jest.fn(),
dbGetSeriesPlaybackPositions: jest.fn(),
dbHasCategories: jest.fn(),
dbHasContent: jest.fn(),
dbIsFavorite: jest.fn(),
dbRemoveFavorite: jest.fn(),
dbRemoveRecentItem: jest.fn(),
dbRestoreXtreamUserData: jest.fn(),
dbSaveCategories: jest.fn(),
dbSaveContent: jest.fn(),
dbSavePlaybackPosition: jest.fn(),
dbSearchContent: jest.fn(),
dbSetAppState: jest.fn(),
dbUpdateCategoryVisibility: jest.fn(),
dbUpdatePlaylist: jest.fn(),
};
}
@@ -130,6 +130,18 @@ export class RuntimeCapabilitiesService {
].every((methodName) => this.hasElectronMethod(methodName));
}
get supportsAppStateStorage(): boolean {
return ['dbGetAppState', 'dbSetAppState'].every((methodName) =>
this.hasElectronMethod(methodName)
);
}
get supportsStalkerPlaylistSqliteSync(): boolean {
return ['dbGetPlaylist', 'dbCreatePlaylist'].every((methodName) =>
this.hasElectronMethod(methodName)
);
}
get supportsPlaylistRefresh(): boolean {
return [
'refreshPlaylist',
@@ -195,7 +195,9 @@ 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'] }));
storageGet.mockReturnValue(
of({ epgUrl: ['https://example.com/epg.xml'] })
);
fixture.detectChanges();
@@ -203,6 +205,18 @@ describe('ChannelListContainerComponent', () => {
expect(storageGet).not.toHaveBeenCalled();
});
it('enables EPG rows when runtime EPG support and an EPG URL are available', () => {
runtimeCapabilities.supportsEpg = true;
storageGet.mockReturnValue(
of({ epgUrl: ['https://example.com/epg.xml'] })
);
fixture.detectChanges();
expect(storageGet).toHaveBeenCalled();
expect(fixture.componentInstance.shouldShowEpg()).toBe(true);
});
it('dispatches playlist meta updates when hidden group titles change', () => {
fixture.componentInstance.onHiddenGroupTitlesChanged([
'Movies',
@@ -80,7 +80,7 @@ export class EpgSourceStatusComponent implements OnInit {
}
async ngOnInit(): Promise<void> {
if (!this.runtime.supportsEpg || !window.electron?.checkEpgFreshness) {
if (!this.runtime.supportsEpg) {
return;
}
const url = this.url();
@@ -85,6 +85,26 @@ describe('MultiEpgContainerComponent runtime gates', () => {
expect(component.isLoading()).toBe(false);
});
it('requests EPG channel ranges when runtime EPG support is enabled', async () => {
const getEpgChannelsByRange = jest.fn().mockResolvedValue([
{
channel_id: 'channel-1',
display_name: 'Channel One',
programs: [],
},
]);
window.electron = {
...window.electron,
getEpgChannelsByRange,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpg = true;
await component.requestPrograms();
expect(getEpgChannelsByRange).toHaveBeenCalledWith(0, 20);
expect(component.isLoading()).toBe(false);
});
it('does not search EPG programs when runtime EPG support is disabled', () => {
jest.useFakeTimers();
const searchEpgPrograms = jest.fn().mockResolvedValue([]);
@@ -102,4 +122,32 @@ describe('MultiEpgContainerComponent runtime gates', () => {
expect(component.isSearchingPrograms()).toBe(false);
expect(component.programSearchResults()).toEqual([]);
});
it('searches EPG programs when runtime EPG support is enabled', async () => {
jest.useFakeTimers();
const results = [
{
channelId: 'channel-1',
start: '2026-05-22T10:00:00.000Z',
stop: '2026-05-22T11:00:00.000Z',
title: 'News',
},
];
const searchEpgPrograms = jest.fn().mockResolvedValue(results);
window.electron = {
...window.electron,
searchEpgPrograms,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpg = true;
component.onProgramSearchInput({
target: { value: 'news' },
} as unknown as Event);
jest.advanceTimersByTime(500);
await Promise.resolve();
expect(searchEpgPrograms).toHaveBeenCalledWith('news', 20);
expect(component.programSearchResults()).toEqual(results);
expect(component.isSearchingPrograms()).toBe(false);
});
});
@@ -697,8 +697,7 @@ describe('WorkspaceShellFacade', () => {
});
it('hides the downloads command when downloads are unsupported', () => {
(facade as unknown as { supportsDownloads: boolean }).supportsDownloads =
false;
runtime.supportsDownloads = false;
activePlaylistSignal.set(null);
playlistsSignal.set([]);
facade.currentUrl.set('/workspace/dashboard');