refactor(runtime): gate playlist refresh by capability

This commit is contained in:
4gray committed 2026-05-22 13:36:39 +03:00
1 parent 9a5620bf95
commit 4603bd83d5
10 files changed
+177 -46

No files matched your search

+2 -1
View File
@@ -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);
}
}
@@ -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"
@@ -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 = {
@@ -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(
@@ -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)