mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge pull request #1086 from 4gray/agent/dashboard-rail-performance
fix(dashboard): speed up startup rails
This commit is contained in:
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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+14
@@ -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 (
|
||||
|
||||
+33
@@ -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', () => {
|
||||
|
||||
+14
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user