mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -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> * fix(m3u): wait for the playlist entity before hydrating hidden groups The startup inventory publishes playlists only after settings and EPG reconciliation, while a restored or deep-linked playlist route waits for the metadata read alone. The route session's full read could therefore finish first, and updateOne dropped the hydration for the missing entity for the whole session. The hydration now waits for the entity. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(m3u): cover hidden groups when the Groups route opens before the inventory An Electron E2E reloads the renderer on the Groups route while the startup inventory is held (the EPG reconcile fails once and its retry is parked), and records from document start whether a hidden group is ever rendered visibly. It fails on the previous hydration that did not wait for the playlist entity. A service test pins that getPlaylist reads hidden groups from a meta update that is still being written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(m3u): type empty hidden-group arrays in the route session spec The CI spec typecheck (apps/web/tsconfig.spec.json) rejected `[]` inferred as never[] inside the PlaylistMeta and Playlist casts with TS2352. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
7 files changed
+551
-6
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,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<void>;
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
type ReconcileGlobal = typeof globalThis & {
|
||||
__e2eReconcileScript?: ReconcileScript;
|
||||
};
|
||||
|
||||
// Serialized into the test bootstrap; all runtime dependencies are arguments.
|
||||
function installReconcileScript(
|
||||
{ ipcMain }: Pick<typeof import('electron'), 'ipcMain'>,
|
||||
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<LaunchedElectronApp> {
|
||||
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<void>((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<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;
|
||||
});
|
||||
}
|
||||
|
||||
/** Imports the playlist and hides {@link HIDDEN_GROUPS} in Manage groups. */
|
||||
async function importAndHideGroups(
|
||||
app: LaunchedElectronApp,
|
||||
dataDir: string
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
+6
-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,11 @@ 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(),
|
||||
select: jest.fn(() => of({})),
|
||||
selectSignal: jest.fn(() => signal({})),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
|
||||
+130
-3
@@ -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<string, PlaylistMeta | undefined>
|
||||
>({});
|
||||
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<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.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'))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<void>((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 };
|
||||
|
||||
Reference in new issue
Block a user