Merge pull request #1086 from 4gray/agent/dashboard-rail-performance

fix(dashboard): speed up startup rails
This commit is contained in:
4gray authored and GitHub committed 2026-06-21 01:35:55 +02:00
commit d37e0f84b9
26 files changed
+1099 -103

No files matched your search

@@ -73,12 +73,24 @@ export const dbPreloadCases: PreloadInvokeCase[] = [
channel: 'DB_GET_APP_PLAYLISTS',
forwardedArgs: [],
},
{
method: 'dbGetAppPlaylistMetas',
args: [],
channel: 'DB_GET_APP_PLAYLIST_METAS',
forwardedArgs: [],
},
{
method: 'dbGetAppPlaylist',
args: [playlistId],
channel: 'DB_GET_APP_PLAYLIST',
forwardedArgs: [playlistId],
},
{
method: 'dbGetAppPlaylistFavoriteChannels',
args: [playlistId],
channel: 'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS',
forwardedArgs: [playlistId],
},
{
method: 'dbUpdatePlaylist',
args: [playlistId, playlistUpdates],
@@ -516,8 +516,12 @@ const electronApi: ElectronBridgeApi = {
dbUpsertAppPlaylists: (playlists: Playlist[]) =>
ipcRenderer.invoke('DB_UPSERT_APP_PLAYLISTS', playlists),
dbGetAppPlaylists: () => ipcRenderer.invoke('DB_GET_APP_PLAYLISTS'),
dbGetAppPlaylistMetas: () =>
ipcRenderer.invoke('DB_GET_APP_PLAYLIST_METAS'),
dbGetAppPlaylist: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_APP_PLAYLIST', playlistId),
dbGetAppPlaylistFavoriteChannels: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS', playlistId),
dbUpdatePlaylist: (
playlistId: string,
updates: Partial<Playlist> | ElectronBridgePlaylistInput
@@ -7,6 +7,11 @@ const eqMock = jest.fn((left: unknown, right: unknown) => ({
left,
right,
}));
const inArrayMock = jest.fn((left: unknown, values: unknown[]) => ({
kind: 'inArray',
left,
values,
}));
const sqlJoinMock = jest.fn((chunks: unknown[], separator?: unknown) => ({
kind: 'sql.join',
chunks,
@@ -28,7 +33,7 @@ jest.mock('drizzle-orm', () => ({
asc: jest.fn(),
desc: jest.fn(),
eq: (left: unknown, right: unknown) => eqMock(left, right),
inArray: jest.fn(),
inArray: (left: unknown, values: unknown[]) => inArrayMock(left, values),
or: jest.fn(),
sql: sqlMock,
}));
@@ -39,6 +44,7 @@ import {
buildM3uGlobalSearchResults,
globalSearch,
getContentByXtreamId,
getGlobalRecentlyAdded,
saveContent,
scoreSearchTextMatch,
} from './content.operations';
@@ -61,10 +67,50 @@ function createDbMock(result: unknown[] = []) {
};
}
function createRecentlyAddedDbMock(resultsByCall: unknown[][]) {
const queries: Array<{
from: jest.Mock;
innerJoin: jest.Mock;
limit: jest.Mock;
orderBy: jest.Mock;
where: jest.Mock;
}> = [];
let resultIndex = 0;
const select = jest.fn(() => {
const query = {
from: jest.fn(),
innerJoin: jest.fn(),
limit: jest.fn(),
orderBy: jest.fn(),
where: jest.fn(),
};
query.from.mockReturnValue(query);
query.innerJoin.mockReturnValue(query);
query.where.mockReturnValue(query);
query.orderBy.mockReturnValue(query);
query.limit.mockImplementation(async () => {
const result = resultsByCall[resultIndex] ?? [];
resultIndex += 1;
return result;
});
queries.push(query);
return query;
});
return {
db: {
select,
} as unknown as AppDatabase,
queries,
select,
};
}
describe('content.operations', () => {
beforeEach(() => {
andMock.mockClear();
eqMock.mockClear();
inArrayMock.mockClear();
sqlJoinMock.mockClear();
sqlMock.mockClear();
});
@@ -279,6 +325,38 @@ describe('content.operations', () => {
}
});
it('loads global recently added movie and series rows separately before merging the top results', async () => {
const { db, select } = createRecentlyAddedDbMock([
[
{
id: 1,
added_at: '200',
type: 'movie',
title: 'Movie',
},
],
[
{
id: 2,
added_at: '300',
type: 'series',
title: 'Series',
},
],
]);
const result = await getGlobalRecentlyAdded(db, 'all', 20, 'xtream');
expect(select).toHaveBeenCalledTimes(2);
expect(eqMock).toHaveBeenCalledWith(schema.content.type, 'movie');
expect(eqMock).toHaveBeenCalledWith(schema.content.type, 'series');
expect(inArrayMock).not.toHaveBeenCalledWith(
schema.content.type,
expect.anything()
);
expect(result.map((item) => item.id)).toEqual([2, 1]);
});
it('builds M3U global search results from playlist payloads and respects hidden groups', () => {
const visibleChannel = {
id: 'channel-news',
@@ -793,13 +793,59 @@ export async function getGlobalRecentlyAdded(
limit = 200,
playlistType?: RecentlyAddedPlaylistType
) {
const contentTypes = getRecentlyAddedContentTypes(kind);
const normalizedLimit = Number.isFinite(limit)
? Math.min(Math.max(Math.trunc(limit), 1), 200)
: 200;
const contentTypes = getRecentlyAddedContentTypes(kind);
if (contentTypes.length > 1) {
const rows = await Promise.all(
contentTypes.map((type) =>
getGlobalRecentlyAddedByType(
db,
type,
normalizedLimit,
playlistType
)
)
);
return rows
.flat()
.sort(
(left, right) =>
getRecentlyAddedSortValue(right) -
getRecentlyAddedSortValue(left)
)
.slice(0, normalizedLimit);
}
return getGlobalRecentlyAddedByType(
db,
contentTypes[0],
normalizedLimit,
playlistType
);
}
function getRecentlyAddedSortValue(item: {
added?: string | null;
added_at?: string | null;
}): number {
const value = Number(item.added_at || item.added || 0);
return Number.isFinite(value) ? value : 0;
}
function getGlobalRecentlyAddedByType(
db: AppDatabase,
type: 'movie' | 'series',
limit: number,
playlistType?: RecentlyAddedPlaylistType
) {
const whereConditions = [
inArray(schema.content.type, contentTypes),
eq(schema.content.type, type),
eq(schema.categories.hidden, false),
sql`${schema.content.added} <> ''`,
sql`${schema.content.added} <= ${getXtreamRecentlyAddedMaxEpochSeconds()}`,
@@ -833,7 +879,7 @@ export async function getGlobalRecentlyAdded(
)
.where(and(...whereConditions))
.orderBy(desc(schema.content.added))
.limit(normalizedLimit);
.limit(limit);
}
type XtreamContentValue = {
@@ -0,0 +1,79 @@
const eqMock = jest.fn((left: unknown, right: unknown) => ({
kind: 'eq',
left,
right,
}));
const whereMock = jest.fn();
jest.mock('drizzle-orm', () => ({
and: jest.fn((...conditions: unknown[]) => ({ kind: 'and', conditions })),
asc: jest.fn((value: unknown) => ({ kind: 'asc', value })),
desc: jest.fn((value: unknown) => ({ kind: 'desc', value })),
eq: (left: unknown, right: unknown) => eqMock(left, right),
inArray: jest.fn(),
sql: jest.fn(),
}));
import type { AppDatabase } from '../database.types';
import { getGlobalFavorites } from './favorites.operations';
function createGlobalFavoritesDbMock(rows: unknown[]) {
const query = {
from: jest.fn(),
innerJoin: jest.fn(),
limit: jest.fn(),
orderBy: jest.fn(),
then: jest.fn((resolve, reject) =>
Promise.resolve(rows).then(resolve, reject)
),
where: whereMock,
};
query.from.mockReturnValue(query);
query.innerJoin.mockReturnValue(query);
query.where.mockReturnValue(query);
query.orderBy.mockReturnValue(query);
query.limit.mockResolvedValue(rows);
const select = jest.fn().mockReturnValue(query);
return {
db: {
select,
} as unknown as AppDatabase,
query,
select,
};
}
describe('favorites.operations', () => {
beforeEach(() => {
eqMock.mockClear();
whereMock.mockClear();
});
it('filters live global favorites after scanning the small favorites set', async () => {
const { db, query } = createGlobalFavoritesDbMock([
{
id: 1,
title: 'Saved Movie',
type: 'movie',
},
{
id: 2,
title: 'Saved Live Channel',
type: 'live',
},
]);
const result = await getGlobalFavorites(db);
expect(whereMock).not.toHaveBeenCalled();
expect(query.limit).not.toHaveBeenCalled();
expect(result).toEqual([
expect.objectContaining({
id: 2,
title: 'Saved Live Channel',
type: 'live',
}),
]);
});
});
@@ -89,40 +89,21 @@ export async function getFavorites(db: AppDatabase, playlistId: string) {
}
export async function getGlobalFavorites(db: AppDatabase) {
return db
.select({
id: schema.content.id,
category_id: schema.content.categoryId,
title: schema.content.title,
rating: schema.content.rating,
added: schema.content.added,
poster_url: schema.content.posterUrl,
xtream_id: schema.content.xtreamId,
type: schema.content.type,
playlist_id: schema.playlists.id,
playlist_name: schema.playlists.name,
added_at: schema.favorites.addedAt,
position: schema.favorites.position,
})
.from(schema.favorites)
.innerJoin(
schema.content,
eq(schema.favorites.contentId, schema.content.id)
)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.innerJoin(
schema.playlists,
eq(schema.categories.playlistId, schema.playlists.id)
)
.where(eq(schema.content.type, 'live'))
.orderBy(asc(schema.favorites.position), desc(schema.favorites.addedAt))
.limit(300);
const favorites = await selectGlobalFavoriteRows(db, {
includeBackdrop: false,
});
return favorites.filter((favorite) => favorite.type === 'live').slice(0, 300);
}
export async function getAllGlobalFavorites(db: AppDatabase) {
return selectGlobalFavoriteRows(db, { includeBackdrop: true }).limit(500);
}
function selectGlobalFavoriteRows(
db: AppDatabase,
options: { includeBackdrop: boolean }
) {
return db
.select({
id: schema.content.id,
@@ -131,7 +112,9 @@ export async function getAllGlobalFavorites(db: AppDatabase) {
rating: schema.content.rating,
added: schema.content.added,
poster_url: schema.content.posterUrl,
backdrop_url: schema.content.backdropUrl,
...(options.includeBackdrop
? { backdrop_url: schema.content.backdropUrl }
: {}),
xtream_id: schema.content.xtreamId,
type: schema.content.type,
playlist_id: schema.playlists.id,
@@ -152,8 +135,7 @@ export async function getAllGlobalFavorites(db: AppDatabase) {
schema.playlists,
eq(schema.categories.playlistId, schema.playlists.id)
)
.orderBy(asc(schema.favorites.position), desc(schema.favorites.addedAt))
.limit(500);
.orderBy(asc(schema.favorites.position), desc(schema.favorites.addedAt));
}
export async function reorderGlobalFavorites(
@@ -1,4 +1,26 @@
import { parseAppPlaylist } from './playlist.operations';
import {
getAppPlaylistFavoriteChannels,
getAppPlaylistMetas,
parseAppPlaylist,
} from './playlist.operations';
import type { AppDatabase } from '../database.types';
function createPlaylistFavoriteChannelsDbMock(row: unknown | null) {
const limit = jest.fn().mockResolvedValue(row ? [row] : []);
const where = jest.fn().mockReturnValue({ limit });
const from = jest.fn().mockReturnValue({ where });
const select = jest.fn().mockReturnValue({ from });
return {
db: {
select,
} as unknown as AppDatabase,
from,
limit,
select,
where,
};
}
describe('playlist.operations', () => {
it('hydrates updateDate from lastUpdated when payload is stale', async () => {
@@ -33,4 +55,93 @@ describe('playlist.operations', () => {
})
);
});
it('loads app playlist metadata without selecting the large payload column', async () => {
const from = jest.fn().mockResolvedValue([
{
id: 'playlist-meta',
name: 'Metadata Playlist',
type: 'm3u-url',
dateCreated: '2026-04-01T00:00:00.000Z',
lastUpdated: null,
count: 2,
importDate: '2026-04-01T00:00:00.000Z',
favorites: JSON.stringify(['channel-1']),
recentlyViewed: JSON.stringify([{ id: 'recent-1' }]),
autoRefresh: false,
url: 'https://example.com/list.m3u',
},
]);
const select = jest.fn().mockReturnValue({ from });
const db = {
select,
} as unknown as AppDatabase;
await expect(getAppPlaylistMetas(db)).resolves.toEqual([
expect.objectContaining({
_id: 'playlist-meta',
title: 'Metadata Playlist',
count: 2,
favorites: ['channel-1'],
recentlyViewed: [{ id: 'recent-1' }],
url: 'https://example.com/list.m3u',
}),
]);
expect(select).toHaveBeenCalledWith(
expect.not.objectContaining({
payload: expect.anything(),
})
);
});
it('resolves M3U favorite channels in the worker without returning the full playlist payload', async () => {
const firstChannel = {
id: 'channel-1',
name: 'Channel One',
url: 'https://example.com/stream-1.m3u8',
tvg: {
id: 'tvg-1',
name: 'Channel One',
logo: 'https://example.com/logo-1.png',
},
};
const secondChannel = {
id: 'channel-2',
name: 'Channel Two',
url: 'https://example.com/stream-2.m3u8',
tvg: {
id: 'tvg-2',
name: 'Channel Two',
logo: 'https://example.com/logo-2.png',
},
};
const { db } = createPlaylistFavoriteChannelsDbMock({
id: 'playlist-1',
favorites: JSON.stringify([
'https://example.com/stream-2.m3u8',
'channel-1',
'missing-channel',
]),
payload: JSON.stringify({
playlist: {
items: [firstChannel, secondChannel],
},
}),
});
await expect(
getAppPlaylistFavoriteChannels(db, 'playlist-1')
).resolves.toEqual([
{
favoriteId: 'https://example.com/stream-2.m3u8',
favoriteIndex: 0,
channel: secondChannel,
},
{
favoriteId: 'channel-1',
favoriteIndex: 1,
channel: firstChannel,
},
]);
});
});
@@ -1,5 +1,6 @@
import { eq, inArray } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type { Channel, M3uFavoriteChannel } from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import {
checkpointOperation,
@@ -58,6 +59,36 @@ function parseJsonValue<T>(value: string | null | undefined, fallback: T): T {
}
}
function getFavoriteChannelMatch(
channel: Channel,
favoritePositions: ReadonlyMap<string, number>
): { favoriteId: string; favoriteIndex: number } | null {
const channelId = String(channel.id ?? '').trim();
const channelUrl = String(channel.url ?? '').trim();
const channelIdFavoritePosition = favoritePositions.get(channelId);
const channelUrlFavoritePosition = favoritePositions.get(channelUrl);
if (
channelIdFavoritePosition !== undefined &&
(channelUrlFavoritePosition === undefined ||
channelIdFavoritePosition <= channelUrlFavoritePosition)
) {
return {
favoriteId: channelId,
favoriteIndex: channelIdFavoritePosition,
};
}
if (channelUrlFavoritePosition !== undefined) {
return {
favoriteId: channelUrl,
favoriteIndex: channelUrlFavoritePosition,
};
}
return null;
}
function inferPlaylistType(playlist: Record<string, unknown>): PlaylistType {
const explicitType = getStringValue(playlist.type);
if (
@@ -277,6 +308,43 @@ export async function getAppPlaylists(db: AppDatabase) {
return rows.map((row) => parseAppPlaylist(row));
}
export async function getAppPlaylistMetas(db: AppDatabase) {
const rows = await db
.select({
id: schema.playlists.id,
name: schema.playlists.name,
serverUrl: schema.playlists.serverUrl,
username: schema.playlists.username,
password: schema.playlists.password,
dateCreated: schema.playlists.dateCreated,
lastUpdated: schema.playlists.lastUpdated,
type: schema.playlists.type,
userAgent: schema.playlists.userAgent,
origin: schema.playlists.origin,
referrer: schema.playlists.referrer,
filePath: schema.playlists.filePath,
autoRefresh: schema.playlists.autoRefresh,
macAddress: schema.playlists.macAddress,
url: schema.playlists.url,
portalUrl: schema.playlists.portalUrl,
count: schema.playlists.count,
importDate: schema.playlists.importDate,
updateDate: schema.playlists.updateDate,
position: schema.playlists.position,
favorites: schema.playlists.favorites,
recentlyViewed: schema.playlists.recentlyViewed,
lastUsage: schema.playlists.lastUsage,
})
.from(schema.playlists);
return rows.map((row) =>
parseAppPlaylist({
...row,
payload: null,
} as schema.Playlist)
);
}
export async function getAppPlaylist(db: AppDatabase, playlistId: string) {
const rows = await db
.select()
@@ -287,6 +355,70 @@ export async function getAppPlaylist(db: AppDatabase, playlistId: string) {
return rows[0] ? parseAppPlaylist(rows[0]) : null;
}
export async function getAppPlaylistFavoriteChannels(
db: AppDatabase,
playlistId: string
): Promise<M3uFavoriteChannel[]> {
const rows = await db
.select({
id: schema.playlists.id,
favorites: schema.playlists.favorites,
payload: schema.playlists.payload,
})
.from(schema.playlists)
.where(eq(schema.playlists.id, playlistId))
.limit(1);
const row = rows[0];
if (!row) {
return [];
}
const favorites = parseJsonValue<unknown[]>(row.favorites, []).filter(
(favorite): favorite is string =>
typeof favorite === 'string' && favorite.trim().length > 0
);
if (favorites.length === 0) {
return [];
}
const payload = parseJsonValue<{
playlist?: { items?: Channel[] };
} | null>(row.payload, null);
const channels = Array.isArray(payload?.playlist?.items)
? payload.playlist.items
: [];
if (channels.length === 0) {
return [];
}
const favoritePositions = new Map<string, number>();
favorites.forEach((favorite, index) => {
if (!favoritePositions.has(favorite)) {
favoritePositions.set(favorite, index);
}
});
const resolved: M3uFavoriteChannel[] = [];
for (const channel of channels) {
const match = getFavoriteChannelMatch(channel, favoritePositions);
if (!match) {
continue;
}
resolved.push({
favoriteId: match.favoriteId,
favoriteIndex: match.favoriteIndex,
channel,
});
if (resolved.length === favoritePositions.size) {
break;
}
}
return resolved.sort((a, b) => a.favoriteIndex - b.favoriteIndex);
}
export async function getPlaylist(db: AppDatabase, playlistId: string) {
const result = await db
.select()
@@ -20,7 +20,11 @@ handleWorkerRequest(
(playlists: Record<string, unknown>[]) => playlists
);
handleWorkerRequest('DB_GET_APP_PLAYLISTS', () => ({}));
handleWorkerRequest('DB_GET_APP_PLAYLIST_METAS', () => ({}));
handleWorkerRequest('DB_GET_APP_PLAYLIST', (playlistId: string) => ({ playlistId }));
handleWorkerRequest('DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS', (playlistId: string) => ({
playlistId,
}));
handleWorkerRequest('DB_GET_PLAYLIST', (playlistId: string) => ({ playlistId }));
handleWorkerRequest(
'DB_UPDATE_PLAYLIST',
@@ -46,11 +46,21 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
args: [],
payload: {},
},
{
operation: 'DB_GET_APP_PLAYLIST_METAS',
args: [],
payload: {},
},
{
operation: 'DB_GET_APP_PLAYLIST',
args: [playlistId],
payload: { playlistId },
},
{
operation: 'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS',
args: [playlistId],
payload: { playlistId },
},
{
operation: 'DB_GET_PLAYLIST',
args: [playlistId],
@@ -17,7 +17,9 @@ export const DB_WORKER_OPERATIONS = [
'DB_UPSERT_APP_PLAYLIST',
'DB_UPSERT_APP_PLAYLISTS',
'DB_GET_APP_PLAYLISTS',
'DB_GET_APP_PLAYLIST_METAS',
'DB_GET_APP_PLAYLIST',
'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS',
'DB_GET_PLAYLIST',
'DB_UPDATE_PLAYLIST',
'DB_DELETE_PLAYLIST',
@@ -58,6 +58,8 @@ import {
deleteAllPlaylists,
deletePlaylist,
getAppPlaylist,
getAppPlaylistFavoriteChannels,
getAppPlaylistMetas,
getAppPlaylists,
getAppState,
getPlaylist,
@@ -508,11 +510,19 @@ async function executeRequest(message: DbWorkerRequestMessage) {
case 'DB_GET_APP_PLAYLISTS':
return getAppPlaylists(db);
case 'DB_GET_APP_PLAYLIST_METAS':
return getAppPlaylistMetas(db);
case 'DB_GET_APP_PLAYLIST': {
const payload = message.payload as { playlistId: string };
return getAppPlaylist(db, payload.playlistId);
}
case 'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS': {
const payload = message.payload as { playlistId: string };
return getAppPlaylistFavoriteChannels(db, payload.playlistId);
}
case 'DB_GET_PLAYLIST': {
const payload = message.payload as { playlistId: string };
return getPlaylist(db, payload.playlistId);
+21 -12
View File
@@ -1,5 +1,6 @@
import type { APIRequestContext, Page } from '@playwright/test';
import { expect, test } from './fixtures';
import { setInputValue } from './e2e-helpers';
import {
getRegisteredProviderUrl,
interceptProviderTargetRegistration,
@@ -82,10 +83,10 @@ async function addXtreamPortal(
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
await dialog.getByRole('radio', { name: /Xtream credentials/i }).click();
await dialog.locator('#title').fill(name);
await dialog.locator('#serverUrl').fill(MOCK_SERVER);
await dialog.locator('#username').fill(username);
await dialog.locator('#password').fill(password);
await setInputValue(dialog.locator('#title'), name);
await setInputValue(dialog.locator('#serverUrl'), MOCK_SERVER);
await setInputValue(dialog.locator('#username'), username);
await setInputValue(dialog.locator('#password'), password);
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
@@ -475,14 +476,22 @@ test('@xtream playlist details edit is retained in the PWA browser context', asy
page,
'Editable PWA Xtream Portal'
);
await dialog
.locator('input[formcontrolname="title"]')
.fill('Edited PWA Xtream Portal');
await dialog
.locator('input[formcontrolname="serverUrl"]')
.fill(MOCK_SERVER);
await dialog.locator('input[formcontrolname="username"]').fill('minimal');
await dialog.locator('input[formcontrolname="password"]').fill('minimal');
await setInputValue(
dialog.locator('input[formcontrolname="title"]'),
'Edited PWA Xtream Portal'
);
await setInputValue(
dialog.locator('input[formcontrolname="serverUrl"]'),
MOCK_SERVER
);
await setInputValue(
dialog.locator('input[formcontrolname="username"]'),
'minimal'
);
await setInputValue(
dialog.locator('input[formcontrolname="password"]'),
'minimal'
);
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(dialog).toBeHidden();
+22 -7
View File
@@ -259,13 +259,28 @@ Xtream-only row shape:
2. `DB_UPSERT_APP_PLAYLIST`
3. `DB_UPSERT_APP_PLAYLISTS`
4. `DB_GET_APP_PLAYLISTS`
5. `DB_GET_APP_PLAYLIST`
6. `DB_GET_PLAYLIST`
7. `DB_UPDATE_PLAYLIST`
8. `DB_DELETE_PLAYLIST`
9. `DB_DELETE_ALL_PLAYLISTS`
10. `DB_GET_APP_STATE`
11. `DB_SET_APP_STATE`
5. `DB_GET_APP_PLAYLIST_METAS`
6. `DB_GET_APP_PLAYLIST`
7. `DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS`
8. `DB_GET_PLAYLIST`
9. `DB_UPDATE_PLAYLIST`
10. `DB_DELETE_PLAYLIST`
11. `DB_DELETE_ALL_PLAYLISTS`
12. `DB_GET_APP_STATE`
13. `DB_SET_APP_STATE`
`DB_GET_APP_PLAYLIST_METAS` is the preferred path for summary surfaces such as
the workspace sidebar and dashboard source rail. It selects playlist metadata
columns only and deliberately skips the large `payload` column, which can
contain full parsed M3U channel lists. Full playlist reads must continue using
`DB_GET_APP_PLAYLIST` for one playlist or `DB_GET_APP_PLAYLISTS` for legacy
full-data workflows.
`DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS` is a dashboard-oriented M3U fast path.
It resolves a playlist's favorite IDs to matching channel payloads inside the
DB worker and returns only the matched channels plus favorite order metadata.
Renderer code must still fall back to `DB_GET_APP_PLAYLIST` when the fast path
is unavailable or the SQLite playlist migration has not completed.
### Xtream refresh helpers
+26 -5
View File
@@ -51,10 +51,10 @@ Core implementation:
Render rules:
1. `dashboardReady() === false` → render the page-level skeleton rails/hero.
The first-load gate waits for playlist metadata plus the first global
recent/global favorites reloads and, when Xtream playlists exist, the first
Xtream recently-added reload.
1. Dashboard rails render independently as their data sources resolve. The
page no longer uses `dashboardReady()` as a page-wide skeleton gate.
Initial hero/recent/favorites loading states render scoped skeletons so one
slow rail does not hide already available content.
2. `hasPlaylists() === false` → render `<app-empty-state type="welcome">`
full-bleed. All rails and the hero are skipped.
3. `hero()` = `globalRecentItems()[0]`. If present, render the hero panel.
@@ -66,6 +66,10 @@ Render rules:
6. The mixed global favorites rail is not rendered on the dashboard. Live
favorites are promoted into the live rail, while mixed favorites stay on
`/workspace/global-favorites`.
7. The live favorites rail keeps its scoped skeleton until the initial global
favorites load has completed for both Xtream-backed and playlist-backed
favorites. This avoids first-paint partial counts such as a single Stalker
favorite appearing before M3U favorites finish resolving.
## Rail Contract
@@ -106,7 +110,10 @@ Render rules:
(PWA returns `[]`) and auto-hides when empty, so users without Xtream
playlists never see it. Cards carry a `playlist_name · type` subtitle
so users can tell which provider each item came from. Driven by an
effect that re-runs whenever the Xtream playlist count changes.
effect that re-runs whenever the Xtream playlist count changes, but the
first run waits for `globalFavoritesLoaded()` so the slower
recently-added DB query does not block the live favorites rail on
startup.
5. `sourceCards` — maps `recentPlaylists()` to rail cards. `recentPlaylists()`
ranks M3U, Xtream, and Stalker sources by their latest recent activity
from `globalRecentItems()`, then falls back to playlist
@@ -128,6 +135,20 @@ Render rules:
8. Playback-position reloads are keyed by the VOD/series recent set and should
call `reloadPlaybackPositions()` through `untracked()` so live-only recent
changes do not trigger unnecessary IPC round-trips.
9. Electron M3U dashboard favorites should use
`PlaylistsService.getM3uFavoriteChannels()` first. That method checks the
SQLite playlist migration flag and then calls
`dbGetAppPlaylistFavoriteChannels(playlistId)`, letting the DB worker return
only matched favorite channels instead of sending the full playlist payload
back to the renderer. If the bridge method is missing or migration is
incomplete, the dashboard falls back to the full playlist read.
10. Electron playlist summary loads should use
`dbGetAppPlaylistMetas()` through `PlaylistsService.getAllPlaylists()`.
This keeps dashboard/source/sidebar startup on a metadata-only SQLite path
and avoids parsing full M3U `payload` blobs for surfaces that only need
playlist title, type, counts, favorites, recent activity, and source
connection fields. Workflows that need channel payloads still call
`getPlaylistById()`.
## Empty State
@@ -130,6 +130,48 @@ describe('PlaylistsService', () => {
expect(playlists[0]).not.toHaveProperty('items');
});
it('loads Electron playlist summaries from the metadata-only bridge when available', async () => {
const electron = {
dbGetAppPlaylist: jest.fn(),
dbGetAppPlaylistMetas: jest.fn(async () => [
{
_id: 'playlist-meta',
title: 'Metadata Playlist',
count: 2,
favorites: ['channel-1'],
playlist: {
items: [{ id: 'channel-1' }],
},
},
]),
dbGetAppPlaylists: jest.fn(async () => []),
dbGetAppState: jest.fn(async (key: string) =>
key === SQLITE_PLAYLIST_MIGRATION_FLAG ||
key === STALKER_PLAYLIST_METADATA_MIGRATION_FLAG
? '1'
: null
),
dbSetAppState: jest.fn(),
dbUpsertAppPlaylist: jest.fn(),
dbUpsertAppPlaylists: jest.fn(),
};
testWindow.electron = electron;
const service = createService();
const playlists = await firstValueFrom(service.getAllPlaylists());
expect(electron.dbGetAppPlaylistMetas).toHaveBeenCalledTimes(1);
expect(electron.dbGetAppPlaylists).not.toHaveBeenCalled();
expect(playlists).toEqual([
expect.objectContaining({
_id: 'playlist-meta',
title: 'Metadata Playlist',
favorites: ['channel-1'],
}),
]);
expect(playlists[0]).not.toHaveProperty('playlist');
});
it('normalizes partial SQLite playlists when loading by id', async () => {
const electron = {
dbGetAppPlaylist: jest.fn(async () => ({
@@ -168,6 +210,75 @@ describe('PlaylistsService', () => {
);
});
it('loads resolved M3U favorite channels from Electron without fetching the full playlist payload', async () => {
const resolvedFavorites = [
{
favoriteId: 'channel-1',
favoriteIndex: 0,
channel: {
id: 'channel-1',
name: 'Channel One',
url: 'https://example.com/stream-1.m3u8',
},
},
];
const electron = {
dbGetAppPlaylist: jest.fn(),
dbGetAppPlaylistFavoriteChannels: jest.fn(
async () => resolvedFavorites
),
dbGetAppPlaylists: jest.fn(async () => []),
dbGetAppState: jest.fn(async (key: string) =>
key === SQLITE_PLAYLIST_MIGRATION_FLAG ||
key === STALKER_PLAYLIST_METADATA_MIGRATION_FLAG
? '1'
: null
),
dbSetAppState: jest.fn(),
dbUpsertAppPlaylist: jest.fn(),
dbUpsertAppPlaylists: jest.fn(),
};
testWindow.electron = electron;
const service = createService();
await expect(
firstValueFrom(service.getM3uFavoriteChannels('playlist-1'))
).resolves.toBe(resolvedFavorites);
expect(electron.dbGetAppState).toHaveBeenCalledWith(
SQLITE_PLAYLIST_MIGRATION_FLAG
);
expect(electron.dbGetAppPlaylistFavoriteChannels).toHaveBeenCalledWith(
'playlist-1'
);
expect(electron.dbGetAppPlaylist).not.toHaveBeenCalled();
expect(electron.dbGetAppPlaylists).not.toHaveBeenCalled();
});
it('falls back from resolved M3U favorites when SQLite playlist migration is incomplete', async () => {
const electron = {
dbGetAppPlaylist: jest.fn(),
dbGetAppPlaylistFavoriteChannels: jest.fn(),
dbGetAppPlaylists: jest.fn(async () => []),
dbGetAppState: jest.fn(async (key: string) =>
key === SQLITE_PLAYLIST_MIGRATION_FLAG ? null : '1'
),
dbSetAppState: jest.fn(),
dbUpsertAppPlaylist: jest.fn(),
dbUpsertAppPlaylists: jest.fn(),
};
testWindow.electron = electron;
const service = createService();
await expect(
firstValueFrom(service.getM3uFavoriteChannels('playlist-1'))
).resolves.toBeNull();
expect(electron.dbGetAppPlaylistFavoriteChannels).not.toHaveBeenCalled();
expect(electron.dbGetAppPlaylist).not.toHaveBeenCalled();
expect(electron.dbGetAppPlaylists).not.toHaveBeenCalled();
});
it('adds browser playlists through IndexedDB and returns the original playlist', async () => {
const playlist = {
_id: 'playlist-add',
+35 -1
View File
@@ -21,6 +21,7 @@ import {
DbStores,
extractStalkerItemId,
isM3uRecentlyViewedItem,
M3uFavoriteChannel,
M3uRecentlyViewedItem,
Playlist,
PlaylistMeta,
@@ -50,6 +51,10 @@ type PlaylistStorageElectronApi = {
dbDeleteAllPlaylists: () => Promise<unknown>;
dbDeletePlaylist: (playlistId: string) => Promise<unknown>;
dbGetAppPlaylist: (playlistId: string) => Promise<Playlist | null>;
dbGetAppPlaylistFavoriteChannels?: (
playlistId: string
) => Promise<M3uFavoriteChannel[]>;
dbGetAppPlaylistMetas?: () => Promise<Playlist[]>;
dbGetAppPlaylists: () => Promise<Playlist[]>;
dbGetAppState: (key: string) => Promise<string | null>;
dbSetAppState: (key: string, value: string) => Promise<unknown>;
@@ -379,7 +384,8 @@ export class PlaylistsService {
return this.runOnSqlite(async () => {
const electron = this.electronApi;
const playlists = electron
? await electron.dbGetAppPlaylists()
? await (electron.dbGetAppPlaylistMetas?.() ??
electron.dbGetAppPlaylists())
: [];
return (playlists as Playlist[]).map((playlist) =>
this.toPlaylistMeta(playlist)
@@ -649,6 +655,34 @@ export class PlaylistsService {
);
}
getM3uFavoriteChannels(
playlistId: string
): Observable<M3uFavoriteChannel[] | null> {
const electron = this.electronApi;
const getFavoriteChannels =
electron?.dbGetAppPlaylistFavoriteChannels;
if (
!electron ||
!this.isElectronStorageAvailable ||
typeof getFavoriteChannels !== 'function'
) {
return of(null);
}
return from(
(async () => {
const alreadyMigrated = await electron.dbGetAppState(
SQLITE_PLAYLIST_MIGRATION_FLAG
);
if (alreadyMigrated !== '1') {
return null;
}
return getFavoriteChannels(playlistId);
})()
);
}
getPortalFavorites(portalId: string) {
if (!portalId) {
throw new Error('Portal ID is required');
+1
View File
@@ -15,6 +15,7 @@ export * from './lib/indexed-db.config';
export * from './lib/ipc-command.class';
export * from './lib/ipc-commands';
export * from './lib/language.enum';
export * from './lib/m3u-favorite-channel.interface';
export * from './lib/parsed-playlist.interface';
export * from './lib/playback-position.interface';
export * from './lib/playlist-backup.interface';
@@ -12,6 +12,7 @@ import {
GlobalSearchResult,
GlobalSearchResultSource,
} from './global-search-result.interface';
import { M3uFavoriteChannel } from './m3u-favorite-channel.interface';
import { PlaybackPositionData } from './playback-position.interface';
import {
XtreamBackupFavoriteItem,
@@ -565,7 +566,11 @@ export interface ElectronBridgeApi {
playlists: Playlist[]
) => Promise<ElectronBridgeCountResult>;
dbGetAppPlaylists: () => Promise<Playlist[]>;
dbGetAppPlaylistMetas: () => Promise<Playlist[]>;
dbGetAppPlaylist: (playlistId: string) => Promise<Playlist | null>;
dbGetAppPlaylistFavoriteChannels: (
playlistId: string
) => Promise<M3uFavoriteChannel[]>;
dbUpdatePlaylist: (
playlistId: string,
updates: Partial<Playlist> | ElectronBridgePlaylistInput
@@ -0,0 +1,7 @@
import { Channel } from './channel.interface';
export interface M3uFavoriteChannel {
favoriteId: string;
favoriteIndex: number;
channel: Channel;
}
@@ -6,7 +6,7 @@ import {
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { of } from 'rxjs';
import { of, Subject } from 'rxjs';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import {
PlaybackPositionData,
@@ -25,6 +25,17 @@ describe('DashboardDataService', () => {
let service: DashboardDataService;
const playlistsLoadedSignal = signal(true);
const waitForMockCall = async (mock: jest.Mock, attempts = 10) => {
for (let index = 0; index < attempts; index++) {
if (mock.mock.calls.length > 0) {
return;
}
await Promise.resolve();
}
throw new Error('Expected mock to be called');
};
const createPendingItems = <T = never>() => {
let resolvePending: (items: T[]) => void = () => {
throw new Error('Pending item promise resolved before init');
@@ -135,6 +146,7 @@ describe('DashboardDataService', () => {
},
} as Playlist;
const playlistsServiceMock = {
getM3uFavoriteChannels: jest.fn().mockReturnValue(of(null)),
getPlaylistById: jest.fn().mockReturnValue(of(playlistMock)),
setFavorites: jest.fn().mockReturnValue(of(undefined)),
removeFromM3uRecentlyViewed: jest.fn().mockReturnValue(
@@ -177,6 +189,8 @@ describe('DashboardDataService', () => {
});
playlistsLoadedSignal.set(true);
playlistsSignal.set(createDefaultPlaylists());
playlistsServiceMock.getM3uFavoriteChannels.mockClear();
playlistsServiceMock.getM3uFavoriteChannels.mockReturnValue(of(null));
playlistsServiceMock.getPlaylistById.mockClear();
playlistsServiceMock.getPlaylistById.mockReturnValue(of(playlistMock));
playlistsServiceMock.setFavorites.mockClear();
@@ -249,6 +263,8 @@ describe('DashboardDataService', () => {
expect(dbServiceMock.getAllGlobalFavorites).not.toHaveBeenCalled();
expect(dbServiceMock.getGlobalRecentlyAdded).not.toHaveBeenCalled();
expect(playlistsServiceMock.getPlaylistById).not.toHaveBeenCalled();
expect(service.globalFavoritesLoaded()).toBe(false);
expect(service.globalFavoritesLoading()).toBe(true);
expect(service.dashboardReady()).toBe(false);
});
@@ -312,6 +328,131 @@ describe('DashboardDataService', () => {
);
});
it('uses resolved M3U favorite channels when Electron can provide them without loading the full playlist payload', async () => {
playlistsServiceMock.getM3uFavoriteChannels.mockReturnValue(
of([
{
favoriteId: 'channel-1',
favoriteIndex: 0,
channel: playlistMock.playlist.items[0],
},
{
favoriteId: 'https://example.com/stream-2.m3u8',
favoriteIndex: 1,
channel: playlistMock.playlist.items[1],
},
])
);
await service.reloadGlobalFavorites();
expect(
playlistsServiceMock.getM3uFavoriteChannels
).toHaveBeenCalledWith('m3u-1');
expect(playlistsServiceMock.getPlaylistById).not.toHaveBeenCalled();
expect(service.globalFavoriteItems()).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'channel-1',
title: 'Channel One',
source: 'm3u',
}),
expect.objectContaining({
id: 'https://example.com/stream-2.m3u8',
title: 'Channel Two',
source: 'm3u',
}),
])
);
});
it('keeps initial global favorites loading until playlist-backed favorites finish loading', async () => {
const pendingM3uPlaylist = new Subject<Playlist>();
playlistsServiceMock.getPlaylistById.mockReturnValue(
pendingM3uPlaylist.asObservable()
);
dbServiceMock.getAllGlobalFavorites.mockResolvedValue([
{
id: 501,
category_id: 19,
title: 'Fast Xtream Channel',
added_at: '2026-04-22T10:00:00.000Z',
poster_url: 'https://example.com/fav-channel.png',
xtream_id: 5501,
type: 'live',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
},
]);
const reload = service.reloadGlobalFavorites();
await Promise.resolve();
await waitForMockCall(playlistsServiceMock.getPlaylistById);
expect(service.globalFavoriteLiveItems()).toEqual([
expect.objectContaining({ title: 'Fast Xtream Channel' }),
]);
expect(service.globalFavoritesLoaded()).toBe(false);
expect(service.globalFavoritesLoading()).toBe(true);
pendingM3uPlaylist.next(playlistMock);
pendingM3uPlaylist.complete();
await reload;
expect(service.globalFavoritesLoaded()).toBe(true);
expect(service.globalFavoritesLoading()).toBe(false);
expect(service.globalFavoriteLiveItems()).toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Fast Xtream Channel' }),
expect.objectContaining({ title: 'Channel One' }),
expect.objectContaining({ title: 'Channel Two' }),
])
);
});
it('reloads M3U favorites when playlists finish loading before the first global favorites reload completes', async () => {
playlistsLoadedSignal.set(false);
playlistsSignal.set([]);
TestBed.flushEffects();
const pendingXtreamFavorites = createPendingItems();
dbServiceMock.getAllGlobalFavorites.mockReturnValue(
pendingXtreamFavorites.promise
);
const reload = service.reloadGlobalFavorites();
await Promise.resolve();
expect(playlistsServiceMock.getPlaylistById).not.toHaveBeenCalled();
playlistsSignal.set(createDefaultPlaylists());
playlistsLoadedSignal.set(true);
TestBed.flushEffects();
await Promise.resolve();
expect(playlistsServiceMock.getPlaylistById).not.toHaveBeenCalled();
pendingXtreamFavorites.resolve([]);
await reload;
TestBed.flushEffects();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(playlistsServiceMock.getPlaylistById).toHaveBeenCalledWith(
'm3u-1'
);
expect(service.globalFavoriteLiveItems()).toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Channel One' }),
expect.objectContaining({ title: 'Channel Two' }),
])
);
expect(service.globalFavoritesLoaded()).toBe(true);
expect(service.globalFavoritesLoading()).toBe(false);
});
it('keeps the earliest matching M3U favorite id when channel id and URL both match', async () => {
const channel = playlistMock.playlist.items[0];
const channelUrl = channel.url;
@@ -28,6 +28,7 @@ import {
import {
buildPlaylistRecentItems,
Channel,
M3uFavoriteChannel,
Playlist,
PortalAddedItem,
PortalActivityItem,
@@ -116,7 +117,7 @@ export class DashboardDataService {
private readonly ngZone = inject(NgZone);
private readonly translate = inject(TranslateService);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private favoritesAutoRefreshEnabled = false;
private readonly favoritesAutoRefreshEnabled = signal(false);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
@@ -171,6 +172,8 @@ export class DashboardDataService {
);
private readonly globalFavoritesLoadingState = signal(true);
private readonly globalFavoritesLoadedState = signal(false);
private readonly xtreamGlobalFavoritesLoadedState = signal(false);
private readonly playlistBackedGlobalFavoritesLoadedState = signal(false);
private readonly xtreamRecentlyAddedLoadingState = signal(true);
private readonly xtreamRecentlyAddedLoadedState = signal(false);
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
@@ -427,11 +430,14 @@ export class DashboardDataService {
constructor() {
effect(() => {
this.playlistFavoritesReloadKey();
if (!this.playlistsLoaded() || !this.favoritesAutoRefreshEnabled) {
if (
!this.playlistsLoaded() ||
!this.favoritesAutoRefreshEnabled()
) {
return;
}
void this.refreshPlaylistBackedGlobalFavorites();
void this.reloadPlaylistBackedGlobalFavorites();
});
effect(() => {
@@ -523,13 +529,27 @@ export class DashboardDataService {
async reloadGlobalFavorites(): Promise<void> {
if (!this.globalFavoritesLoaded()) {
this.globalFavoritesLoadingState.set(true);
this.xtreamGlobalFavoritesLoadedState.set(false);
this.playlistBackedGlobalFavoritesLoadedState.set(false);
}
const xtreamReload = this.reloadXtreamGlobalFavorites();
const m3uReload = this.refreshPlaylistBackedGlobalFavorites();
const xtreamReload = this.reloadXtreamGlobalFavorites().finally(() => {
this.ngZone.run(() =>
this.xtreamGlobalFavoritesLoadedState.set(true)
);
this.finishInitialGlobalFavoritesLoadIfReady();
});
const m3uReload = this.reloadPlaylistBackedGlobalFavorites();
await Promise.all([xtreamReload, m3uReload]);
this.favoritesAutoRefreshEnabled = true;
if (
this.playlistsLoaded() &&
!this.playlistBackedGlobalFavoritesLoadedState()
) {
await this.reloadPlaylistBackedGlobalFavorites();
}
this.finishInitialGlobalFavoritesLoadIfReady();
this.favoritesAutoRefreshEnabled.set(true);
}
async getGlobalRecentlyAddedItems(
@@ -600,7 +620,6 @@ export class DashboardDataService {
if (!this.hasPortalActivityStorage) {
const favorites = await this.loadPwaXtreamGlobalFavorites();
this.ngZone.run(() => this.xtreamGlobalFavorites.set(favorites));
this.finishInitialGlobalFavoritesLoadIfReady();
return;
}
@@ -616,8 +635,6 @@ export class DashboardDataService {
err
);
this.ngZone.run(() => this.xtreamGlobalFavorites.set([]));
} finally {
this.finishInitialGlobalFavoritesLoadIfReady();
}
}
@@ -710,11 +727,27 @@ export class DashboardDataService {
return 'movie';
}
private async refreshPlaylistBackedGlobalFavorites(): Promise<void> {
await this.reloadM3uGlobalFavorites();
private async reloadPlaylistBackedGlobalFavorites(): Promise<void> {
const loaded = await this.refreshPlaylistBackedGlobalFavorites();
if (!loaded) {
return;
}
this.ngZone.run(() =>
this.playlistBackedGlobalFavoritesLoadedState.set(true)
);
this.finishInitialGlobalFavoritesLoadIfReady();
}
private async refreshPlaylistBackedGlobalFavorites(): Promise<boolean> {
if (!this.playlistsLoaded()) {
return false;
}
await this.reloadM3uGlobalFavorites();
return true;
}
private async reloadM3uGlobalFavorites(): Promise<void> {
const m3uPlaylists = this.playlists().filter(
(playlist) =>
@@ -762,7 +795,12 @@ export class DashboardDataService {
let items: DashboardFavoriteItem[];
try {
items = await this.loadM3uPlaylistFavorites(playlist);
} catch {
} catch (err) {
console.warn(
'[DashboardData] Failed to load M3U favorites for playlist',
playlist._id,
err
);
items = [];
}
@@ -778,7 +816,11 @@ export class DashboardDataService {
}
private finishInitialGlobalFavoritesLoadIfReady(): void {
if (!this.playlistsLoaded()) {
if (
!this.playlistsLoaded() ||
!this.xtreamGlobalFavoritesLoadedState() ||
!this.playlistBackedGlobalFavoritesLoadedState()
) {
return;
}
@@ -1059,16 +1101,22 @@ export class DashboardDataService {
): Promise<DashboardFavoriteItem[]> {
// Cache fingerprint covers what affects the result: the favorites
// list itself and the playlist's update timestamp (changes mean
// channels may have been added/removed by a refresh). A cache hit
// skips the heavyweight getPlaylistById() call — for large M3U
// playlists that's a serialized payload of 90K+ channels avoided
// entirely on repeat dashboard mounts.
// channels may have been added/removed by a refresh).
const fingerprint = this.buildM3uFavoritesFingerprint(playlistMeta);
const cached = this.m3uFavoritesCache.get(playlistMeta._id);
if (cached && cached.fingerprint === fingerprint) {
return cached.items;
}
const fastPathItems =
await this.loadM3uPlaylistFavoritesFromResolvedChannels(
playlistMeta,
fingerprint
);
if (fastPathItems !== null) {
return fastPathItems;
}
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(playlistMeta._id)
)) as Playlist & {
@@ -1126,25 +1174,14 @@ export class DashboardDataService {
return acc;
}
acc.push({
id: matchedFavoriteId,
title:
channel.name?.trim() || channel.tvg?.name || channelId,
type: 'live',
playlist_id: playlistMeta._id,
playlist_name:
playlistMeta.title || playlistMeta.filename || 'M3U',
added_at: fallbackTimestamp,
category_id: 'live',
xtream_id: matchedFavoriteId,
poster_url: channel.tvg?.logo || undefined,
epg_lookup_key:
channel.tvg?.id?.trim() ||
channel.tvg?.name?.trim() ||
channel.name?.trim() ||
undefined,
source: 'm3u',
});
acc.push(
this.createM3uFavoriteItem(
playlistMeta,
matchedFavoriteId,
channel,
fallbackTimestamp
)
);
return acc;
},
[]
@@ -1157,6 +1194,77 @@ export class DashboardDataService {
return computedItems;
}
private async loadM3uPlaylistFavoritesFromResolvedChannels(
playlistMeta: PlaylistMeta,
fingerprint: string
): Promise<DashboardFavoriteItem[] | null> {
let resolvedChannels: M3uFavoriteChannel[] | null;
try {
resolvedChannels = await firstValueFrom(
this.playlistsService.getM3uFavoriteChannels(playlistMeta._id)
);
} catch (err) {
console.warn(
'[DashboardData] Failed to load resolved M3U favorites, falling back to full playlist payload',
err
);
return null;
}
if (resolvedChannels === null) {
return null;
}
const fallbackTimestamp =
this.getM3uFavoriteTimestamp(playlistMeta) ??
new Date(0).toISOString();
const items = resolvedChannels.slice().map((favorite) =>
this.createM3uFavoriteItem(
playlistMeta,
favorite.favoriteId,
favorite.channel,
fallbackTimestamp
)
);
this.m3uFavoritesCache.set(playlistMeta._id, {
fingerprint,
items,
});
return items;
}
private createM3uFavoriteItem(
playlistMeta: PlaylistMeta,
favoriteId: string,
channel: Channel,
fallbackTimestamp: string
): DashboardFavoriteItem {
const channelId = String(channel.id ?? '').trim();
return {
id: favoriteId,
title:
channel.name?.trim() ||
channel.tvg?.name?.trim() ||
channelId ||
favoriteId,
type: 'live',
playlist_id: playlistMeta._id,
playlist_name: playlistMeta.title || playlistMeta.filename || 'M3U',
added_at: fallbackTimestamp,
category_id: 'live',
xtream_id: favoriteId,
poster_url: channel.tvg?.logo || undefined,
epg_lookup_key:
channel.tvg?.id?.trim() ||
channel.tvg?.name?.trim() ||
channel.name?.trim() ||
undefined,
source: 'm3u',
};
}
private buildM3uFavoritesFingerprint(playlist: PlaylistMeta): string {
// updateDate changes on refresh (channel list may have changed);
// favorites JSON changes on add/remove. Together they cover every
@@ -120,6 +120,10 @@ type DashboardRecentRailSettings = Pick<
DashboardRailsSettings,
'continueWatching' | 'recentlyWatchedLive'
>;
type DashboardLiveFavoriteRailSettings = Pick<
DashboardRailsSettings,
'liveFavorites'
>;
export interface DashboardRecentContentSkeletonInput {
readonly continueWatchingCount: number;
@@ -140,3 +144,14 @@ export function shouldShowRecentContentSkeleton(
(rails.recentlyWatchedLive && input.recentLiveCount === 0)
);
}
export interface DashboardLiveFavoritesSkeletonInput {
readonly globalFavoritesLoading: boolean;
}
export function shouldShowLiveFavoritesSkeleton(
rails: DashboardLiveFavoriteRailSettings,
input: DashboardLiveFavoritesSkeletonInput
): boolean {
return rails.liveFavorites && input.globalFavoritesLoading;
}
@@ -192,6 +192,7 @@
<!-- Live favorites: no fallback. Recently watched live gets its own rail. -->
@if (
dashboardRails().liveFavorites &&
!showLiveFavoritesSkeleton() &&
liveFavoriteCardsEnriched().length > 0
) {
<lib-dashboard-rail
@@ -203,6 +204,19 @@
layout="channel"
[testId]="'dashboard-live-favorites-rail'"
/>
} @else if (showLiveFavoritesSkeleton()) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-header"></div>
<div class="rails-page__skeleton-track">
@for (slot of skeletonSlots; track slot) {
<div class="rails-page__skeleton-card"></div>
}
</div>
</section>
}
@if (
@@ -5,6 +5,7 @@ import {
buildDashboardRailSeeAllState,
buildDashboardSourceActions,
liveRailTitleKeyForSource,
shouldShowLiveFavoritesSkeleton,
shouldShowRecentContentSkeleton,
} from './dashboard-rail.utils';
import {
@@ -465,6 +466,38 @@ describe('recent content skeleton helper', () => {
});
});
describe('live favorites skeleton helper', () => {
it('shows the skeleton while initial global favorites are still loading', () => {
expect(
shouldShowLiveFavoritesSkeleton(
{
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
liveFavorites: true,
},
{ globalFavoritesLoading: true }
)
).toBe(true);
});
it('hides the skeleton when live favorites are disabled or loading is complete', () => {
expect(
shouldShowLiveFavoritesSkeleton(
{
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
liveFavorites: false,
},
{ globalFavoritesLoading: true }
)
).toBe(false);
expect(
shouldShowLiveFavoritesSkeleton(DEFAULT_DASHBOARD_RAILS_SETTINGS, {
globalFavoritesLoading: false,
})
).toBe(false);
});
});
describe('playback-position helpers', () => {
describe('playbackProgressPercent', () => {
it('returns null when there is no position or no duration', () => {
@@ -72,6 +72,7 @@ import {
isXtreamAccountPlaylist,
liveRailTitleKeyForSource,
RAIL_ITEM_LIMIT,
shouldShowLiveFavoritesSkeleton,
shouldShowRecentContentSkeleton,
SKELETON_CARDS_PER_RAIL,
SKELETON_RAILS,
@@ -201,6 +202,12 @@ export class WorkspaceDashboardRailsComponent {
.map((item) => this.toRecentCard(item))
);
readonly showLiveFavoritesSkeleton = computed(() =>
shouldShowLiveFavoritesSkeleton(this.dashboardRails(), {
globalFavoritesLoading: this.data.globalFavoritesLoading(),
})
);
readonly showRecentContentSkeleton = computed(() =>
shouldShowRecentContentSkeleton(this.dashboardRails(), {
continueWatchingCount: this.continueWatchingCards().length,
@@ -311,9 +318,14 @@ export class WorkspaceDashboardRailsComponent {
void this.data.reloadGlobalFavorites();
// Refresh when Xtream playlist count changes so a newly added provider
// populates the rail without a manual dashboard reload.
// populates the rail without a manual dashboard reload. The Xtream
// recently-added query can be the slowest dashboard worker request on
// startup, so let favorites claim the worker first.
effect(() => {
if (this.xtreamPlaylistCount() === 0) {
if (
this.xtreamPlaylistCount() === 0 ||
!this.data.globalFavoritesLoaded()
) {
return;
}