Files
iptvnator/libs/services/src/lib/runtime-capabilities.service.ts
T
4gray 2fa03c8efc fix(xtream): preserve pending backups and restore positions atomically (#1873)
* fix(xtream): preserve pending backups and restore positions atomically

* fix(xtream): retain sparse duplicate playback metadata on restore

* fix(xtream): require atomic restore in SQLite capability gate

* fix(xtream): preflight atomic restore without changing storage
2026-10-10 16:27:01 +02:00

367 lines
11 KiB
TypeScript

import { Injectable } from '@angular/core';
import { ElectronBridgeApi } from '@iptvnator/shared/interfaces';
export type RuntimeEnvironment = 'electron' | 'pwa';
type RuntimeElectronBridge = Partial<ElectronBridgeApi>;
// Ordinary playback methods include season batches, so partial bridges use
// the in-memory path instead of throwing mid-action. Atomic backup restore is
// checked separately by a restore-only preflight; it never changes data-source
// selection or disables ordinary SQLite reads and playback writes.
const playbackPositionStorageMethods = [
'dbSavePlaybackPosition',
'dbGetPlaybackPosition',
'dbGetSeriesPlaybackPositions',
'dbGetRecentPlaybackPositions',
'dbGetAllPlaybackPositions',
'dbClearAllPlaybackPositions',
'dbClearPlaybackPosition',
'dbSavePlaybackPositionsBatch',
'dbClearPlaybackPositionsBatch',
];
@Injectable({ providedIn: 'root' })
export class RuntimeCapabilitiesService {
get environment(): RuntimeEnvironment {
return this.isElectron ? 'electron' : 'pwa';
}
get isElectron(): boolean {
return !!this.electronBridge;
}
get isPwa(): boolean {
return !this.isElectron;
}
get platform(): string | undefined {
const platform = this.electronBridge?.['platform'];
return typeof platform === 'string' ? platform : undefined;
}
get isMacOS(): boolean {
return this.platform === 'darwin';
}
get isWindows(): boolean {
return this.platform === 'win32';
}
get isLinux(): boolean {
return this.platform === 'linux';
}
/**
* True when the window has no native title bar and the renderer must
* draw its own window-management buttons (Windows/Linux). macOS keeps
* the native traffic lights instead.
*/
get usesCustomWindowControls(): boolean {
return (
(this.isWindows || this.isLinux) &&
[
'minimizeWindow',
'toggleMaximizeWindow',
'closeWindow',
'getWindowState',
'onWindowStateChange',
].every((methodName) => this.hasElectronMethod(methodName))
);
}
/** Desktop-only preference: PWA clients cannot configure the shared server guard. */
get supportsPortalConnectivityGuard(): boolean {
return (
this.hasElectronMethod('updateSettings') &&
this.hasElectronMethod('resetHostConnectivityGuard')
);
}
/**
* Desktop window mode at launch (normal / maximized / fullscreen) plus
* the F11 toggle. The mode reaches the main process through
* `updateSettings`, and the toggle is what makes a fullscreen launch
* escapable on Windows/Linux, where the title bar is hidden — so the
* setting is offered only when both halves exist.
*/
get supportsStartupWindowMode(): boolean {
return (
this.hasElectronMethod('updateSettings') &&
this.hasElectronMethod('toggleFullScreenWindow')
);
}
get supportsSourceHealth(): boolean {
return (
this.hasElectronMethod('probeM3uSource') &&
this.hasElectronMethod('cancelSourceProbe')
);
}
get supportsEpg(): boolean {
return (
this.supportsEpgImport &&
this.supportsEpgProgramLookup &&
this.supportsEpgSourceFreshness &&
this.supportsEpgDataManagement &&
this.supportsEpgGuide &&
this.supportsEpgProgramSearch
);
}
get supportsEpgImport(): boolean {
return this.hasElectronMethod('fetchEpg');
}
/** Native file picker for local XMLTV sources (Settings → EPG, playlist dialog). */
get supportsEpgFilePicker(): boolean {
return this.hasElectronMethod('openEpgFileDialog');
}
get supportsEpgProgress(): boolean {
return this.hasElectronMethod('onEpgProgress');
}
get supportsEpgProgramLookup(): boolean {
return this.hasElectronMethod('getChannelPrograms');
}
get supportsEpgCurrentProgramBatch(): boolean {
return this.hasElectronMethod('getCurrentProgramsBatch');
}
get supportsEpgChannelMetadata(): boolean {
return this.hasElectronMethod('getEpgChannelMetadata');
}
get supportsEpgSourceFreshness(): boolean {
return this.hasElectronMethod('checkEpgFreshness');
}
get supportsEpgDataManagement(): boolean {
return (
this.hasElectronMethod('forceFetchEpg') &&
this.hasElectronMethod('clearEpgData') &&
this.hasElectronMethod('clearEpgDataForSource')
);
}
/** Both guide reads exist: programmes for a channel batch and coverage. */
get supportsEpgGuide(): boolean {
return (
this.hasElectronMethod('getEpgProgramsForChannels') &&
this.hasElectronMethod('getEpgProgramCoverage')
);
}
get supportsEpgProgramSearch(): boolean {
return this.hasElectronMethod('searchEpgPrograms');
}
get supportsEpgMapping(): boolean {
return (
this.hasElectronMethod('getEpgMapping') &&
this.hasElectronMethod('setEpgMapping') &&
this.hasElectronMethod('deleteEpgMapping') &&
this.hasElectronMethod('searchEpgChannels')
);
}
get supportsSqlite(): boolean {
return [
'dbDeleteAllPlaylists',
'dbDeletePlaylist',
'dbGetAppPlaylist',
'dbGetAppPlaylists',
'dbGetAppState',
'dbSetAppState',
'dbUpsertAppPlaylist',
'dbUpsertAppPlaylists',
].every((methodName) => this.hasElectronMethod(methodName));
}
/**
* The worker-side parental lock filter: the lock state reaches the
* SQLite worker and the `categories.locked` index can be stamped. When
* the Xtream SQLite data source is used without it, the worker cannot
* withhold locked rows and the lock service fails closed instead.
*/
get supportsParentalLockSqliteFilter(): boolean {
return (
this.hasElectronMethod('setParentalLockState') &&
this.hasElectronMethod('dbSetCategoryLocks')
);
}
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',
...playbackPositionStorageMethods,
'dbDeleteXtreamContent',
'dbRestoreXtreamUserData',
].every((methodName) => this.hasElectronMethod(methodName));
}
get supportsPlaybackPositionStorage(): boolean {
return playbackPositionStorageMethods.every((methodName) =>
this.hasElectronMethod(methodName)
);
}
get supportsPlaybackPositionUpdates(): boolean {
return this.hasElectronMethod('onPlaybackPositionUpdate');
}
get supportsDownloads(): boolean {
return [
'downloadsStart',
'downloadsCancel',
'downloadsPause',
'downloadsResume',
'downloadsRetry',
'downloadsRedownloadMissing',
'downloadsRemove',
'downloadsGetList',
'downloadsGet',
'downloadsGetDefaultFolder',
'downloadsSelectFolder',
'downloadsRevealFile',
'downloadsPlayFile',
'downloadsClearCompleted',
'onDownloadsUpdate',
].every((methodName) => this.hasElectronMethod(methodName));
}
// Deliberately separate from supportsDownloads: that allowlist is
// all-or-nothing, and folding the recordings bridge into it would make
// older Electron builds lose the entire download manager.
get supportsRecordings(): boolean {
return [
'recordingsGetList',
'recordingsGet',
'recordingsStop',
'recordingsRemove',
'recordingsUpdatePrograms',
'recordingsRevealFile',
'recordingsPlayFile',
'onRecordingsUpdate',
].every((methodName) => this.hasElectronMethod(methodName));
}
get supportsPortalActivityStorage(): boolean {
return [
'dbGetRecentlyViewed',
'dbClearRecentlyViewed',
'dbGetAllGlobalFavorites',
'dbGetGlobalRecentlyAdded',
'dbAddFavorite',
'dbRemoveFavorite',
'dbGetFavorites',
'dbReorderGlobalFavorites',
'dbGetRecentItems',
'dbAddRecentItem',
'dbClearPlaylistRecentItems',
'dbRemoveRecentItem',
'dbRemoveRecentItemsBatch',
'dbGetContentByXtreamId',
].every((methodName) => this.hasElectronMethod(methodName));
}
get supportsAppStateStorage(): boolean {
return ['dbGetAppState', 'dbSetAppState'].every((methodName) =>
this.hasElectronMethod(methodName)
);
}
get supportsStalkerPlaylistSqliteSync(): boolean {
return ['dbGetPlaylist', 'dbCreatePlaylist'].every((methodName) =>
this.hasElectronMethod(methodName)
);
}
get supportsPlaylistRefresh(): boolean {
return [
'refreshPlaylist',
'cancelPlaylistRefresh',
'onPlaylistRefreshEvent',
].every((methodName) => this.hasElectronMethod(methodName));
}
get supportsManagedExternalPlayers(): boolean {
return ['openInMpv', 'openInVlc'].every((methodName) =>
this.hasElectronMethod(methodName)
);
}
get supportsExternalPlayerPathSettings(): boolean {
return ['setMpvPlayerPath', 'setVlcPlayerPath'].every((methodName) =>
this.hasElectronMethod(methodName)
);
}
get supportsEmbeddedMpv(): boolean {
return this.hasElectronMethod('prepareEmbeddedMpv');
}
get supportsDesktopFileSave(): boolean {
return (
this.hasElectronMethod('saveFileDialog') &&
this.hasElectronMethod('writeFile')
);
}
get supportsRemoteControl(): boolean {
return (
this.hasElectronMethod('updateRemoteControlStatus') &&
this.hasElectronMethod('onChannelChange') &&
this.hasElectronMethod('onRemoteControlCommand')
);
}
get supportsXtreamSectionNavigation(): boolean {
return (
this.isPwa ||
this.supportsXtreamSqliteDataSource ||
this.hasElectronMethod('xtreamRequest')
);
}
private hasElectronMethod(methodName: string): boolean {
const bridge = this.electronBridge as
Record<string, unknown> | undefined;
return typeof bridge?.[methodName] === 'function';
}
private get electronBridge(): RuntimeElectronBridge | undefined {
if (typeof window === 'undefined') {
return undefined;
}
return window.electron;
}
}