mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
Continue Watching and the hero gate on every playlist's playback positions (#1841). Those were requested one playlist after another and queued behind the TMDB rails' batched title match, which held the DB worker for up to 95 s on a 3.9M-title library. The positions are now loaded for all playlists at once (dashboard-playback-positions.ts), and the trending and recommendation rails start only after Continue Watching has settled (rails/dashboard-tmdb-rails.ts). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2865 lines
104 KiB
TypeScript
2865 lines
104 KiB
TypeScript
import { signal } from '@angular/core';
|
|
import { TestBed } from '@angular/core/testing';
|
|
import { Store } from '@ngrx/store';
|
|
import { TranslateService } from '@ngx-translate/core';
|
|
import {
|
|
selectAllPlaylistsMeta,
|
|
selectPlaylistsLoadingFlag,
|
|
} from '@iptvnator/m3u-state';
|
|
import { EMPTY, of, Subject } from 'rxjs';
|
|
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
|
|
import {
|
|
PlaybackPositionData,
|
|
Playlist,
|
|
PlaylistMeta,
|
|
} from '@iptvnator/shared/interfaces';
|
|
import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util';
|
|
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
|
|
import {
|
|
DashboardDataService,
|
|
DashboardFavoriteItem,
|
|
GlobalRecentItem,
|
|
} from './dashboard-data.service';
|
|
import {
|
|
CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT,
|
|
CONTINUE_WATCHING_VISIBLE_ITEMS,
|
|
CONTINUE_WATCHING_SERIES_LOOKUP_WAIT_MS,
|
|
} from './dashboard-series-continuation.util';
|
|
import {
|
|
DashboardSeriesEpisodesService,
|
|
dashboardSeriesEpisodesKey,
|
|
type DashboardSeriesEpisodes,
|
|
type DashboardSeriesEpisodesRequest,
|
|
} from './dashboard-series-episodes.service';
|
|
|
|
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');
|
|
};
|
|
const promise = new Promise<T[]>((resolve) => {
|
|
resolvePending = resolve;
|
|
});
|
|
|
|
return {
|
|
promise,
|
|
resolve: resolvePending,
|
|
};
|
|
};
|
|
|
|
const createDefaultPlaylists = (): PlaylistMeta[] => [
|
|
{
|
|
_id: 'm3u-1',
|
|
title: 'M3U Playlist',
|
|
count: 1,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
favorites: ['channel-1', 'https://example.com/stream-2.m3u8'],
|
|
recentlyViewed: [
|
|
{
|
|
source: 'm3u',
|
|
id: 'https://example.com/stream-1.m3u8',
|
|
url: 'https://example.com/stream-1.m3u8',
|
|
title: 'Channel One',
|
|
channel_id: 'channel-1',
|
|
poster_url: 'https://example.com/logo-1.png',
|
|
tvg_id: 'tvg-1',
|
|
tvg_name: 'Channel One',
|
|
group_title: 'News',
|
|
category_id: 'live',
|
|
added_at: '2026-02-01T12:00:00.000Z',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
_id: 'xtream-1',
|
|
title: 'Xtream Playlist',
|
|
count: 1,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
serverUrl: 'https://example.com',
|
|
},
|
|
];
|
|
|
|
const playlistsSignal = signal<PlaylistMeta[]>(createDefaultPlaylists());
|
|
|
|
const storeMock = {
|
|
selectSignal: jest.fn((selector: unknown) => {
|
|
if (selector === selectAllPlaylistsMeta) {
|
|
return playlistsSignal;
|
|
}
|
|
if (selector === selectPlaylistsLoadingFlag) {
|
|
return playlistsLoadedSignal;
|
|
}
|
|
return signal(null);
|
|
}),
|
|
dispatch: jest.fn(),
|
|
};
|
|
|
|
const dbServiceMock = {
|
|
getGlobalRecentlyAdded: jest.fn().mockResolvedValue([]),
|
|
getGlobalRecentlyViewed: jest.fn().mockResolvedValue([]),
|
|
getAllGlobalFavorites: jest.fn().mockResolvedValue([]),
|
|
removeFromFavorites: jest.fn().mockResolvedValue(undefined),
|
|
removeRecentItem: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
const xtreamDataSourceMock = {
|
|
getFavorites: jest.fn().mockResolvedValue([]),
|
|
getRecentItems: jest.fn().mockResolvedValue([]),
|
|
removeFavorite: jest.fn().mockResolvedValue(undefined),
|
|
removeRecentItem: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
const playlistMock: Playlist = {
|
|
_id: 'm3u-1',
|
|
title: 'M3U Playlist',
|
|
count: 2,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
lastUsage: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
favorites: ['channel-1', 'https://example.com/stream-2.m3u8'],
|
|
playlist: {
|
|
items: [
|
|
{
|
|
id: 'channel-1',
|
|
name: 'Channel One',
|
|
url: 'https://example.com/stream-1.m3u8',
|
|
tvg: {
|
|
logo: 'https://example.com/logo-1.png',
|
|
id: 'tvg-1',
|
|
name: 'Channel One',
|
|
},
|
|
},
|
|
{
|
|
id: 'channel-2',
|
|
name: 'Channel Two',
|
|
url: 'https://example.com/stream-2.m3u8',
|
|
tvg: {
|
|
logo: 'https://example.com/logo-2.png',
|
|
id: 'tvg-2',
|
|
name: 'Channel Two',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
} as Playlist;
|
|
const playlistsServiceMock = {
|
|
getM3uFavoriteChannels: jest.fn().mockReturnValue(of(null)),
|
|
getPlaylistById: jest.fn().mockReturnValue(of(playlistMock)),
|
|
transformPlaylistFavorites: jest.fn().mockReturnValue(of(playlistMock)),
|
|
removeFromM3uRecentlyViewed: jest.fn().mockReturnValue(
|
|
of({
|
|
...playlistMock,
|
|
recentlyViewed: [],
|
|
})
|
|
),
|
|
};
|
|
|
|
const playbackPositionsMock = {
|
|
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
|
|
savePlaybackPositionOrThrow: jest.fn().mockResolvedValue(undefined),
|
|
getPlaybackPosition: jest.fn().mockResolvedValue(null),
|
|
getSeriesPlaybackPositions: jest.fn().mockResolvedValue([]),
|
|
getAllPlaybackPositions: jest
|
|
.fn<Promise<PlaybackPositionData[]>, [string]>()
|
|
.mockResolvedValue([]),
|
|
clearPlaybackPosition: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
|
|
// Episode lists answer from `seriesEpisodeAnswers`; a series with no
|
|
// answer stays loading, as a slow portal would.
|
|
const seriesEpisodeEntries = signal<
|
|
ReadonlyMap<string, DashboardSeriesEpisodes>
|
|
>(new Map());
|
|
const seriesEpisodeAnswers = new Map<string, DashboardSeriesEpisodes>();
|
|
const seriesEpisodesMock = {
|
|
episodes: seriesEpisodeEntries.asReadonly(),
|
|
request: jest.fn((requests: DashboardSeriesEpisodesRequest[]) => {
|
|
seriesEpisodeEntries.update((entries) => {
|
|
const next = new Map(entries);
|
|
for (const { playlistId, seriesId } of requests) {
|
|
const key = dashboardSeriesEpisodesKey(
|
|
playlistId,
|
|
seriesId
|
|
);
|
|
next.set(
|
|
key,
|
|
seriesEpisodeAnswers.get(key) ?? { status: 'loading' }
|
|
);
|
|
}
|
|
return next;
|
|
});
|
|
}),
|
|
};
|
|
|
|
const createTestingModuleProviders = () => ({
|
|
providers: [
|
|
DashboardDataService,
|
|
{ provide: Store, useValue: storeMock },
|
|
{ provide: DatabaseService, useValue: dbServiceMock },
|
|
{
|
|
provide: XTREAM_DATA_SOURCE,
|
|
useValue: xtreamDataSourceMock,
|
|
},
|
|
{
|
|
provide: PlaylistsService,
|
|
useValue: playlistsServiceMock,
|
|
},
|
|
{
|
|
provide: TranslateService,
|
|
useValue: {
|
|
instant: (key: string) => key,
|
|
onLangChange: of(null),
|
|
onDefaultLangChange: EMPTY,
|
|
onTranslationChange: EMPTY,
|
|
currentLang: 'en',
|
|
defaultLang: 'en',
|
|
},
|
|
},
|
|
{
|
|
provide: PORTAL_PLAYBACK_POSITIONS,
|
|
useValue: playbackPositionsMock,
|
|
},
|
|
{
|
|
provide: DashboardSeriesEpisodesService,
|
|
useValue: seriesEpisodesMock,
|
|
},
|
|
],
|
|
});
|
|
|
|
beforeEach(() => {
|
|
Object.defineProperty(window, 'electron', {
|
|
value: {
|
|
dbGetRecentlyViewed: jest.fn(),
|
|
dbClearRecentlyViewed: jest.fn(),
|
|
dbGetAllGlobalFavorites: jest.fn(),
|
|
dbGetGlobalRecentlyAdded: jest.fn(),
|
|
dbAddFavorite: jest.fn(),
|
|
dbRemoveRecentItem: jest.fn(),
|
|
dbRemoveFavorite: jest.fn(),
|
|
dbGetFavorites: jest.fn(),
|
|
dbReorderGlobalFavorites: jest.fn(),
|
|
dbGetRecentItems: jest.fn(),
|
|
dbAddRecentItem: jest.fn(),
|
|
dbClearPlaylistRecentItems: jest.fn(),
|
|
dbRemoveRecentItemsBatch: jest.fn(),
|
|
dbGetContentByXtreamId: jest.fn(),
|
|
} as unknown as Window['electron'],
|
|
configurable: true,
|
|
});
|
|
playlistsLoadedSignal.set(true);
|
|
playlistsSignal.set(createDefaultPlaylists());
|
|
playlistsServiceMock.getM3uFavoriteChannels.mockClear();
|
|
playlistsServiceMock.getM3uFavoriteChannels.mockReturnValue(of(null));
|
|
playlistsServiceMock.getPlaylistById.mockClear();
|
|
playlistsServiceMock.getPlaylistById.mockReturnValue(of(playlistMock));
|
|
playlistsServiceMock.transformPlaylistFavorites.mockClear();
|
|
playlistsServiceMock.transformPlaylistFavorites.mockReturnValue(
|
|
of(playlistMock)
|
|
);
|
|
playlistsServiceMock.removeFromM3uRecentlyViewed.mockClear();
|
|
playlistsServiceMock.removeFromM3uRecentlyViewed.mockReturnValue(
|
|
of({
|
|
...playlistMock,
|
|
recentlyViewed: [],
|
|
})
|
|
);
|
|
dbServiceMock.getAllGlobalFavorites.mockClear();
|
|
dbServiceMock.getAllGlobalFavorites.mockResolvedValue([]);
|
|
dbServiceMock.getGlobalRecentlyAdded.mockClear();
|
|
dbServiceMock.getGlobalRecentlyAdded.mockResolvedValue([]);
|
|
dbServiceMock.getGlobalRecentlyViewed.mockClear();
|
|
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([]);
|
|
dbServiceMock.removeFromFavorites.mockClear();
|
|
dbServiceMock.removeRecentItem.mockClear();
|
|
xtreamDataSourceMock.getFavorites.mockClear();
|
|
xtreamDataSourceMock.getFavorites.mockResolvedValue([]);
|
|
xtreamDataSourceMock.getRecentItems.mockClear();
|
|
xtreamDataSourceMock.getRecentItems.mockResolvedValue([]);
|
|
xtreamDataSourceMock.removeFavorite.mockClear();
|
|
xtreamDataSourceMock.removeFavorite.mockResolvedValue(undefined);
|
|
xtreamDataSourceMock.removeRecentItem.mockClear();
|
|
xtreamDataSourceMock.removeRecentItem.mockResolvedValue(undefined);
|
|
storeMock.dispatch.mockClear();
|
|
|
|
playbackPositionsMock.getAllPlaybackPositions.mockClear();
|
|
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue([]);
|
|
seriesEpisodeEntries.set(new Map());
|
|
seriesEpisodeAnswers.clear();
|
|
seriesEpisodesMock.request.mockClear();
|
|
|
|
TestBed.configureTestingModule(createTestingModuleProviders());
|
|
service = TestBed.inject(DashboardDataService);
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('does not eagerly load dashboard datasets on construction', () => {
|
|
expect(dbServiceMock.getGlobalRecentlyViewed).not.toHaveBeenCalled();
|
|
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);
|
|
});
|
|
|
|
it('keeps dashboardReady false until xtream recently added finishes its first load', async () => {
|
|
expect(service.dashboardReady()).toBe(false);
|
|
|
|
await service.reloadGlobalRecentItems();
|
|
expect(service.dashboardReady()).toBe(false);
|
|
|
|
await service.reloadGlobalFavorites();
|
|
expect(service.dashboardReady()).toBe(false);
|
|
|
|
await service.reloadXtreamRecentlyAddedItems();
|
|
expect(service.dashboardReady()).toBe(true);
|
|
});
|
|
|
|
it('does not wait on xtream recently added when no xtream playlists exist', async () => {
|
|
playlistsSignal.set([
|
|
{
|
|
_id: 'm3u-1',
|
|
title: 'M3U Playlist',
|
|
count: 1,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
favorites: [],
|
|
recentlyViewed: [],
|
|
},
|
|
]);
|
|
|
|
await service.reloadGlobalRecentItems();
|
|
expect(service.dashboardReady()).toBe(false);
|
|
|
|
await service.reloadGlobalFavorites();
|
|
expect(service.dashboardReady()).toBe(true);
|
|
});
|
|
|
|
it('keeps xtream recently added loading until the playlist inventory has loaded', async () => {
|
|
// Startup: the dashboard exists before the inventory, which is empty
|
|
// until it loads, so "no Xtream playlists" is not known yet.
|
|
TestBed.resetTestingModule();
|
|
playlistsLoadedSignal.set(false);
|
|
playlistsSignal.set([]);
|
|
TestBed.configureTestingModule(createTestingModuleProviders());
|
|
service = TestBed.inject(DashboardDataService);
|
|
TestBed.tick();
|
|
expect(service.xtreamRecentlyAddedLoading()).toBe(true);
|
|
expect(service.xtreamRecentlyAddedLoaded()).toBe(false);
|
|
|
|
playlistsSignal.set(createDefaultPlaylists());
|
|
playlistsLoadedSignal.set(true);
|
|
TestBed.tick();
|
|
expect(service.xtreamRecentlyAddedLoading()).toBe(true);
|
|
|
|
await service.reloadXtreamRecentlyAddedItems();
|
|
expect(service.xtreamRecentlyAddedLoading()).toBe(false);
|
|
expect(service.xtreamRecentlyAddedLoaded()).toBe(true);
|
|
});
|
|
|
|
it('settles xtream recently added once the loaded inventory has no xtream playlists', () => {
|
|
TestBed.resetTestingModule();
|
|
playlistsLoadedSignal.set(false);
|
|
playlistsSignal.set([]);
|
|
TestBed.configureTestingModule(createTestingModuleProviders());
|
|
service = TestBed.inject(DashboardDataService);
|
|
TestBed.tick();
|
|
expect(service.xtreamRecentlyAddedLoading()).toBe(true);
|
|
|
|
playlistsLoadedSignal.set(true);
|
|
TestBed.tick();
|
|
expect(service.xtreamRecentlyAddedLoading()).toBe(false);
|
|
expect(service.xtreamRecentlyAddedLoaded()).toBe(true);
|
|
});
|
|
|
|
it('includes M3U favorites in global favorite items', async () => {
|
|
await service.reloadGlobalFavorites();
|
|
|
|
expect(service.globalFavoriteItems()).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: 'channel-1',
|
|
title: 'Channel One',
|
|
type: 'live',
|
|
playlist_id: 'm3u-1',
|
|
source: 'm3u',
|
|
poster_url: 'https://example.com/logo-1.png',
|
|
epg_lookup_key: 'tvg-1',
|
|
}),
|
|
expect.objectContaining({
|
|
id: 'https://example.com/stream-2.m3u8',
|
|
title: 'Channel Two',
|
|
type: 'live',
|
|
playlist_id: 'm3u-1',
|
|
source: 'm3u',
|
|
poster_url: 'https://example.com/logo-2.png',
|
|
epg_lookup_key: 'tvg-2',
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
|
|
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;
|
|
const channelId = channel.id;
|
|
|
|
playlistsSignal.set([
|
|
{
|
|
...createDefaultPlaylists()[0],
|
|
favorites: [channelUrl, channelId],
|
|
},
|
|
]);
|
|
playlistsServiceMock.getPlaylistById.mockReturnValue(
|
|
of({
|
|
...playlistMock,
|
|
favorites: [channelUrl, channelId],
|
|
playlist: {
|
|
items: [channel],
|
|
},
|
|
} as Playlist)
|
|
);
|
|
|
|
await service.reloadGlobalFavorites();
|
|
|
|
expect(
|
|
service.globalFavoriteItems().find((item) => item.source === 'm3u')
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
id: channelUrl,
|
|
title: 'Channel One',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('includes Xtream movies and series in global favorite items', async () => {
|
|
dbServiceMock.getAllGlobalFavorites.mockResolvedValue([
|
|
{
|
|
id: 51,
|
|
category_id: 12,
|
|
title: 'Action Movie',
|
|
rating: '8.0',
|
|
added_at: '2026-02-02T10:00:00.000Z',
|
|
poster_url: 'https://example.com/movie.png',
|
|
xtream_id: 5001,
|
|
type: 'movie',
|
|
playlist_id: 'xtream-1',
|
|
playlist_name: 'Xtream Playlist',
|
|
},
|
|
{
|
|
id: 52,
|
|
category_id: 13,
|
|
title: 'Sci-Fi Series',
|
|
rating: '8.7',
|
|
added_at: '2026-02-03T10:00:00.000Z',
|
|
poster_url: 'https://example.com/series.png',
|
|
xtream_id: 5002,
|
|
type: 'series',
|
|
playlist_id: 'xtream-1',
|
|
playlist_name: 'Xtream Playlist',
|
|
},
|
|
]);
|
|
|
|
await service.reloadGlobalFavorites();
|
|
|
|
expect(service.globalFavoriteItems()).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: 51,
|
|
title: 'Action Movie',
|
|
type: 'movie',
|
|
source: 'xtream',
|
|
}),
|
|
expect.objectContaining({
|
|
id: 52,
|
|
title: 'Sci-Fi Series',
|
|
type: 'series',
|
|
source: 'xtream',
|
|
}),
|
|
])
|
|
);
|
|
expect(
|
|
service
|
|
.globalFavoriteItems()
|
|
.filter((item) => item.type === 'movie')
|
|
).toHaveLength(1);
|
|
expect(
|
|
service
|
|
.globalFavoriteItems()
|
|
.filter((item) => item.type === 'series')
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it('includes PWA Xtream favorites from the active data source', async () => {
|
|
Object.defineProperty(window, 'electron', {
|
|
value: undefined,
|
|
configurable: true,
|
|
});
|
|
xtreamDataSourceMock.getFavorites.mockResolvedValue([
|
|
{
|
|
id: 51,
|
|
category_id: 12,
|
|
title: 'Action Movie',
|
|
rating: '8.0',
|
|
added_at: '2026-02-02T10:00:00.000Z',
|
|
poster_url: 'https://example.com/movie.png',
|
|
xtream_id: 5001,
|
|
type: 'movie',
|
|
},
|
|
]);
|
|
|
|
await service.reloadGlobalFavorites();
|
|
|
|
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith(
|
|
'xtream-1'
|
|
);
|
|
expect(dbServiceMock.getAllGlobalFavorites).not.toHaveBeenCalled();
|
|
expect(service.globalFavoriteItems()).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: 51,
|
|
title: 'Action Movie',
|
|
type: 'movie',
|
|
playlist_id: 'xtream-1',
|
|
playlist_name: 'Xtream Playlist',
|
|
source: 'xtream',
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
|
|
it('loads PWA Xtream favorites for multiple playlists in parallel', async () => {
|
|
Object.defineProperty(window, 'electron', {
|
|
value: undefined,
|
|
configurable: true,
|
|
});
|
|
playlistsSignal.set([
|
|
{
|
|
_id: 'xtream-1',
|
|
title: 'Xtream One',
|
|
count: 0,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
serverUrl: 'https://one.example.com',
|
|
},
|
|
{
|
|
_id: 'xtream-2',
|
|
title: 'Xtream Two',
|
|
count: 0,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
serverUrl: 'https://two.example.com',
|
|
},
|
|
]);
|
|
const firstFavorites = createPendingItems();
|
|
xtreamDataSourceMock.getFavorites.mockImplementation((playlistId) =>
|
|
playlistId === 'xtream-1'
|
|
? firstFavorites.promise
|
|
: Promise.resolve([])
|
|
);
|
|
|
|
const reload = service.reloadGlobalFavorites();
|
|
await Promise.resolve();
|
|
|
|
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith(
|
|
'xtream-1'
|
|
);
|
|
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith(
|
|
'xtream-2'
|
|
);
|
|
|
|
firstFavorites.resolve([]);
|
|
await reload;
|
|
});
|
|
|
|
it('uses the epoch fallback for PWA Xtream favorites with empty added dates', async () => {
|
|
Object.defineProperty(window, 'electron', {
|
|
value: undefined,
|
|
configurable: true,
|
|
});
|
|
xtreamDataSourceMock.getFavorites.mockResolvedValue([
|
|
{
|
|
id: 52,
|
|
category_id: 12,
|
|
title: 'Undated Movie',
|
|
rating: '8.0',
|
|
added: '',
|
|
poster_url: 'https://example.com/movie.png',
|
|
xtream_id: 5002,
|
|
type: 'movie',
|
|
},
|
|
]);
|
|
|
|
await service.reloadGlobalFavorites();
|
|
|
|
expect(service.globalFavoriteItems()).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: 52,
|
|
added_at: new Date(0).toISOString(),
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
|
|
it('does not load Stalker playlists through the PWA Xtream favorites path', async () => {
|
|
Object.defineProperty(window, 'electron', {
|
|
value: undefined,
|
|
configurable: true,
|
|
});
|
|
playlistsSignal.set([
|
|
{
|
|
_id: 'xtream-1',
|
|
title: 'Xtream Playlist',
|
|
count: 0,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
serverUrl: 'https://xtream.example.com',
|
|
},
|
|
{
|
|
_id: 'stalker-1',
|
|
title: 'Stalker Playlist',
|
|
count: 0,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
serverUrl: 'https://stalker.example.com',
|
|
macAddress: '00:11:22:33:44:55',
|
|
},
|
|
]);
|
|
|
|
await service.reloadGlobalFavorites();
|
|
|
|
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledTimes(1);
|
|
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith(
|
|
'xtream-1'
|
|
);
|
|
});
|
|
|
|
it('builds the M3U favorites route', async () => {
|
|
await service.reloadGlobalFavorites();
|
|
const m3uItem = service
|
|
.globalFavoriteItems()
|
|
.find((item) => item.source === 'm3u');
|
|
|
|
expect(m3uItem).toBeDefined();
|
|
expect(service.getGlobalFavoriteLink(m3uItem!)).toEqual([
|
|
'/workspace',
|
|
'playlists',
|
|
'm3u-1',
|
|
'favorites',
|
|
]);
|
|
expect(service.getGlobalFavoriteNavigationState(m3uItem!)).toEqual({
|
|
openLiveCollectionItem: {
|
|
contentType: 'live',
|
|
sourceType: 'm3u',
|
|
playlistId: 'm3u-1',
|
|
itemId: 'channel-1',
|
|
title: 'Channel One',
|
|
imageUrl: 'https://example.com/logo-1.png',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('routes Xtream non-live favorites through the global favorites page with inline detail state', async () => {
|
|
dbServiceMock.getAllGlobalFavorites.mockResolvedValue([
|
|
{
|
|
id: 51,
|
|
category_id: 12,
|
|
title: 'Action Movie',
|
|
rating: '8.0',
|
|
added_at: '2026-02-02T10:00:00.000Z',
|
|
poster_url: 'https://example.com/movie.png',
|
|
xtream_id: 5001,
|
|
type: 'movie',
|
|
playlist_id: 'xtream-1',
|
|
playlist_name: 'Xtream Playlist',
|
|
},
|
|
]);
|
|
|
|
await service.reloadGlobalFavorites();
|
|
const movieItem = service
|
|
.globalFavoriteItems()
|
|
.find((item) => item.type === 'movie');
|
|
|
|
expect(movieItem).toBeDefined();
|
|
expect(service.getGlobalFavoriteLink(movieItem!)).toEqual([
|
|
'/workspace',
|
|
'global-favorites',
|
|
]);
|
|
expect(service.getGlobalFavoriteNavigationState(movieItem!)).toEqual({
|
|
openCollectionDetailItem: {
|
|
item: expect.objectContaining({
|
|
uid: 'xtream::xtream-1::movie:5001',
|
|
name: 'Action Movie',
|
|
contentType: 'movie',
|
|
sourceType: 'xtream',
|
|
playlistId: 'xtream-1',
|
|
}),
|
|
},
|
|
});
|
|
});
|
|
|
|
it('removes M3U favorites via PlaylistsService', async () => {
|
|
await service.reloadGlobalFavorites();
|
|
const m3uItem = service
|
|
.globalFavoriteItems()
|
|
.find((item) => item.id === 'channel-1');
|
|
|
|
expect(m3uItem).toBeDefined();
|
|
|
|
await service.removeGlobalFavorite(m3uItem!);
|
|
|
|
expect(
|
|
playlistsServiceMock.transformPlaylistFavorites
|
|
).toHaveBeenCalledWith('m3u-1', expect.any(Function));
|
|
const [, transform] =
|
|
playlistsServiceMock.transformPlaylistFavorites.mock.calls[0];
|
|
expect(
|
|
transform(['channel-1', 'https://example.com/stream-2.m3u8'])
|
|
).toEqual(['https://example.com/stream-2.m3u8']);
|
|
});
|
|
|
|
it('removes PWA Xtream favorites through the active data source', async () => {
|
|
Object.defineProperty(window, 'electron', {
|
|
value: undefined,
|
|
configurable: true,
|
|
});
|
|
const item = {
|
|
id: 51,
|
|
category_id: 12,
|
|
title: 'Action Movie',
|
|
poster_url: 'https://example.com/movie.png',
|
|
xtream_id: 5001,
|
|
type: 'movie',
|
|
playlist_id: 'xtream-1',
|
|
playlist_name: 'Xtream Playlist',
|
|
source: 'xtream',
|
|
added_at: '2026-01-01T00:00:00.000Z',
|
|
} satisfies DashboardFavoriteItem;
|
|
|
|
await service.removeGlobalFavorite(item);
|
|
|
|
expect(xtreamDataSourceMock.removeFavorite).toHaveBeenCalledWith(
|
|
{ id: 51, type: 'movie' },
|
|
'xtream-1'
|
|
);
|
|
expect(dbServiceMock.removeFromFavorites).not.toHaveBeenCalled();
|
|
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith(
|
|
'xtream-1'
|
|
);
|
|
});
|
|
|
|
it('includes M3U recently viewed items in global recent items', () => {
|
|
expect(service.globalRecentItems()).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: 'https://example.com/stream-1.m3u8',
|
|
title: 'Channel One',
|
|
type: 'live',
|
|
playlist_id: 'm3u-1',
|
|
source: 'm3u',
|
|
xtream_id: 'https://example.com/stream-1.m3u8',
|
|
epg_lookup_key: 'tvg-1',
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
|
|
it('splits global recent items into VOD and live computed signals so the dashboard can render two rails', async () => {
|
|
// Seed an Xtream movie and an Xtream series alongside the M3U live
|
|
// channel already present in defaultPlaylists, then verify the
|
|
// splits land on the right computed signal.
|
|
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
|
{
|
|
id: 401,
|
|
category_id: 18,
|
|
title: 'Recent Movie',
|
|
rating: '7.8',
|
|
viewed_at: '2026-04-21T10:00:00.000Z',
|
|
poster_url: 'https://example.com/recent-movie.png',
|
|
xtream_id: 7001,
|
|
type: 'movie',
|
|
playlist_id: 'xtream-1',
|
|
playlist_name: 'Xtream Playlist',
|
|
},
|
|
{
|
|
id: 402,
|
|
category_id: 19,
|
|
title: 'Recent Series',
|
|
rating: '8.1',
|
|
viewed_at: '2026-04-22T10:00:00.000Z',
|
|
poster_url: 'https://example.com/recent-series.png',
|
|
xtream_id: 7002,
|
|
type: 'series',
|
|
playlist_id: 'xtream-1',
|
|
playlist_name: 'Xtream Playlist',
|
|
},
|
|
]);
|
|
|
|
playlistsSignal.set([
|
|
...playlistsSignal(),
|
|
{
|
|
_id: 'xtream-1',
|
|
title: 'Xtream Playlist',
|
|
count: 1,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
serverUrl: 'https://example.com',
|
|
},
|
|
]);
|
|
|
|
await service.reloadGlobalRecentItems();
|
|
|
|
const vodTitles = service
|
|
.globalRecentVodItems()
|
|
.map((item) => item.title);
|
|
const liveTitles = service
|
|
.globalRecentLiveItems()
|
|
.map((item) => item.title);
|
|
|
|
expect(vodTitles).toEqual(
|
|
expect.arrayContaining(['Recent Movie', 'Recent Series'])
|
|
);
|
|
expect(vodTitles).not.toContain('Channel One');
|
|
|
|
expect(liveTitles).toContain('Channel One');
|
|
expect(liveTitles).not.toEqual(
|
|
expect.arrayContaining(['Recent Movie', 'Recent Series'])
|
|
);
|
|
|
|
// Splits stay mutually exclusive — sum of partitions equals the whole.
|
|
expect(
|
|
service.globalRecentVodItems().length +
|
|
service.globalRecentLiveItems().length
|
|
).toBe(service.globalRecentItems().length);
|
|
});
|
|
|
|
it('loads playback positions for every playlist that owns VOD/series recent items and exposes them by content key', async () => {
|
|
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
|
{
|
|
id: 101,
|
|
category_id: 18,
|
|
title: 'Atlantic City',
|
|
rating: '7.3',
|
|
viewed_at: '2026-04-21T10:00:00.000Z',
|
|
poster_url: 'https://example.com/atlantic.png',
|
|
xtream_id: 4242,
|
|
type: 'movie',
|
|
playlist_id: 'xtream-A',
|
|
playlist_name: 'Xtream A',
|
|
},
|
|
{
|
|
id: 102,
|
|
category_id: 19,
|
|
title: 'Color Orchard S02E04',
|
|
rating: '8.1',
|
|
viewed_at: '2026-04-22T10:00:00.000Z',
|
|
poster_url: 'https://example.com/orchard.png',
|
|
xtream_id: 909,
|
|
type: 'series',
|
|
playlist_id: 'xtream-B',
|
|
playlist_name: 'Xtream B',
|
|
},
|
|
]);
|
|
|
|
playlistsSignal.set([
|
|
...playlistsSignal(),
|
|
{
|
|
_id: 'xtream-A',
|
|
title: 'Xtream A',
|
|
count: 1,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
serverUrl: 'https://a.example.com',
|
|
},
|
|
{
|
|
_id: 'xtream-B',
|
|
title: 'Xtream B',
|
|
count: 1,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
serverUrl: 'https://b.example.com',
|
|
},
|
|
]);
|
|
|
|
playbackPositionsMock.getAllPlaybackPositions.mockImplementation(
|
|
(playlistId: string) => {
|
|
if (playlistId === 'xtream-A') {
|
|
return Promise.resolve([
|
|
{
|
|
contentXtreamId: 4242,
|
|
contentType: 'vod',
|
|
positionSeconds: 3600,
|
|
durationSeconds: 6000,
|
|
playlistId,
|
|
} as PlaybackPositionData,
|
|
]);
|
|
}
|
|
if (playlistId === 'xtream-B') {
|
|
return Promise.resolve([
|
|
{
|
|
contentXtreamId: 909,
|
|
contentType: 'episode',
|
|
seriesXtreamId: 900,
|
|
seasonNumber: 2,
|
|
episodeNumber: 4,
|
|
positionSeconds: 720,
|
|
durationSeconds: 1800,
|
|
playlistId,
|
|
} as PlaybackPositionData,
|
|
]);
|
|
}
|
|
return Promise.resolve([]);
|
|
}
|
|
);
|
|
|
|
await service.reloadGlobalRecentItems();
|
|
await service.reloadPlaybackPositions();
|
|
|
|
// Bulk fetched once per playlist that owns a tracked item — and ONLY
|
|
// for the playlists that own VOD/series recent items. The default M3U
|
|
// playlist (live channel) must not be queried.
|
|
const queriedPlaylists =
|
|
playbackPositionsMock.getAllPlaybackPositions.mock.calls.map(
|
|
(call) => call[0]
|
|
);
|
|
expect(new Set(queriedPlaylists)).toEqual(
|
|
new Set(['xtream-A', 'xtream-B'])
|
|
);
|
|
|
|
const vodItems = service.globalRecentVodItems();
|
|
const movie = vodItems.find((item) => item.title === 'Atlantic City');
|
|
const series = vodItems.find(
|
|
(item) => item.title === 'Color Orchard S02E04'
|
|
);
|
|
if (!series) {
|
|
throw new Error('expected the series recent item');
|
|
}
|
|
|
|
// Movie: vod position with 60% watched.
|
|
const moviePos = service.getPlaybackPositionForItem(movie!);
|
|
expect(moviePos?.positionSeconds).toBe(3600);
|
|
expect(moviePos?.contentType).toBe('vod');
|
|
|
|
// Series: episode position keyed by the recent item's xtream_id (the
|
|
// episode id, not the series id).
|
|
const seriesPos = service.getPlaybackPositionForItem(series);
|
|
expect(seriesPos?.contentType).toBe('episode');
|
|
expect(seriesPos?.positionSeconds).toBe(720);
|
|
// Season/episode metadata travels with the position so cards can
|
|
// render an "S2 · E5" badge without an extra round-trip.
|
|
expect(seriesPos?.seasonNumber).toBe(2);
|
|
expect(seriesPos?.episodeNumber).toBe(4);
|
|
expect(service.getRecentItemNavigationState(series)).toEqual({
|
|
openCollectionDetailItem: {
|
|
item: expect.objectContaining({
|
|
contentType: 'series',
|
|
xtreamId: 900,
|
|
}),
|
|
seriesResume: {
|
|
seriesXtreamId: 900,
|
|
contentXtreamId: 909,
|
|
seasonNumber: 2,
|
|
episodeNumber: 4,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('asks every playlist for its playback positions at once, not one after another', async () => {
|
|
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
|
{
|
|
id: 101,
|
|
category_id: 18,
|
|
title: 'Atlantic City',
|
|
rating: '7.3',
|
|
viewed_at: '2026-04-21T10:00:00.000Z',
|
|
poster_url: 'https://example.com/atlantic.png',
|
|
xtream_id: 4242,
|
|
type: 'movie',
|
|
playlist_id: 'xtream-A',
|
|
playlist_name: 'Xtream A',
|
|
},
|
|
{
|
|
id: 102,
|
|
category_id: 19,
|
|
title: 'Color Orchard S02E04',
|
|
rating: '8.1',
|
|
viewed_at: '2026-04-22T10:00:00.000Z',
|
|
poster_url: 'https://example.com/orchard.png',
|
|
xtream_id: 909,
|
|
type: 'series',
|
|
playlist_id: 'xtream-B',
|
|
playlist_name: 'Xtream B',
|
|
},
|
|
]);
|
|
|
|
// Hold every answer: asked one after another, the second playlist
|
|
// would not be asked until the first had answered, and a DB worker
|
|
// busy with a long request would delay the set once per playlist.
|
|
const pending = new Map<string, (rows: PlaybackPositionData[]) => void>();
|
|
playbackPositionsMock.getAllPlaybackPositions.mockImplementation(
|
|
(playlistId: string) =>
|
|
new Promise<PlaybackPositionData[]>((resolve) => {
|
|
pending.set(playlistId, resolve);
|
|
})
|
|
);
|
|
|
|
await service.reloadGlobalRecentItems();
|
|
const reload = service.reloadPlaybackPositions();
|
|
|
|
expect(new Set(pending.keys())).toEqual(
|
|
new Set(['xtream-A', 'xtream-B'])
|
|
);
|
|
|
|
pending.get('xtream-A')?.([
|
|
{
|
|
contentXtreamId: 4242,
|
|
contentType: 'vod',
|
|
positionSeconds: 3600,
|
|
durationSeconds: 6000,
|
|
playlistId: 'xtream-A',
|
|
} as PlaybackPositionData,
|
|
]);
|
|
pending.get('xtream-B')?.([]);
|
|
await reload;
|
|
|
|
const movie = service
|
|
.globalRecentVodItems()
|
|
.find((item) => item.playlist_id === 'xtream-A');
|
|
expect(movie).toBeDefined();
|
|
expect(
|
|
service.getPlaybackPositionForItem(movie as GlobalRecentItem)
|
|
).toMatchObject({ contentXtreamId: 4242, positionSeconds: 3600 });
|
|
});
|
|
|
|
it('resolves episode metadata for a Stalker VOD is_series recent item', async () => {
|
|
playlistsSignal.set([
|
|
...createDefaultPlaylists(),
|
|
{
|
|
_id: 'stalker-series',
|
|
title: 'Ministra Portal',
|
|
count: 1,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
macAddress: '00:11:22:33:44:55',
|
|
recentlyViewed: [
|
|
{
|
|
id: '50001',
|
|
title: 'VOD Flagged Series',
|
|
category_id: 'vod',
|
|
is_series: '1',
|
|
added_at: '2026-07-20T12:00:00.000Z',
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
playbackPositionsMock.getAllPlaybackPositions.mockImplementation(
|
|
async (playlistId: string) =>
|
|
playlistId === 'stalker-series'
|
|
? [
|
|
{
|
|
playlistId,
|
|
contentXtreamId: 5000101,
|
|
contentType: 'episode',
|
|
seriesXtreamId: 50001,
|
|
seasonNumber: 1,
|
|
episodeNumber: 1,
|
|
positionSeconds: 120,
|
|
durationSeconds: 1800,
|
|
},
|
|
]
|
|
: []
|
|
);
|
|
|
|
await service.reloadPlaybackPositions();
|
|
|
|
const item = service
|
|
.globalRecentItems()
|
|
.find((recent) => recent.playlist_id === 'stalker-series');
|
|
if (!item) {
|
|
throw new Error('expected the Stalker is_series recent item');
|
|
}
|
|
|
|
expect(item.type).toBe('series');
|
|
expect(service.getPlaybackPositionForItem(item)).toEqual(
|
|
expect.objectContaining({
|
|
seasonNumber: 1,
|
|
episodeNumber: 1,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('resolves episode progress for a Stalker embedded-VOD show stored without is_series', async () => {
|
|
// Real stored shape (RUcolor-style portal): the recent entry keeps
|
|
// the `series[]` episode array and a numeric VOD category, but no
|
|
// `is_series` flag. It must keep routing as a movie while its
|
|
// progress, badge, and resume handoff come from the episode rows
|
|
// saved under the parent id.
|
|
playlistsSignal.set([
|
|
...createDefaultPlaylists(),
|
|
{
|
|
_id: 'stalker-embedded',
|
|
title: 'RUcolor.tv',
|
|
count: 1,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
macAddress: '00:11:22:33:44:55',
|
|
recentlyViewed: [
|
|
{
|
|
id: '17572',
|
|
title: 'Fake (10 episodes)',
|
|
category_id: '7',
|
|
cmd: '/media/17572.mpg',
|
|
series: [1, 2, 3, 4, 5, 6, 7, 8],
|
|
added_at: '2026-09-19T16:13:50.000Z',
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
playbackPositionsMock.getAllPlaybackPositions.mockImplementation(
|
|
async (playlistId: string) =>
|
|
playlistId === 'stalker-embedded'
|
|
? [
|
|
{
|
|
playlistId,
|
|
contentXtreamId: 1750797719,
|
|
contentType: 'episode',
|
|
seriesXtreamId: 17572,
|
|
seasonNumber: 1,
|
|
episodeNumber: 2,
|
|
positionSeconds: 2673,
|
|
durationSeconds: 2761,
|
|
updatedAt: '2026-09-17T10:55:35.000Z',
|
|
},
|
|
{
|
|
playlistId,
|
|
contentXtreamId: 1750797722,
|
|
contentType: 'episode',
|
|
seriesXtreamId: 17572,
|
|
seasonNumber: 1,
|
|
episodeNumber: 5,
|
|
positionSeconds: 2306,
|
|
durationSeconds: 2920,
|
|
updatedAt: '2026-09-18T21:45:56.000Z',
|
|
},
|
|
]
|
|
: []
|
|
);
|
|
|
|
await service.reloadPlaybackPositions();
|
|
|
|
const item = service
|
|
.globalRecentItems()
|
|
.find((recent) => recent.playlist_id === 'stalker-embedded');
|
|
if (!item) {
|
|
throw new Error('expected the Stalker embedded-series recent item');
|
|
}
|
|
|
|
expect(item.type).toBe('movie');
|
|
expect(item.watch_kind).toBe('series');
|
|
expect(service.getPlaybackPositionForItem(item)).toEqual(
|
|
expect.objectContaining({
|
|
contentType: 'episode',
|
|
seasonNumber: 1,
|
|
episodeNumber: 5,
|
|
positionSeconds: 2306,
|
|
})
|
|
);
|
|
expect(service.getRecentItemResumeNavigation(item)).toEqual(
|
|
expect.objectContaining({
|
|
link: ['/workspace', 'global-recent'],
|
|
state: {
|
|
openCollectionDetailItem: expect.objectContaining({
|
|
item: expect.objectContaining({
|
|
sourceType: 'stalker',
|
|
contentType: 'movie',
|
|
}),
|
|
seriesResume: {
|
|
seriesXtreamId: 17572,
|
|
contentXtreamId: 1750797722,
|
|
seasonNumber: 1,
|
|
episodeNumber: 5,
|
|
},
|
|
}),
|
|
},
|
|
})
|
|
);
|
|
});
|
|
|
|
it('keeps legacy episode-keyed recents detail-only when the position row lacks the parent series id', async () => {
|
|
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
|
{
|
|
id: 103,
|
|
category_id: 19,
|
|
title: 'Legacy Orchard S02E04',
|
|
rating: '8.1',
|
|
viewed_at: '2026-04-22T10:00:00.000Z',
|
|
poster_url: 'https://example.com/legacy-orchard.png',
|
|
xtream_id: 909,
|
|
type: 'series',
|
|
playlist_id: 'xtream-L',
|
|
playlist_name: 'Xtream Legacy',
|
|
},
|
|
]);
|
|
|
|
playlistsSignal.set([
|
|
...playlistsSignal(),
|
|
{
|
|
_id: 'xtream-L',
|
|
title: 'Xtream Legacy',
|
|
count: 1,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
serverUrl: 'https://legacy.example.com',
|
|
},
|
|
]);
|
|
|
|
// Rows saved before seriesXtreamId existed: keyed by the episode id
|
|
// with no pointer back to the parent series.
|
|
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue([
|
|
{
|
|
contentXtreamId: 909,
|
|
contentType: 'episode',
|
|
seasonNumber: 2,
|
|
episodeNumber: 4,
|
|
positionSeconds: 720,
|
|
durationSeconds: 1800,
|
|
playlistId: 'xtream-L',
|
|
} as PlaybackPositionData,
|
|
]);
|
|
|
|
await service.reloadGlobalRecentItems();
|
|
await service.reloadPlaybackPositions();
|
|
|
|
const series = service
|
|
.globalRecentVodItems()
|
|
.find((item) => item.title === 'Legacy Orchard S02E04');
|
|
if (!series) {
|
|
throw new Error('expected the series recent item');
|
|
}
|
|
|
|
// The episode id must not be promoted to a series id: no resume
|
|
// target, and the detail item keeps its original identifier.
|
|
expect(service.getRecentItemNavigationState(series)).toEqual({
|
|
openCollectionDetailItem: {
|
|
item: expect.objectContaining({
|
|
contentType: 'series',
|
|
xtreamId: 909,
|
|
}),
|
|
},
|
|
});
|
|
});
|
|
|
|
it('resolves the latest episode position for series whose recent_items row carries the series id', async () => {
|
|
// The Xtream series landing page records the recent item with the
|
|
// SERIES id, while each played episode saves its position keyed by
|
|
// the EPISODE id (with seriesXtreamId pointing back). The lookup
|
|
// must follow seriesXtreamId so both card shapes show progress.
|
|
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
|
{
|
|
id: 200,
|
|
category_id: 30,
|
|
title: 'Shadow Bay',
|
|
rating: '7.5',
|
|
viewed_at: '2026-05-01T09:00:00.000Z',
|
|
poster_url: 'https://example.com/shadow-bay.png',
|
|
xtream_id: 4000, // series id
|
|
type: 'series',
|
|
playlist_id: 'xtream-C',
|
|
playlist_name: 'Xtream C',
|
|
},
|
|
]);
|
|
playlistsSignal.set([
|
|
...playlistsSignal(),
|
|
{
|
|
_id: 'xtream-C',
|
|
title: 'Xtream C',
|
|
count: 1,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
serverUrl: 'https://c.example.com',
|
|
},
|
|
]);
|
|
playbackPositionsMock.getAllPlaybackPositions.mockImplementation(
|
|
(playlistId: string) => {
|
|
if (playlistId === 'xtream-C') {
|
|
return Promise.resolve([
|
|
// Earlier episode — should NOT win even if it also
|
|
// matches the series, because the newer one is more
|
|
// relevant for "where you left off".
|
|
{
|
|
contentXtreamId: 4001,
|
|
contentType: 'episode',
|
|
seriesXtreamId: 4000,
|
|
seasonNumber: 1,
|
|
episodeNumber: 1,
|
|
positionSeconds: 60,
|
|
durationSeconds: 1800,
|
|
playlistId,
|
|
updatedAt: '2026-04-25T12:00:00.000Z',
|
|
} as PlaybackPositionData,
|
|
{
|
|
contentXtreamId: 4007,
|
|
contentType: 'episode',
|
|
seriesXtreamId: 4000,
|
|
seasonNumber: 3,
|
|
episodeNumber: 7,
|
|
positionSeconds: 540,
|
|
durationSeconds: 1800,
|
|
playlistId,
|
|
updatedAt: '2026-05-01T09:30:00.000Z',
|
|
} as PlaybackPositionData,
|
|
]);
|
|
}
|
|
return Promise.resolve([]);
|
|
}
|
|
);
|
|
|
|
await service.reloadGlobalRecentItems();
|
|
await service.reloadPlaybackPositions();
|
|
|
|
const series = service
|
|
.globalRecentVodItems()
|
|
.find((item) => item.title === 'Shadow Bay');
|
|
const positionsMap = service.playbackPositions$();
|
|
const failOnLinearScan = jest.fn(() => {
|
|
throw new Error('series lookup should not scan all positions');
|
|
});
|
|
(
|
|
positionsMap as unknown as {
|
|
values: () => IterableIterator<PlaybackPositionData>;
|
|
}
|
|
).values =
|
|
failOnLinearScan as unknown as () => IterableIterator<PlaybackPositionData>;
|
|
|
|
expect(series).toBeDefined();
|
|
if (!series) {
|
|
throw new Error('expected Shadow Bay recent item');
|
|
}
|
|
|
|
const position = service.getPlaybackPositionForItem(series);
|
|
expect(position?.contentType).toBe('episode');
|
|
expect(position?.seasonNumber).toBe(3);
|
|
expect(position?.episodeNumber).toBe(7);
|
|
expect(service.getRecentItemNavigationState(series)).toEqual({
|
|
openCollectionDetailItem: {
|
|
item: expect.objectContaining({
|
|
contentType: 'series',
|
|
sourceType: 'xtream',
|
|
xtreamId: 4000,
|
|
}),
|
|
seriesResume: {
|
|
seriesXtreamId: 4000,
|
|
contentXtreamId: 4007,
|
|
seasonNumber: 3,
|
|
episodeNumber: 7,
|
|
},
|
|
},
|
|
});
|
|
expect(failOnLinearScan).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe('Continue Watching', () => {
|
|
const PLAYLIST = 'xtream-cw';
|
|
|
|
const recentRow = (
|
|
id: number,
|
|
title: string,
|
|
type: 'movie' | 'series',
|
|
xtreamId: number,
|
|
viewedAt: string,
|
|
playlistId = PLAYLIST
|
|
) => ({
|
|
id,
|
|
category_id: 1,
|
|
title,
|
|
rating: '7.0',
|
|
viewed_at: viewedAt,
|
|
poster_url: `https://example.com/${xtreamId}.png`,
|
|
xtream_id: xtreamId,
|
|
type,
|
|
playlist_id: playlistId,
|
|
playlist_name: 'Xtream CW',
|
|
});
|
|
const vodPosition = (
|
|
contentXtreamId: number,
|
|
positionSeconds: number,
|
|
durationSeconds: number
|
|
): PlaybackPositionData => ({
|
|
contentXtreamId,
|
|
contentType: 'vod',
|
|
positionSeconds,
|
|
durationSeconds,
|
|
playlistId: PLAYLIST,
|
|
});
|
|
const episodePosition = (
|
|
contentXtreamId: number,
|
|
seriesXtreamId: number,
|
|
positionSeconds: number,
|
|
durationSeconds: number,
|
|
updatedAt: string
|
|
): PlaybackPositionData => ({
|
|
contentXtreamId,
|
|
contentType: 'episode',
|
|
seriesXtreamId,
|
|
seasonNumber: 1,
|
|
episodeNumber: contentXtreamId % 100,
|
|
positionSeconds,
|
|
durationSeconds,
|
|
playlistId: PLAYLIST,
|
|
updatedAt,
|
|
});
|
|
const titles = () =>
|
|
service.continueWatchingItems().map((item) => item.title);
|
|
/** The portal's episode list for a series: season 1, these ids. */
|
|
const answerEpisodes = (seriesId: number, ids: number[]) =>
|
|
seriesEpisodeAnswers.set(
|
|
dashboardSeriesEpisodesKey(PLAYLIST, seriesId),
|
|
{
|
|
status: 'loaded',
|
|
seasons: {
|
|
'1': ids.map((id, index) => ({
|
|
id: String(id),
|
|
season: 1,
|
|
episode_num: index + 1,
|
|
title: `Episode ${index + 1}`,
|
|
})) as never,
|
|
},
|
|
}
|
|
);
|
|
/** History, then positions, then the episode lookups they trigger. */
|
|
const loadDashboard = async () => {
|
|
await service.reloadGlobalRecentItems();
|
|
await service.reloadPlaybackPositions();
|
|
TestBed.tick();
|
|
};
|
|
|
|
beforeEach(() => {
|
|
playbackPositionsMock.savePlaybackPositionOrThrow.mockResolvedValue(
|
|
undefined
|
|
);
|
|
playlistsSignal.set([
|
|
...playlistsSignal(),
|
|
{
|
|
_id: PLAYLIST,
|
|
title: 'Xtream CW',
|
|
count: 1,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
serverUrl: 'https://cw.example.com',
|
|
username: 'cw-user',
|
|
password: 'cw-pass',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('lists only the titles the user has not finished', async () => {
|
|
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
|
recentRow(
|
|
1,
|
|
'Credits Movie',
|
|
'movie',
|
|
101,
|
|
'2026-10-04T10:00:00Z'
|
|
),
|
|
recentRow(
|
|
2,
|
|
'Halfway Movie',
|
|
'movie',
|
|
102,
|
|
'2026-10-04T09:00:00Z'
|
|
),
|
|
recentRow(
|
|
3,
|
|
'Next Episode Series',
|
|
'series',
|
|
200,
|
|
'2026-10-04T08:30:00Z'
|
|
),
|
|
recentRow(
|
|
4,
|
|
'Ongoing Series',
|
|
'series',
|
|
300,
|
|
'2026-10-04T08:00:00Z'
|
|
),
|
|
recentRow(
|
|
5,
|
|
'Finished Series',
|
|
'series',
|
|
400,
|
|
'2026-10-04T07:30:00Z'
|
|
),
|
|
recentRow(
|
|
6,
|
|
'Threshold Movie',
|
|
'movie',
|
|
103,
|
|
'2026-10-04T06:00:00Z'
|
|
),
|
|
recentRow(
|
|
7,
|
|
'Almost Movie',
|
|
'movie',
|
|
104,
|
|
'2026-10-04T05:00:00Z'
|
|
),
|
|
]);
|
|
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue([
|
|
// Stopped while the credits rolled: 61 s of 59 min left.
|
|
vodPosition(101, 3491, 3552),
|
|
vodPosition(102, 2700, 5400),
|
|
// Episodes 1 and 2 watched; episode 3 is next.
|
|
episodePosition(201, 200, 1887, 1942, '2026-10-03T08:00:00Z'),
|
|
episodePosition(202, 200, 1887, 1942, '2026-10-04T08:00:00Z'),
|
|
// Latest episode started after an earlier one was finished.
|
|
episodePosition(301, 300, 1887, 1942, '2026-10-02T08:00:00Z'),
|
|
episodePosition(302, 300, 600, 1942, '2026-10-04T07:00:00Z'),
|
|
// Every episode watched.
|
|
episodePosition(401, 400, 1887, 1942, '2026-10-03T07:00:00Z'),
|
|
episodePosition(402, 400, 1887, 1942, '2026-10-04T07:00:00Z'),
|
|
vodPosition(103, 4860, 5400), // 90%
|
|
vodPosition(104, 4800, 5400), // 89%
|
|
]);
|
|
answerEpisodes(200, [201, 202, 203]);
|
|
answerEpisodes(400, [401, 402]);
|
|
|
|
await loadDashboard();
|
|
|
|
expect(titles()).toEqual([
|
|
'Halfway Movie',
|
|
'Next Episode Series',
|
|
'Ongoing Series',
|
|
'Almost Movie',
|
|
]);
|
|
// Finished titles stay in the watch history.
|
|
expect(service.globalRecentVodItems()).toHaveLength(7);
|
|
// Only series whose newest episode is watched are looked up.
|
|
expect(seriesEpisodesMock.request).toHaveBeenCalledWith(
|
|
[
|
|
expect.objectContaining({ seriesId: 200 }),
|
|
expect.objectContaining({ seriesId: 400 }),
|
|
],
|
|
expect.any(Number)
|
|
);
|
|
|
|
// The series goes on with episode 3, from the beginning.
|
|
const nextEpisode = service
|
|
.continueWatchingItems()
|
|
.find((item) => item.title === 'Next Episode Series');
|
|
if (!nextEpisode) {
|
|
throw new Error('expected the series with a next episode');
|
|
}
|
|
const position = service.getPlaybackPositionForItem(nextEpisode);
|
|
expect(position).toMatchObject({
|
|
contentXtreamId: 203,
|
|
contentType: 'episode',
|
|
seriesXtreamId: 200,
|
|
seasonNumber: 1,
|
|
episodeNumber: 3,
|
|
positionSeconds: 0,
|
|
});
|
|
expect(position?.durationSeconds).toBeUndefined();
|
|
expect(
|
|
service.getRecentItemResumeNavigation(nextEpisode)?.state
|
|
).toMatchObject({
|
|
openCollectionDetailItem: {
|
|
seriesResume: {
|
|
seriesXtreamId: 200,
|
|
contentXtreamId: 203,
|
|
seasonNumber: 1,
|
|
episodeNumber: 3,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
describe('a series whose newest episode is watched', () => {
|
|
beforeEach(() => {
|
|
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
|
recentRow(
|
|
3,
|
|
'The Penguin',
|
|
'series',
|
|
200,
|
|
'2026-10-04T08:30:00Z'
|
|
),
|
|
]);
|
|
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue(
|
|
// Episodes 1-4 watched, the newest last night.
|
|
[201, 202, 203, 204].map((id) =>
|
|
episodePosition(
|
|
id,
|
|
200,
|
|
3491,
|
|
3552,
|
|
`2026-10-0${id - 200}T08:00:00Z`
|
|
)
|
|
)
|
|
);
|
|
});
|
|
|
|
it('asks the portal for its episodes with the playlist credentials', async () => {
|
|
answerEpisodes(200, [201, 202, 203, 204, 205]);
|
|
|
|
await loadDashboard();
|
|
|
|
expect(seriesEpisodesMock.request).toHaveBeenCalledWith(
|
|
[
|
|
{
|
|
playlistId: PLAYLIST,
|
|
seriesId: 200,
|
|
credentials: {
|
|
serverUrl: 'https://cw.example.com',
|
|
username: 'cw-user',
|
|
password: 'cw-pass',
|
|
},
|
|
},
|
|
],
|
|
expect.any(Number)
|
|
);
|
|
expect(titles()).toEqual(['The Penguin']);
|
|
expect(
|
|
service.getPlaybackPositionForItem(
|
|
service.continueWatchingItems()[0]
|
|
)
|
|
).toMatchObject({
|
|
contentXtreamId: 205,
|
|
seasonNumber: 1,
|
|
episodeNumber: 5,
|
|
});
|
|
});
|
|
|
|
it('asks for the list again when it predates the episode watched last', async () => {
|
|
// The cached list ends before the newest episode played: it
|
|
// cannot tell "finale" from "a 5th episode is out now".
|
|
answerEpisodes(200, [201, 202, 203]);
|
|
|
|
await loadDashboard();
|
|
|
|
expect(titles()).toEqual(['The Penguin']);
|
|
expect(
|
|
service.getPlaybackPositionForItem(
|
|
service.continueWatchingItems()[0]
|
|
)
|
|
).toMatchObject({ contentXtreamId: 204 });
|
|
expect(seriesEpisodesMock.request).toHaveBeenLastCalledWith(
|
|
[expect.objectContaining({ seriesId: 200, refresh: true })],
|
|
expect.any(Number)
|
|
);
|
|
});
|
|
|
|
it('waits for the episode list on first open, for a bounded time', async () => {
|
|
jest.useFakeTimers();
|
|
try {
|
|
await loadDashboard();
|
|
|
|
// The answer decides between "next episode" and "gone".
|
|
expect(service.continueWatchingSettled()).toBe(false);
|
|
expect(titles()).toEqual([]);
|
|
|
|
jest.advanceTimersByTime(
|
|
CONTINUE_WATCHING_SERIES_LOOKUP_WAIT_MS
|
|
);
|
|
|
|
// Still unknown: the series keeps its place and its
|
|
// newest episode.
|
|
expect(service.continueWatchingSettled()).toBe(true);
|
|
expect(titles()).toEqual(['The Penguin']);
|
|
expect(
|
|
service.getPlaybackPositionForItem(
|
|
service.continueWatchingItems()[0]
|
|
)
|
|
).toMatchObject({ contentXtreamId: 204 });
|
|
} finally {
|
|
jest.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('keeps its place when the episode list cannot be loaded', async () => {
|
|
seriesEpisodeAnswers.set(
|
|
dashboardSeriesEpisodesKey(PLAYLIST, 200),
|
|
{ status: 'failed' }
|
|
);
|
|
|
|
await loadDashboard();
|
|
|
|
expect(service.continueWatchingSettled()).toBe(true);
|
|
expect(titles()).toEqual(['The Penguin']);
|
|
});
|
|
|
|
it('looks a failed list up again only when positions reload', async () => {
|
|
const key = dashboardSeriesEpisodesKey(PLAYLIST, 200);
|
|
seriesEpisodeAnswers.set(key, { status: 'failed' });
|
|
await loadDashboard();
|
|
expect(seriesEpisodesMock.request).toHaveBeenCalledTimes(1);
|
|
|
|
// Playlist store churn elsewhere in the app: an M3U
|
|
// playlist added, every playlist object replaced.
|
|
playlistsSignal.set([
|
|
...playlistsSignal().map((playlist) => ({ ...playlist })),
|
|
{
|
|
_id: 'm3u-new',
|
|
title: 'New M3U',
|
|
count: 1,
|
|
importDate: '2026-10-04T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
} as never,
|
|
]);
|
|
TestBed.tick();
|
|
expect(seriesEpisodesMock.request).toHaveBeenCalledTimes(1);
|
|
|
|
// The dashboard is opened again: the portal answers now.
|
|
answerEpisodes(200, [201, 202, 203, 204, 205]);
|
|
await service.reloadPlaybackPositions();
|
|
TestBed.tick();
|
|
|
|
expect(seriesEpisodesMock.request).toHaveBeenCalledTimes(2);
|
|
expect(
|
|
service.getPlaybackPositionForItem(
|
|
service.continueWatchingItems()[0]
|
|
)
|
|
).toMatchObject({ contentXtreamId: 205 });
|
|
});
|
|
|
|
it('looks the list up again with the credentials of an edited playlist', async () => {
|
|
seriesEpisodeAnswers.set(
|
|
dashboardSeriesEpisodesKey(PLAYLIST, 200),
|
|
{ status: 'failed' }
|
|
);
|
|
await loadDashboard();
|
|
|
|
playlistsSignal.set(
|
|
playlistsSignal().map((playlist) =>
|
|
playlist._id === PLAYLIST
|
|
? { ...playlist, password: 'new-pass' }
|
|
: playlist
|
|
)
|
|
);
|
|
TestBed.tick();
|
|
|
|
expect(seriesEpisodesMock.request).toHaveBeenCalledTimes(2);
|
|
expect(seriesEpisodesMock.request).toHaveBeenLastCalledWith(
|
|
[
|
|
expect.objectContaining({
|
|
credentials: expect.objectContaining({
|
|
password: 'new-pass',
|
|
}),
|
|
}),
|
|
],
|
|
expect.any(Number)
|
|
);
|
|
});
|
|
|
|
it('leaves once its last episode is watched', async () => {
|
|
answerEpisodes(200, [201, 202, 203, 204]);
|
|
|
|
await loadDashboard();
|
|
|
|
expect(titles()).toEqual([]);
|
|
expect(service.globalRecentVodItems()).toHaveLength(1);
|
|
});
|
|
|
|
it('keeps to the run when an extra left unfinished is the newest activity', async () => {
|
|
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue(
|
|
[
|
|
episodePosition(
|
|
201,
|
|
200,
|
|
3491,
|
|
3552,
|
|
'2026-10-01T08:00:00Z'
|
|
),
|
|
{
|
|
...episodePosition(
|
|
290,
|
|
200,
|
|
600,
|
|
1800,
|
|
'2026-10-02T08:00:00Z'
|
|
),
|
|
seasonNumber: 0,
|
|
episodeNumber: 1,
|
|
},
|
|
]
|
|
);
|
|
seriesEpisodeAnswers.set(
|
|
dashboardSeriesEpisodesKey(PLAYLIST, 200),
|
|
{
|
|
status: 'loaded',
|
|
seasons: {
|
|
'0': [
|
|
{
|
|
id: '290',
|
|
season: 0,
|
|
episode_num: 1,
|
|
title: 'Behind the scenes',
|
|
},
|
|
],
|
|
'1': [201, 202].map((id, index) => ({
|
|
id: String(id),
|
|
season: 1,
|
|
episode_num: index + 1,
|
|
title: `Episode ${index + 1}`,
|
|
})),
|
|
} as never,
|
|
}
|
|
);
|
|
|
|
await loadDashboard();
|
|
|
|
expect(seriesEpisodesMock.request).toHaveBeenCalledWith(
|
|
[expect.objectContaining({ seriesId: 200 })],
|
|
expect.any(Number)
|
|
);
|
|
expect(
|
|
service.getPlaybackPositionForItem(
|
|
service.continueWatchingItems()[0]
|
|
)
|
|
).toMatchObject({ contentXtreamId: 202, positionSeconds: 0 });
|
|
});
|
|
|
|
it('takes the later episode as the newest when two were saved in the same second', async () => {
|
|
// Episode 5 left early, then episode 6 finished within the
|
|
// same second (the database keeps whole seconds).
|
|
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue(
|
|
[
|
|
episodePosition(
|
|
205,
|
|
200,
|
|
30,
|
|
3552,
|
|
'2026-10-05 08:00:00'
|
|
),
|
|
episodePosition(
|
|
206,
|
|
200,
|
|
3491,
|
|
3552,
|
|
'2026-10-05 08:00:00'
|
|
),
|
|
]
|
|
);
|
|
answerEpisodes(200, [201, 202, 203, 204, 205, 206, 207]);
|
|
|
|
await loadDashboard();
|
|
|
|
expect(
|
|
service.getPlaybackPositionForItem(
|
|
service.continueWatchingItems()[0]
|
|
)
|
|
).toMatchObject({ contentXtreamId: 207 });
|
|
});
|
|
});
|
|
|
|
describe('with more series to look up than the rail shows', () => {
|
|
/** `count` series, newest first, each with episode x1 watched. */
|
|
const historyOfSeries = (count: number) => {
|
|
const seriesIds = Array.from(
|
|
{ length: count },
|
|
(_, i) => 500 + i
|
|
);
|
|
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue(
|
|
seriesIds.map((seriesId, i) =>
|
|
recentRow(
|
|
seriesId,
|
|
`Series ${i}`,
|
|
'series',
|
|
seriesId,
|
|
new Date(
|
|
Date.UTC(2026, 9, 4, 12) - i * 60_000
|
|
).toISOString()
|
|
)
|
|
)
|
|
);
|
|
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue(
|
|
seriesIds.map((seriesId) =>
|
|
episodePosition(
|
|
seriesId * 10 + 1,
|
|
seriesId,
|
|
3491,
|
|
3552,
|
|
'2026-10-04T08:00:00Z'
|
|
)
|
|
)
|
|
);
|
|
return seriesIds;
|
|
};
|
|
/** Lookups answer at once here: let the window grow. */
|
|
const settleLookups = () => {
|
|
for (let i = 0; i < 5; i++) {
|
|
TestBed.tick();
|
|
}
|
|
};
|
|
const finished = (seriesId: number) =>
|
|
answerEpisodes(seriesId, [seriesId * 10 + 1]);
|
|
const continuing = (seriesId: number) =>
|
|
answerEpisodes(seriesId, [
|
|
seriesId * 10 + 1,
|
|
seriesId * 10 + 2,
|
|
]);
|
|
|
|
it('looks up series until the rail is filled, and keeps older ones in place', async () => {
|
|
const seriesIds = historyOfSeries(
|
|
CONTINUE_WATCHING_VISIBLE_ITEMS + 1
|
|
);
|
|
seriesIds.forEach(continuing);
|
|
|
|
await loadDashboard();
|
|
settleLookups();
|
|
|
|
expect(seriesEpisodesMock.request).toHaveBeenCalledTimes(1);
|
|
expect(
|
|
seriesEpisodesMock.request.mock.calls[0][0]
|
|
).toHaveLength(CONTINUE_WATCHING_VISIBLE_ITEMS);
|
|
// Not looked up: it keeps its place, never hidden on a guess.
|
|
expect(titles()).toEqual(
|
|
seriesIds.map((_, i) => `Series ${i}`)
|
|
);
|
|
});
|
|
|
|
it('looks past finished series for one that goes on, in the same round', async () => {
|
|
const seriesIds = historyOfSeries(
|
|
CONTINUE_WATCHING_VISIBLE_ITEMS + 1
|
|
);
|
|
seriesIds.slice(0, -1).forEach(finished);
|
|
continuing(seriesIds[seriesIds.length - 1]);
|
|
|
|
await loadDashboard();
|
|
settleLookups();
|
|
|
|
expect(titles()).toEqual([
|
|
`Series ${CONTINUE_WATCHING_VISIBLE_ITEMS}`,
|
|
]);
|
|
const calls = seriesEpisodesMock.request.mock.calls;
|
|
expect(calls[calls.length - 1][0]).toHaveLength(
|
|
seriesIds.length
|
|
);
|
|
// Growing the window is no new round: nothing is retried.
|
|
const rounds = calls.map((call) => (call as unknown[])[1]);
|
|
expect(new Set(rounds).size).toBe(1);
|
|
});
|
|
|
|
it(`looks up ${CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT} series at most and keeps the rest in place`, async () => {
|
|
const seriesIds = historyOfSeries(
|
|
CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT + 5
|
|
);
|
|
seriesIds.forEach(finished);
|
|
|
|
await loadDashboard();
|
|
settleLookups();
|
|
|
|
const calls = seriesEpisodesMock.request.mock.calls;
|
|
expect(calls[calls.length - 1][0]).toHaveLength(
|
|
CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT
|
|
);
|
|
expect(titles()).toEqual(
|
|
seriesIds
|
|
.slice(CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT)
|
|
.map(
|
|
(_, i) =>
|
|
`Series ${CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT + i}`
|
|
)
|
|
);
|
|
});
|
|
});
|
|
|
|
it('lists nothing until the positions of every recent playlist have loaded', async () => {
|
|
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
|
recentRow(
|
|
1,
|
|
'Credits Movie',
|
|
'movie',
|
|
101,
|
|
'2026-10-04T10:00:00Z'
|
|
),
|
|
recentRow(
|
|
2,
|
|
'Halfway Movie',
|
|
'movie',
|
|
102,
|
|
'2026-10-04T09:00:00Z'
|
|
),
|
|
]);
|
|
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue([
|
|
vodPosition(101, 3491, 3552),
|
|
vodPosition(102, 2700, 5400),
|
|
]);
|
|
|
|
await service.reloadGlobalRecentItems();
|
|
|
|
// Without positions a finished title looks like an unfinished one.
|
|
expect(service.continueWatchingSettled()).toBe(false);
|
|
expect(titles()).toEqual([]);
|
|
|
|
await service.reloadPlaybackPositions();
|
|
|
|
expect(service.continueWatchingSettled()).toBe(true);
|
|
expect(titles()).toEqual(['Halfway Movie']);
|
|
|
|
// Once settled, a title from a not yet loaded playlist shows at
|
|
// once instead of holding the whole rail back again.
|
|
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
|
recentRow(
|
|
3,
|
|
'Other Source Movie',
|
|
'movie',
|
|
501,
|
|
'2026-10-04T11:00:00Z',
|
|
'xtream-1'
|
|
),
|
|
recentRow(
|
|
1,
|
|
'Credits Movie',
|
|
'movie',
|
|
101,
|
|
'2026-10-04T10:00:00Z'
|
|
),
|
|
recentRow(
|
|
2,
|
|
'Halfway Movie',
|
|
'movie',
|
|
102,
|
|
'2026-10-04T09:00:00Z'
|
|
),
|
|
]);
|
|
await service.reloadGlobalRecentItems();
|
|
|
|
expect(service.continueWatchingSettled()).toBe(true);
|
|
expect(titles()).toEqual(['Other Source Movie', 'Halfway Movie']);
|
|
});
|
|
|
|
it('settles at once when the history holds no movies or series', async () => {
|
|
await service.reloadGlobalRecentItems();
|
|
|
|
expect(service.continueWatchingSettled()).toBe(true);
|
|
expect(titles()).toEqual([]);
|
|
});
|
|
|
|
describe('in the PWA', () => {
|
|
const pwaRow = (id: number, title: string, xtreamId: number) => ({
|
|
id,
|
|
category_id: 1,
|
|
title,
|
|
viewed_at: '2026-10-04T09:00:00.000Z',
|
|
poster_url: `https://example.com/${xtreamId}.png`,
|
|
xtream_id: xtreamId,
|
|
type: 'movie',
|
|
});
|
|
|
|
beforeEach(() => {
|
|
// Constructed without the Electron bridge: the history comes
|
|
// from the Xtream data source, after the playlists are in.
|
|
Object.defineProperty(window, 'electron', {
|
|
value: undefined,
|
|
configurable: true,
|
|
});
|
|
TestBed.resetTestingModule();
|
|
TestBed.configureTestingModule(createTestingModuleProviders());
|
|
service = TestBed.inject(DashboardDataService);
|
|
TestBed.tick();
|
|
});
|
|
|
|
it('waits for the history read before listing anything', async () => {
|
|
const pending = createPendingItems<ReturnType<typeof pwaRow>>();
|
|
xtreamDataSourceMock.getRecentItems.mockImplementation(
|
|
(playlistId: string) =>
|
|
playlistId === PLAYLIST
|
|
? pending.promise
|
|
: Promise.resolve([])
|
|
);
|
|
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue(
|
|
[vodPosition(101, 3491, 3552), vodPosition(102, 2700, 5400)]
|
|
);
|
|
|
|
const reload = service.reloadGlobalRecentItems();
|
|
TestBed.tick();
|
|
// The playlists alone used to count as the loaded history:
|
|
// with no movie known yet the rail settled empty, then listed
|
|
// the finished one until its position arrived.
|
|
expect(service.globalRecentLoaded()).toBe(false);
|
|
expect(service.continueWatchingSettled()).toBe(false);
|
|
|
|
pending.resolve([
|
|
pwaRow(1, 'Credits Movie', 101),
|
|
pwaRow(2, 'Halfway Movie', 102),
|
|
]);
|
|
await reload;
|
|
expect(service.globalRecentLoaded()).toBe(true);
|
|
expect(titles()).toEqual([]);
|
|
|
|
await service.reloadPlaybackPositions();
|
|
expect(titles()).toEqual(['Halfway Movie']);
|
|
});
|
|
|
|
it('settles when the history read fails', async () => {
|
|
xtreamDataSourceMock.getRecentItems.mockRejectedValue(
|
|
new Error('IndexedDB unavailable')
|
|
);
|
|
|
|
await expect(
|
|
service.reloadGlobalRecentItems()
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(service.globalRecentLoaded()).toBe(true);
|
|
expect(service.globalRecentLoading()).toBe(false);
|
|
expect(service.continueWatchingSettled()).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('keeps the newest positions when an older reload finishes last', async () => {
|
|
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
|
recentRow(
|
|
1,
|
|
'Credits Movie',
|
|
'movie',
|
|
101,
|
|
'2026-10-04T10:00:00Z'
|
|
),
|
|
]);
|
|
await service.reloadGlobalRecentItems();
|
|
let resolveStale: (rows: PlaybackPositionData[]) => void = () =>
|
|
undefined;
|
|
playbackPositionsMock.getAllPlaybackPositions
|
|
.mockImplementationOnce(
|
|
() =>
|
|
new Promise<PlaybackPositionData[]>((resolve) => {
|
|
resolveStale = resolve;
|
|
})
|
|
)
|
|
.mockResolvedValueOnce([vodPosition(101, 3491, 3552)]);
|
|
|
|
const staleReload = service.reloadPlaybackPositions();
|
|
await service.reloadPlaybackPositions();
|
|
expect(titles()).toEqual([]);
|
|
|
|
// The older read answers last, from before the movie finished.
|
|
resolveStale([vodPosition(101, 1800, 3552)]);
|
|
await staleReload;
|
|
|
|
const [movie] = service.globalRecentVodItems();
|
|
expect(
|
|
service.getPlaybackPositionForItem(movie)?.positionSeconds
|
|
).toBe(3491);
|
|
expect(titles()).toEqual([]);
|
|
});
|
|
|
|
it('drops a title the user marks as watched', async () => {
|
|
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
|
recentRow(
|
|
2,
|
|
'Halfway Movie',
|
|
'movie',
|
|
102,
|
|
'2026-10-04T09:00:00Z'
|
|
),
|
|
]);
|
|
let stored = vodPosition(102, 2700, 5400);
|
|
playbackPositionsMock.getAllPlaybackPositions.mockImplementation(
|
|
() => Promise.resolve([stored])
|
|
);
|
|
playbackPositionsMock.savePlaybackPositionOrThrow.mockImplementation(
|
|
(_playlistId: string, data: PlaybackPositionData) => {
|
|
stored = data;
|
|
return Promise.resolve();
|
|
}
|
|
);
|
|
await service.reloadGlobalRecentItems();
|
|
await service.reloadPlaybackPositions();
|
|
expect(titles()).toEqual(['Halfway Movie']);
|
|
|
|
await expect(
|
|
service.markRecentItemWatched(
|
|
service.continueWatchingItems()[0]
|
|
)
|
|
).resolves.toBe(true);
|
|
|
|
expect(titles()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it('exposes a live-only slice of global favorites so the dashboard can promote favorited channels into the Live rail', async () => {
|
|
// Seed two favorites: a movie and a live channel.
|
|
dbServiceMock.getAllGlobalFavorites.mockResolvedValue([
|
|
{
|
|
id: 500,
|
|
category_id: 18,
|
|
title: 'Favorite Movie',
|
|
rating: '7.0',
|
|
added_at: '2026-04-21T10:00:00.000Z',
|
|
poster_url: 'https://example.com/fav-movie.png',
|
|
xtream_id: 5500,
|
|
type: 'movie',
|
|
playlist_id: 'xtream-1',
|
|
playlist_name: 'Xtream Playlist',
|
|
},
|
|
{
|
|
id: 501,
|
|
category_id: 19,
|
|
title: 'Favorite Channel HD',
|
|
rating: '0',
|
|
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',
|
|
},
|
|
]);
|
|
playlistsSignal.set([
|
|
...playlistsSignal(),
|
|
{
|
|
_id: 'xtream-1',
|
|
title: 'Xtream Playlist',
|
|
count: 2,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
serverUrl: 'https://example.com',
|
|
},
|
|
]);
|
|
|
|
await service.reloadGlobalFavorites();
|
|
|
|
const liveOnly = service.globalFavoriteLiveItems();
|
|
// Includes the Xtream "Favorite Channel HD" plus the two M3U live
|
|
// favorites already seeded by the default fixture.
|
|
expect(liveOnly.map((it) => it.title)).toEqual(
|
|
expect.arrayContaining([
|
|
'Favorite Channel HD',
|
|
'Channel One',
|
|
'Channel Two',
|
|
])
|
|
);
|
|
|
|
// Crucially, the movie favorite is excluded — the rail source must
|
|
// never leak VOD posters into the channel layout.
|
|
expect(liveOnly.map((it) => it.title)).not.toContain('Favorite Movie');
|
|
expect(
|
|
service.globalFavoriteLiveItems().every((it) => it.type === 'live')
|
|
).toBe(true);
|
|
});
|
|
|
|
it('returns null playback position for live channels (no position tracking in schema)', async () => {
|
|
// The default M3U playlist contains a live channel. Without
|
|
// reloading playback positions the lookup still gracefully returns
|
|
// null — and `reloadPlaybackPositions` should skip the IPC entirely.
|
|
const liveItem = service
|
|
.globalRecentItems()
|
|
.find((item) => item.type === 'live');
|
|
expect(liveItem).toBeDefined();
|
|
expect(service.getPlaybackPositionForItem(liveItem!)).toBeNull();
|
|
|
|
await service.reloadPlaybackPositions();
|
|
expect(
|
|
playbackPositionsMock.getAllPlaybackPositions
|
|
).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('prioritizes recently used Xtream sources over newer imported M3U sources', async () => {
|
|
playlistsSignal.set([
|
|
{
|
|
_id: 'm3u-fresh',
|
|
title: 'Fresh Import',
|
|
count: 1,
|
|
importDate: '2026-04-20T12:00:00.000Z',
|
|
autoRefresh: false,
|
|
favorites: [],
|
|
recentlyViewed: [],
|
|
},
|
|
{
|
|
_id: 'xtream-1',
|
|
title: 'Xtream Playlist',
|
|
count: 1,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
serverUrl: 'https://example.com',
|
|
},
|
|
]);
|
|
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
|
{
|
|
id: 91,
|
|
category_id: 18,
|
|
title: 'Recent Movie',
|
|
rating: '7.8',
|
|
viewed_at: '2026-04-21T10:00:00.000Z',
|
|
poster_url: 'https://example.com/recent-movie.png',
|
|
xtream_id: 7001,
|
|
type: 'movie',
|
|
playlist_id: 'xtream-1',
|
|
playlist_name: 'Xtream Playlist',
|
|
},
|
|
]);
|
|
|
|
await service.reloadGlobalRecentItems();
|
|
|
|
expect(
|
|
service.recentPlaylists().map((playlist) => playlist._id)
|
|
).toEqual(['xtream-1', 'm3u-fresh']);
|
|
});
|
|
|
|
it('includes M3U, Xtream, and Stalker sources when all have recent activity', async () => {
|
|
playlistsSignal.set([
|
|
{
|
|
_id: 'm3u-1',
|
|
title: 'M3U Playlist',
|
|
count: 1,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
favorites: [],
|
|
recentlyViewed: [
|
|
{
|
|
source: 'm3u',
|
|
id: 'https://example.com/stream-1.m3u8',
|
|
url: 'https://example.com/stream-1.m3u8',
|
|
title: 'Channel One',
|
|
category_id: 'live',
|
|
added_at: '2026-04-19T10:00:00.000Z',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
_id: 'stalker-1',
|
|
title: 'Stalker Playlist',
|
|
count: 1,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
macAddress: '00:11:22:33:44:55',
|
|
recentlyViewed: [
|
|
{
|
|
id: 'stalker-item-1',
|
|
name: 'Stalker Movie',
|
|
category_id: 'vod',
|
|
added_at: '2026-04-20T10:00:00.000Z',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
_id: 'xtream-1',
|
|
title: 'Xtream Playlist',
|
|
count: 1,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
serverUrl: 'https://example.com',
|
|
},
|
|
]);
|
|
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
|
{
|
|
id: 91,
|
|
category_id: 18,
|
|
title: 'Recent Movie',
|
|
rating: '7.8',
|
|
viewed_at: '2026-04-21T10:00:00.000Z',
|
|
poster_url: 'https://example.com/recent-movie.png',
|
|
xtream_id: 7001,
|
|
type: 'movie',
|
|
playlist_id: 'xtream-1',
|
|
playlist_name: 'Xtream Playlist',
|
|
},
|
|
]);
|
|
|
|
await service.reloadGlobalRecentItems();
|
|
|
|
expect(
|
|
service.recentPlaylists().map((playlist) => playlist._id)
|
|
).toEqual(['xtream-1', 'stalker-1', 'm3u-1']);
|
|
});
|
|
|
|
it('includes PWA Xtream recent items from the active data source', async () => {
|
|
Object.defineProperty(window, 'electron', {
|
|
value: undefined,
|
|
configurable: true,
|
|
});
|
|
xtreamDataSourceMock.getRecentItems.mockResolvedValue([
|
|
{
|
|
id: 91,
|
|
category_id: 18,
|
|
title: 'Recent Movie',
|
|
rating: '7.8',
|
|
viewed_at: '2026-04-21T10:00:00.000Z',
|
|
poster_url: 'https://example.com/recent-movie.png',
|
|
xtream_id: 7001,
|
|
type: 'movie',
|
|
},
|
|
]);
|
|
|
|
await service.reloadGlobalRecentItems();
|
|
|
|
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith(
|
|
'xtream-1'
|
|
);
|
|
expect(dbServiceMock.getGlobalRecentlyViewed).not.toHaveBeenCalled();
|
|
expect(service.globalRecentItems()).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: 91,
|
|
title: 'Recent Movie',
|
|
type: 'movie',
|
|
playlist_id: 'xtream-1',
|
|
playlist_name: 'Xtream Playlist',
|
|
source: 'xtream',
|
|
viewed_at: '2026-04-21T10:00:00.000Z',
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
|
|
it('loads PWA Xtream recent items for multiple playlists in parallel', async () => {
|
|
Object.defineProperty(window, 'electron', {
|
|
value: undefined,
|
|
configurable: true,
|
|
});
|
|
playlistsSignal.set([
|
|
{
|
|
_id: 'xtream-1',
|
|
title: 'Xtream One',
|
|
count: 0,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
serverUrl: 'https://one.example.com',
|
|
},
|
|
{
|
|
_id: 'xtream-2',
|
|
title: 'Xtream Two',
|
|
count: 0,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
serverUrl: 'https://two.example.com',
|
|
},
|
|
]);
|
|
const firstRecentItems = createPendingItems();
|
|
xtreamDataSourceMock.getRecentItems.mockImplementation((playlistId) =>
|
|
playlistId === 'xtream-1'
|
|
? firstRecentItems.promise
|
|
: Promise.resolve([])
|
|
);
|
|
|
|
const reload = service.reloadGlobalRecentItems();
|
|
await Promise.resolve();
|
|
|
|
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith(
|
|
'xtream-1'
|
|
);
|
|
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith(
|
|
'xtream-2'
|
|
);
|
|
|
|
firstRecentItems.resolve([]);
|
|
await reload;
|
|
});
|
|
|
|
it('does not load Stalker playlists through the PWA Xtream recent path', async () => {
|
|
Object.defineProperty(window, 'electron', {
|
|
value: undefined,
|
|
configurable: true,
|
|
});
|
|
playlistsSignal.set([
|
|
{
|
|
_id: 'xtream-1',
|
|
title: 'Xtream Playlist',
|
|
count: 0,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
serverUrl: 'https://xtream.example.com',
|
|
},
|
|
{
|
|
_id: 'stalker-1',
|
|
title: 'Stalker Playlist',
|
|
count: 0,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
serverUrl: 'https://stalker.example.com',
|
|
macAddress: '00:11:22:33:44:55',
|
|
},
|
|
]);
|
|
|
|
await service.reloadGlobalRecentItems();
|
|
|
|
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledTimes(1);
|
|
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith(
|
|
'xtream-1'
|
|
);
|
|
});
|
|
|
|
it('falls back to playlist metadata ordering when sources have no recent activity', async () => {
|
|
playlistsSignal.set([
|
|
{
|
|
_id: 'xtream-1',
|
|
title: 'Xtream Playlist',
|
|
count: 1,
|
|
importDate: '2026-01-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
serverUrl: 'https://example.com',
|
|
},
|
|
{
|
|
_id: 'stalker-1',
|
|
title: 'Stalker Playlist',
|
|
count: 1,
|
|
importDate: '2026-03-01T00:00:00.000Z',
|
|
autoRefresh: false,
|
|
macAddress: '00:11:22:33:44:55',
|
|
recentlyViewed: [],
|
|
},
|
|
{
|
|
_id: 'm3u-1',
|
|
title: 'M3U Playlist',
|
|
count: 1,
|
|
importDate: '2026-02-01T00:00:00.000Z',
|
|
updateDate: Date.parse('2026-04-01T00:00:00.000Z'),
|
|
autoRefresh: false,
|
|
favorites: [],
|
|
recentlyViewed: [],
|
|
},
|
|
]);
|
|
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([]);
|
|
|
|
await service.reloadGlobalRecentItems();
|
|
|
|
expect(
|
|
service.recentPlaylists().map((playlist) => playlist._id)
|
|
).toEqual(['m3u-1', 'stalker-1', 'xtream-1']);
|
|
});
|
|
|
|
it('maps recently added Xtream rows for the widget', async () => {
|
|
dbServiceMock.getGlobalRecentlyAdded.mockResolvedValue([
|
|
{
|
|
id: 11,
|
|
category_id: 77,
|
|
title: 'Fresh Movie',
|
|
rating: '8.1',
|
|
added: '1774298340',
|
|
added_at: '1774298340',
|
|
poster_url: 'https://example.com/poster.png',
|
|
xtream_id: 501,
|
|
type: 'movie',
|
|
playlist_id: 'xtream-1',
|
|
playlist_name: 'Xtream Playlist',
|
|
},
|
|
]);
|
|
|
|
await expect(
|
|
service.getGlobalRecentlyAddedItems('all', 25)
|
|
).resolves.toEqual([
|
|
expect.objectContaining({
|
|
id: 11,
|
|
title: 'Fresh Movie',
|
|
type: 'movie',
|
|
playlist_id: 'xtream-1',
|
|
source: 'xtream',
|
|
added_at: expect.any(String),
|
|
}),
|
|
]);
|
|
expect(dbServiceMock.getGlobalRecentlyAdded).toHaveBeenCalledWith(
|
|
'all',
|
|
25
|
|
);
|
|
});
|
|
|
|
it('builds the Xtream recently added route', () => {
|
|
const item = {
|
|
id: 11,
|
|
title: 'Fresh Series',
|
|
type: 'series',
|
|
playlist_id: 'xtream-1',
|
|
playlist_name: 'Xtream Playlist',
|
|
added_at: '2026-03-27T14:39:00.000Z',
|
|
category_id: 77,
|
|
xtream_id: 501,
|
|
poster_url: 'https://example.com/poster.png',
|
|
source: 'xtream',
|
|
} as const;
|
|
|
|
expect(service.getRecentlyAddedLink(item)).toEqual([
|
|
'/workspace',
|
|
'xtreams',
|
|
'xtream-1',
|
|
'series',
|
|
'77',
|
|
'501',
|
|
]);
|
|
expect(service.getRecentlyAddedNavigationState(item)).toBeUndefined();
|
|
});
|
|
|
|
it('builds the M3U recent route with collection auto-open state', () => {
|
|
const m3uItem = service
|
|
.globalRecentItems()
|
|
.find((item) => item.source === 'm3u');
|
|
|
|
expect(m3uItem).toBeDefined();
|
|
expect(service.getRecentItemLink(m3uItem!)).toEqual([
|
|
'/workspace',
|
|
'playlists',
|
|
'm3u-1',
|
|
'recent',
|
|
]);
|
|
expect(service.getRecentItemNavigationState(m3uItem!)).toEqual({
|
|
openLiveCollectionItem: {
|
|
contentType: 'live',
|
|
sourceType: 'm3u',
|
|
playlistId: 'm3u-1',
|
|
itemId: 'https://example.com/stream-1.m3u8',
|
|
title: 'Channel One',
|
|
imageUrl: 'https://example.com/logo-1.png',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('routes Xtream non-live recents through the global recent page with inline detail state', async () => {
|
|
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
|
{
|
|
id: 91,
|
|
category_id: 18,
|
|
title: 'Recent Movie',
|
|
rating: '7.8',
|
|
viewed_at: '2026-02-04T10:00:00.000Z',
|
|
poster_url: 'https://example.com/recent-movie.png',
|
|
backdrop_url: 'https://example.com/recent-movie-backdrop.png',
|
|
xtream_id: 7001,
|
|
type: 'movie',
|
|
playlist_id: 'xtream-1',
|
|
playlist_name: 'Xtream Playlist',
|
|
},
|
|
]);
|
|
|
|
await service.reloadGlobalRecentItems();
|
|
const movieItem = service
|
|
.globalRecentItems()
|
|
.find((item) => item.source === 'xtream');
|
|
|
|
expect(movieItem).toEqual(
|
|
expect.objectContaining({
|
|
backdrop_url: 'https://example.com/recent-movie-backdrop.png',
|
|
})
|
|
);
|
|
expect(service.getRecentItemLink(movieItem!)).toEqual([
|
|
'/workspace',
|
|
'global-recent',
|
|
]);
|
|
expect(service.getRecentItemNavigationState(movieItem!)).toEqual({
|
|
openCollectionDetailItem: {
|
|
item: expect.objectContaining({
|
|
uid: 'xtream::xtream-1::movie:7001',
|
|
name: 'Recent Movie',
|
|
contentType: 'movie',
|
|
sourceType: 'xtream',
|
|
playlistId: 'xtream-1',
|
|
}),
|
|
},
|
|
});
|
|
});
|
|
|
|
it('normalizes SQLite-style Xtream recent timestamps before sorting and rendering', async () => {
|
|
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
|
{
|
|
id: 91,
|
|
category_id: 18,
|
|
title: 'Recent Movie',
|
|
rating: '7.8',
|
|
viewed_at: '2026-02-04 10:00:00',
|
|
poster_url: 'https://example.com/recent-movie.png',
|
|
xtream_id: 7001,
|
|
type: 'movie',
|
|
playlist_id: 'xtream-1',
|
|
playlist_name: 'Xtream Playlist',
|
|
},
|
|
]);
|
|
|
|
await service.reloadGlobalRecentItems();
|
|
const movieItem = service
|
|
.globalRecentItems()
|
|
.find((item) => item.source === 'xtream');
|
|
|
|
expect(movieItem).toEqual(
|
|
expect.objectContaining({
|
|
viewed_at: '2026-02-04T10:00:00.000Z',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('removes M3U recently viewed via PlaylistsService', async () => {
|
|
const m3uItem = service
|
|
.globalRecentItems()
|
|
.find((item) => item.source === 'm3u');
|
|
|
|
expect(m3uItem).toBeDefined();
|
|
|
|
await service.removeGlobalRecentItem(m3uItem!);
|
|
|
|
expect(
|
|
playlistsServiceMock.removeFromM3uRecentlyViewed
|
|
).toHaveBeenCalledWith('m3u-1', 'https://example.com/stream-1.m3u8');
|
|
});
|
|
|
|
it('removes PWA Xtream recently viewed through the active data source', async () => {
|
|
Object.defineProperty(window, 'electron', {
|
|
value: undefined,
|
|
configurable: true,
|
|
});
|
|
const item = {
|
|
id: 91,
|
|
category_id: 18,
|
|
title: 'Recent Movie',
|
|
viewed_at: '2026-04-21T10:00:00.000Z',
|
|
poster_url: 'https://example.com/recent-movie.png',
|
|
xtream_id: 7001,
|
|
type: 'movie',
|
|
playlist_id: 'xtream-1',
|
|
playlist_name: 'Xtream Playlist',
|
|
source: 'xtream',
|
|
} satisfies GlobalRecentItem;
|
|
|
|
await service.removeGlobalRecentItem(item);
|
|
|
|
expect(xtreamDataSourceMock.removeRecentItem).toHaveBeenCalledWith(
|
|
{ id: 91, type: 'movie' },
|
|
'xtream-1'
|
|
);
|
|
expect(dbServiceMock.removeRecentItem).not.toHaveBeenCalled();
|
|
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith(
|
|
'xtream-1'
|
|
);
|
|
});
|
|
});
|