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..0045d3905 --- /dev/null +++ b/apps/electron-backend-e2e/src/m3u-group-management.e2e.ts @@ -0,0 +1,121 @@ +import type { Locator, Page } from '@playwright/test'; +import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces'; +import { + closeElectronApp, + expect, + importM3uPlaylistFromNativeDialog, + launchElectronApp, + openSources, + openWorkspaceSection, + restartElectronApp, + sourceRowByTitle, + test, + waitForM3uCatalog, + writeTemporaryM3uFile, +} from './electron-test-fixtures'; + +const SOURCE_TITLE = 'group-management-source'; +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; + }); +} + +test.describe('Electron M3U group management', () => { + test('keeps groups hidden in Manage groups after a restart (#1894)', async ({ + dataDir, + }) => { + const filePath = writeTemporaryM3uFile( + dataDir, + `${SOURCE_TITLE}.m3u`, + CHANNELS + ); + const app = await launchElectronApp(dataDir); + + try { + 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); + await groupCheckbox(dialog, 'Movies').uncheck(); + await groupCheckbox(dialog, 'Sports').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(['Movies', 'Sports']); + + 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); + } + }); +}); diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index dc9991bb4..d8bc102ea 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -633,6 +633,11 @@ 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). - 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..5984de01f 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,10 @@ 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(), + 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..9a340703b 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,15 @@ +import { signal } from '@angular/core'; 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 { + 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 +81,12 @@ describe('M3uWorkspaceRouteSession', () => { const playlistsService = { getPlaylist: jest.fn(), }; + const playlistEntities = signal>( + {} + ); const store = { dispatch: jest.fn(), + selectSignal: jest.fn(() => playlistEntities.asReadonly()), }; const electronApi = { setUserAgent: jest.fn().mockResolvedValue(true), @@ -99,6 +108,9 @@ describe('M3uWorkspaceRouteSession', () => { electronApi.setUserAgent.mockClear(); playlistsService.getPlaylist.mockReset(); store.dispatch.mockClear(); + playlistEntities.set({ + [PLAYLIST_ID]: { _id: PLAYLIST_ID } as PlaylistMeta, + }); await TestBed.configureTestingModule({ providers: [ @@ -205,6 +217,86 @@ 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 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.set({ + [PLAYLIST_ID]: { + _id: PLAYLIST_ID, + hiddenGroupTitles: ['News'], + } as PlaylistMeta, + }); + response.next({ + hiddenGroupTitles: [], + 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('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..a8ada978c 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,7 +8,12 @@ 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, @@ -16,6 +21,7 @@ import { import { filter, firstValueFrom } 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,6 +32,9 @@ 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; @@ -93,6 +102,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 +114,12 @@ export class M3uWorkspaceRouteSession { return; } + this.hydrateHiddenGroupTitles( + playlistId, + playlist.hiddenGroupTitles, + hiddenGroupTitlesBeforeRead + ); + void window.electron ?.setUserAgent(playlist.userAgent, playlist.referrer) .catch((error: unknown) => { @@ -143,6 +161,35 @@ 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. + */ + private hydrateHiddenGroupTitles( + playlistId: string, + storedHiddenGroupTitles: string[] | undefined, + hiddenGroupTitlesBeforeRead: string[] | undefined + ): void { + if ( + this.playlistEntities()[playlistId]?.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 {