fix(electron): isolate refresh performance correlation

This commit is contained in:
4gray committed 2026-07-26 22:37:52 +02:00
1 parent e3ce60a35e
commit f9e71a6d8d
25 files changed
+728 -402

No files matched your search

+8 -1
View File
@@ -1,5 +1,10 @@
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { Channel, EpgProgram, Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
Channel,
EpgProgram,
Playlist,
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
export const PlaylistActions = createActionGroup({
source: 'Playlists',
@@ -11,6 +16,8 @@ export const PlaylistActions = createActionGroup({
'Remove Playlist': props<{ playlistId: string }>(),
'Update Playlist Meta': props<{ playlist: PlaylistMeta }>(),
'Update Playlist': props<{
/** Instrumentation-only; never persisted or sent to main/worker IPC. */
operationId?: string;
playlist: Playlist;
playlistId: string;
refreshEpg?: boolean;
+25 -21
View File
@@ -55,6 +55,7 @@ import {
} from './external-player-payload.util';
import { resolvePlaylistScopedEpgFetchPlan } from './playlist-scoped-epg-fetch.util';
import { resolveActiveEpgProgramAction } from './resolve-active-epg-program.util';
import { persistPlaylistUpdate } from './playlist-update.effect-handler';
@Injectable({ providedIn: 'any' })
export class PlaylistEffects {
@@ -305,10 +306,7 @@ export class PlaylistEffects {
return this.actions$.pipe(
ofType(PlaylistActions.updatePlaylist),
switchMap((action) =>
this.playlistsService.updatePlaylist(action.playlistId, {
...action.playlist,
_id: action.playlistId,
}).pipe(
persistPlaylistUpdate(this.playlistsService, action).pipe(
tap(() => {
this.fetchPlaylistScopedEpg(action.playlist, {
force: action.refreshEpg === true,
@@ -353,12 +351,14 @@ export class PlaylistEffects {
if ('isTemporary' in action && action.isTemporary) {
return EMPTY;
}
return this.playlistsService.addPlaylist(action.playlist).pipe(
tap(() => {
this.fetchPlaylistScopedEpg(action.playlist);
this.navigateToPlaylist(action.playlist);
})
);
return this.playlistsService
.addPlaylist(action.playlist)
.pipe(
tap(() => {
this.fetchPlaylistScopedEpg(action.playlist);
this.navigateToPlaylist(action.playlist);
})
);
})
);
},
@@ -370,17 +370,21 @@ export class PlaylistEffects {
return this.actions$.pipe(
ofType(PlaylistActions.updatePlaylistMeta),
switchMap((action) =>
this.playlistsService.updatePlaylistMeta(action.playlist).pipe(
tap(() => {
if (
this.hasPlaylistScopedEpgSourceChange(
action.playlist
)
) {
this.fetchPlaylistScopedEpg(action.playlist);
}
})
)
this.playlistsService
.updatePlaylistMeta(action.playlist)
.pipe(
tap(() => {
if (
this.hasPlaylistScopedEpgSourceChange(
action.playlist
)
) {
this.fetchPlaylistScopedEpg(
action.playlist
);
}
})
)
)
);
},
@@ -0,0 +1,29 @@
import type { Playlist } from '@iptvnator/shared/interfaces';
interface PlaylistUpdateAction {
operationId?: string;
playlist: Playlist;
playlistId: string;
}
interface PlaylistUpdater<TResult> {
updatePlaylist: (
playlistId: string,
playlist: Playlist,
operationId?: string
) => TResult;
}
export function persistPlaylistUpdate<TResult>(
playlistsService: PlaylistUpdater<TResult>,
action: PlaylistUpdateAction
): TResult {
return playlistsService.updatePlaylist(
action.playlistId,
{
...action.playlist,
_id: action.playlistId,
},
action.operationId
);
}
@@ -0,0 +1,30 @@
import { type Playlist } from '@iptvnator/shared/interfaces';
import { of } from 'rxjs';
import { PlaylistActions } from './actions';
import { persistPlaylistUpdate } from './playlist-update.effect-handler';
describe('PlaylistEffects updatePlaylist', () => {
it('forwards the refresh operation ID to playlist persistence', () => {
const playlistsService = {
updatePlaylist: jest.fn(() => of(undefined)),
};
const playlist = {
_id: 'playlist-1',
playlist: { items: [] },
} as unknown as Playlist;
const action = PlaylistActions.updatePlaylist({
playlist,
playlistId: playlist._id,
refreshEpg: true,
operationId: 'playlist-refresh-operation',
});
persistPlaylistUpdate(playlistsService, action);
expect(playlistsService.updatePlaylist).toHaveBeenCalledWith(
playlist._id,
playlist,
'playlist-refresh-operation'
);
});
});
@@ -630,6 +630,7 @@ describe('PlaylistRefreshActionService', () => {
playlist: refreshedPlaylist,
playlistId: item._id,
refreshEpg: true,
operationId: 'playlist-refresh-op',
})
);
});
@@ -205,12 +205,11 @@ export class PlaylistRefreshActionService {
}
try {
const operationId =
this.databaseService.createOperationId('playlist-refresh');
const refreshedPlaylist =
await this.playlistRefreshService.refreshPlaylist({
operationId:
this.databaseService.createOperationId(
'playlist-refresh'
),
operationId,
playlistId: item._id,
title: item.title,
url: item.url,
@@ -228,6 +227,7 @@ export class PlaylistRefreshActionService {
},
playlistId: item._id,
refreshEpg: true,
operationId,
})
);
@@ -549,5 +549,15 @@ describe('RecentPlaylistsComponent busy state', () => {
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
await Promise.resolve();
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylist({
playlist: expect.objectContaining({
_id: item._id,
}),
playlistId: item._id,
refreshEpg: true,
operationId: 'playlist-refresh-op',
})
);
});
});
@@ -463,6 +463,7 @@ export class RecentPlaylistsComponent {
},
playlistId: item._id,
refreshEpg: true,
operationId,
})
);
@@ -1336,6 +1336,37 @@ describe('PlaylistsService', () => {
expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledTimes(2);
});
it('forwards refresh instrumentation only to its SQLite read and write', async () => {
const { electron } = createStatefulElectronStore(
createBasePlaylist('playlist-instrumented-refresh')
);
testWindow.electron = electron;
const service = createService();
const operationId = 'playlist-refresh-operation';
await firstValueFrom(
service.updatePlaylist(
'playlist-instrumented-refresh',
{
_id: 'playlist-instrumented-refresh',
playlist: { items: [] },
} as Playlist,
operationId
)
);
expect(electron.dbGetAppPlaylist).toHaveBeenCalledWith(
'playlist-instrumented-refresh',
operationId
);
expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
_id: 'playlist-instrumented-refresh',
}),
operationId
);
});
it('keeps both favorites when two rapid favorite adds overlap (SQLite)', async () => {
const { store, electron } = createStatefulElectronStore(
createBasePlaylist('portal-race-two-favorites')
+36 -13
View File
@@ -52,7 +52,10 @@ type PlaylistRawItem = {
type PlaylistStorageElectronApi = {
dbDeleteAllPlaylists: () => Promise<unknown>;
dbDeletePlaylist: (playlistId: string) => Promise<unknown>;
dbGetAppPlaylist: (playlistId: string) => Promise<Playlist | null>;
dbGetAppPlaylist: (
playlistId: string,
operationId?: string
) => Promise<Playlist | null>;
dbGetAppPlaylistFavoriteChannels?: (
playlistId: string
) => Promise<M3uFavoriteChannel[]>;
@@ -60,7 +63,10 @@ type PlaylistStorageElectronApi = {
dbGetAppPlaylists: () => Promise<Playlist[]>;
dbGetAppState: (key: string) => Promise<string | null>;
dbSetAppState: (key: string, value: string) => Promise<unknown>;
dbUpsertAppPlaylist: (playlist: Playlist) => Promise<unknown>;
dbUpsertAppPlaylist: (
playlist: Playlist,
operationId?: string
) => Promise<unknown>;
dbUpsertAppPlaylists: (playlists: Playlist[]) => Promise<unknown>;
};
@@ -362,14 +368,18 @@ export class PlaylistsService {
}
}
private upsertSqlitePlaylist(playlist: Playlist) {
private upsertSqlitePlaylist(playlist: Playlist, operationId?: string) {
return this.runOnSqlite(async () => {
const electron = this.electronApi;
if (!electron) {
return playlist;
}
await electron.dbUpsertAppPlaylist(playlist);
if (operationId === undefined) {
await electron.dbUpsertAppPlaylist(playlist);
} else {
await electron.dbUpsertAppPlaylist(playlist, operationId);
}
return playlist;
});
}
@@ -411,9 +421,14 @@ export class PlaylistsService {
});
}
private persistPlaylistMutation(nextPlaylist: Playlist) {
private persistPlaylistMutation(
nextPlaylist: Playlist,
operationId?: string
) {
if (this.isElectronStorageAvailable) {
return firstValueFrom(this.upsertSqlitePlaylist(nextPlaylist));
return firstValueFrom(
this.upsertSqlitePlaylist(nextPlaylist, operationId)
);
}
return firstValueFrom(
@@ -554,10 +569,14 @@ export class PlaylistsService {
};
}
updatePlaylist(playlistId: string, updatedPlaylist: Playlist) {
updatePlaylist(
playlistId: string,
updatedPlaylist: Playlist,
operationId?: string
) {
return this.serializePlaylistWrite(playlistId, async () => {
const currentPlaylist = await firstValueFrom(
this.getPlaylistById(playlistId)
this.getPlaylistById(playlistId, operationId)
);
const mergedPlaylist = this.mergeRefreshedPlaylist(
currentPlaylist,
@@ -565,17 +584,21 @@ export class PlaylistsService {
playlistId
);
return this.persistPlaylistMutation(mergedPlaylist);
return this.persistPlaylistMutation(mergedPlaylist, operationId);
});
}
getPlaylistById(id: string) {
getPlaylistById(id: string, operationId?: string) {
if (this.isElectronStorageAvailable) {
return this.runOnSqlite(async () => {
const electron = this.electronApi;
const playlist = electron
? await electron.dbGetAppPlaylist(id)
: null;
let playlist: Playlist | null = null;
if (electron) {
playlist =
operationId === undefined
? await electron.dbGetAppPlaylist(id)
: await electron.dbGetAppPlaylist(id, operationId);
}
return playlist
? this.createSqliteFallbackPlaylist(playlist as Playlist)
: (undefined as unknown as Playlist);
@@ -684,13 +684,21 @@ export interface ElectronBridgeApi {
dbGetPlaylist: (
playlistId: string
) => Promise<ElectronBridgePlaylistRow | null>;
dbUpsertAppPlaylist: (playlist: Playlist) => Promise<ElectronBridgeResult>;
dbUpsertAppPlaylist: (
playlist: Playlist,
/** Instrumentation-only; never persisted or sent to main/worker IPC. */
operationId?: string
) => Promise<ElectronBridgeResult>;
dbUpsertAppPlaylists: (
playlists: Playlist[]
) => Promise<ElectronBridgeCountResult>;
dbGetAppPlaylists: () => Promise<Playlist[]>;
dbGetAppPlaylistMetas: () => Promise<Playlist[]>;
dbGetAppPlaylist: (playlistId: string) => Promise<Playlist | null>;
dbGetAppPlaylist: (
playlistId: string,
/** Instrumentation-only; never persisted or sent to main/worker IPC. */
operationId?: string
) => Promise<Playlist | null>;
dbGetAppPlaylistFavoriteChannels: (
playlistId: string
) => Promise<M3uFavoriteChannel[]>;