mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
fix(m3u): keep groups hidden in Manage groups after a restart (#1894)
Electron fills the playlist store from DB_GET_APP_PLAYLIST_METAS, which reads the metadata columns only. hiddenGroupTitles lives only in the JSON payload, so after a restart the store had none and the groups rail showed every group, although SQLite still held the saved selection; saving the dialog from that state then overwrote it. M3uWorkspaceRouteSession already reads the full playlist to publish its channels and favorites. It now copies hiddenGroupTitles from that read into the store (updatePlaylistMeta, persist: false) before the channels, unless the store value changed while the read was queued behind a save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a9f4ae38ea
commit
9d1181b3e5
6 files changed
+281
-4
No files matched your search
@@ -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.
|
||||
@@ -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<Locator> {
|
||||
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<unknown> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
+5
-1
@@ -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<void> {
|
||||
|
||||
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', {
|
||||
|
||||
+94
-2
@@ -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<Record<string, PlaylistMeta | undefined>>(
|
||||
{}
|
||||
);
|
||||
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<Playlist>();
|
||||
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'))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user