From b6c5367d639434b8d554606e9875ad4512707969 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 20 Jun 2026 21:46:42 +0200 Subject: [PATCH 1/3] fix(dashboard): speed up startup rails --- .../src/app/api/main.preload.spec-data.ts | 12 ++ .../src/app/api/main.preload.ts | 4 + .../operations/content.operations.spec.ts | 85 ++++++++- .../database/operations/content.operations.ts | 52 ++++- .../operations/favorites.operations.spec.ts | 75 ++++++++ .../operations/favorites.operations.ts | 51 ++--- .../operations/playlist.operations.spec.ts | 113 ++++++++++- .../operations/playlist.operations.ts | 132 +++++++++++++ .../app/events/database/playlist.events.ts | 4 + .../database/worker-ipc-contract.spec-data.ts | 10 + .../src/app/workers/database-worker.types.ts | 2 + .../src/app/workers/database.worker.ts | 10 + docs/architecture/sqlite-db-worker.md | 29 ++- docs/architecture/workspace-dashboard.md | 31 ++- .../src/lib/playlists.service.spec.ts | 111 +++++++++++ libs/services/src/lib/playlists.service.ts | 36 +++- libs/shared/interfaces/src/index.ts | 1 + .../src/lib/electron-api.interface.ts | 5 + .../src/lib/m3u-favorite-channel.interface.ts | 7 + .../src/lib/dashboard-data.service.spec.ts | 141 +++++++++++++- .../src/lib/dashboard-data.service.ts | 177 ++++++++++++++---- .../src/lib/rails/dashboard-rail.utils.ts | 15 ++ .../workspace-dashboard-rails.component.html | 14 ++ ...orkspace-dashboard-rails.component.spec.ts | 33 ++++ .../workspace-dashboard-rails.component.ts | 16 +- 25 files changed, 1074 insertions(+), 92 deletions(-) create mode 100644 apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts create mode 100644 libs/shared/interfaces/src/lib/m3u-favorite-channel.interface.ts diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index bfe134b59..2fa9c19ce 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -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], diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 38eda2a7b..eb795d588 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -514,8 +514,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 | ElectronBridgePlaylistInput diff --git a/apps/electron-backend/src/app/database/operations/content.operations.spec.ts b/apps/electron-backend/src/app/database/operations/content.operations.spec.ts index 1688b1fb5..98b787783 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.spec.ts @@ -7,20 +7,29 @@ const eqMock = jest.fn((left: unknown, right: unknown) => ({ left, right, })); +const inArrayMock = jest.fn((left: unknown, values: unknown[]) => ({ + kind: 'inArray', + left, + values, +})); jest.mock('drizzle-orm', () => ({ and: (...conditions: unknown[]) => andMock(...conditions), 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: jest.fn(), })); import * as schema from '@iptvnator/shared/database/schema'; import type { AppDatabase } from '../database.types'; -import { getContentByXtreamId, saveContent } from './content.operations'; +import { + getContentByXtreamId, + getGlobalRecentlyAdded, + saveContent, +} from './content.operations'; function createDbMock(result: unknown[] = []) { const limit = jest.fn().mockResolvedValue(result); @@ -40,10 +49,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(); }); it('adds the content type filter when resolving by xtream ID', async () => { @@ -255,4 +304,36 @@ describe('content.operations', () => { dateNowSpy.mockRestore(); } }); + + 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]); + }); }); diff --git a/apps/electron-backend/src/app/database/operations/content.operations.ts b/apps/electron-backend/src/app/database/operations/content.operations.ts index 200bb7cf3..4133974de 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -124,13 +124,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()}`, @@ -164,7 +210,7 @@ export async function getGlobalRecentlyAdded( ) .where(and(...whereConditions)) .orderBy(desc(schema.content.added)) - .limit(normalizedLimit); + .limit(limit); } type XtreamContentValue = { diff --git a/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts b/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts new file mode 100644 index 000000000..7c3df2120 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts @@ -0,0 +1,75 @@ +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(), + 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 } = 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(result).toEqual([ + expect.objectContaining({ + id: 2, + title: 'Saved Live Channel', + type: 'live', + }), + ]); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/favorites.operations.ts b/apps/electron-backend/src/app/database/operations/favorites.operations.ts index 9ee5ba4c7..830653f6b 100644 --- a/apps/electron-backend/src/app/database/operations/favorites.operations.ts +++ b/apps/electron-backend/src/app/database/operations/favorites.operations.ts @@ -10,6 +10,7 @@ import { import { persistContentBackdropIfMissing } from './content-backdrop.operations'; const DEFAULT_BATCH_SIZE = 100; +const GLOBAL_LIVE_FAVORITES_SCAN_LIMIT = 2000; export async function addFavorite( db: AppDatabase, @@ -89,40 +90,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, + }).limit(GLOBAL_LIVE_FAVORITES_SCAN_LIMIT); + + 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 +113,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 +136,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( diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts index 6b3416117..350bb1817 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts @@ -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, + }, + ]); + }); }); diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.ts index b3eb93980..f2b2c400c 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.ts @@ -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(value: string | null | undefined, fallback: T): T { } } +function getFavoriteChannelMatch( + channel: Channel, + favoritePositions: ReadonlyMap +): { 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): 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 { + 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(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(); + 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() diff --git a/apps/electron-backend/src/app/events/database/playlist.events.ts b/apps/electron-backend/src/app/events/database/playlist.events.ts index e1b369a76..58d75af18 100644 --- a/apps/electron-backend/src/app/events/database/playlist.events.ts +++ b/apps/electron-backend/src/app/events/database/playlist.events.ts @@ -20,7 +20,11 @@ handleWorkerRequest( (playlists: Record[]) => 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', diff --git a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts index 2993aaa5c..3002b8c36 100644 --- a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts +++ b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts @@ -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], diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index e7a86da44..1478846c4 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -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', diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 899732a32..5248322c1 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -58,6 +58,8 @@ import { deleteAllPlaylists, deletePlaylist, getAppPlaylist, + getAppPlaylistFavoriteChannels, + getAppPlaylistMetas, getAppPlaylists, getAppState, getPlaylist, @@ -501,11 +503,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); diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 66cdaf999..d589680f8 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -209,13 +209,28 @@ state handlers that still used direct main-thread SQLite access. 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 diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 15b8ad5f1..011a3a86a 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -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 `` 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 diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index fa18f83ea..f1701fdca 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -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', diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 33fddbabd..4d1c55ca1 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -21,6 +21,7 @@ import { DbStores, extractStalkerItemId, isM3uRecentlyViewedItem, + M3uFavoriteChannel, M3uRecentlyViewedItem, Playlist, PlaylistMeta, @@ -50,6 +51,10 @@ type PlaylistStorageElectronApi = { dbDeleteAllPlaylists: () => Promise; dbDeletePlaylist: (playlistId: string) => Promise; dbGetAppPlaylist: (playlistId: string) => Promise; + dbGetAppPlaylistFavoriteChannels?: ( + playlistId: string + ) => Promise; + dbGetAppPlaylistMetas?: () => Promise; dbGetAppPlaylists: () => Promise; dbGetAppState: (key: string) => Promise; dbSetAppState: (key: string, value: string) => Promise; @@ -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 { + 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'); diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 79d3c0086..7ec0c6d84 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -14,6 +14,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'; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 538f84f1f..086ed178f 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -7,6 +7,7 @@ import { import { EpgChannelMetadata } from './epg-channel-metadata.model'; import { EpgProgram } from './epg-program.model'; import { ExternalPlayerSession } from './external-player-session.interface'; +import { M3uFavoriteChannel } from './m3u-favorite-channel.interface'; import { PlaybackPositionData } from './playback-position.interface'; import { XtreamBackupFavoriteItem, @@ -563,7 +564,11 @@ export interface ElectronBridgeApi { playlists: Playlist[] ) => Promise; dbGetAppPlaylists: () => Promise; + dbGetAppPlaylistMetas: () => Promise; dbGetAppPlaylist: (playlistId: string) => Promise; + dbGetAppPlaylistFavoriteChannels: ( + playlistId: string + ) => Promise; dbUpdatePlaylist: ( playlistId: string, updates: Partial | ElectronBridgePlaylistInput diff --git a/libs/shared/interfaces/src/lib/m3u-favorite-channel.interface.ts b/libs/shared/interfaces/src/lib/m3u-favorite-channel.interface.ts new file mode 100644 index 000000000..beb1c1ddb --- /dev/null +++ b/libs/shared/interfaces/src/lib/m3u-favorite-channel.interface.ts @@ -0,0 +1,7 @@ +import { Channel } from './channel.interface'; + +export interface M3uFavoriteChannel { + favoriteId: string; + favoriteIndex: number; + channel: Channel; +} diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index d5cc41009..ca50a1300 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -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 = () => { 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,129 @@ 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(); + 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' }), + ]) + ); + }); + 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; diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 8eef38a39..83cbe91df 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -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,21 @@ export class DashboardDataService { async reloadGlobalFavorites(): Promise { 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; + this.finishInitialGlobalFavoritesLoadIfReady(); + this.favoritesAutoRefreshEnabled.set(true); } async getGlobalRecentlyAddedItems( @@ -600,7 +614,6 @@ export class DashboardDataService { if (!this.hasPortalActivityStorage) { const favorites = await this.loadPwaXtreamGlobalFavorites(); this.ngZone.run(() => this.xtreamGlobalFavorites.set(favorites)); - this.finishInitialGlobalFavoritesLoadIfReady(); return; } @@ -616,8 +629,6 @@ export class DashboardDataService { err ); this.ngZone.run(() => this.xtreamGlobalFavorites.set([])); - } finally { - this.finishInitialGlobalFavoritesLoadIfReady(); } } @@ -710,11 +721,27 @@ export class DashboardDataService { return 'movie'; } - private async refreshPlaylistBackedGlobalFavorites(): Promise { - await this.reloadM3uGlobalFavorites(); + private async reloadPlaylistBackedGlobalFavorites(): Promise { + const loaded = await this.refreshPlaylistBackedGlobalFavorites(); + if (!loaded) { + return; + } + + this.ngZone.run(() => + this.playlistBackedGlobalFavoritesLoadedState.set(true) + ); this.finishInitialGlobalFavoritesLoadIfReady(); } + private async refreshPlaylistBackedGlobalFavorites(): Promise { + if (!this.playlistsLoaded()) { + return false; + } + + await this.reloadM3uGlobalFavorites(); + return true; + } + private async reloadM3uGlobalFavorites(): Promise { const m3uPlaylists = this.playlists().filter( (playlist) => @@ -762,7 +789,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 +810,11 @@ export class DashboardDataService { } private finishInitialGlobalFavoritesLoadIfReady(): void { - if (!this.playlistsLoaded()) { + if ( + !this.playlistsLoaded() || + !this.xtreamGlobalFavoritesLoadedState() || + !this.playlistBackedGlobalFavoritesLoadedState() + ) { return; } @@ -1059,16 +1095,22 @@ export class DashboardDataService { ): Promise { // 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 +1168,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 +1188,80 @@ export class DashboardDataService { return computedItems; } + private async loadM3uPlaylistFavoritesFromResolvedChannels( + playlistMeta: PlaylistMeta, + fingerprint: string + ): Promise { + 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() + .sort((a, b) => a.favoriteIndex - b.favoriteIndex) + .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 diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts index 382a256b3..767946b7d 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts @@ -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; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html index 91f91f2d7..1ab02c2d8 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html @@ -192,6 +192,7 @@ @if ( dashboardRails().liveFavorites && + !showLiveFavoritesSkeleton() && liveFavoriteCardsEnriched().length > 0 ) { + } @else if (showLiveFavoritesSkeleton()) { + } @if ( diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts index 15dc7c3dd..de9db5143 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts @@ -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', () => { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index b5a932fc4..049309769 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -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; } From dbc90bed06daeb590d79a552b31c949cee48207a Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 20 Jun 2026 23:10:18 +0200 Subject: [PATCH 2/3] fix(dashboard): address rail review feedback --- .../operations/favorites.operations.spec.ts | 6 ++++- .../operations/favorites.operations.ts | 3 +-- .../src/lib/dashboard-data.service.spec.ts | 2 ++ .../src/lib/dashboard-data.service.ts | 25 +++++++++++-------- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts b/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts index 7c3df2120..6db591360 100644 --- a/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts @@ -23,6 +23,9 @@ function createGlobalFavoritesDbMock(rows: unknown[]) { 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); @@ -48,7 +51,7 @@ describe('favorites.operations', () => { }); it('filters live global favorites after scanning the small favorites set', async () => { - const { db } = createGlobalFavoritesDbMock([ + const { db, query } = createGlobalFavoritesDbMock([ { id: 1, title: 'Saved Movie', @@ -64,6 +67,7 @@ describe('favorites.operations', () => { const result = await getGlobalFavorites(db); expect(whereMock).not.toHaveBeenCalled(); + expect(query.limit).not.toHaveBeenCalled(); expect(result).toEqual([ expect.objectContaining({ id: 2, diff --git a/apps/electron-backend/src/app/database/operations/favorites.operations.ts b/apps/electron-backend/src/app/database/operations/favorites.operations.ts index 830653f6b..c0b402a63 100644 --- a/apps/electron-backend/src/app/database/operations/favorites.operations.ts +++ b/apps/electron-backend/src/app/database/operations/favorites.operations.ts @@ -10,7 +10,6 @@ import { import { persistContentBackdropIfMissing } from './content-backdrop.operations'; const DEFAULT_BATCH_SIZE = 100; -const GLOBAL_LIVE_FAVORITES_SCAN_LIMIT = 2000; export async function addFavorite( db: AppDatabase, @@ -92,7 +91,7 @@ export async function getFavorites(db: AppDatabase, playlistId: string) { export async function getGlobalFavorites(db: AppDatabase) { const favorites = await selectGlobalFavoriteRows(db, { includeBackdrop: false, - }).limit(GLOBAL_LIVE_FAVORITES_SCAN_LIMIT); + }); return favorites.filter((favorite) => favorite.type === 'live').slice(0, 300); } diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index ca50a1300..7db523906 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -449,6 +449,8 @@ describe('DashboardDataService', () => { 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 () => { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 83cbe91df..989c839cd 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -542,6 +542,12 @@ export class DashboardDataService { const m3uReload = this.reloadPlaylistBackedGlobalFavorites(); await Promise.all([xtreamReload, m3uReload]); + if ( + this.playlistsLoaded() && + !this.playlistBackedGlobalFavoritesLoadedState() + ) { + await this.reloadPlaylistBackedGlobalFavorites(); + } this.finishInitialGlobalFavoritesLoadIfReady(); this.favoritesAutoRefreshEnabled.set(true); } @@ -1212,17 +1218,14 @@ export class DashboardDataService { const fallbackTimestamp = this.getM3uFavoriteTimestamp(playlistMeta) ?? new Date(0).toISOString(); - const items = resolvedChannels - .slice() - .sort((a, b) => a.favoriteIndex - b.favoriteIndex) - .map((favorite) => - this.createM3uFavoriteItem( - playlistMeta, - favorite.favoriteId, - favorite.channel, - fallbackTimestamp - ) - ); + const items = resolvedChannels.slice().map((favorite) => + this.createM3uFavoriteItem( + playlistMeta, + favorite.favoriteId, + favorite.channel, + fallbackTimestamp + ) + ); this.m3uFavoritesCache.set(playlistMeta._id, { fingerprint, From f3b87f53c891318517ecd206a6de95c08d8cb6a7 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 20 Jun 2026 23:49:46 +0200 Subject: [PATCH 3/3] test(web-e2e): stabilize xtream playlist edit inputs --- apps/web-e2e/src/xtream.e2e.ts | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index f2a64f928..a53c23c50 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -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();