diff --git a/.changes/m3u-hidden-groups-restart.md b/.changes/m3u-hidden-groups-restart.md new file mode 100644 index 000000000..4c08c74fd --- /dev/null +++ b/.changes/m3u-hidden-groups-restart.md @@ -0,0 +1,8 @@ +--- +type: fix +area: m3u +issues: [1894] +--- + +Groups hidden in "Manage groups" stay hidden after the desktop app restarts, +instead of every group of the playlist showing again. diff --git a/apps/electron-backend-e2e/src/m3u-group-management.e2e.ts b/apps/electron-backend-e2e/src/m3u-group-management.e2e.ts new file mode 100644 index 000000000..1dfcf7f82 --- /dev/null +++ b/apps/electron-backend-e2e/src/m3u-group-management.e2e.ts @@ -0,0 +1,301 @@ +import type { Locator, Page } from '@playwright/test'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces'; +import { + closeElectronApp, + electronMainPath, + expect, + type LaunchedElectronApp, + importM3uPlaylistFromNativeDialog, + launchElectronApp, + openSources, + openWorkspaceSection, + restartElectronApp, + sourceRowByTitle, + test, + waitForM3uCatalog, + writeTemporaryM3uFile, +} from './electron-test-fixtures'; +import { + expectRendererReloadedOnRoute, + reloadFromMainProcess, +} from './renderer-reload.support'; + +const SOURCE_TITLE = 'group-management-source'; +const HIDDEN_GROUPS = ['Movies', 'Sports']; +const VISIBLE_HIDDEN_GROUPS_KEY = '__e2eVisibleHiddenGroups'; +const RECONCILE_CHANNEL = 'EPG_RECONCILE_SOURCES'; + +interface ReconcileScript { + calls: number; + gate: Promise; + release: () => void; +} + +type ReconcileGlobal = typeof globalThis & { + __e2eReconcileScript?: ReconcileScript; +}; + +// Serialized into the test bootstrap; all runtime dependencies are arguments. +function installReconcileScript( + { ipcMain }: Pick, + channel: string +): void { + const register = ipcMain.handle; + const scope = globalThis as ReconcileGlobal; + ipcMain.handle = function (name, listener) { + if (name !== channel) { + return register.call(this, name, listener); + } + return register.call(this, name, async (...args) => { + const script = scope.__e2eReconcileScript; + if (script) { + script.calls += 1; + if (script.calls === 1) return { success: false }; + await script.gate; + } + return listener(...args); + }); + }; +} + +function launchWithReconcileScript( + dataDir: string +): Promise { + const entryPoint = join(dataDir, 'reconcile-script.cjs'); + writeFileSync( + entryPoint, + [ + `(${installReconcileScript.toString()})(require('electron'), ${JSON.stringify(RECONCILE_CHANNEL)});`, + `require(${JSON.stringify(electronMainPath)});`, + ].join('\n') + ); + return launchElectronApp(dataDir, { entryPoint }); +} + +/** + * The next renderer's startup reconciliation fails, and the retry the + * playlist inventory awaits is parked until `release`: the M3U route then + * activates (its settings resolver has finished) while the store still has no + * playlists. + */ +async function delayPlaylistInventory(app: LaunchedElectronApp) { + await app.electronApp.evaluate(() => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + (globalThis as ReconcileGlobal).__e2eReconcileScript = { + calls: 0, + gate, + release, + }; + }); + return { + waitUntilParked: () => + expect + .poll(() => + app.electronApp.evaluate( + () => + (globalThis as ReconcileGlobal).__e2eReconcileScript + ?.calls ?? 0 + ) + ) + .toBeGreaterThan(1), + release: () => + app.electronApp.evaluate(() => { + const scope = globalThis as ReconcileGlobal; + const script = scope.__e2eReconcileScript; + delete scope.__e2eReconcileScript; + script?.release(); + }), + }; +} +const CHANNELS = ['News', 'Sports', 'Movies'].flatMap((group) => + [1, 2].map((index) => ({ + groupTitle: group, + name: `${group} Channel ${index}`, + url: `https://streams.example.test/${group.toLowerCase()}-${index}.m3u8`, + })) +); + +function groupNavLabels(page: Page): Locator { + return page.locator('app-groups-view .group-nav-item .nav-item-label'); +} + +async function openGroupManagement(page: Page): Promise { + await page + .getByRole('button', { name: 'Manage groups', exact: true }) + .click(); + const dialog = page.locator('mat-dialog-container').last(); + await expect(dialog.locator('.group-item')).toHaveCount(3); + return dialog; +} + +function groupCheckbox(dialog: Locator, group: string): Locator { + return dialog + .locator('.group-item') + .filter({ hasText: group }) + .getByRole('checkbox'); +} + +async function storedHiddenGroupTitles(page: Page): Promise { + return page.evaluate(async () => { + const playlistId = location.pathname.match( + /\/workspace\/playlists\/([^/]+)/ + )?.[1]; + if (!playlistId) throw new Error('M3U playlist route is missing'); + const api = (window as unknown as { electron: ElectronBridgeApi }) + .electron; + const playlist = (await api.dbGetAppPlaylist(playlistId)) as { + hiddenGroupTitles?: unknown; + } | null; + const hidden = playlist?.hiddenGroupTitles; + return Array.isArray(hidden) ? [...hidden].sort() : hidden; + }); +} + +/** Imports the playlist and hides {@link HIDDEN_GROUPS} in Manage groups. */ +async function importAndHideGroups( + app: LaunchedElectronApp, + dataDir: string +): Promise { + const filePath = writeTemporaryM3uFile( + dataDir, + `${SOURCE_TITLE}.m3u`, + CHANNELS + ); + await importM3uPlaylistFromNativeDialog(app, filePath); + await waitForM3uCatalog(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Groups'); + await expect(groupNavLabels(app.mainWindow)).toHaveText([ + 'Movies', + 'News', + 'Sports', + ]); + + const dialog = await openGroupManagement(app.mainWindow); + for (const group of HIDDEN_GROUPS) { + await groupCheckbox(dialog, group).uncheck(); + } + await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(dialog).toBeHidden(); + await expect(groupNavLabels(app.mainWindow)).toHaveText(['News']); + await expect + .poll(() => storedHiddenGroupTitles(app.mainWindow)) + .toEqual(HIDDEN_GROUPS); +} + +/** + * Records, from the start of every new document, each hidden group the + * groups rail ever renders visibly — including before the startup inventory + * publishes the playlist. + */ +async function recordVisibleHiddenGroups( + app: LaunchedElectronApp +): Promise { + await app.electronApp.context().addInitScript( + ({ hiddenGroups, key }) => { + const seen: string[] = []; + Reflect.set(window, key, seen); + const check = () => { + for (const item of document.querySelectorAll( + 'app-groups-view .group-nav-item' + )) { + const group = item.getAttribute('data-group-key') ?? ''; + if ( + hiddenGroups.includes(group) && + !seen.includes(group) && + item.checkVisibility() + ) { + seen.push(group); + } + } + }; + new MutationObserver(check).observe(document, { + attributes: true, + attributeFilter: ['class', 'style'], + childList: true, + subtree: true, + }); + }, + { hiddenGroups: HIDDEN_GROUPS, key: VISIBLE_HIDDEN_GROUPS_KEY } + ); +} + +test.describe('Electron M3U group management', () => { + test('keeps groups hidden in Manage groups after a restart (#1894)', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + await importAndHideGroups(app, dataDir); + + const restarted = await restartElectronApp(app, dataDir); + app.electronApp = restarted.electronApp; + app.mainWindow = restarted.mainWindow; + + await openSources(app.mainWindow); + await sourceRowByTitle(app.mainWindow, SOURCE_TITLE) + .first() + .click(); + await waitForM3uCatalog(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Groups'); + await expect(groupNavLabels(app.mainWindow)).toHaveText(['News']); + + // The dialog opens from the restored state, so saving it again + // cannot silently unhide the groups. + const reopened = await openGroupManagement(app.mainWindow); + await expect(groupCheckbox(reopened, 'News')).toBeChecked(); + await expect(groupCheckbox(reopened, 'Movies')).not.toBeChecked(); + await expect(groupCheckbox(reopened, 'Sports')).not.toBeChecked(); + await reopened + .getByRole('button', { name: 'Close', exact: true }) + .click(); + await expect(reopened).toBeHidden(); + } finally { + await closeElectronApp(app); + } + }); + + test('never shows hidden groups when the Groups route opens before the playlist inventory', async ({ + dataDir, + }) => { + const app = await launchWithReconcileScript(dataDir); + + try { + await importAndHideGroups(app, dataDir); + await recordVisibleHiddenGroups(app); + const inventory = await delayPlaylistInventory(app); + + // A reload boots a fresh store straight on this route. + await reloadFromMainProcess(app); + await inventory.waitUntilParked(); + // The route session has read the playlist and published its + // channels, still behind the startup screen, before the store + // has the playlist the hidden groups belong to. + const rail = app.mainWindow.locator( + 'app-groups-view .group-nav-item' + ); + await expect(rail).toHaveCount(3); + await expect(rail.first()).toBeHidden(); + + await inventory.release(); + await expectRendererReloadedOnRoute( + app.mainWindow, + /\/workspace\/playlists\/[^/]+\/groups$/ + ); + await expect(groupNavLabels(app.mainWindow)).toHaveText(['News']); + expect( + await app.mainWindow.evaluate( + (key) => Reflect.get(window, key), + VISIBLE_HIDDEN_GROUPS_KEY + ) + ).toEqual([]); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index dc9991bb4..fa0e4d6de 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -633,6 +633,19 @@ channel-list-container/ - `PlaylistsService.updatePlaylistMeta()` persists `hiddenGroupTitles` into the stored playlist payload, and M3U refresh/update flows preserve the existing value when refreshed playlist data omits the field. +- Electron loads the playlist store from `DB_GET_APP_PLAYLIST_METAS`, which + reads columns only, so `hiddenGroupTitles` is absent from the store after a + restart. `M3uWorkspaceRouteSession` copies it from the full playlist read into + the store (`updatePlaylistMeta` with `persist: false`) before publishing the + channels, unless the store value changed while that read was queued (#1894). + A restored or deep-linked route can finish that read before the startup + inventory, which waits for settings and EPG reconciliation, publishes the + playlist; the hydration then waits for the entity, since `updateOne` drops an + update for a missing one. The startup screen keeps the router outlet hidden + until that inventory is published, and the hydration runs in the same store + update, so the rail never renders a hidden group. A save's meta write joins + the per-playlist queue that `getPlaylist()` reads through, so a reopened + route reads the saved value. - The groups route keeps the manage action reachable even when every group is hidden by separating "playlist has no groups" from "no visible/search-matching groups" empty states. diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.performance.spec.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.performance.spec.ts index ae41228fa..3fe831e86 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.performance.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.performance.spec.ts @@ -1,3 +1,4 @@ +import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; @@ -30,7 +31,11 @@ async function flushEffects(): Promise { describe('M3uWorkspaceRouteSession performance phases', () => { const hookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); - const store = { dispatch: jest.fn() }; + const store = { + dispatch: jest.fn(), + select: jest.fn(() => of({})), + selectSignal: jest.fn(() => signal({})), + }; beforeEach(() => { Object.defineProperty(window, 'electron', { diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts index 4581781d4..1ae68eff7 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts @@ -1,10 +1,14 @@ import { TestBed } from '@angular/core/testing'; import { NavigationEnd, Router } from '@angular/router'; -import { of, Subject, throwError } from 'rxjs'; -import { ChannelActions, FavoritesActions } from '@iptvnator/m3u-state'; +import { BehaviorSubject, of, Subject, throwError } from 'rxjs'; +import { + ChannelActions, + FavoritesActions, + PlaylistActions, +} from '@iptvnator/m3u-state'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PlaylistsService } from '@iptvnator/services'; -import { Channel, Playlist } from '@iptvnator/shared/interfaces'; +import { Channel, Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces'; import { Store } from '@ngrx/store'; import { M3uWorkspaceRouteSession } from './m3u-workspace-route-session.service'; @@ -76,8 +80,13 @@ describe('M3uWorkspaceRouteSession', () => { const playlistsService = { getPlaylist: jest.fn(), }; + const playlistEntities = new BehaviorSubject< + Record + >({}); const store = { dispatch: jest.fn(), + select: jest.fn(() => playlistEntities.asObservable()), + selectSignal: jest.fn(() => () => playlistEntities.value), }; const electronApi = { setUserAgent: jest.fn().mockResolvedValue(true), @@ -99,6 +108,9 @@ describe('M3uWorkspaceRouteSession', () => { electronApi.setUserAgent.mockClear(); playlistsService.getPlaylist.mockReset(); store.dispatch.mockClear(); + playlistEntities.next({ + [PLAYLIST_ID]: { _id: PLAYLIST_ID } as PlaylistMeta, + }); await TestBed.configureTestingModule({ providers: [ @@ -205,6 +217,121 @@ describe('M3uWorkspaceRouteSession', () => { ).toBe(false); }); + it('hydrates the hidden groups the startup metadata list leaves out, before the channels (#1894)', async () => { + playlistsService.getPlaylist.mockReturnValue( + of({ + hiddenGroupTitles: ['Sports'], + playlist: { + items: [PRIMARY_CHANNEL], + }, + } as Playlist) + ); + + TestBed.inject(M3uWorkspaceRouteSession); + await flushEffects(); + + const types = store.dispatch.mock.calls.map(([action]) => action.type); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylistMeta({ + playlist: { + _id: PLAYLIST_ID, + hiddenGroupTitles: ['Sports'], + } as PlaylistMeta, + persist: false, + }) + ); + expect( + types.indexOf(PlaylistActions.updatePlaylistMeta.type) + ).toBeLessThan(types.indexOf(ChannelActions.setChannels.type)); + }); + + it('hydrates no hidden groups for a playlist that never saved any', async () => { + playlistsService.getPlaylist.mockReturnValue( + of({ playlist: { items: [PRIMARY_CHANNEL] } } as Playlist) + ); + + TestBed.inject(M3uWorkspaceRouteSession); + await flushEffects(); + + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylistMeta({ + playlist: { + _id: PLAYLIST_ID, + hiddenGroupTitles: [] as string[], + } as PlaylistMeta, + persist: false, + }) + ); + }); + + it('keeps hidden groups saved while the playlist read was queued', async () => { + const response = new Subject(); + playlistsService.getPlaylist.mockReturnValue(response.asObservable()); + + TestBed.inject(M3uWorkspaceRouteSession); + await flushEffects(); + + // The dialog's save lands in the store before the queued read returns + // the row as it was before that save. + playlistEntities.next({ + [PLAYLIST_ID]: { + _id: PLAYLIST_ID, + hiddenGroupTitles: ['News'], + } as PlaylistMeta, + }); + response.next({ + hiddenGroupTitles: [] as string[], + playlist: { items: [PRIMARY_CHANNEL] }, + } as Playlist); + response.complete(); + await flushEffects(); + + expect(store.dispatch).toHaveBeenCalledWith( + ChannelActions.setChannels({ channels: [PRIMARY_CHANNEL] }) + ); + expect( + store.dispatch.mock.calls.some( + ([action]) => + action.type === PlaylistActions.updatePlaylistMeta.type + ) + ).toBe(false); + }); + + it('waits for the startup inventory when a restored route reads the playlist first', async () => { + playlistEntities.next({}); + playlistsService.getPlaylist.mockReturnValue( + of({ + hiddenGroupTitles: ['Sports'], + playlist: { items: [PRIMARY_CHANNEL] }, + } as Playlist) + ); + const hydration = PlaylistActions.updatePlaylistMeta({ + playlist: { + _id: PLAYLIST_ID, + hiddenGroupTitles: ['Sports'], + } as PlaylistMeta, + persist: false, + }); + + TestBed.inject(M3uWorkspaceRouteSession); + await flushEffects(); + + // An update for an entity that is not in the store yet is dropped. + expect(store.dispatch).not.toHaveBeenCalledWith(hydration); + + playlistEntities.next({ + [PLAYLIST_ID]: { _id: PLAYLIST_ID } as PlaylistMeta, + }); + + expect(store.dispatch).toHaveBeenCalledWith(hydration); + expect( + store.dispatch.mock.calls.filter( + ([action]) => + action.type === PlaylistActions.updatePlaylistMeta.type + ) + ).toHaveLength(1); + }); + it('clears in-memory favorites without dispatching persistence when playlist loading fails', async () => { playlistsService.getPlaylist.mockReturnValue( throwError(() => new Error('Playlist loading failed')) diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts index c0bba152b..334050945 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts @@ -8,14 +8,20 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NavigationEnd, Router } from '@angular/router'; import { Store } from '@ngrx/store'; -import { ChannelActions, FavoritesActions } from '@iptvnator/m3u-state'; +import { + ChannelActions, + FavoritesActions, + PlaylistActions, + selectPlaylistEntities, +} from '@iptvnator/m3u-state'; import { measureRendererPerformancePhase, RENDERER_PERFORMANCE_PHASE, } from '@iptvnator/shared/logging'; -import { filter, firstValueFrom } from 'rxjs'; +import { filter, firstValueFrom, map, Subscription, take } from 'rxjs'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PlaylistsService } from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; type M3uLoadedSection = 'all' | 'groups'; @@ -26,10 +32,14 @@ export class M3uWorkspaceRouteSession { private readonly playlistsService = inject(PlaylistsService); private readonly router = inject(Router); private readonly store = inject(Store); + private readonly playlistEntities = this.store.selectSignal( + selectPlaylistEntities + ); private currentPlaylistId: string | null = null; private currentSection: string | null = null; private loadRequestId = 0; + private pendingHiddenGroupsHydration: Subscription | null = null; constructor() { this.router.events @@ -93,6 +103,9 @@ export class M3uWorkspaceRouteSession { ChannelActions.setChannelsLoading({ loading: true }) ); + const hiddenGroupTitlesBeforeRead = + this.playlistEntities()[playlistId]?.hiddenGroupTitles; + try { const playlist = await firstValueFrom( this.playlistsService.getPlaylist(playlistId) @@ -102,6 +115,12 @@ export class M3uWorkspaceRouteSession { return; } + this.hydrateHiddenGroupTitles( + playlistId, + playlist.hiddenGroupTitles, + hiddenGroupTitlesBeforeRead + ); + void window.electron ?.setUserAgent(playlist.userAgent, playlist.referrer) .catch((error: unknown) => { @@ -143,6 +162,49 @@ export class M3uWorkspaceRouteSession { } } + /** + * Electron's startup playlist list reads the metadata columns only, which + * leave out the hidden groups stored in the payload (#1894). Published + * before the channels so the groups rail never shows them; a save made + * while the read was queued is newer and stays. + * + * A restored or deep-linked route can finish this read before the startup + * inventory publishes the playlist (that waits for settings and EPG + * reconciliation), and `updateOne` drops an update for a missing entity, + * so the hydration waits for it. + */ + private hydrateHiddenGroupTitles( + playlistId: string, + storedHiddenGroupTitles: string[] | undefined, + hiddenGroupTitlesBeforeRead: string[] | undefined + ): void { + this.pendingHiddenGroupsHydration?.unsubscribe(); + this.pendingHiddenGroupsHydration = this.store + .select(selectPlaylistEntities) + .pipe( + map((entities) => entities[playlistId]), + filter((playlist) => playlist !== undefined), + take(1), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((playlist) => { + if ( + playlist.hiddenGroupTitles !== hiddenGroupTitlesBeforeRead + ) { + return; + } + this.store.dispatch( + PlaylistActions.updatePlaylistMeta({ + playlist: { + _id: playlistId, + hiddenGroupTitles: storedHiddenGroupTitles ?? [], + } as PlaylistMeta, + persist: false, + }) + ); + }); + } + private isLoadedSection( section: string | null ): section is M3uLoadedSection { diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index 7c44c6acf..f59e131a4 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -2005,6 +2005,35 @@ describe('PlaylistsService', () => { } ); + it('reads hidden groups saved by a meta update that is still being written', async () => { + const original = createBasePlaylist('catalog-hidden-groups'); + const { store, electron } = createStatefulElectronStore(original); + testWindow.electron = electron; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + electron.dbUpsertAppPlaylist.mockImplementationOnce( + async (playlist) => { + await gate; + store.current = playlist; + } + ); + const service = createService(); + // The meta effect subscribes while the save is dispatched, so the + // write is queued before the M3U route reopens the playlist. + const write = firstValueFrom( + service.updatePlaylistMeta({ + _id: original._id, + hiddenGroupTitles: ['Sports'], + } as PlaylistMeta) + ); + const read = firstValueFrom(service.getPlaylist(original._id)); + release(); + await write; + expect((await read).hiddenGroupTitles).toEqual(['Sports']); + }); + it('orders a browser catalog read after a queued IndexedDB refresh', async () => { const original = createBasePlaylist('browser-catalog'); const state = { current: original };