mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(runtime): address consolidated review feedback
This commit is contained in:
1 parent
582422c512
commit
ed8680116c
17 files changed
+293
-35
No files matched your search
@@ -48,7 +48,7 @@
|
|||||||
[players]="players()"
|
[players]="players()"
|
||||||
[streamFormatEnum]="streamFormatEnum"
|
[streamFormatEnum]="streamFormatEnum"
|
||||||
[isDesktop]="isDesktop"
|
[isDesktop]="isDesktop"
|
||||||
[supportsManagedExternalPlayers]="supportsManagedExternalPlayers"
|
[supportsManagedExternalPlayers]="supportsExternalPlayerPathSettings"
|
||||||
(selectRecordingFolder)="selectRecordingFolder()"
|
(selectRecordingFolder)="selectRecordingFolder()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -934,10 +934,11 @@ describe('SettingsComponent', () => {
|
|||||||
const clickSpy = jest
|
const clickSpy = jest
|
||||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||||
.mockImplementation();
|
.mockImplementation();
|
||||||
|
let partialFileSaveFixture: ComponentFixture<SettingsComponent> | null =
|
||||||
|
null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const partialFileSaveFixture =
|
partialFileSaveFixture = TestBed.createComponent(SettingsComponent);
|
||||||
TestBed.createComponent(SettingsComponent);
|
|
||||||
const partialFileSaveComponent =
|
const partialFileSaveComponent =
|
||||||
partialFileSaveFixture.componentInstance;
|
partialFileSaveFixture.componentInstance;
|
||||||
partialFileSaveComponent.checkAppVersion = jest.fn();
|
partialFileSaveComponent.checkAppVersion = jest.fn();
|
||||||
@@ -958,6 +959,7 @@ describe('SettingsComponent', () => {
|
|||||||
expect(clickSpy).toHaveBeenCalled();
|
expect(clickSpy).toHaveBeenCalled();
|
||||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:backup');
|
expect(revokeObjectURL).toHaveBeenCalledWith('blob:backup');
|
||||||
} finally {
|
} finally {
|
||||||
|
partialFileSaveFixture?.destroy();
|
||||||
clickSpy.mockRestore();
|
clickSpy.mockRestore();
|
||||||
Object.defineProperty(window.URL, 'createObjectURL', {
|
Object.defineProperty(window.URL, 'createObjectURL', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
|
|||||||
@@ -612,7 +612,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
|||||||
if (!this.supportsEpg || !url) {
|
if (!this.supportsEpg || !url) {
|
||||||
return;
|
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[])
|
const urls = (this.epgUrl.value as string[])
|
||||||
.map((url) => url?.trim())
|
.map((url) => url?.trim())
|
||||||
.filter((url): url is string => Boolean(url));
|
.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);
|
this.isClearingEpgData.set(true);
|
||||||
try {
|
try {
|
||||||
const result = await window.electron!.clearEpgData!();
|
const result = await window.electron.clearEpgData();
|
||||||
if (result && result.success === false) {
|
if (result && result.success === false) {
|
||||||
throw new Error('Clear EPG returned success=false');
|
throw new Error('Clear EPG returned success=false');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,22 @@ describe('EpgProgressService', () => {
|
|||||||
expect(forceFetchEpg).not.toHaveBeenCalled();
|
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', () => {
|
it('updates imports from Electron progress events when runtime EPG support is enabled', () => {
|
||||||
let listener: ((progress: EpgImportProgress) => void) | undefined;
|
let listener: ((progress: EpgImportProgress) => void) | undefined;
|
||||||
window.electron = {
|
window.electron = {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export class EpgProgressService {
|
|||||||
if (!this.runtime.supportsEpg) {
|
if (!this.runtime.supportsEpg) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void window.electron?.forceFetchEpg?.(url);
|
void window.electron.forceFetchEpg(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
private initializeListener(): void {
|
private initializeListener(): void {
|
||||||
|
|||||||
@@ -54,6 +54,26 @@ describe('EpgService', () => {
|
|||||||
expect(fetchEpg).not.toHaveBeenCalled();
|
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 () => {
|
it('returns an empty batch result when the desktop bridge is unavailable', async () => {
|
||||||
const result = await firstValueFrom(
|
const result = await firstValueFrom(
|
||||||
service.getCurrentProgramsForChannels(['channel-1'])
|
service.getCurrentProgramsForChannels(['channel-1'])
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Store } from '@ngrx/store';
|
|||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { DialogService } from '@iptvnator/ui/components';
|
import { DialogService } from '@iptvnator/ui/components';
|
||||||
import {
|
import {
|
||||||
|
DataService,
|
||||||
DatabaseService,
|
DatabaseService,
|
||||||
DbOperationEvent,
|
DbOperationEvent,
|
||||||
PlaybackPositionService,
|
PlaybackPositionService,
|
||||||
@@ -13,7 +14,11 @@ import {
|
|||||||
RuntimeCapabilitiesService,
|
RuntimeCapabilitiesService,
|
||||||
} from '@iptvnator/services';
|
} from '@iptvnator/services';
|
||||||
import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state';
|
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 { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||||
import { PlaylistRefreshActionService } from './playlist-refresh-action.service';
|
import { PlaylistRefreshActionService } from './playlist-refresh-action.service';
|
||||||
|
|
||||||
@@ -58,6 +63,9 @@ describe('PlaylistRefreshActionService', () => {
|
|||||||
deleteXtreamPlaylistContent: jest.Mock;
|
deleteXtreamPlaylistContent: jest.Mock;
|
||||||
updateXtreamPlaylistDetails: jest.Mock;
|
updateXtreamPlaylistDetails: jest.Mock;
|
||||||
};
|
};
|
||||||
|
let dataService: {
|
||||||
|
sendIpcEvent: jest.Mock;
|
||||||
|
};
|
||||||
let dialogService: {
|
let dialogService: {
|
||||||
openConfirmDialog: jest.Mock;
|
openConfirmDialog: jest.Mock;
|
||||||
};
|
};
|
||||||
@@ -113,6 +121,9 @@ describe('PlaylistRefreshActionService', () => {
|
|||||||
}),
|
}),
|
||||||
updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(true),
|
updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(true),
|
||||||
};
|
};
|
||||||
|
dataService = {
|
||||||
|
sendIpcEvent: jest.fn(),
|
||||||
|
};
|
||||||
dialogService = {
|
dialogService = {
|
||||||
openConfirmDialog: jest.fn(),
|
openConfirmDialog: jest.fn(),
|
||||||
};
|
};
|
||||||
@@ -167,6 +178,10 @@ describe('PlaylistRefreshActionService', () => {
|
|||||||
provide: DatabaseService,
|
provide: DatabaseService,
|
||||||
useValue: databaseService,
|
useValue: databaseService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: DataService,
|
||||||
|
useValue: dataService,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: PlaylistRefreshService,
|
provide: PlaylistRefreshService,
|
||||||
useValue: playlistRefreshService,
|
useValue: playlistRefreshService,
|
||||||
@@ -227,6 +242,45 @@ describe('PlaylistRefreshActionService', () => {
|
|||||||
).toBe(false);
|
).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', () => {
|
it('treats Xtream playlists as refreshable only when the SQLite data source is available', () => {
|
||||||
runtime.supportsXtreamSqliteDataSource = true;
|
runtime.supportsXtreamSqliteDataSource = true;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { TranslateService } from '@ngx-translate/core';
|
|||||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
import { DialogService } from '@iptvnator/ui/components';
|
import { DialogService } from '@iptvnator/ui/components';
|
||||||
import {
|
import {
|
||||||
|
DataService,
|
||||||
DatabaseService,
|
DatabaseService,
|
||||||
type DbOperationEvent,
|
type DbOperationEvent,
|
||||||
isDbAbortError,
|
isDbAbortError,
|
||||||
@@ -14,7 +15,7 @@ import {
|
|||||||
XtreamPendingRestoreService,
|
XtreamPendingRestoreService,
|
||||||
} from '@iptvnator/services';
|
} from '@iptvnator/services';
|
||||||
import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state';
|
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';
|
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||||
|
|
||||||
export interface XtreamRefreshPreparationState {
|
export interface XtreamRefreshPreparationState {
|
||||||
@@ -33,6 +34,7 @@ export class PlaylistRefreshActionService {
|
|||||||
private readonly snackBar = inject(MatSnackBar);
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
private readonly dialogService = inject(DialogService);
|
private readonly dialogService = inject(DialogService);
|
||||||
private readonly databaseService = inject(DatabaseService);
|
private readonly databaseService = inject(DatabaseService);
|
||||||
|
private readonly dataService = inject(DataService);
|
||||||
private readonly playbackPositionService = inject(PlaybackPositionService);
|
private readonly playbackPositionService = inject(PlaybackPositionService);
|
||||||
private readonly playlistRefreshService = inject(PlaylistRefreshService);
|
private readonly playlistRefreshService = inject(PlaylistRefreshService);
|
||||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||||
@@ -56,10 +58,11 @@ export class PlaylistRefreshActionService {
|
|||||||
return this.runtime.supportsXtreamSqliteDataSource;
|
return this.runtime.supportsXtreamSqliteDataSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (playlist.url) {
|
||||||
this.runtime.supportsPlaylistRefresh &&
|
return true;
|
||||||
Boolean(playlist.url || playlist.filePath)
|
}
|
||||||
);
|
|
||||||
|
return this.runtime.supportsPlaylistRefresh && Boolean(playlist.filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh(playlist: PlaylistMeta): void {
|
refresh(playlist: PlaylistMeta): void {
|
||||||
@@ -74,6 +77,12 @@ export class PlaylistRefreshActionService {
|
|||||||
(playlist.url || playlist.filePath)
|
(playlist.url || playlist.filePath)
|
||||||
) {
|
) {
|
||||||
void this.refreshM3u(playlist);
|
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[]> {
|
private async getSavedOrder(): Promise<string[]> {
|
||||||
if (!this.runtime.hasElectronMethod('dbGetAppState')) return [];
|
if (!this.runtime.supportsAppStateStorage) return [];
|
||||||
try {
|
try {
|
||||||
const raw = await window.electron?.dbGetAppState(
|
const raw = await window.electron?.dbGetAppState(
|
||||||
GLOBAL_FAVORITES_ORDER_KEY
|
GLOBAL_FAVORITES_ORDER_KEY
|
||||||
@@ -741,7 +741,7 @@ export class UnifiedFavoritesDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async saveOrder(uidOrder: string[]): Promise<void> {
|
private async saveOrder(uidOrder: string[]): Promise<void> {
|
||||||
if (!this.runtime.hasElectronMethod('dbSetAppState')) return;
|
if (!this.runtime.supportsAppStateStorage) return;
|
||||||
try {
|
try {
|
||||||
await window.electron?.dbSetAppState(
|
await window.electron?.dbSetAppState(
|
||||||
GLOBAL_FAVORITES_ORDER_KEY,
|
GLOBAL_FAVORITES_ORDER_KEY,
|
||||||
|
|||||||
+3
-5
@@ -32,7 +32,7 @@ describe('withStalkerPortal', () => {
|
|||||||
let dbCreatePlaylist: jest.Mock;
|
let dbCreatePlaylist: jest.Mock;
|
||||||
let dbGetPlaylist: jest.Mock;
|
let dbGetPlaylist: jest.Mock;
|
||||||
let runtime: {
|
let runtime: {
|
||||||
hasElectronMethod: jest.Mock<boolean, [string]>;
|
supportsStalkerPlaylistSqliteSync: boolean;
|
||||||
};
|
};
|
||||||
let stalkerSession: {
|
let stalkerSession: {
|
||||||
ensureToken: jest.Mock;
|
ensureToken: jest.Mock;
|
||||||
@@ -51,7 +51,7 @@ describe('withStalkerPortal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
runtime = {
|
runtime = {
|
||||||
hasElectronMethod: jest.fn(() => true),
|
supportsStalkerPlaylistSqliteSync: true,
|
||||||
};
|
};
|
||||||
stalkerSession = {
|
stalkerSession = {
|
||||||
ensureToken: jest.fn(),
|
ensureToken: jest.fn(),
|
||||||
@@ -101,9 +101,7 @@ describe('withStalkerPortal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not touch SQLite when the Electron bridge is partial', async () => {
|
it('does not touch SQLite when the Electron bridge is partial', async () => {
|
||||||
runtime.hasElectronMethod.mockImplementation(
|
runtime.supportsStalkerPlaylistSqliteSync = false;
|
||||||
(methodName) => methodName === 'dbGetPlaylist'
|
|
||||||
);
|
|
||||||
|
|
||||||
await store.setCurrentPlaylist(PLAYLIST);
|
await store.setCurrentPlaylist(PLAYLIST);
|
||||||
|
|
||||||
|
|||||||
+4
-11
@@ -13,15 +13,15 @@ import { StalkerSessionService } from '../../stalker-session.service';
|
|||||||
import { toStalkerSessionPlaylist } from '../utils';
|
import { toStalkerSessionPlaylist } from '../utils';
|
||||||
|
|
||||||
type StalkerPortalWindow = Window & {
|
type StalkerPortalWindow = Window & {
|
||||||
electron?: {
|
electron: {
|
||||||
dbCreatePlaylist?: (playlist: {
|
dbCreatePlaylist: (playlist: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
macAddress: string;
|
macAddress: string;
|
||||||
url: string;
|
url: string;
|
||||||
type: 'stalker';
|
type: 'stalker';
|
||||||
}) => Promise<unknown>;
|
}) => 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)
|
// Only sync if this is actually a Stalker playlist (has macAddress and portalUrl)
|
||||||
if (
|
if (
|
||||||
playlist &&
|
playlist &&
|
||||||
runtime.hasElectronMethod('dbGetPlaylist') &&
|
runtime.supportsStalkerPlaylistSqliteSync &&
|
||||||
runtime.hasElectronMethod('dbCreatePlaylist') &&
|
|
||||||
playlist._id &&
|
playlist._id &&
|
||||||
playlist.macAddress &&
|
playlist.macAddress &&
|
||||||
playlist.portalUrl
|
playlist.portalUrl
|
||||||
@@ -105,12 +104,6 @@ export function withStalkerPortal() {
|
|||||||
try {
|
try {
|
||||||
const electronApi = (window as StalkerPortalWindow)
|
const electronApi = (window as StalkerPortalWindow)
|
||||||
.electron;
|
.electron;
|
||||||
if (
|
|
||||||
!electronApi?.dbGetPlaylist ||
|
|
||||||
!electronApi.dbCreatePlaylist
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const playlistId = String(playlist._id);
|
const playlistId = String(playlist._id);
|
||||||
// Check if playlist exists in SQLite
|
// Check if playlist exists in SQLite
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ describe('RuntimeCapabilitiesService', () => {
|
|||||||
expect(service.supportsXtreamSqliteDataSource).toBe(false);
|
expect(service.supportsXtreamSqliteDataSource).toBe(false);
|
||||||
expect(service.supportsDownloads).toBe(false);
|
expect(service.supportsDownloads).toBe(false);
|
||||||
expect(service.supportsPortalActivityStorage).toBe(false);
|
expect(service.supportsPortalActivityStorage).toBe(false);
|
||||||
|
expect(service.supportsAppStateStorage).toBe(false);
|
||||||
|
expect(service.supportsStalkerPlaylistSqliteSync).toBe(false);
|
||||||
expect(service.supportsPlaylistRefresh).toBe(false);
|
expect(service.supportsPlaylistRefresh).toBe(false);
|
||||||
expect(service.supportsManagedExternalPlayers).toBe(false);
|
expect(service.supportsManagedExternalPlayers).toBe(false);
|
||||||
expect(service.supportsExternalPlayerPathSettings).toBe(false);
|
expect(service.supportsExternalPlayerPathSettings).toBe(false);
|
||||||
@@ -128,6 +130,8 @@ describe('RuntimeCapabilitiesService', () => {
|
|||||||
expect(service.supportsXtreamSqliteDataSource).toBe(true);
|
expect(service.supportsXtreamSqliteDataSource).toBe(true);
|
||||||
expect(service.supportsDownloads).toBe(true);
|
expect(service.supportsDownloads).toBe(true);
|
||||||
expect(service.supportsPortalActivityStorage).toBe(true);
|
expect(service.supportsPortalActivityStorage).toBe(true);
|
||||||
|
expect(service.supportsAppStateStorage).toBe(true);
|
||||||
|
expect(service.supportsStalkerPlaylistSqliteSync).toBe(true);
|
||||||
expect(service.supportsPlaylistRefresh).toBe(true);
|
expect(service.supportsPlaylistRefresh).toBe(true);
|
||||||
expect(service.supportsManagedExternalPlayers).toBe(true);
|
expect(service.supportsManagedExternalPlayers).toBe(true);
|
||||||
expect(service.supportsExternalPlayerPathSettings).toBe(true);
|
expect(service.supportsExternalPlayerPathSettings).toBe(true);
|
||||||
@@ -152,6 +156,8 @@ describe('RuntimeCapabilitiesService', () => {
|
|||||||
expect(service.supportsXtreamSqliteDataSource).toBe(false);
|
expect(service.supportsXtreamSqliteDataSource).toBe(false);
|
||||||
expect(service.supportsDownloads).toBe(false);
|
expect(service.supportsDownloads).toBe(false);
|
||||||
expect(service.supportsPortalActivityStorage).toBe(false);
|
expect(service.supportsPortalActivityStorage).toBe(false);
|
||||||
|
expect(service.supportsAppStateStorage).toBe(false);
|
||||||
|
expect(service.supportsStalkerPlaylistSqliteSync).toBe(false);
|
||||||
expect(service.supportsPlaylistRefresh).toBe(false);
|
expect(service.supportsPlaylistRefresh).toBe(false);
|
||||||
expect(service.supportsManagedExternalPlayers).toBe(false);
|
expect(service.supportsManagedExternalPlayers).toBe(false);
|
||||||
expect(service.supportsExternalPlayerPathSettings).toBe(false);
|
expect(service.supportsExternalPlayerPathSettings).toBe(false);
|
||||||
@@ -179,6 +185,45 @@ describe('RuntimeCapabilitiesService', () => {
|
|||||||
const service = new RuntimeCapabilitiesService();
|
const service = new RuntimeCapabilitiesService();
|
||||||
|
|
||||||
expect(service.supportsSqlite).toBe(true);
|
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);
|
expect(service.supportsXtreamSqliteDataSource).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -324,6 +369,16 @@ describe('RuntimeCapabilitiesService', () => {
|
|||||||
expect(service.supportsXtreamSqliteDataSource).toBe(false);
|
expect(service.supportsXtreamSqliteDataSource).toBe(false);
|
||||||
expect(service.supportsXtreamSectionNavigation).toBe(true);
|
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> {
|
function createPlaylistStorageBridge(): Record<string, jest.Mock> {
|
||||||
@@ -338,3 +393,41 @@ function createPlaylistStorageBridge(): Record<string, jest.Mock> {
|
|||||||
dbUpsertAppPlaylists: jest.fn(),
|
dbUpsertAppPlaylists: jest.fn(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createXtreamSqliteBridge(): Record<string, jest.Mock> {
|
||||||
|
return {
|
||||||
|
dbAddFavorite: jest.fn(),
|
||||||
|
dbAddRecentItem: jest.fn(),
|
||||||
|
dbClearAllPlaybackPositions: jest.fn(),
|
||||||
|
dbClearPlaybackPosition: jest.fn(),
|
||||||
|
dbClearPlaylistRecentItems: jest.fn(),
|
||||||
|
dbCreatePlaylist: jest.fn(),
|
||||||
|
dbDeletePlaylist: jest.fn(),
|
||||||
|
dbDeleteXtreamContent: jest.fn(),
|
||||||
|
dbGetAllCategories: jest.fn(),
|
||||||
|
dbGetAllPlaybackPositions: jest.fn(),
|
||||||
|
dbGetAppState: jest.fn(),
|
||||||
|
dbGetCategories: jest.fn(),
|
||||||
|
dbGetContent: jest.fn(),
|
||||||
|
dbGetContentByXtreamId: jest.fn(),
|
||||||
|
dbGetFavorites: jest.fn(),
|
||||||
|
dbGetPlaylist: jest.fn(),
|
||||||
|
dbGetPlaybackPosition: jest.fn(),
|
||||||
|
dbGetRecentItems: jest.fn(),
|
||||||
|
dbGetRecentPlaybackPositions: jest.fn(),
|
||||||
|
dbGetSeriesPlaybackPositions: jest.fn(),
|
||||||
|
dbHasCategories: jest.fn(),
|
||||||
|
dbHasContent: jest.fn(),
|
||||||
|
dbIsFavorite: jest.fn(),
|
||||||
|
dbRemoveFavorite: jest.fn(),
|
||||||
|
dbRemoveRecentItem: jest.fn(),
|
||||||
|
dbRestoreXtreamUserData: jest.fn(),
|
||||||
|
dbSaveCategories: jest.fn(),
|
||||||
|
dbSaveContent: jest.fn(),
|
||||||
|
dbSavePlaybackPosition: jest.fn(),
|
||||||
|
dbSearchContent: jest.fn(),
|
||||||
|
dbSetAppState: jest.fn(),
|
||||||
|
dbUpdateCategoryVisibility: jest.fn(),
|
||||||
|
dbUpdatePlaylist: jest.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -130,6 +130,18 @@ export class RuntimeCapabilitiesService {
|
|||||||
].every((methodName) => this.hasElectronMethod(methodName));
|
].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 {
|
get supportsPlaylistRefresh(): boolean {
|
||||||
return [
|
return [
|
||||||
'refreshPlaylist',
|
'refreshPlaylist',
|
||||||
|
|||||||
+15
-1
@@ -195,7 +195,9 @@ describe('ChannelListContainerComponent', () => {
|
|||||||
|
|
||||||
it('does not enable EPG rows when runtime EPG support is unavailable', () => {
|
it('does not enable EPG rows when runtime EPG support is unavailable', () => {
|
||||||
runtimeCapabilities.supportsEpg = false;
|
runtimeCapabilities.supportsEpg = false;
|
||||||
storageGet.mockReturnValue(of({ epgUrl: ['https://example.com/epg.xml'] }));
|
storageGet.mockReturnValue(
|
||||||
|
of({ epgUrl: ['https://example.com/epg.xml'] })
|
||||||
|
);
|
||||||
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
@@ -203,6 +205,18 @@ describe('ChannelListContainerComponent', () => {
|
|||||||
expect(storageGet).not.toHaveBeenCalled();
|
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', () => {
|
it('dispatches playlist meta updates when hidden group titles change', () => {
|
||||||
fixture.componentInstance.onHiddenGroupTitlesChanged([
|
fixture.componentInstance.onHiddenGroupTitlesChanged([
|
||||||
'Movies',
|
'Movies',
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export class EpgSourceStatusComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
if (!this.runtime.supportsEpg || !window.electron?.checkEpgFreshness) {
|
if (!this.runtime.supportsEpg) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const url = this.url();
|
const url = this.url();
|
||||||
|
|||||||
@@ -85,6 +85,26 @@ describe('MultiEpgContainerComponent runtime gates', () => {
|
|||||||
expect(component.isLoading()).toBe(false);
|
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', () => {
|
it('does not search EPG programs when runtime EPG support is disabled', () => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
const searchEpgPrograms = jest.fn().mockResolvedValue([]);
|
const searchEpgPrograms = jest.fn().mockResolvedValue([]);
|
||||||
@@ -102,4 +122,32 @@ describe('MultiEpgContainerComponent runtime gates', () => {
|
|||||||
expect(component.isSearchingPrograms()).toBe(false);
|
expect(component.isSearchingPrograms()).toBe(false);
|
||||||
expect(component.programSearchResults()).toEqual([]);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+1
-2
@@ -697,8 +697,7 @@ describe('WorkspaceShellFacade', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('hides the downloads command when downloads are unsupported', () => {
|
it('hides the downloads command when downloads are unsupported', () => {
|
||||||
(facade as unknown as { supportsDownloads: boolean }).supportsDownloads =
|
runtime.supportsDownloads = false;
|
||||||
false;
|
|
||||||
activePlaylistSignal.set(null);
|
activePlaylistSignal.set(null);
|
||||||
playlistsSignal.set([]);
|
playlistsSignal.set([]);
|
||||||
facade.currentUrl.set('/workspace/dashboard');
|
facade.currentUrl.set('/workspace/dashboard');
|
||||||
|
|||||||
Reference in new issue
Block a user