mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(electron): isolate refresh performance correlation
This commit is contained in:
1 parent
e3ce60a35e
commit
f9e71a6d8d
25 files changed
+728
-402
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
Reference in new issue
Block a user