mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
refactor(runtime): gate playlist refresh by capability
This commit is contained in:
1 parent
9a5620bf95
commit
4603bd83d5
10 files changed
+177
-46
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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<string | null>(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[] = [];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -167,7 +167,7 @@
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (item.url || item.filePath) {
|
||||
@if (item.url || (item.filePath && supportsPlaylistRefresh)) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="refresh-btn"
|
||||
@@ -188,7 +188,7 @@
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (item.serverUrl && isElectron) {
|
||||
@if (item.serverUrl && supportsXtreamSqliteDataSource) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="refresh-btn"
|
||||
|
||||
+52
-5
@@ -14,12 +14,14 @@ describe('PlaylistItemComponent', () => {
|
||||
let component: PlaylistItemComponent;
|
||||
let fixture: ComponentFixture<PlaylistItemComponent>;
|
||||
let runtime: {
|
||||
isElectron: boolean;
|
||||
supportsPlaylistRefresh: boolean;
|
||||
supportsXtreamSqliteDataSource: boolean;
|
||||
};
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
runtime = {
|
||||
isElectron: true,
|
||||
supportsPlaylistRefresh: true,
|
||||
supportsXtreamSqliteDataSource: true,
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
@@ -91,6 +93,7 @@ describe('PlaylistItemComponent', () => {
|
||||
|
||||
it('renders a refresh action for file-backed M3U playlists', () => {
|
||||
fixture.destroy();
|
||||
runtime.supportsPlaylistRefresh = true;
|
||||
fixture = TestBed.createComponent(PlaylistItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.item = {
|
||||
@@ -108,9 +111,53 @@ describe('PlaylistItemComponent', () => {
|
||||
expect(nativeElement.querySelector('.refresh-btn')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the Xtream refresh action only when Electron capabilities are available', () => {
|
||||
it('hides file-backed M3U refresh without the refresh bridge', () => {
|
||||
fixture.destroy();
|
||||
runtime.isElectron = true;
|
||||
runtime.supportsPlaylistRefresh = false;
|
||||
fixture = TestBed.createComponent(PlaylistItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.item = {
|
||||
title: 'Local Source',
|
||||
_id: 'local-source',
|
||||
count: 10,
|
||||
importDate: Date.now().toString(),
|
||||
autoRefresh: false,
|
||||
filePath: '/tmp/local-source.m3u',
|
||||
};
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'.refresh-btn'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps URL-backed M3U refresh visible without the refresh bridge', () => {
|
||||
fixture.destroy();
|
||||
runtime.supportsPlaylistRefresh = false;
|
||||
fixture = TestBed.createComponent(PlaylistItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.item = {
|
||||
title: 'Remote Source',
|
||||
_id: 'remote-source',
|
||||
count: 10,
|
||||
importDate: Date.now().toString(),
|
||||
autoRefresh: false,
|
||||
url: 'https://example.com/playlist.m3u',
|
||||
};
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'.refresh-btn'
|
||||
)
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the Xtream refresh action only when the SQLite data source is available', () => {
|
||||
fixture.destroy();
|
||||
runtime.supportsXtreamSqliteDataSource = true;
|
||||
fixture = TestBed.createComponent(PlaylistItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.item = {
|
||||
@@ -132,7 +179,7 @@ describe('PlaylistItemComponent', () => {
|
||||
).not.toBeNull();
|
||||
|
||||
fixture.destroy();
|
||||
runtime.isElectron = false;
|
||||
runtime.supportsXtreamSqliteDataSource = false;
|
||||
fixture = TestBed.createComponent(PlaylistItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.item = {
|
||||
|
||||
+3
-1
@@ -67,7 +67,9 @@ export class PlaylistItemComponent implements OnInit {
|
||||
{ initialValue: null }
|
||||
);
|
||||
|
||||
readonly isElectron = this.runtime.isElectron;
|
||||
readonly supportsPlaylistRefresh = this.runtime.supportsPlaylistRefresh;
|
||||
readonly supportsXtreamSqliteDataSource =
|
||||
this.runtime.supportsXtreamSqliteDataSource;
|
||||
readonly currentLocale = computed(() => {
|
||||
this.languageTick();
|
||||
return normalizeDateLocale(
|
||||
|
||||
+37
-22
@@ -87,7 +87,11 @@ describe('RecentPlaylistsComponent busy state', () => {
|
||||
cancelRefresh: jest.Mock;
|
||||
refreshPlaylist: jest.Mock;
|
||||
};
|
||||
let runtimeIsElectron: boolean;
|
||||
let runtime: {
|
||||
isElectron: boolean;
|
||||
supportsPlaylistRefresh: boolean;
|
||||
supportsXtreamSqliteDataSource: boolean;
|
||||
};
|
||||
let router: {
|
||||
navigate: jest.Mock;
|
||||
};
|
||||
@@ -95,13 +99,8 @@ describe('RecentPlaylistsComponent busy state', () => {
|
||||
open: jest.Mock;
|
||||
};
|
||||
let store: MockStore;
|
||||
const originalElectron = window.electron;
|
||||
|
||||
beforeEach(async () => {
|
||||
window.electron = {
|
||||
platform: 'darwin',
|
||||
} as typeof window.electron;
|
||||
|
||||
databaseService = {
|
||||
cancelOperation: jest.fn().mockResolvedValue(true),
|
||||
createOperationId: jest.fn((prefix: string) => `${prefix}-op`),
|
||||
@@ -128,7 +127,11 @@ describe('RecentPlaylistsComponent busy state', () => {
|
||||
items: [],
|
||||
}),
|
||||
};
|
||||
runtimeIsElectron = true;
|
||||
runtime = {
|
||||
isElectron: true,
|
||||
supportsPlaylistRefresh: true,
|
||||
supportsXtreamSqliteDataSource: true,
|
||||
};
|
||||
router = {
|
||||
navigate: jest.fn(),
|
||||
};
|
||||
@@ -177,11 +180,7 @@ describe('RecentPlaylistsComponent busy state', () => {
|
||||
},
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: {
|
||||
get isElectron() {
|
||||
return runtimeIsElectron;
|
||||
},
|
||||
},
|
||||
useValue: runtime,
|
||||
},
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
@@ -236,7 +235,6 @@ describe('RecentPlaylistsComponent busy state', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
window.electron = originalElectron;
|
||||
});
|
||||
|
||||
it('tracks delete progress and clears the busy row after completion', async () => {
|
||||
@@ -479,7 +477,27 @@ describe('RecentPlaylistsComponent busy state', () => {
|
||||
});
|
||||
|
||||
it('uses the legacy IPC refresh flow for non-Xtream playlists', () => {
|
||||
runtimeIsElectron = false;
|
||||
runtime.supportsPlaylistRefresh = false;
|
||||
const item = createPlaylistMeta({
|
||||
_id: 'playlist-m3u-1',
|
||||
serverUrl: undefined,
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
filePath: undefined,
|
||||
url: 'https://example.com/test.m3u',
|
||||
});
|
||||
|
||||
component.refreshPlaylist(item);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(PLAYLIST_UPDATE, {
|
||||
id: item._id,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not use legacy IPC refresh for file-backed playlists without the refresh bridge', () => {
|
||||
runtime.supportsPlaylistRefresh = false;
|
||||
const item = createPlaylistMeta({
|
||||
_id: 'playlist-m3u-1',
|
||||
serverUrl: undefined,
|
||||
@@ -490,19 +508,16 @@ describe('RecentPlaylistsComponent busy state', () => {
|
||||
|
||||
component.refreshPlaylist(item);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(PLAYLIST_UPDATE, {
|
||||
id: item._id,
|
||||
title: item.title,
|
||||
filePath: item.filePath,
|
||||
});
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-evaluates Electron availability when refreshing local M3U playlists', async () => {
|
||||
runtimeIsElectron = false;
|
||||
it('re-evaluates refresh bridge availability when refreshing local M3U playlists', async () => {
|
||||
runtime.supportsPlaylistRefresh = false;
|
||||
const lateComponent = TestBed.createComponent(
|
||||
RecentPlaylistsComponent
|
||||
).componentInstance;
|
||||
runtimeIsElectron = true;
|
||||
runtime.supportsPlaylistRefresh = true;
|
||||
const item = createPlaylistMeta({
|
||||
_id: 'playlist-m3u-2',
|
||||
serverUrl: undefined,
|
||||
|
||||
@@ -101,6 +101,14 @@ export class RecentPlaylistsComponent {
|
||||
return this.runtime.isElectron;
|
||||
}
|
||||
|
||||
get supportsPlaylistRefresh(): boolean {
|
||||
return this.runtime.supportsPlaylistRefresh;
|
||||
}
|
||||
|
||||
get supportsXtreamSqliteDataSource(): boolean {
|
||||
return this.runtime.supportsXtreamSqliteDataSource;
|
||||
}
|
||||
|
||||
readonly allPlaylistsLoaded = this.store.selectSignal(
|
||||
selectPlaylistsLoadingFlag
|
||||
);
|
||||
@@ -291,17 +299,20 @@ export class RecentPlaylistsComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.serverUrl) {
|
||||
if (item.serverUrl && this.supportsXtreamSqliteDataSource) {
|
||||
// For Xtream playlists, delete and re-import
|
||||
this.refreshXtreamPlaylist(item);
|
||||
} else if (this.runtime.isElectron && (item.url || item.filePath)) {
|
||||
} else if (
|
||||
this.supportsPlaylistRefresh &&
|
||||
(item.url || item.filePath)
|
||||
) {
|
||||
void this.refreshM3uPlaylist(item);
|
||||
} else {
|
||||
// For M3U playlists, use existing refresh logic
|
||||
} else if (item.url) {
|
||||
// Browser/PWA URL refresh uses the PWA data service path.
|
||||
this.dataService.sendIpcEvent(PLAYLIST_UPDATE, {
|
||||
id: item._id,
|
||||
title: item.title,
|
||||
...(item.url ? { url: item.url } : { filePath: item.filePath }),
|
||||
url: item.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
expect(service.supportsXtreamSqliteDataSource).toBe(false);
|
||||
expect(service.supportsDownloads).toBe(false);
|
||||
expect(service.supportsPortalActivityStorage).toBe(false);
|
||||
expect(service.supportsPlaylistRefresh).toBe(false);
|
||||
expect(service.supportsManagedExternalPlayers).toBe(false);
|
||||
expect(service.supportsEmbeddedMpv).toBe(false);
|
||||
expect(service.supportsDesktopFileSave).toBe(false);
|
||||
@@ -88,6 +89,9 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
downloadsPlayFile: jest.fn(),
|
||||
downloadsClearCompleted: jest.fn(),
|
||||
onDownloadsUpdate: jest.fn(),
|
||||
refreshPlaylist: jest.fn(),
|
||||
cancelPlaylistRefresh: jest.fn(),
|
||||
onPlaylistRefreshEvent: jest.fn(),
|
||||
openInMpv: jest.fn(),
|
||||
openInVlc: jest.fn(),
|
||||
prepareEmbeddedMpv: jest.fn(),
|
||||
@@ -110,6 +114,7 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
expect(service.supportsXtreamSqliteDataSource).toBe(true);
|
||||
expect(service.supportsDownloads).toBe(true);
|
||||
expect(service.supportsPortalActivityStorage).toBe(true);
|
||||
expect(service.supportsPlaylistRefresh).toBe(true);
|
||||
expect(service.supportsManagedExternalPlayers).toBe(true);
|
||||
expect(service.supportsEmbeddedMpv).toBe(true);
|
||||
expect(service.supportsDesktopFileSave).toBe(true);
|
||||
@@ -132,6 +137,7 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
expect(service.supportsXtreamSqliteDataSource).toBe(false);
|
||||
expect(service.supportsDownloads).toBe(false);
|
||||
expect(service.supportsPortalActivityStorage).toBe(false);
|
||||
expect(service.supportsPlaylistRefresh).toBe(false);
|
||||
expect(service.supportsManagedExternalPlayers).toBe(false);
|
||||
expect(service.supportsEmbeddedMpv).toBe(false);
|
||||
expect(service.supportsDesktopFileSave).toBe(false);
|
||||
@@ -210,4 +216,23 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
|
||||
expect(service.supportsDownloads).toBe(true);
|
||||
});
|
||||
|
||||
it('requires the complete playlist refresh preload surface', () => {
|
||||
testWindow.electron = {
|
||||
refreshPlaylist: jest.fn(),
|
||||
};
|
||||
|
||||
const service = new RuntimeCapabilitiesService();
|
||||
|
||||
expect(service.isElectron).toBe(true);
|
||||
expect(service.supportsPlaylistRefresh).toBe(false);
|
||||
|
||||
testWindow.electron = {
|
||||
refreshPlaylist: jest.fn(),
|
||||
cancelPlaylistRefresh: jest.fn(),
|
||||
onPlaylistRefreshEvent: jest.fn(),
|
||||
};
|
||||
|
||||
expect(service.supportsPlaylistRefresh).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -118,6 +118,14 @@ export class RuntimeCapabilitiesService {
|
||||
].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)
|
||||
|
||||
Reference in new issue
Block a user