Merge pull request #961 from 4gray/agent/pwa-provider-target-flow

fix(docker): exclude agent worktrees from build context
This commit is contained in:
4gray authored and GitHub committed 2026-05-22 00:47:22 +03:00
commit 67d512d44c
24 files changed
+3030 -324

No files matched your search

+40
View File
@@ -7,13 +7,53 @@
.angular
.electron
.husky
.nx
.agent
.agents
.aider*
.amp
.augment
.claude
.codeium
.codex
.continue
.crush
.cursor
.entire
.gemini
.goose
.idea
.kiro
.opencode
.plans
.qodo
.roo
.serena
.supermaven
.tabnine
.windsurf
.zed
.worktrees
worktrees
Dockerfile
README.md
dist
e2e
node_modules
**/node_modules
screenshots
test-results
coverage
build
release
tmp
out-tsc
playwright-report
.astro
.cache
.next
.parcel-cache
.svelte-kit
.turbo
.vercel
apps/xtream-mock-server/public/marketing
+89
View File
@@ -48,6 +48,27 @@ async function addXtreamPortal(page: Page): Promise<void> {
await page.waitForURL(/xtreams.*vod/);
}
async function addM3uPlaylist(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await setInputValue(
dialog.getByRole('textbox', { name: /Playlist URL/ }),
`${XTREAM_MOCK_SERVER}/playlist.m3u`
);
await setInputValue(
dialog.getByRole('textbox', { name: 'Playlist title' }),
'Self-hosted M3U'
);
await dialog
.getByRole('button', { name: 'Add playlist', exact: true })
.click();
await expect(dialog).toBeHidden();
await page.waitForURL(/playlists.*all/);
}
async function addStalkerPortal(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
@@ -65,6 +86,29 @@ async function addStalkerPortal(page: Page): Promise<void> {
await page.waitForURL(/stalker.*vod/);
}
function collectBackendRequests(page: Page, path: string): string[] {
const requests: string[] = [];
page.on('request', (request) => {
const requestUrl = request.url();
const url = new URL(requestUrl);
if (url.origin === WEB_BACKEND_URL && url.pathname === path) {
requests.push(requestUrl);
}
});
return requests;
}
function expectRequestsUseTargetId(requests: string[], path: string): void {
expect(requests.length).toBeGreaterThan(0);
for (const requestUrl of requests) {
const url = new URL(requestUrl);
expect(url.pathname).toBe(path);
expect(url.searchParams.get('targetId')).not.toBeNull();
expect(url.searchParams.get('targetId')).not.toBe('');
expect(url.searchParams.has('url')).toBe(false);
}
}
test.beforeEach(async ({ page, request }) => {
await request.post(`${XTREAM_MOCK_SERVER}/reset`);
await request.post(`${STALKER_MOCK_SERVER}/reset`);
@@ -90,17 +134,62 @@ test('@self-hosted runtime config points PWA calls at the monorepo backend', asy
test('@self-hosted Xtream portal loads through web-backend proxy', async ({
page,
}) => {
const xtreamRequests = collectBackendRequests(page, '/xtream');
const consoleErrors: string[] = [];
page.on('console', (message) => {
if (message.type() === 'error') {
consoleErrors.push(message.text());
}
});
await addXtreamPortal(page);
const rail = page.locator('app-workspace-shell-rail');
await expect(rail.locator('a[aria-label="Movies"]')).toBeVisible();
await expect(rail.locator('a[aria-label="Live TV"]')).toBeVisible();
await expect(rail.locator('a[aria-label="Series"]')).toBeVisible();
await expect(rail.locator('a[aria-label="Recently added"]')).toBeVisible();
await expect(rail.locator('a[aria-label="Advanced search"]')).toBeVisible();
const categoryItems = page.locator('.category-item');
await expect(categoryItems.first()).toBeVisible({ timeout: 15_000 });
const vodItem = page.locator('app-grid-list mat-card').first();
await expect(vodItem).toBeVisible({ timeout: 30_000 });
await vodItem.click();
await expect(page).toHaveURL(/\/workspace\/xtreams\/[^/]+\/vod\/\d+\/\d+/);
await expect(
page.getByRole('button', { name: 'Play', exact: true })
).toBeVisible({ timeout: 15_000 });
expect(
consoleErrors.filter((message) =>
/db(SetAppState|GetContentByXtreamId)/.test(message)
)
).toEqual([]);
expectRequestsUseTargetId(xtreamRequests, '/xtream');
});
test('@self-hosted M3U URL loads through web-backend proxy', async ({
page,
}) => {
const parseRequests = collectBackendRequests(page, '/parse');
await addM3uPlaylist(page);
await expect(page.getByText('4 channels')).toBeVisible({ timeout: 15_000 });
await expect(page.getByText('1. Channel 1')).toBeVisible();
await expect(page.getByText('4. HappyKids TV')).toBeVisible();
expectRequestsUseTargetId(parseRequests, '/parse');
});
test('@self-hosted Stalker portal loads through web-backend proxy', async ({
page,
}) => {
const stalkerRequests = collectBackendRequests(page, '/stalker');
await addStalkerPortal(page);
const categoryItems = page.locator('.category-item');
await expect(categoryItems.first()).toBeVisible({ timeout: 15_000 });
expectRequestsUseTargetId(stalkerRequests, '/stalker');
});
+33 -8
View File
@@ -9,6 +9,16 @@ import { renderMarketingAssetSvg } from './app/generators/marketing.generator.js
const app = express();
const PORT = parseInt(process.env['PORT'] ?? '3211', 10);
const M3U_FIXTURE = `#EXTM3U
#EXTINF:0 tvg-id="1" tvg-logo="http://channel.icons.url/img/1.png" group-title="News", Channel 1
https://example.channels/path-to-file/1.m3u8
#EXTINF:0 tvg-id="2" tvg-logo="http://channel.icons.url/img/2.png" group-title="News", Positive News TV
https://example.channels/path-to-file/2.m3u8
#EXTINF:0 tvg-id="3" tvg-logo="http://channel.icons.url/img/3.png" group-title="Sport", Sport TVX
https://example.channels/path-to-file/3.m3u8
#EXTINF:0 tvg-id="4" tvg-logo="http://channel.icons.url/img/4.png" group-title="Kids", HappyKids TV
https://example.channels/path-to-file/4.m3u8
`;
const marketingRasterAssetRoot = join(
process.cwd(),
'apps/xtream-mock-server/public/marketing'
@@ -28,6 +38,13 @@ app.post('/reset', (_req, res) => {
res.json({ status: 'reset' });
});
// ─── M3U fixture endpoint for self-hosted PWA URL import tests ───────────────
app.get('/playlist.m3u', (_req, res) => {
res.type('audio/x-mpegurl')
.set('Cache-Control', 'no-store')
.send(M3U_FIXTURE);
});
// ─── Local fictional artwork for release screenshots ──────────────────────────
app.get('/assets/marketing/:kind/:slug', (req, res) => {
const kind = req.params['kind'] as
@@ -46,18 +63,20 @@ app.get('/assets/marketing/:kind/:slug', (req, res) => {
}
const rasterSlug = slug.replace(/\.(svg|png)$/i, '');
const rasterPath = join(marketingRasterAssetRoot, kind, `${rasterSlug}.png`);
const rasterPath = join(
marketingRasterAssetRoot,
kind,
`${rasterSlug}.png`
);
if (existsSync(rasterPath)) {
res
.type('image/png')
res.type('image/png')
.set('Cache-Control', 'public, max-age=3600')
.send(readFileSync(rasterPath));
return;
}
res
.type('image/svg+xml')
res.type('image/svg+xml')
.set('Cache-Control', 'public, max-age=3600')
.send(renderMarketingAssetSvg(kind, slug, size));
});
@@ -149,8 +168,12 @@ server.on('error', (err: NodeJS.ErrnoException) => {
server.listen(PORT, () => {
console.log(`[xtream-mock] Listening on http://localhost:${PORT}`);
console.log(`[xtream-mock] Direct API: http://localhost:${PORT}/player_api.php?username=user1&password=pass1&action=get_account_info`);
console.log(`[xtream-mock] PWA proxy: http://localhost:${PORT}/xtream?url=http://localhost:${PORT}&username=user1&password=pass1&action=get_account_info`);
console.log(
`[xtream-mock] Direct API: http://localhost:${PORT}/player_api.php?username=user1&password=pass1&action=get_account_info`
);
console.log(
`[xtream-mock] PWA proxy: http://localhost:${PORT}/xtream?url=http://localhost:${PORT}&username=user1&password=pass1&action=get_account_info`
);
console.log(`[xtream-mock] Health: http://localhost:${PORT}/health`);
});
@@ -173,4 +196,6 @@ process.on('unhandledRejection', (reason) => {
// When Nx (or any process manager) closes stdin, prevent auto-exit.
// The HTTP server handle is what keeps the process alive.
process.stdin.resume();
process.stdin.on('end', () => { /* ignore stdin close */ });
process.stdin.on('end', () => {
/* ignore stdin close */
});
+10
View File
@@ -95,6 +95,16 @@ Response: `{ payload: <data>, action: <action> }`
This mirrors the backend proxy in `apps/electron-backend` so the same
Angular service code works in both environments.
### M3U fixture endpoint
```
GET /playlist.m3u
```
Returns a small deterministic four-channel playlist. The self-hosted PWA E2E
suite uses this endpoint to verify M3U URL imports through `apps/web-backend`
and the provider target registry.
### Stream stub endpoints
```
@@ -10,6 +10,7 @@ import {
StalkerPortalItem,
} from '@iptvnator/shared/interfaces';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import { UnifiedFavoritesDataService } from './unified-favorites-data.service';
describe('UnifiedFavoritesDataService', () => {
@@ -24,6 +25,12 @@ describe('UnifiedFavoritesDataService', () => {
getContentByXtreamId: jest.Mock;
getFavorites: jest.Mock;
};
let xtreamDataSource: {
addFavorite: jest.Mock;
getContentByXtreamId: jest.Mock;
getFavorites: jest.Mock;
removeFavorite: jest.Mock;
};
let store: {
dispatch: jest.Mock;
select: jest.Mock;
@@ -111,6 +118,12 @@ describe('UnifiedFavoritesDataService', () => {
getContentByXtreamId: jest.fn().mockResolvedValue(null),
getFavorites: jest.fn().mockResolvedValue([]),
};
xtreamDataSource = {
addFavorite: jest.fn().mockResolvedValue(undefined),
getContentByXtreamId: jest.fn().mockResolvedValue(null),
getFavorites: jest.fn().mockResolvedValue([]),
removeFavorite: jest.fn().mockResolvedValue(undefined),
};
store = {
dispatch: jest.fn(),
select: jest.fn(() =>
@@ -141,6 +154,10 @@ describe('UnifiedFavoritesDataService', () => {
provide: DatabaseService,
useValue: databaseService,
},
{
provide: XTREAM_DATA_SOURCE,
useValue: xtreamDataSource,
},
{
provide: PlaylistsService,
useValue: playlistsService,
@@ -223,6 +240,80 @@ describe('UnifiedFavoritesDataService', () => {
);
});
it('loads Xtream playlist favorites through the active data source in PWA', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
store.select.mockReturnValue(
of([
{
_id: 'xtream-1',
title: 'Xtream PWA',
serverUrl: 'https://example.com',
} satisfies Partial<PlaylistMeta>,
])
);
xtreamDataSource.getFavorites.mockResolvedValue([
{
id: 202,
category_id: 20,
title: 'Movie One',
type: 'movie',
poster_url: 'movie.png',
xtream_id: 202,
added_at: '2026-05-21T12:00:00.000Z',
},
]);
const items = await service.getFavorites(
'playlist',
'xtream-1',
'xtream'
);
expect(xtreamDataSource.getFavorites).toHaveBeenCalledWith('xtream-1');
expect(databaseService.getFavorites).not.toHaveBeenCalled();
expect(items).toEqual([
expect.objectContaining({
uid: 'xtream::xtream-1::movie:202',
sourceType: 'xtream',
contentType: 'movie',
name: 'Movie One',
playlistName: 'Xtream PWA',
posterUrl: 'movie.png',
}),
]);
});
it('does not load Stalker portals through the PWA Xtream global favorites path', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
store.select.mockReturnValue(
of([
{
_id: 'xtream-1',
title: 'Xtream PWA',
serverUrl: 'https://xtream.example.com',
},
{
_id: 'stalker-1',
title: 'Stalker Portal',
serverUrl: 'https://stalker.example.com',
macAddress: '00:11:22:33:44:55',
favorites: stalkerFavorites,
},
] satisfies Partial<PlaylistMeta>[])
);
await service.getFavorites('all');
expect(xtreamDataSource.getFavorites).toHaveBeenCalledTimes(1);
expect(xtreamDataSource.getFavorites).toHaveBeenCalledWith('xtream-1');
});
it('preserves persisted M3U favorites order when extracting playlist favorites', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
@@ -400,6 +491,37 @@ describe('UnifiedFavoritesDataService', () => {
);
});
it('uses the Xtream id as the favorite key in PWA when cached content is cold', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
xtreamDataSource.getContentByXtreamId.mockResolvedValue(null);
await service.addFavorite({
uid: 'xtream::xtream-1::movie:101',
name: 'Xtream Movie',
contentType: 'movie',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
posterUrl: 'movie.png',
xtreamId: 101,
} satisfies UnifiedCollectionItem);
expect(xtreamDataSource.getContentByXtreamId).toHaveBeenCalledWith(
101,
'xtream-1',
'movie'
);
expect(xtreamDataSource.addFavorite).toHaveBeenCalledWith(
101,
'xtream-1',
'movie.png'
);
expect(electronApi.dbAddFavorite).not.toHaveBeenCalled();
});
it('adds Stalker favorites through portal favorites', async () => {
await service.addFavorite({
uid: 'stalker::stalker-1::101',
@@ -25,6 +25,10 @@ import {
xtreamContentType,
XtreamFavoriteRow,
} from '@iptvnator/portal/shared/util';
import {
XTREAM_DATA_SOURCE,
XtreamContentItem,
} from '@iptvnator/portal/xtream/data-access';
const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1';
@@ -43,6 +47,7 @@ export class UnifiedFavoritesDataService {
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly translate = inject(TranslateService);
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
async getFavorites(
scope: CollectionScope,
@@ -86,12 +91,20 @@ export class UnifiedFavoritesDataService {
break;
}
case 'xtream':
if (window.electron && item.contentId != null) {
if (item.contentId == null) {
return;
}
if (window.electron) {
await window.electron.dbRemoveFavorite(
item.contentId,
item.playlistId
);
return;
}
await this.xtreamDataSource.removeFavorite(
item.contentId,
item.playlistId
);
break;
case 'stalker': {
const sourceItemId = item.uid.split('::')[2];
@@ -134,26 +147,25 @@ export class UnifiedFavoritesDataService {
private async addXtreamFavorite(
item: UnifiedCollectionItem
): Promise<void> {
if (!window.electron) {
return;
}
const contentId =
item.contentId ??
(item.xtreamId != null
? (
await this.dbService.getContentByXtreamId(
item.xtreamId,
item.playlistId,
item.contentType
)
)?.id
? await this.resolveXtreamContentId(item)
: null);
if (contentId == null) {
return;
}
if (!window.electron) {
await this.xtreamDataSource.addFavorite(
contentId,
item.playlistId,
item.posterUrl ?? item.logo ?? undefined
);
return;
}
await window.electron.dbAddFavorite(
contentId,
item.playlistId,
@@ -346,6 +358,22 @@ export class UnifiedFavoritesDataService {
): Promise<void> {
const electron = window.electron;
if (!electron) {
await Promise.all(
items
.filter(
(
item
): item is UnifiedCollectionItem & {
readonly contentId: number;
} => item.contentId != null
)
.map((item) =>
this.xtreamDataSource.removeFavorite(
item.contentId,
item.playlistId
)
)
);
return;
}
@@ -486,6 +514,19 @@ export class UnifiedFavoritesDataService {
}
private async getXtreamAllFavorites(): Promise<UnifiedCollectionItem[]> {
if (!window.electron) {
const allMeta = await this.getAllMeta();
const results: UnifiedCollectionItem[] = [];
for (const meta of allMeta.filter(
(p) => p._id && this.isXtreamPlaylist(p)
)) {
results.push(
...(await this.getXtreamPlaylistFavorites(meta._id))
);
}
return results;
}
if (!window.electron?.dbGetAllGlobalFavorites) return [];
try {
const rows =
@@ -499,10 +540,17 @@ export class UnifiedFavoritesDataService {
private async getXtreamPlaylistFavorites(
playlistId: string
): Promise<UnifiedCollectionItem[]> {
if (!window.electron) return [];
try {
const rows = await this.dbService.getFavorites(playlistId);
const meta = await this.getPlaylistMeta(playlistId);
if (!window.electron) {
const rows =
await this.xtreamDataSource.getFavorites(playlistId);
return rows.map((row) =>
this.mapXtreamContentItem(row, playlistId, meta?.title)
);
}
const rows = await this.dbService.getFavorites(playlistId);
return (rows as unknown as XtreamFavoriteRow[]).map((r) => ({
...this.mapXtreamRow(r),
playlistId,
@@ -513,6 +561,31 @@ export class UnifiedFavoritesDataService {
}
}
private async resolveXtreamContentId(
item: UnifiedCollectionItem
): Promise<number | null> {
if (item.xtreamId == null) {
return null;
}
if (!window.electron) {
const content = await this.xtreamDataSource.getContentByXtreamId(
item.xtreamId,
item.playlistId,
item.contentType
);
return content?.id ?? item.xtreamId;
}
const content = await this.dbService.getContentByXtreamId(
item.xtreamId,
item.playlistId,
item.contentType
);
return content?.id ?? null;
}
private mapXtreamRow(row: XtreamFavoriteRow): UnifiedCollectionItem {
const ct = xtreamContentType(row.type);
return {
@@ -535,6 +608,33 @@ export class UnifiedFavoritesDataService {
};
}
private mapXtreamContentItem(
item: XtreamContentItem,
playlistId: string,
playlistName?: string
): UnifiedCollectionItem {
const ct = xtreamContentType(item.type);
return {
uid: buildXtreamCollectionUid(playlistId, ct, item.xtream_id),
name: item.title,
contentType: ct,
sourceType: 'xtream',
playlistId,
playlistName: playlistName ?? item.playlist_name ?? 'Xtream',
logo: ct === 'live' ? (item.poster_url ?? null) : null,
posterUrl: ct !== 'live' ? (item.poster_url ?? null) : null,
xtreamId: item.xtream_id,
categoryId: item.category_id,
tvgId: ct === 'live' ? String(item.xtream_id) : undefined,
rating: item.rating ?? undefined,
addedAt:
normalizeStalkerDate(item.added_at ?? item.added) ||
new Date(0).toISOString(),
position: item.position ?? 0,
contentId: item.id,
};
}
private async getStalkerAllFavorites(): Promise<UnifiedCollectionItem[]> {
const allMeta = await this.getAllMeta();
const results: UnifiedCollectionItem[] = [];
@@ -726,6 +826,12 @@ export class UnifiedFavoritesDataService {
}, new Map<string, UnifiedCollectionItem[]>());
}
private isXtreamPlaylist(
playlist: Pick<PlaylistMeta, 'serverUrl' | 'macAddress'>
): boolean {
return Boolean(playlist.serverUrl) && !playlist.macAddress;
}
private getStalkerFavoriteId(
favorite:
| Pick<UnifiedCollectionItem, 'stalkerId' | 'uid'>
@@ -9,6 +9,7 @@ import {
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import { UnifiedRecentDataService } from './unified-recent-data.service';
describe('UnifiedRecentDataService', () => {
@@ -33,6 +34,13 @@ describe('UnifiedRecentDataService', () => {
addRecentItem: jest.Mock;
getContentByXtreamId: jest.Mock;
};
let xtreamDataSource: {
addRecentItem: jest.Mock;
clearRecentItems: jest.Mock;
getContentByXtreamId: jest.Mock;
getRecentItems: jest.Mock;
removeRecentItem: jest.Mock;
};
const playlistMeta = {
_id: 'm3u-1',
@@ -87,6 +95,10 @@ describe('UnifiedRecentDataService', () => {
];
beforeEach(() => {
Object.defineProperty(window, 'electron', {
value: {} as Window['electron'],
configurable: true,
});
store = {
select: jest.fn(() => of([playlistMeta])),
dispatch: jest.fn(),
@@ -136,6 +148,13 @@ describe('UnifiedRecentDataService', () => {
addRecentItem: jest.fn().mockResolvedValue(true),
getContentByXtreamId: jest.fn().mockResolvedValue(null),
};
xtreamDataSource = {
addRecentItem: jest.fn().mockResolvedValue(undefined),
clearRecentItems: jest.fn().mockResolvedValue(undefined),
getContentByXtreamId: jest.fn().mockResolvedValue(null),
getRecentItems: jest.fn().mockResolvedValue([]),
removeRecentItem: jest.fn().mockResolvedValue(undefined),
};
TestBed.configureTestingModule({
providers: [
@@ -143,6 +162,10 @@ describe('UnifiedRecentDataService', () => {
{ provide: Store, useValue: store },
{ provide: PlaylistsService, useValue: playlistsService },
{ provide: DatabaseService, useValue: dbService },
{
provide: XTREAM_DATA_SOURCE,
useValue: xtreamDataSource,
},
],
});
@@ -235,6 +258,43 @@ describe('UnifiedRecentDataService', () => {
);
});
it('uses the Xtream id as the PWA recent key when cached content is cold', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
xtreamDataSource.getContentByXtreamId.mockResolvedValue(null);
const item = {
uid: 'xtream::xtream-1::movie:290',
name: 'PWA Movie',
contentType: 'movie',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
xtreamId: 290,
} satisfies UnifiedCollectionItem;
const recorded = await service.recordLivePlayback(item);
expect(xtreamDataSource.getContentByXtreamId).toHaveBeenCalledWith(
290,
'xtream-1',
'movie'
);
expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith(
290,
'xtream-1'
);
expect(dbService.addRecentItem).not.toHaveBeenCalled();
expect(recorded).toEqual(
expect.objectContaining({
contentId: 290,
viewedAt: expect.any(String),
})
);
});
it('builds distinct Xtream recent UIDs when live and series share an xtream id', async () => {
store.select.mockReturnValue(
of([
@@ -288,6 +348,131 @@ describe('UnifiedRecentDataService', () => {
);
});
it('loads Xtream playlist recent items through the active data source in PWA', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
store.select.mockReturnValue(
of([
{
_id: 'xtream-1',
title: 'Xtream PWA',
serverUrl: 'https://example.com',
} satisfies Partial<PlaylistMeta>,
])
);
xtreamDataSource.getRecentItems.mockResolvedValue([
{
id: 202,
category_id: 20,
title: 'Movie One',
type: 'movie',
poster_url: 'movie.png',
backdrop_url: 'backdrop.png',
xtream_id: 202,
viewed_at: '2026-05-21T12:00:00.000Z',
},
]);
const items = await service.getRecentItems(
'playlist',
'xtream-1',
'xtream'
);
expect(xtreamDataSource.getRecentItems).toHaveBeenCalledWith(
'xtream-1'
);
expect(dbService.getRecentItems).not.toHaveBeenCalled();
expect(items).toEqual([
expect.objectContaining({
uid: 'xtream::xtream-1::movie:202',
sourceType: 'xtream',
contentType: 'movie',
name: 'Movie One',
playlistName: 'Xtream PWA',
posterUrl: 'movie.png',
viewedAt: '2026-05-21T12:00:00.000Z',
}),
]);
});
it('does not load Stalker portals through the PWA Xtream global recent path', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
store.select.mockReturnValue(
of([
{
_id: 'xtream-1',
title: 'Xtream PWA',
serverUrl: 'https://xtream.example.com',
},
{
_id: 'stalker-1',
title: 'Stalker Portal',
serverUrl: 'https://stalker.example.com',
macAddress: '00:11:22:33:44:55',
},
] satisfies Partial<PlaylistMeta>[])
);
await service.getRecentItems('all');
expect(xtreamDataSource.getRecentItems).toHaveBeenCalledTimes(1);
expect(xtreamDataSource.getRecentItems).toHaveBeenCalledWith(
'xtream-1'
);
});
it('clears Xtream recent localStorage during global PWA clear', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
store.select.mockReturnValue(
of([
{
_id: 'xtream-1',
title: 'Xtream PWA',
serverUrl: 'https://xtream.example.com',
} satisfies Partial<PlaylistMeta>,
])
);
playlistsService.getAllPlaylists.mockReturnValue(
of([
{
_id: 'm3u-1',
title: 'M3U List',
},
{
_id: 'stalker-1',
title: 'Stalker Portal',
serverUrl: 'https://stalker.example.com',
macAddress: '00:11:22:33:44:55',
},
] satisfies Partial<Playlist>[])
);
await service.clearRecentItems('all');
expect(dbService.clearGlobalRecentlyViewed).not.toHaveBeenCalled();
expect(xtreamDataSource.clearRecentItems).toHaveBeenCalledWith(
'xtream-1'
);
expect(
playlistsService.clearPlaylistRecentlyViewed
).toHaveBeenCalledWith('m3u-1');
expect(
playlistsService.clearPlaylistRecentlyViewed
).toHaveBeenCalledWith('stalker-1');
expect(
playlistsService.clearPlaylistRecentlyViewed
).not.toHaveBeenCalledWith('xtream-1');
});
it('keeps Stalker radio recent items in the live collection with radio metadata', async () => {
store.select.mockReturnValue(
of([
@@ -26,6 +26,10 @@ import {
UnifiedCollectionItem,
xtreamContentType,
} from '@iptvnator/portal/shared/util';
import {
XTREAM_DATA_SOURCE,
XtreamContentItem,
} from '@iptvnator/portal/xtream/data-access';
type PlaylistWithChannels = Playlist & {
readonly playlist?: { readonly items?: Channel[] };
@@ -36,6 +40,7 @@ export class UnifiedRecentDataService {
private readonly store = inject(Store);
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
async getRecentItems(
scope: CollectionScope,
@@ -55,6 +60,14 @@ export class UnifiedRecentDataService {
return;
}
if (!window.electron) {
await this.xtreamDataSource.removeRecentItem(
item.contentId,
item.playlistId
);
return;
}
await this.dbService.removeRecentItem(
item.contentId,
item.playlistId
@@ -126,7 +139,18 @@ export class UnifiedRecentDataService {
const tasks: Promise<unknown>[] = [];
if (xtreamBatch.length > 0) {
tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch));
if (window.electron) {
tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch));
} else {
tasks.push(
...xtreamBatch.map((item) =>
this.xtreamDataSource.removeRecentItem(
item.contentId,
item.playlistId
)
)
);
}
}
for (const [playlistId, identities] of groupedByPlaylist) {
@@ -153,7 +177,11 @@ export class UnifiedRecentDataService {
playlistId?: string
): Promise<void> {
if (scope === 'playlist' && playlistId) {
await this.dbService.clearPlaylistRecentItems(playlistId);
if (window.electron) {
await this.dbService.clearPlaylistRecentItems(playlistId);
} else {
await this.xtreamDataSource.clearRecentItems(playlistId);
}
const updatedPlaylist = await firstValueFrom(
this.playlistsService.clearPlaylistRecentlyViewed(playlistId)
);
@@ -161,18 +189,26 @@ export class UnifiedRecentDataService {
return;
}
await this.dbService.clearGlobalRecentlyViewed();
if (window.electron) {
await this.dbService.clearGlobalRecentlyViewed();
} else {
const allMeta = await this.getAllMeta();
await Promise.all(
allMeta
.filter((playlist) => playlist._id)
.filter((playlist) => this.isXtreamPlaylist(playlist))
.map((playlist) =>
this.xtreamDataSource.clearRecentItems(playlist._id)
)
);
}
const playlists = (await firstValueFrom(
this.playlistsService.getAllPlaylists()
)) as Playlist[];
await Promise.all(
playlists
.filter(
(playlist) =>
Boolean(playlist.macAddress) || !playlist.serverUrl
)
.map(async (playlist) => {
playlists.map(async (playlist) => {
if (this.isPlaylistBackedRecentPlaylist(playlist)) {
const updatedPlaylist = await firstValueFrom(
this.playlistsService.clearPlaylistRecentlyViewed(
playlist._id
@@ -182,7 +218,8 @@ export class UnifiedRecentDataService {
playlist._id,
updatedPlaylist
);
})
}
})
);
}
@@ -223,17 +260,21 @@ export class UnifiedRecentDataService {
const contentId =
item.contentId ??
(item.xtreamId != null
? (
await this.dbService.getContentByXtreamId(
item.xtreamId,
item.playlistId,
item.contentType
)
)?.id
? await this.resolveXtreamContentId(item)
: null);
if (contentId != null) {
await this.dbService.addRecentItem(contentId, item.playlistId);
if (window.electron) {
await this.dbService.addRecentItem(
contentId,
item.playlistId
);
} else {
await this.xtreamDataSource.addRecentItem(
contentId,
item.playlistId
);
}
}
return {
@@ -309,6 +350,17 @@ export class UnifiedRecentDataService {
}
private async getXtreamGlobalRecent(): Promise<UnifiedCollectionItem[]> {
if (!window.electron) {
const allMeta = await this.getAllMeta();
const results: UnifiedCollectionItem[] = [];
for (const meta of allMeta.filter(
(playlist) => playlist._id && this.isXtreamPlaylist(playlist)
)) {
results.push(...(await this.getXtreamPlaylistRecent(meta._id)));
}
return results;
}
try {
const rows = await this.dbService.getGlobalRecentlyViewed();
return (rows || []).map((row) => ({
@@ -340,9 +392,16 @@ export class UnifiedRecentDataService {
playlistId: string
): Promise<UnifiedCollectionItem[]> {
try {
const rows = await this.dbService.getRecentItems(playlistId);
const meta = await this.getPlaylistMeta(playlistId);
if (!window.electron) {
const rows =
await this.xtreamDataSource.getRecentItems(playlistId);
return rows.map((row) =>
this.mapXtreamContentItem(row, playlistId, meta?.title)
);
}
const rows = await this.dbService.getRecentItems(playlistId);
return (rows || []).map((row) => ({
uid: buildXtreamCollectionUid(
playlistId,
@@ -368,6 +427,60 @@ export class UnifiedRecentDataService {
}
}
private async resolveXtreamContentId(
item: UnifiedCollectionItem
): Promise<number | null> {
if (item.xtreamId == null) {
return null;
}
if (!window.electron) {
const content = await this.xtreamDataSource.getContentByXtreamId(
item.xtreamId,
item.playlistId,
item.contentType
);
return content?.id ?? item.xtreamId;
}
const content = await this.dbService.getContentByXtreamId(
item.xtreamId,
item.playlistId,
item.contentType
);
return content?.id ?? null;
}
private mapXtreamContentItem(
item: XtreamContentItem,
playlistId: string,
playlistName?: string
): UnifiedCollectionItem {
const contentType = xtreamContentType(item.type);
return {
uid: buildXtreamCollectionUid(
playlistId,
contentType,
item.xtream_id
),
name: item.title,
contentType,
sourceType: 'xtream',
playlistId,
playlistName: playlistName ?? item.playlist_name ?? 'Xtream',
logo: contentType === 'live' ? (item.poster_url ?? null) : null,
posterUrl:
contentType !== 'live' ? (item.poster_url ?? null) : null,
xtreamId: item.xtream_id,
categoryId: item.category_id,
tvgId: contentType === 'live' ? String(item.xtream_id) : undefined,
contentId: item.id,
viewedAt: normalizeStalkerDate(item.viewed_at),
};
}
private async getM3uGlobalRecent(): Promise<UnifiedCollectionItem[]> {
const allMeta = await this.getAllMeta();
const results: UnifiedCollectionItem[] = [];
@@ -589,4 +702,16 @@ export class UnifiedRecentDataService {
): boolean {
return !playlist.serverUrl && !playlist.macAddress;
}
private isXtreamPlaylist(
playlist: Pick<PlaylistMeta, 'serverUrl' | 'macAddress'>
): boolean {
return Boolean(playlist.serverUrl) && !playlist.macAddress;
}
private isPlaylistBackedRecentPlaylist(
playlist: Pick<PlaylistMeta, 'serverUrl' | 'macAddress'>
): boolean {
return Boolean(playlist.macAddress) || this.isM3uPlaylist(playlist);
}
}
@@ -24,7 +24,7 @@ describe('buildPortalRailLinks', () => {
expect(links.secondary[2]?.tooltip).toBe('Downloads (this playlist)');
});
it('builds workspace Xtream links without downloads on web', () => {
it('builds workspace Xtream content links on web without downloads', () => {
const links = buildPortalRailLinks({
provider: 'xtreams',
playlistId: 'xtream-web',
@@ -32,24 +32,26 @@ describe('buildPortalRailLinks', () => {
workspace: true,
});
expect(links.primary).toEqual([
{
icon: 'movie',
tooltip: 'Xtream library (this playlist)',
path: ['/workspace', 'xtreams', 'xtream-web'],
exact: true,
section: 'library',
},
expect(links.primary.map((link) => link.section)).toEqual([
'vod',
'live',
'series',
]);
expect(links.secondary).toEqual([]);
expect(links.secondary.map((link) => link.section)).toEqual([
'recently-added',
'search',
]);
expect(
links.secondary.some((link) => link.section === 'downloads')
).toBe(false);
});
it('builds Stalker links with scoped tooltip labels', () => {
it('builds workspace Stalker links with scoped tooltip labels on web', () => {
const links = buildPortalRailLinks({
provider: 'stalker',
playlistId: 'portal-1',
isElectron: false,
workspace: false,
workspace: true,
});
expect(links.primary.map((link) => link.section)).toEqual([
@@ -63,6 +65,9 @@ describe('buildPortalRailLinks', () => {
expect(links.primary[1]?.tooltip).toBe('Live TV (this playlist)');
expect(links.primary[2]?.tooltip).toBe('Radio (this playlist)');
expect(links.secondary[0]?.tooltip).toBe('Search (this playlist)');
expect(
links.secondary.some((link) => link.section === 'downloads')
).toBe(false);
});
it('builds M3U playlist links with scoped tooltip labels', () => {
@@ -46,17 +46,6 @@ export function buildPortalRailLinks(
const primary: PortalRailLink[] = [];
const secondary: PortalRailLink[] = [];
if (workspace && !isElectron) {
primary.push({
icon: 'movie',
tooltip: 'Xtream library (this playlist)',
path: root,
exact: true,
section: 'library',
});
return { primary, secondary };
}
primary.push(
{
icon: 'movie',
@@ -452,6 +452,18 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
);
}
async setContentBackdropIfMissing(
contentId: number,
playlistId: string,
backdropUrl: string
): Promise<void> {
void playlistId;
await this.dbService.setContentBackdropIfMissing(
contentId,
backdropUrl
);
}
// =========================================================================
// Playback Position Operations
// =========================================================================
@@ -0,0 +1,503 @@
import { TestBed } from '@angular/core/testing';
import { PwaXtreamDataSource } from './pwa-xtream-data-source';
import {
XtreamApiService,
XtreamCredentials,
} from '../services/xtream-api.service';
describe('PwaXtreamDataSource', () => {
let dataSource: PwaXtreamDataSource;
let apiService: {
getStreams: jest.Mock;
};
const credentials: XtreamCredentials = {
serverUrl: 'http://localhost:3211',
username: 'demo',
password: 'secret',
};
beforeEach(() => {
localStorage.clear();
apiService = {
getStreams: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
PwaXtreamDataSource,
{
provide: XtreamApiService,
useValue: apiService,
},
],
});
dataSource = TestBed.inject(PwaXtreamDataSource);
});
afterEach(() => {
localStorage.clear();
});
it('keeps Xtream passwords out of localStorage playlist metadata', async () => {
await dataSource.createPlaylist({
id: 'playlist-1',
name: 'Xtream PWA',
serverUrl: credentials.serverUrl,
username: credentials.username,
password: credentials.password,
type: 'xtream',
});
const storedPlaylists = JSON.parse(
localStorage.getItem('xtream-playlists') || '[]'
);
expect(storedPlaylists).toEqual([
expect.not.objectContaining({
password: credentials.password,
}),
]);
await expect(dataSource.getPlaylist('playlist-1')).resolves.toEqual(
expect.objectContaining({
password: credentials.password,
})
);
});
it('normalizes Xtream API stream identifiers for PWA catalog navigation', async () => {
apiService.getStreams.mockImplementation(
(_credentials: XtreamCredentials, type: string) => {
switch (type) {
case 'live':
return Promise.resolve([
{
id: '0',
stream_id: 101,
name: 'News Live',
stream_icon: 'live.png',
category_id: '10',
added: '1',
},
]);
case 'movie':
return Promise.resolve([
{
id: 0,
stream_id: 202,
name: 'Movie One',
stream_icon: 'movie.png',
category_id: '20',
added: '2',
},
]);
case 'series':
return Promise.resolve([
{
series_id: 303,
name: 'Series One',
cover: 'series.png',
category_id: 30,
last_modified: '3',
},
]);
default:
return Promise.resolve([]);
}
}
);
const live = (await dataSource.getContent(
'playlist-1',
credentials,
'live'
)) as Array<Record<string, unknown>>;
const vod = (await dataSource.getContent(
'playlist-1',
credentials,
'movie'
)) as Array<Record<string, unknown>>;
const series = (await dataSource.getContent(
'playlist-1',
credentials,
'series'
)) as Array<Record<string, unknown>>;
expect(live[0]).toEqual(
expect.objectContaining({
id: 101,
stream_id: 101,
title: 'News Live',
poster_url: 'live.png',
type: 'live',
xtream_id: 101,
})
);
expect(vod[0]).toEqual(
expect.objectContaining({
id: 202,
stream_id: 202,
title: 'Movie One',
poster_url: 'movie.png',
type: 'movie',
xtream_id: 202,
})
);
expect(series[0]).toEqual(
expect.objectContaining({
id: 303,
series_id: 303,
title: 'Series One',
poster_url: 'series.png',
type: 'series',
xtream_id: 303,
})
);
await expect(
dataSource.getContentByXtreamId(202, 'playlist-1', 'movie')
).resolves.toEqual(
expect.objectContaining({
title: 'Movie One',
type: 'movie',
xtream_id: 202,
})
);
});
it('matches legacy string favorite and recent ids against numeric content identities', async () => {
apiService.getStreams.mockResolvedValue([
{
stream_id: 202,
name: 'Movie One',
stream_icon: 'movie.png',
category_id: '20',
added: '2',
},
]);
localStorage.setItem(
'xtream-favorites',
JSON.stringify({ 'playlist-1': ['202'] })
);
localStorage.setItem(
'xtream-recent-items',
JSON.stringify({
'playlist-1': [
{
id: '202',
viewedAt: '2026-05-21T12:00:00.000Z',
},
],
})
);
await dataSource.getContent('playlist-1', credentials, 'movie');
await expect(dataSource.isFavorite(202, 'playlist-1')).resolves.toBe(
true
);
await expect(dataSource.getFavorites('playlist-1')).resolves.toEqual([
expect.objectContaining({
title: 'Movie One',
xtream_id: 202,
}),
]);
await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([
expect.objectContaining({
title: 'Movie One',
viewed_at: '2026-05-21T12:00:00.000Z',
xtream_id: 202,
}),
]);
});
it('does not persist zero or invalid Xtream identities as user collections', async () => {
apiService.getStreams.mockResolvedValue([
{
id: 0,
name: 'Headless Zero',
category_id: '20',
},
{
id: 'not-a-number',
name: 'Headless Invalid',
category_id: '20',
},
]);
const content = (await dataSource.getContent(
'playlist-1',
credentials,
'movie'
)) as Array<Record<string, unknown>>;
expect(content).toEqual([
expect.objectContaining({
id: -1,
xtream_id: -1,
}),
expect.objectContaining({
id: -1,
xtream_id: -1,
}),
]);
await dataSource.addFavorite(-1, 'playlist-1');
await dataSource.addRecentItem(-1, 'playlist-1');
expect(localStorage.getItem('xtream-favorites')).toBeNull();
expect(localStorage.getItem('xtream-recent-items')).toBeNull();
localStorage.setItem(
'xtream-favorites',
JSON.stringify({ 'playlist-1': [-1, 0, 'not-a-number'] })
);
localStorage.setItem(
'xtream-recent-items',
JSON.stringify({
'playlist-1': [
{
id: -1,
viewedAt: '2026-05-21T12:00:00.000Z',
},
{
id: 0,
viewedAt: '2026-05-21T12:00:00.000Z',
},
{
id: 'not-a-number',
viewedAt: '2026-05-21T12:00:00.000Z',
},
],
})
);
await expect(dataSource.getFavorites('playlist-1')).resolves.toEqual(
[]
);
await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual(
[]
);
});
it('hydrates stored favorites and recent items from the API when content cache is cold', async () => {
await dataSource.createPlaylist({
id: 'playlist-1',
name: 'Xtream PWA',
serverUrl: credentials.serverUrl,
username: credentials.username,
password: credentials.password,
type: 'xtream',
});
apiService.getStreams.mockImplementation(
(_credentials: XtreamCredentials, type: string) =>
Promise.resolve(
type === 'movie'
? [
{
stream_id: 202,
name: 'Movie One',
stream_icon: 'movie.png',
category_id: '20',
},
]
: []
)
);
localStorage.setItem(
'xtream-favorites',
JSON.stringify({ 'playlist-1': [202] })
);
localStorage.setItem(
'xtream-recent-items',
JSON.stringify({
'playlist-1': [
{
id: 202,
viewedAt: '2026-05-21T12:00:00.000Z',
},
],
})
);
await expect(dataSource.getFavorites('playlist-1')).resolves.toEqual([
expect.objectContaining({
title: 'Movie One',
xtream_id: 202,
}),
]);
await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([
expect.objectContaining({
title: 'Movie One',
viewed_at: '2026-05-21T12:00:00.000Z',
xtream_id: 202,
}),
]);
expect(apiService.getStreams).toHaveBeenCalledWith(
credentials,
'movie'
);
});
it('uses the Xtream stream ID as the PWA content ID when raw id differs', async () => {
apiService.getStreams.mockResolvedValue([
{
id: 5,
stream_id: 202,
name: 'Movie One',
stream_icon: 'movie.png',
category_id: '20',
},
]);
const content = (await dataSource.getContent(
'playlist-1',
credentials,
'movie'
)) as Array<Record<string, unknown>>;
expect(content[0]).toEqual(
expect.objectContaining({
id: 202,
xtream_id: 202,
})
);
await dataSource.addFavorite(Number(content[0].id), 'playlist-1');
await dataSource.addRecentItem(Number(content[0].id), 'playlist-1');
await expect(dataSource.getFavorites('playlist-1')).resolves.toEqual([
expect.objectContaining({
id: 202,
title: 'Movie One',
xtream_id: 202,
}),
]);
await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([
expect.objectContaining({
id: 202,
title: 'Movie One',
xtream_id: 202,
}),
]);
});
it('backfills PWA recent-item backdrop metadata without rewriting recency', async () => {
apiService.getStreams.mockResolvedValue([
{
stream_id: 202,
name: 'Movie One',
stream_icon: 'movie.png',
category_id: '20',
},
]);
await dataSource.getContent('playlist-1', credentials, 'movie');
await dataSource.addRecentItem(202, 'playlist-1');
const before = JSON.parse(
localStorage.getItem('xtream-recent-items') || '{}'
)['playlist-1'][0].viewedAt;
await dataSource.setContentBackdropIfMissing(
202,
'playlist-1',
' https://example.com/backdrop.png '
);
const stored = JSON.parse(
localStorage.getItem('xtream-recent-items') || '{}'
)['playlist-1'][0];
const storedSnapshot = JSON.parse(
localStorage.getItem('xtream-collection-items') || '{}'
)['playlist-1']['202'];
expect(stored).toEqual(
expect.objectContaining({
id: 202,
backdropUrl: 'https://example.com/backdrop.png',
viewedAt: before,
})
);
expect(storedSnapshot).toEqual(
expect.objectContaining({
backdrop_url: 'https://example.com/backdrop.png',
title: 'Movie One',
xtream_id: 202,
})
);
await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([
expect.objectContaining({
backdrop_url: 'https://example.com/backdrop.png',
title: 'Movie One',
xtream_id: 202,
}),
]);
});
it('uses stored collection snapshots for fresh-session favorites and recent items', async () => {
await dataSource.createPlaylist({
id: 'playlist-1',
name: 'Xtream PWA',
serverUrl: credentials.serverUrl,
username: credentials.username,
password: credentials.password,
type: 'xtream',
});
apiService.getStreams.mockResolvedValue([
{
stream_id: 202,
name: 'Movie One',
stream_icon: 'movie.png',
category_id: '20',
},
]);
await dataSource.getContent('playlist-1', credentials, 'movie');
await dataSource.addFavorite(202, 'playlist-1');
await dataSource.addRecentItem(
202,
'playlist-1',
'https://example.com/backdrop.png'
);
const storedCollectionItems = JSON.parse(
localStorage.getItem('xtream-collection-items') || '{}'
);
expect(storedCollectionItems['playlist-1']['202']).toEqual(
expect.objectContaining({
title: 'Movie One',
xtream_id: 202,
})
);
TestBed.resetTestingModule();
apiService = {
getStreams: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
PwaXtreamDataSource,
{
provide: XtreamApiService,
useValue: apiService,
},
],
});
dataSource = TestBed.inject(PwaXtreamDataSource);
await expect(dataSource.getFavorites('playlist-1')).resolves.toEqual([
expect.objectContaining({
title: 'Movie One',
xtream_id: 202,
}),
]);
await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([
expect.objectContaining({
backdrop_url: 'https://example.com/backdrop.png',
title: 'Movie One',
xtream_id: 202,
}),
]);
expect(apiService.getStreams).not.toHaveBeenCalled();
});
});
@@ -28,6 +28,7 @@ import {
* LocalStorage keys for PWA persistence
*/
const STORAGE_KEYS = {
COLLECTION_ITEMS: 'xtream-collection-items',
FAVORITES: 'xtream-favorites',
RECENT_ITEMS: 'xtream-recent-items',
PLAYLISTS: 'xtream-playlists',
@@ -36,10 +37,17 @@ const STORAGE_KEYS = {
interface XtreamCachedContentItem {
readonly added?: string;
readonly backdrop_url?: string | null;
readonly category_id?: string | number;
readonly id?: number;
readonly cover?: string;
readonly cover_big?: string;
readonly id?: number | string;
readonly last_modified?: string;
readonly movie_image?: string;
readonly name?: string;
readonly poster?: string;
readonly poster_url?: string;
readonly rating?: string | number;
readonly series_id?: number;
readonly stream_display_name?: string;
readonly stream_id?: number;
@@ -47,13 +55,19 @@ interface XtreamCachedContentItem {
readonly title?: string;
readonly type?: string;
readonly viewed_at?: string;
readonly xtream_id?: number | string;
}
interface StoredRecentItem {
readonly id: number;
readonly viewedAt: string;
readonly backdropUrl?: string;
}
type StoredXtreamPlaylistData = Omit<XtreamPlaylistData, 'password'> & {
readonly password?: string;
};
/**
* PWA implementation of the Xtream data source.
* Uses API-only strategy: always fetch from API, no database caching.
@@ -63,10 +77,12 @@ interface StoredRecentItem {
export class PwaXtreamDataSource implements IXtreamDataSource {
private readonly apiService = inject(XtreamApiService);
private readonly logger = createLogger('PwaXtreamDataSource');
private readonly contentTypes = ['live', 'movie', 'series'] as const;
// In-memory cache for the current session
private categoryCache = new Map<string, XtreamCategory[]>();
private contentCache = new Map<string, XtreamCachedContentItem[]>();
private playlistPasswords = new Map<string, string>();
// =========================================================================
// Playlist Operations (localStorage)
@@ -74,10 +90,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
async getPlaylist(playlistId: string): Promise<XtreamPlaylistData | null> {
const playlists = this.getPlaylistsFromStorage();
return playlists.find((p) => p.id === playlistId) || null;
const playlist = playlists.find((p) => p.id === playlistId);
return playlist?.password ? playlist : null;
}
async createPlaylist(playlist: XtreamPlaylistData): Promise<void> {
this.rememberPlaylistPassword(playlist);
const playlists = this.getPlaylistsFromStorage();
playlists.push(playlist);
this.savePlaylistsToStorage(playlists);
@@ -87,6 +105,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
playlistId: string,
updates: Partial<XtreamPlaylistData>
): Promise<void> {
if (updates.password) {
this.playlistPasswords.set(playlistId, updates.password);
}
const playlists = this.getPlaylistsFromStorage();
const index = playlists.findIndex((p) => p.id === playlistId);
if (index !== -1) {
@@ -101,6 +123,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
this.savePlaylistsToStorage(filtered);
// Also clear favorites and recent items for this playlist
this.clearCollectionItemsForPlaylist(playlistId);
this.clearFavoritesForPlaylist(playlistId);
this.clearRecentItemsForPlaylist(playlistId);
this.clearPlaybackPositionsForPlaylist(playlistId);
@@ -112,14 +135,75 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
private getPlaylistsFromStorage(): XtreamPlaylistData[] {
try {
const data = localStorage.getItem(STORAGE_KEYS.PLAYLISTS);
return data ? JSON.parse(data) : [];
const playlists = data
? (JSON.parse(data) as StoredXtreamPlaylistData[])
: [];
return playlists.map((playlist) =>
this.fromStoredPlaylist(playlist)
);
} catch {
return [];
}
}
private savePlaylistsToStorage(playlists: XtreamPlaylistData[]): void {
localStorage.setItem(STORAGE_KEYS.PLAYLISTS, JSON.stringify(playlists));
const persistedPlaylists = playlists.map((playlist) =>
this.toStoredPlaylist(playlist)
);
localStorage.setItem(
STORAGE_KEYS.PLAYLISTS,
JSON.stringify(persistedPlaylists)
);
}
private toStoredPlaylist(
playlist: XtreamPlaylistData
): StoredXtreamPlaylistData {
return {
id: playlist.id,
name: playlist.name,
title: playlist.title,
updateDate: playlist.updateDate,
serverUrl: playlist.serverUrl,
username: playlist.username,
type: playlist.type,
userAgent: playlist.userAgent,
referrer: playlist.referrer,
origin: playlist.origin,
serverTimezone: playlist.serverTimezone,
};
}
private fromStoredPlaylist(
playlist: StoredXtreamPlaylistData
): XtreamPlaylistData {
const legacyPassword =
typeof playlist.password === 'string' ? playlist.password : '';
if (legacyPassword) {
this.playlistPasswords.set(playlist.id, legacyPassword);
}
return {
id: playlist.id,
name: playlist.name,
title: playlist.title,
updateDate: playlist.updateDate,
serverUrl: playlist.serverUrl,
username: playlist.username,
password: this.playlistPasswords.get(playlist.id) ?? legacyPassword,
type: playlist.type,
userAgent: playlist.userAgent,
referrer: playlist.referrer,
origin: playlist.origin,
serverTimezone: playlist.serverTimezone,
};
}
private rememberPlaylistPassword(playlist: XtreamPlaylistData): void {
if (playlist.password) {
this.playlistPasswords.set(playlist.id, playlist.password);
}
}
// =========================================================================
@@ -219,7 +303,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
onProgress?: (count: number) => void,
onTotal?: (total: number) => void,
options?: XtreamOperationOptions
): Promise<XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[]> {
): Promise<
| XtreamLiveStream[]
| XtreamVodStream[]
| XtreamSerieItem[]
| XtreamContentItem[]
> {
void options;
const cacheKey = `${playlistId}-${type}-content`;
@@ -233,7 +322,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
}
// Fetch from API
const content = await this.apiService.getStreams(credentials, type);
const content = this.normalizeContentItems(
await this.apiService.getStreams(credentials, type),
type
);
// Report total and progress (PWA doesn't have incremental save, so report all at once)
if (onTotal) {
@@ -270,15 +362,72 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
options?: XtreamOperationOptions
): Promise<number> {
void options;
// In PWA mode, we just cache in memory
// In PWA mode, we just cache normalized API items in memory.
const cacheKey = `${playlistId}-${type}-content`;
this.contentCache.set(cacheKey, streams);
const normalizedStreams = this.normalizeContentItems(streams, type);
this.contentCache.set(cacheKey, normalizedStreams);
if (onProgress) {
onProgress(streams.length);
onProgress(normalizedStreams.length);
}
return streams.length;
return normalizedStreams.length;
}
private normalizeContentItems(
streams:
| XtreamLiveStream[]
| XtreamVodStream[]
| XtreamSerieItem[]
| XtreamContentItem[],
type: 'live' | 'movie' | 'series'
): XtreamContentItem[] {
return streams.map((item) =>
this.normalizeContentItem(item as XtreamCachedContentItem, type)
);
}
private normalizeContentItem(
item: XtreamCachedContentItem,
type: 'live' | 'movie' | 'series'
): XtreamContentItem {
const xtreamId = this.getItemIdentity(item, type);
const title = item.title ?? item.name ?? item.stream_display_name ?? '';
const posterUrl =
item.poster_url ??
item.stream_icon ??
item.cover ??
item.cover_big ??
item.movie_image ??
item.poster ??
'';
return {
...item,
added: item.added ?? item.last_modified ?? '',
category_id: item.category_id ?? '',
id: xtreamId,
name: item.name ?? title,
poster_url: posterUrl,
rating: String(item.rating ?? ''),
title,
type,
xtream_id: xtreamId,
} as XtreamContentItem;
}
private getItemIdentity(
item: XtreamCachedContentItem,
type?: 'live' | 'movie' | 'series'
): number {
const preferredId =
item.xtream_id ??
(type === 'series' ? item.series_id : item.stream_id) ??
item.stream_id ??
item.series_id ??
item.id;
const numericId = Number(preferredId);
return Number.isFinite(numericId) && numericId > 0 ? numericId : -1;
}
// =========================================================================
@@ -318,60 +467,68 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
async getFavorites(playlistId: string): Promise<XtreamContentItem[]> {
const allFavorites = this.getFavoritesFromStorage();
const playlistFavorites = allFavorites[playlistId] || [];
const contentById = await this.getCollectionItemsWithHydration(
playlistId,
playlistFavorites
);
// Match favorites with cached content
const results: XtreamCachedContentItem[] = [];
for (const type of ['live', 'movie', 'series']) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
for (const item of content) {
const itemId = item.stream_id || item.series_id || item.id;
if (playlistFavorites.includes(itemId)) {
results.push(item);
}
}
}
return results as XtreamContentItem[];
return Array.from(contentById.values());
}
async addFavorite(
contentId: number,
playlistId: string,
// PWA uses localStorage with no content table, so backdrop persistence
// is electron-only. Accept the param for interface parity.
_backdropUrl?: string
backdropUrl?: string
): Promise<void> {
const normalizedContentId = this.normalizeStoredId(contentId);
if (normalizedContentId == null) {
return;
}
const allFavorites = this.getFavoritesFromStorage();
if (!allFavorites[playlistId]) {
allFavorites[playlistId] = [];
}
if (!allFavorites[playlistId].includes(contentId)) {
allFavorites[playlistId].push(contentId);
if (!allFavorites[playlistId].includes(normalizedContentId)) {
allFavorites[playlistId].push(normalizedContentId);
}
this.saveFavoritesToStorage(allFavorites);
this.saveCollectionItemSnapshot(
playlistId,
normalizedContentId,
backdropUrl
);
}
async removeFavorite(contentId: number, playlistId: string): Promise<void> {
const normalizedContentId = this.normalizeStoredId(contentId);
if (normalizedContentId == null) {
return;
}
const allFavorites = this.getFavoritesFromStorage();
if (allFavorites[playlistId]) {
allFavorites[playlistId] = allFavorites[playlistId].filter(
(id: number) => id !== contentId
(id: number) => id !== normalizedContentId
);
}
this.saveFavoritesToStorage(allFavorites);
}
async isFavorite(contentId: number, playlistId: string): Promise<boolean> {
const normalizedContentId = this.normalizeStoredId(contentId);
if (normalizedContentId == null) {
return false;
}
const allFavorites = this.getFavoritesFromStorage();
return (allFavorites[playlistId] || []).includes(contentId);
return (allFavorites[playlistId] || []).includes(normalizedContentId);
}
private getFavoritesFromStorage(): Record<string, number[]> {
try {
const data = localStorage.getItem(STORAGE_KEYS.FAVORITES);
return data ? JSON.parse(data) : {};
return this.normalizeFavoriteStorage(data ? JSON.parse(data) : {});
} catch {
return {};
}
@@ -525,23 +682,22 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
async getRecentItems(playlistId: string): Promise<XtreamContentItem[]> {
const allRecent = this.getRecentItemsFromStorage();
const playlistRecent = allRecent[playlistId] || [];
// Match recent items with cached content
const results: (XtreamCachedContentItem & { viewed_at: string })[] = [];
for (const type of ['live', 'movie', 'series']) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
for (const item of content) {
const itemId = item.stream_id || item.series_id || item.id;
const recentEntry = playlistRecent.find((r) => r.id === itemId);
if (recentEntry) {
results.push({
...item,
viewed_at: recentEntry.viewedAt,
});
}
const contentById = await this.getCollectionItemsWithHydration(
playlistId,
playlistRecent.map((item) => item.id)
);
const results: (XtreamContentItem & { viewed_at: string })[] = [];
for (const recentEntry of playlistRecent) {
const item = contentById.get(recentEntry.id);
if (!item) {
continue;
}
results.push({
...item,
backdrop_url: recentEntry.backdropUrl ?? item.backdrop_url,
viewed_at: recentEntry.viewedAt,
});
}
// Sort by viewed_at descending
@@ -559,6 +715,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
playlistId: string,
_backdropUrl?: string
): Promise<void> {
const normalizedContentId = this.normalizeStoredId(contentId);
if (normalizedContentId == null) {
return;
}
const normalizedBackdropUrl = _backdropUrl?.trim();
const allRecent = this.getRecentItemsFromStorage();
if (!allRecent[playlistId]) {
allRecent[playlistId] = [];
@@ -566,29 +728,42 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// Remove existing entry if present
allRecent[playlistId] = allRecent[playlistId].filter(
(r) => r.id !== contentId
(r) => r.id !== normalizedContentId
);
// Add new entry at the beginning
allRecent[playlistId].unshift({
id: contentId,
id: normalizedContentId,
viewedAt: new Date().toISOString(),
...(normalizedBackdropUrl
? { backdropUrl: normalizedBackdropUrl }
: {}),
});
// Keep only last 50 items
allRecent[playlistId] = allRecent[playlistId].slice(0, 50);
this.saveRecentItemsToStorage(allRecent);
this.saveCollectionItemSnapshot(
playlistId,
normalizedContentId,
normalizedBackdropUrl
);
}
async removeRecentItem(
contentId: number,
playlistId: string
): Promise<void> {
const normalizedContentId = this.normalizeStoredId(contentId);
if (normalizedContentId == null) {
return;
}
const allRecent = this.getRecentItemsFromStorage();
if (allRecent[playlistId]) {
allRecent[playlistId] = allRecent[playlistId].filter(
(r) => r.id !== contentId
(r) => r.id !== normalizedContentId
);
}
this.saveRecentItemsToStorage(allRecent);
@@ -601,7 +776,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
private getRecentItemsFromStorage(): Record<string, StoredRecentItem[]> {
try {
const data = localStorage.getItem(STORAGE_KEYS.RECENT_ITEMS);
return data ? JSON.parse(data) : {};
return this.normalizeRecentStorage(data ? JSON.parse(data) : {});
} catch {
return {};
}
@@ -622,6 +797,266 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
this.saveRecentItemsToStorage(allRecent);
}
private normalizeStoredId(value: unknown): number | null {
const numericValue = Number(value);
return Number.isFinite(numericValue) && numericValue > 0
? numericValue
: null;
}
private normalizeFavoriteStorage(value: unknown): Record<string, number[]> {
if (!value || typeof value !== 'object') {
return {};
}
const normalized: Record<string, number[]> = {};
Object.entries(value as Record<string, unknown>).forEach(
([playlistId, ids]) => {
if (!Array.isArray(ids)) {
return;
}
normalized[playlistId] = ids
.map((id) => this.normalizeStoredId(id))
.filter((id): id is number => id !== null);
}
);
return normalized;
}
private normalizeRecentStorage(
value: unknown
): Record<string, StoredRecentItem[]> {
if (!value || typeof value !== 'object') {
return {};
}
const normalized: Record<string, StoredRecentItem[]> = {};
Object.entries(value as Record<string, unknown>).forEach(
([playlistId, items]) => {
if (!Array.isArray(items)) {
return;
}
normalized[playlistId] = items
.map((item) => {
const rawItem = item as {
readonly id?: unknown;
readonly viewedAt?: unknown;
readonly backdropUrl?: unknown;
readonly backdrop_url?: unknown;
};
const id = this.normalizeStoredId(rawItem.id);
if (
id == null ||
typeof rawItem.viewedAt !== 'string'
) {
return null;
}
return {
id,
viewedAt: rawItem.viewedAt,
...this.normalizeStoredBackdrop(rawItem),
};
})
.filter((item): item is StoredRecentItem => item !== null);
}
);
return normalized;
}
private normalizeStoredBackdrop(item: {
readonly backdropUrl?: unknown;
readonly backdrop_url?: unknown;
}): Pick<StoredRecentItem, 'backdropUrl'> | Record<string, never> {
const value = item.backdropUrl ?? item.backdrop_url;
if (typeof value !== 'string') {
return {};
}
const backdropUrl = value.trim();
return backdropUrl ? { backdropUrl } : {};
}
private getCollectionItemsFromStorage(): Record<
string,
Record<string, XtreamContentItem>
> {
try {
const data = localStorage.getItem(STORAGE_KEYS.COLLECTION_ITEMS);
const parsed = data ? JSON.parse(data) : {};
if (!parsed || typeof parsed !== 'object') {
return {};
}
return parsed as Record<string, Record<string, XtreamContentItem>>;
} catch {
return {};
}
}
private saveCollectionItemsToStorage(
items: Record<string, Record<string, XtreamContentItem>>
): void {
localStorage.setItem(
STORAGE_KEYS.COLLECTION_ITEMS,
JSON.stringify(items)
);
}
private clearCollectionItemsForPlaylist(playlistId: string): void {
const allItems = this.getCollectionItemsFromStorage();
delete allItems[playlistId];
this.saveCollectionItemsToStorage(allItems);
}
private saveCollectionItemSnapshot(
playlistId: string,
contentId: number,
backdropUrl?: string
): void {
const item = this.findCachedContentItemById(playlistId, contentId);
if (!item) {
return;
}
const normalizedBackdropUrl = backdropUrl?.trim();
const allItems = this.getCollectionItemsFromStorage();
const playlistItems = allItems[playlistId] ?? {};
playlistItems[String(contentId)] = {
...item,
...(normalizedBackdropUrl && !item.backdrop_url
? { backdrop_url: normalizedBackdropUrl }
: {}),
};
this.saveCollectionItemsToStorage({
...allItems,
[playlistId]: playlistItems,
});
}
private setCollectionItemBackdropIfMissing(
playlistId: string,
contentId: number,
backdropUrl: string
): void {
const allItems = this.getCollectionItemsFromStorage();
const playlistItems = allItems[playlistId];
const item = playlistItems?.[String(contentId)];
if (!item || item.backdrop_url) {
return;
}
this.saveCollectionItemsToStorage({
...allItems,
[playlistId]: {
...playlistItems,
[String(contentId)]: {
...item,
backdrop_url: backdropUrl,
},
},
});
}
private getCollectionItemsById(
playlistId: string,
ids: readonly number[]
): Map<number, XtreamContentItem> {
const idSet = new Set(ids);
const results = new Map<number, XtreamContentItem>();
for (const type of this.contentTypes) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
for (const item of content) {
const itemId = this.getItemIdentity(item, type);
if (idSet.has(itemId)) {
results.set(itemId, item as XtreamContentItem);
}
}
}
const storedItems = this.getCollectionItemsFromStorage()[playlistId];
if (!storedItems) {
return results;
}
for (const id of ids) {
if (!results.has(id) && storedItems[String(id)]) {
results.set(id, storedItems[String(id)]);
}
}
return results;
}
private async getCollectionItemsWithHydration(
playlistId: string,
ids: readonly number[]
): Promise<Map<number, XtreamContentItem>> {
const contentById = this.getCollectionItemsById(playlistId, ids);
const missingIds = ids.filter((id) => !contentById.has(id));
if (missingIds.length === 0) {
return contentById;
}
await this.hydrateStoredCollectionContent(playlistId, missingIds);
return this.getCollectionItemsById(playlistId, ids);
}
private findCachedContentItemById(
playlistId: string,
contentId: number
): XtreamContentItem | null {
return (
this.getCollectionItemsById(playlistId, [contentId]).get(
contentId
) ?? null
);
}
private async hydrateStoredCollectionContent(
playlistId: string,
ids: readonly number[]
): Promise<void> {
if (ids.length === 0) {
return;
}
const missingTypes = this.contentTypes.filter(
(type) => !this.contentCache.has(`${playlistId}-${type}-content`)
);
if (missingTypes.length === 0) {
return;
}
const playlist = await this.getPlaylist(playlistId);
if (!playlist) {
return;
}
const credentials: XtreamCredentials = {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
};
await Promise.all(
missingTypes.map(async (type) => {
try {
await this.getContent(playlistId, credentials, type);
} catch (error) {
this.logger.warn(
'Failed to hydrate stored PWA Xtream collection content',
{ playlistId, type, error }
);
}
})
);
}
// =========================================================================
// Content Lookup
// =========================================================================
@@ -640,8 +1075,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
const content = this.contentCache.get(cacheKey) || [];
const found = content.find((item) => {
const itemXtreamId =
item.stream_id || item.series_id || item.id;
const itemXtreamId = this.getItemIdentity(item, type);
return itemXtreamId === xtreamId;
});
@@ -653,6 +1087,67 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return null;
}
async setContentBackdropIfMissing(
contentId: number,
playlistId: string,
backdropUrl: string
): Promise<void> {
const normalizedContentId = this.normalizeStoredId(contentId);
const normalizedBackdropUrl = backdropUrl.trim();
if (normalizedContentId == null || !normalizedBackdropUrl) {
return;
}
for (const type of this.contentTypes) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey);
if (!content) {
continue;
}
this.contentCache.set(
cacheKey,
content.map((item) => {
const itemId = this.getItemIdentity(item, type);
if (itemId !== normalizedContentId || item.backdrop_url) {
return item;
}
return {
...item,
backdrop_url: normalizedBackdropUrl,
};
})
);
}
this.setCollectionItemBackdropIfMissing(
playlistId,
normalizedContentId,
normalizedBackdropUrl
);
const allRecent = this.getRecentItemsFromStorage();
const playlistRecent = allRecent[playlistId];
if (!playlistRecent) {
return;
}
this.saveRecentItemsToStorage({
...allRecent,
[playlistId]: playlistRecent.map((item) => {
if (item.id !== normalizedContentId || item.backdropUrl) {
return item;
}
return {
...item,
backdropUrl: normalizedBackdropUrl,
};
}),
});
}
private findContentIdentity(
playlistId: string,
xtreamId: number,
@@ -667,8 +1162,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
const content = this.contentCache.get(cacheKey) || [];
const found = content.find((item) => {
const itemXtreamId =
item.stream_id || item.series_id || item.id;
const itemXtreamId = this.getItemIdentity(item, type);
return itemXtreamId === xtreamId;
});
@@ -47,6 +47,7 @@ export interface XtreamContentItem {
rating: string;
added: string;
poster_url: string;
backdrop_url?: string | null;
epg_channel_id?: string | null;
tv_archive?: number | null;
tv_archive_duration?: number | null;
@@ -358,6 +359,16 @@ export interface IXtreamDataSource {
contentType?: 'live' | 'movie' | 'series'
): Promise<XtreamContentItem | null>;
/**
* Persist a backdrop URL for an already-known content item without changing
* favorites or recent ordering.
*/
setContentBackdropIfMissing(
contentId: number,
playlistId: string,
backdropUrl: string
): Promise<void>;
// =========================================================================
// Playback Position Operations
// =========================================================================
@@ -0,0 +1,80 @@
import { TestBed } from '@angular/core/testing';
import { firstValueFrom } from 'rxjs';
import { XTREAM_DATA_SOURCE } from '../data-sources/xtream-data-source.interface';
import { FavoritesService } from './favorites.service';
describe('FavoritesService', () => {
let service: FavoritesService;
let dataSource: {
addFavorite: jest.Mock;
getFavorites: jest.Mock;
isFavorite: jest.Mock;
removeFavorite: jest.Mock;
};
beforeEach(() => {
dataSource = {
addFavorite: jest.fn().mockResolvedValue(undefined),
getFavorites: jest.fn().mockResolvedValue([
{
id: 202,
type: 'movie',
title: 'Movie One',
poster_url: 'movie.png',
added_at: '2026-05-21T12:00:00.000Z',
category_id: '20',
xtream_id: 202,
},
]),
isFavorite: jest.fn().mockResolvedValue(true),
removeFavorite: jest.fn().mockResolvedValue(undefined),
};
TestBed.configureTestingModule({
providers: [
FavoritesService,
{
provide: XTREAM_DATA_SOURCE,
useValue: dataSource,
},
],
});
service = TestBed.inject(FavoritesService);
});
it('uses the active Xtream data source for favorite mutations and reads', async () => {
await service.addToFavorites({
content_id: 202,
playlist_id: 'playlist-1',
backdrop_url: 'backdrop.jpg',
});
await service.removeFromFavorites(202, 'playlist-1');
await expect(service.isFavorite(202, 'playlist-1')).resolves.toBe(true);
await expect(
firstValueFrom(service.getFavorites('playlist-1'))
).resolves.toEqual([
expect.objectContaining({
content_id: 202,
playlist_id: 'playlist-1',
type: 'movie',
title: 'Movie One',
poster_url: 'movie.png',
category_id: 20,
xtream_id: 202,
}),
]);
expect(dataSource.addFavorite).toHaveBeenCalledWith(
202,
'playlist-1',
'backdrop.jpg'
);
expect(dataSource.removeFavorite).toHaveBeenCalledWith(
202,
'playlist-1'
);
expect(dataSource.isFavorite).toHaveBeenCalledWith(202, 'playlist-1');
expect(dataSource.getFavorites).toHaveBeenCalledWith('playlist-1');
});
});
@@ -1,20 +1,25 @@
import { inject, Injectable } from '@angular/core';
import { from, map, Observable } from 'rxjs';
import { DatabaseService } from '@iptvnator/services';
import { XTREAM_DATA_SOURCE } from '../data-sources/xtream-data-source.interface';
import { FavoriteItem } from './favorite-item.interface';
function normalizeCategoryId(categoryId: string | number): number {
const numericCategoryId = Number(categoryId);
return Number.isFinite(numericCategoryId) ? numericCategoryId : 0;
}
@Injectable({
providedIn: 'root',
})
export class FavoritesService {
private dbService = inject(DatabaseService);
private dataSource = inject(XTREAM_DATA_SOURCE);
async addToFavorites(item: {
content_id: number;
playlist_id: string;
backdrop_url?: string;
}): Promise<void> {
await this.dbService.addToFavorites(
await this.dataSource.addFavorite(
item.content_id,
item.playlist_id,
item.backdrop_url
@@ -25,15 +30,15 @@ export class FavoritesService {
contentId: number,
playlistId: string
): Promise<void> {
await this.dbService.removeFromFavorites(contentId, playlistId);
await this.dataSource.removeFavorite(contentId, playlistId);
}
async isFavorite(contentId: number, playlistId: string): Promise<boolean> {
return await this.dbService.isFavorite(contentId, playlistId);
return await this.dataSource.isFavorite(contentId, playlistId);
}
getFavorites(playlistId: string): Observable<FavoriteItem[]> {
return from(this.dbService.getFavorites(playlistId)).pipe(
return from(this.dataSource.getFavorites(playlistId)).pipe(
map((items) =>
items.map((item) => ({
content_id: item.id,
@@ -42,7 +47,7 @@ export class FavoritesService {
title: item.title,
poster_url: item.poster_url,
added_at: item.added_at,
category_id: item.category_id,
category_id: normalizeCategoryId(item.category_id),
xtream_id: item.xtream_id,
}))
)
@@ -1,7 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore } from '@ngrx/signals';
import { DatabaseService } from '@iptvnator/services';
import { FavoritesService } from './services/favorites.service';
import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface';
import { withFavorites } from './with-favorites.feature';
jest.mock('@iptvnator/portal/shared/util', () => ({
@@ -16,36 +15,29 @@ jest.mock('@iptvnator/portal/shared/util', () => ({
const TestFavoritesStore = signalStore(withFavorites());
describe('withFavorites', () => {
const originalElectron = window.electron;
let store: InstanceType<typeof TestFavoritesStore>;
let databaseService: {
let dataSource: {
addFavorite: jest.Mock;
getContentByXtreamId: jest.Mock;
};
let favoritesService: {
addToFavorites: jest.Mock;
isFavorite: jest.Mock;
removeFromFavorites: jest.Mock;
removeFavorite: jest.Mock;
};
beforeEach(() => {
databaseService = {
dataSource = {
addFavorite: jest.fn().mockResolvedValue(undefined),
getContentByXtreamId: jest.fn(),
};
favoritesService = {
addToFavorites: jest.fn().mockResolvedValue(undefined),
isFavorite: jest.fn().mockResolvedValue(false),
removeFromFavorites: jest.fn().mockResolvedValue(undefined),
removeFavorite: jest.fn().mockResolvedValue(undefined),
};
TestBed.configureTestingModule({
providers: [
TestFavoritesStore,
{
provide: DatabaseService,
useValue: databaseService,
},
{
provide: FavoritesService,
useValue: favoritesService,
provide: XTREAM_DATA_SOURCE,
useValue: dataSource,
},
],
});
@@ -53,8 +45,16 @@ describe('withFavorites', () => {
store = TestBed.inject(TestFavoritesStore);
});
afterEach(() => {
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
value: originalElectron,
});
});
it('looks favorites up with the requested content type before adding one', async () => {
databaseService.getContentByXtreamId.mockResolvedValue({
dataSource.getContentByXtreamId.mockResolvedValue({
id: 3941697,
title: 'Krypton',
type: 'series',
@@ -63,21 +63,72 @@ describe('withFavorites', () => {
const result = await store.toggleFavorite(290, 'playlist-1', 'series');
expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith(
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
290,
'playlist-1',
'series'
);
expect(favoritesService.addToFavorites).toHaveBeenCalledWith({
content_id: 3941697,
playlist_id: 'playlist-1',
});
expect(dataSource.addFavorite).toHaveBeenCalledWith(
3941697,
'playlist-1',
undefined
);
expect(result).toBe(true);
expect(store.isFavorite()).toBe(true);
});
it('uses the Xtream ID as the PWA favorite key when cached content is cold', async () => {
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
value: undefined,
});
dataSource.getContentByXtreamId.mockResolvedValue(null);
const result = await store.toggleFavorite(
1767451,
'playlist-1',
'movie'
);
expect(dataSource.addFavorite).toHaveBeenCalledWith(
1767451,
'playlist-1',
undefined
);
expect(result).toBe(true);
expect(store.isFavorite()).toBe(true);
});
it('normalizes route-param Xtream IDs before using the PWA favorite fallback', async () => {
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
value: undefined,
});
dataSource.getContentByXtreamId.mockResolvedValue(null);
const result = await store.toggleFavorite(
'1767451',
'playlist-1',
'movie'
);
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
1767451,
'playlist-1',
'movie'
);
expect(dataSource.addFavorite).toHaveBeenCalledWith(
1767451,
'playlist-1',
undefined
);
expect(result).toBe(true);
});
it('looks favorites up with the requested content type before removing one', async () => {
databaseService.getContentByXtreamId.mockResolvedValue({
dataSource.getContentByXtreamId.mockResolvedValue({
id: 3867578,
title: 'SE: V Film Premiere FHD',
type: 'live',
@@ -87,12 +138,12 @@ describe('withFavorites', () => {
const result = await store.toggleFavorite(290, 'playlist-1', 'live');
expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith(
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
290,
'playlist-1',
'live'
);
expect(favoritesService.removeFromFavorites).toHaveBeenCalledWith(
expect(dataSource.removeFavorite).toHaveBeenCalledWith(
3867578,
'playlist-1'
);
@@ -101,25 +152,43 @@ describe('withFavorites', () => {
});
it('checks favorite state against the matching content type', async () => {
databaseService.getContentByXtreamId.mockResolvedValue({
dataSource.getContentByXtreamId.mockResolvedValue({
id: 3829429,
title: 'Dragon Ball Heroes',
type: 'series',
xtream_id: 31,
});
favoritesService.isFavorite.mockResolvedValue(true);
dataSource.isFavorite.mockResolvedValue(true);
await store.checkFavoriteStatus(31, 'playlist-1', 'series');
expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith(
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
31,
'playlist-1',
'series'
);
expect(favoritesService.isFavorite).toHaveBeenCalledWith(
expect(dataSource.isFavorite).toHaveBeenCalledWith(
3829429,
'playlist-1'
);
expect(store.isFavorite()).toBe(true);
});
it('checks PWA favorite state against the Xtream ID when cached content is cold', async () => {
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
value: undefined,
});
dataSource.getContentByXtreamId.mockResolvedValue(null);
dataSource.isFavorite.mockResolvedValue(true);
await store.checkFavoriteStatus(1767451, 'playlist-1', 'movie');
expect(dataSource.isFavorite).toHaveBeenCalledWith(
1767451,
'playlist-1'
);
expect(store.isFavorite()).toBe(true);
});
});
@@ -5,9 +5,8 @@ import {
withMethods,
withState,
} from '@ngrx/signals';
import { DatabaseService } from '@iptvnator/services';
import { FavoritesService } from './services/favorites.service';
import { createLogger } from '@iptvnator/portal/shared/util';
import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface';
export const withFavorites = function () {
const logger = createLogger('withFavorites');
@@ -15,85 +14,94 @@ export const withFavorites = function () {
withState({
isFavorite: false,
}),
withMethods(
(
store,
dbService = inject(DatabaseService),
favoritesService = inject(FavoritesService)
) => ({
async toggleFavorite(
xtreamId: number,
playlistId: string,
contentType: 'live' | 'movie' | 'series',
backdropUrl?: string
withMethods((store, dataSource = inject(XTREAM_DATA_SOURCE)) => ({
async toggleFavorite(
xtreamId: number | string,
playlistId: string,
contentType: 'live' | 'movie' | 'series',
backdropUrl?: string
) {
const normalizedXtreamId = Number(xtreamId);
if (
!Number.isFinite(normalizedXtreamId) ||
normalizedXtreamId <= 0 ||
!playlistId
) {
if (!xtreamId || !playlistId) {
return false;
}
return false;
}
const content = await dbService.getContentByXtreamId(
xtreamId,
playlistId,
contentType
const content = await dataSource.getContentByXtreamId(
normalizedXtreamId,
playlistId,
contentType
);
const contentId =
content?.id ??
(!window.electron ? normalizedXtreamId : null);
if (contentId == null) {
logger.error(
'Content not found for xtream ID',
normalizedXtreamId
);
if (!content) {
logger.error(
'Content not found for xtream ID',
xtreamId
);
return false;
}
return false;
}
const currentStatus = store.isFavorite();
const currentStatus = store.isFavorite();
if (currentStatus) {
// Remove from favorites
await favoritesService.removeFromFavorites(
content.id,
playlistId
);
patchState(store, { isFavorite: false });
return false;
} else {
// Add to favorites
await favoritesService.addToFavorites({
content_id: content.id,
playlist_id: playlistId,
backdrop_url: backdropUrl,
});
patchState(store, { isFavorite: true });
return true;
}
},
if (currentStatus) {
// Remove from favorites
await dataSource.removeFavorite(contentId, playlistId);
patchState(store, { isFavorite: false });
return false;
} else {
// Add to favorites
await dataSource.addFavorite(
contentId,
playlistId,
backdropUrl
);
patchState(store, { isFavorite: true });
return true;
}
},
async checkFavoriteStatus(
xtreamId: number,
playlistId: string,
contentType: 'live' | 'movie' | 'series'
async checkFavoriteStatus(
xtreamId: number | string,
playlistId: string,
contentType: 'live' | 'movie' | 'series'
) {
const normalizedXtreamId = Number(xtreamId);
if (
!Number.isFinite(normalizedXtreamId) ||
normalizedXtreamId <= 0 ||
!playlistId
) {
if (!xtreamId || !playlistId) {
patchState(store, { isFavorite: false });
return;
}
patchState(store, { isFavorite: false });
return;
}
const content = await dbService.getContentByXtreamId(
xtreamId,
playlistId,
contentType
);
if (!content) {
patchState(store, { isFavorite: false });
return;
}
const content = await dataSource.getContentByXtreamId(
normalizedXtreamId,
playlistId,
contentType
);
const contentId =
content?.id ??
(!window.electron ? normalizedXtreamId : null);
const isFavorite = await favoritesService.isFavorite(
content.id,
playlistId
);
if (contentId == null) {
patchState(store, { isFavorite: false });
return;
}
patchState(store, { isFavorite });
},
})
)
const isFavorite = await dataSource.isFavorite(
contentId,
playlistId
);
patchState(store, { isFavorite });
},
}))
);
};
@@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing';
import { signalStore } from '@ngrx/signals';
import { of } from 'rxjs';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface';
import { withRecentItems } from './with-recent-items';
jest.mock('@iptvnator/portal/shared/util', () => ({
@@ -17,13 +18,28 @@ jest.mock('@iptvnator/portal/shared/util', () => ({
const TestRecentItemsStore = signalStore(withRecentItems());
describe('withRecentItems', () => {
const originalElectron = window.electron;
let store: InstanceType<typeof TestRecentItemsStore>;
let databaseService: {
addRecentItem: jest.Mock;
clearGlobalRecentlyViewed: jest.Mock;
clearPlaylistRecentItems: jest.Mock;
getContentByXtreamId: jest.Mock;
getRecentItems: jest.Mock;
removeRecentItem: jest.Mock;
setContentBackdropIfMissing: jest.Mock;
};
let dataSource: {
addRecentItem: jest.Mock;
clearRecentItems: jest.Mock;
getContentByXtreamId: jest.Mock;
getRecentItems: jest.Mock;
removeRecentItem: jest.Mock;
setContentBackdropIfMissing: jest.Mock;
};
let playlistsService: {
clearPlaylistRecentlyViewed: jest.Mock;
getAllPlaylists: jest.Mock;
};
beforeEach(() => {
Object.defineProperty(window, 'electron', {
@@ -32,7 +48,8 @@ describe('withRecentItems', () => {
});
databaseService = {
addRecentItem: jest.fn().mockResolvedValue(undefined),
clearGlobalRecentlyViewed: jest.fn().mockResolvedValue(undefined),
clearPlaylistRecentItems: jest.fn().mockResolvedValue(undefined),
getContentByXtreamId: jest.fn(),
getRecentItems: jest.fn().mockResolvedValue([
{
@@ -46,8 +63,34 @@ describe('withRecentItems', () => {
category_id: 17,
},
]),
removeRecentItem: jest.fn().mockResolvedValue(undefined),
setContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined),
};
dataSource = {
addRecentItem: jest.fn().mockResolvedValue(undefined),
clearRecentItems: jest.fn().mockResolvedValue(undefined),
getContentByXtreamId: jest.fn(),
getRecentItems: jest.fn().mockResolvedValue([
{
id: 3941697,
title: 'Krypton',
type: 'series',
poster_url: 'https://example.com/krypton.png',
backdrop_url: 'https://example.com/krypton-backdrop.png',
viewed_at: '2026-04-21T20:42:27.000Z',
xtream_id: 290,
category_id: 17,
},
]),
removeRecentItem: jest.fn().mockResolvedValue(undefined),
setContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined),
};
playlistsService = {
clearPlaylistRecentlyViewed: jest
.fn()
.mockReturnValue(of(undefined)),
getAllPlaylists: jest.fn().mockReturnValue(of([])),
};
TestBed.configureTestingModule({
providers: [
@@ -56,14 +99,13 @@ describe('withRecentItems', () => {
provide: DatabaseService,
useValue: databaseService,
},
{
provide: XTREAM_DATA_SOURCE,
useValue: dataSource,
},
{
provide: PlaylistsService,
useValue: {
clearPlaylistRecentlyViewed: jest.fn().mockReturnValue(
of(undefined)
),
getAllPlaylists: jest.fn().mockReturnValue(of([])),
},
useValue: playlistsService,
},
],
});
@@ -71,8 +113,16 @@ describe('withRecentItems', () => {
store = TestBed.inject(TestRecentItemsStore);
});
afterEach(() => {
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
value: originalElectron,
});
});
it('looks recent items up with the requested content type before saving one', async () => {
databaseService.getContentByXtreamId.mockResolvedValue({
dataSource.getContentByXtreamId.mockResolvedValue({
id: 3941697,
title: 'Krypton',
type: 'series',
@@ -86,12 +136,12 @@ describe('withRecentItems', () => {
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith(
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
290,
'playlist-1',
'series'
);
expect(databaseService.addRecentItem).toHaveBeenCalledWith(
expect(dataSource.addRecentItem).toHaveBeenCalledWith(
3941697,
'playlist-1',
undefined
@@ -107,8 +157,56 @@ describe('withRecentItems', () => {
]);
});
it('uses the Xtream ID as the PWA recent key when cached content is cold', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
dataSource.getContentByXtreamId.mockResolvedValue(null);
store.addRecentItem({
xtreamId: 1767451,
contentType: 'movie',
playlist: signal({ id: 'playlist-1' }),
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(dataSource.addRecentItem).toHaveBeenCalledWith(
1767451,
'playlist-1',
undefined
);
expect(databaseService.getRecentItems).not.toHaveBeenCalled();
});
it('normalizes route-param Xtream IDs before using the PWA recent fallback', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
dataSource.getContentByXtreamId.mockResolvedValue(null);
store.addRecentItem({
xtreamId: '1767451',
contentType: 'movie',
playlist: signal({ id: 'playlist-1' }),
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
1767451,
'playlist-1',
'movie'
);
expect(dataSource.addRecentItem).toHaveBeenCalledWith(
1767451,
'playlist-1',
undefined
);
});
it('forwards backdrop urls on recent-item saves', async () => {
databaseService.getContentByXtreamId.mockResolvedValue({
dataSource.getContentByXtreamId.mockResolvedValue({
id: 3941697,
title: 'Krypton',
type: 'series',
@@ -123,7 +221,7 @@ describe('withRecentItems', () => {
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(databaseService.addRecentItem).toHaveBeenCalledWith(
expect(dataSource.addRecentItem).toHaveBeenCalledWith(
3941697,
'playlist-1',
'https://example.com/krypton-backdrop.png'
@@ -131,7 +229,7 @@ describe('withRecentItems', () => {
});
it('backfills a backdrop without rewriting recent ordering', async () => {
databaseService.getContentByXtreamId.mockResolvedValue({
dataSource.getContentByXtreamId.mockResolvedValue({
id: 3941697,
title: 'Krypton',
type: 'series',
@@ -145,17 +243,113 @@ describe('withRecentItems', () => {
backdropUrl: ' https://example.com/krypton-backdrop.png ',
});
expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith(
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
290,
'playlist-1',
'series'
);
expect(
databaseService.setContentBackdropIfMissing
).toHaveBeenCalledWith(
expect(dataSource.setContentBackdropIfMissing).toHaveBeenCalledWith(
3941697,
'playlist-1',
'https://example.com/krypton-backdrop.png'
);
expect(databaseService.addRecentItem).not.toHaveBeenCalled();
expect(dataSource.addRecentItem).not.toHaveBeenCalled();
expect(databaseService.getContentByXtreamId).not.toHaveBeenCalled();
expect(
databaseService.setContentBackdropIfMissing
).not.toHaveBeenCalled();
});
it('clears recent items through the active data source in PWA', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
store.clearRecentItems({ id: 'playlist-1' });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(dataSource.clearRecentItems).toHaveBeenCalledWith('playlist-1');
expect(databaseService.clearPlaylistRecentItems).not.toHaveBeenCalled();
expect(store.recentItems()).toEqual([]);
});
it('clears Xtream recent items during global PWA clear', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
playlistsService.getAllPlaylists.mockReturnValue(
of([
{
_id: 'xtream-1',
serverUrl: 'https://xtream.example.com',
},
{
_id: 'm3u-1',
},
{
_id: 'stalker-1',
serverUrl: 'https://stalker.example.com',
macAddress: '00:11:22:33:44:55',
},
])
);
await store.clearGlobalRecentlyViewed();
expect(
databaseService.clearGlobalRecentlyViewed
).not.toHaveBeenCalled();
expect(dataSource.clearRecentItems).toHaveBeenCalledWith('xtream-1');
expect(
playlistsService.clearPlaylistRecentlyViewed
).toHaveBeenCalledWith('m3u-1');
expect(
playlistsService.clearPlaylistRecentlyViewed
).toHaveBeenCalledWith('stalker-1');
expect(
playlistsService.clearPlaylistRecentlyViewed
).not.toHaveBeenCalledWith('xtream-1');
expect(store.recentItems()).toEqual([]);
});
it('removes recent items through the active data source in PWA', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
store.removeRecentItem({ itemId: 3941697, playlistId: 'playlist-1' });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(dataSource.removeRecentItem).toHaveBeenCalledWith(
3941697,
'playlist-1'
);
expect(databaseService.removeRecentItem).not.toHaveBeenCalled();
expect(store.recentItems()).toEqual([
expect.objectContaining({
id: 3941697,
title: 'Krypton',
}),
]);
});
it('removes recent items through the active data source in Electron mode', async () => {
store.removeRecentItem({ itemId: 3941697, playlistId: 'playlist-1' });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(dataSource.removeRecentItem).toHaveBeenCalledWith(
3941697,
'playlist-1'
);
expect(databaseService.removeRecentItem).not.toHaveBeenCalled();
expect(store.recentItems()).toEqual([
expect.objectContaining({
id: 3941697,
title: 'Krypton',
}),
]);
});
});
@@ -14,6 +14,7 @@ import {
PortalRecentItem,
} from '@iptvnator/shared/interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface';
export interface RecentlyViewedItem extends PortalRecentItem {
/** @deprecated Redundant — always equals `id`. Retained for compat. */
@@ -29,7 +30,7 @@ function mapDbRecentItem(
backdrop_url?: string | null;
viewed_at?: string;
xtream_id: number;
category_id: number;
category_id: number | string;
},
playlistId: string
): RecentlyViewedItem {
@@ -53,11 +54,11 @@ export const withRecentItems = function () {
withState({
recentItems: [],
}),
withMethods((store, dbService = inject(DatabaseService)) => ({
withMethods((store, dataSource = inject(XTREAM_DATA_SOURCE)) => ({
loadRecentItems: rxMethod<{ id: string }>(
pipe(
switchMap(async (playlist) => {
const items = await dbService.getRecentItems(
const items = await dataSource.getRecentItems(
playlist.id
);
return items.map((item) =>
@@ -74,10 +75,11 @@ export const withRecentItems = function () {
(
store,
dbService = inject(DatabaseService),
playlistsService = inject(PlaylistsService)
playlistsService = inject(PlaylistsService),
dataSource = inject(XTREAM_DATA_SOURCE)
) => ({
addRecentItem: rxMethod<{
xtreamId: number;
xtreamId: number | string;
contentType: 'live' | 'movie' | 'series';
playlist: Signal<{ id: string }>;
backdropUrl?: string;
@@ -90,30 +92,49 @@ export const withRecentItems = function () {
playlist,
backdropUrl,
}) => {
const playlistId = playlist().id;
const content = await dbService.getContentByXtreamId(
xtreamId,
playlistId,
contentType
);
if (content) {
await dbService.addRecentItem(
content.id,
playlistId,
backdropUrl
);
const playlistId = playlist().id;
const normalizedXtreamId = Number(xtreamId);
if (
!playlistId ||
!Number.isFinite(normalizedXtreamId) ||
normalizedXtreamId <= 0
) {
return;
}
// Reload after add/update so re-watched items
// immediately move to the top in recently-viewed.
const items =
await dbService.getRecentItems(playlistId);
patchState(store, {
recentItems: items.map((item) =>
mapDbRecentItem(item, playlistId)
),
});
const content =
await dataSource.getContentByXtreamId(
normalizedXtreamId,
playlistId,
contentType
);
const contentId =
content?.id ??
(!window.electron
? normalizedXtreamId
: null);
if (contentId != null) {
await dataSource.addRecentItem(
contentId,
playlistId,
backdropUrl
);
// Reload after add/update so re-watched items
// immediately move to the top in recently-viewed.
const items =
await dataSource.getRecentItems(
playlistId
);
patchState(store, {
recentItems: items.map((item) =>
mapDbRecentItem(item, playlistId)
),
});
}
}
})
)
)
),
async backfillContentBackdrop({
@@ -122,23 +143,25 @@ export const withRecentItems = function () {
playlist,
backdropUrl,
}: {
xtreamId: number;
xtreamId: number | string;
contentType: 'live' | 'movie' | 'series';
playlist: Signal<{ id: string }>;
backdropUrl?: string;
}): Promise<void> {
if (!window.electron) {
return;
}
const playlistId = playlist().id;
const normalizedXtreamId = Number(xtreamId);
const normalizedBackdropUrl = backdropUrl?.trim();
if (!playlistId || !normalizedBackdropUrl) {
if (
!playlistId ||
!Number.isFinite(normalizedXtreamId) ||
normalizedXtreamId <= 0 ||
!normalizedBackdropUrl
) {
return;
}
const content = await dbService.getContentByXtreamId(
xtreamId,
const content = await dataSource.getContentByXtreamId(
normalizedXtreamId,
playlistId,
contentType
);
@@ -146,17 +169,22 @@ export const withRecentItems = function () {
return;
}
await dbService.setContentBackdropIfMissing(
await dataSource.setContentBackdropIfMissing(
content.id,
playlistId,
normalizedBackdropUrl
);
},
clearRecentItems: rxMethod<{ id: string }>(
pipe(
switchMap(async (playlist) => {
await dbService.clearPlaylistRecentItems(
playlist.id
);
if (window.electron) {
await dbService.clearPlaylistRecentItems(
playlist.id
);
} else {
await dataSource.clearRecentItems(playlist.id);
}
patchState(store, { recentItems: [] });
})
)
@@ -167,13 +195,13 @@ export const withRecentItems = function () {
}>(
pipe(
switchMap(async ({ itemId, playlistId }) => {
await dbService.removeRecentItem(
await dataSource.removeRecentItem(
itemId,
playlistId
);
// Reload recent items to update UI
const items =
await dbService.getRecentItems(playlistId);
await dataSource.getRecentItems(playlistId);
patchState(store, {
recentItems: items.map((item) =>
mapDbRecentItem(item, playlistId)
@@ -189,14 +217,16 @@ export const withRecentItems = function () {
const playlists = (await firstValueFrom(
playlistsService.getAllPlaylists()
)) as Playlist[];
const playlistBackedItems =
buildPlaylistRecentItems(playlists, {
const playlistBackedItems = buildPlaylistRecentItems(
playlists,
{
stalker: 'Stalker Portal',
m3u: 'M3U',
}).map((item) => ({
...item,
content_id: item.id,
})) as RecentlyViewedItem[];
}
).map((item) => ({
...item,
content_id: item.id,
})) as RecentlyViewedItem[];
const normalizedXtream: RecentlyViewedItem[] = (
xtreamItems || []
@@ -236,24 +266,35 @@ export const withRecentItems = function () {
},
async clearGlobalRecentlyViewed() {
try {
await dbService.clearGlobalRecentlyViewed();
if (window.electron) {
await dbService.clearGlobalRecentlyViewed();
}
const playlists = (await firstValueFrom(
playlistsService.getAllPlaylists()
)) as Playlist[];
await Promise.all(
playlists
.filter(
(playlist) =>
Boolean(playlist.macAddress) ||
!playlist.serverUrl
)
.map((playlist) =>
firstValueFrom(
playlists.map(async (playlist) => {
if (
!window.electron &&
playlist.serverUrl &&
!playlist.macAddress
) {
await dataSource.clearRecentItems(
playlist._id
);
}
if (
Boolean(playlist.macAddress) ||
!playlist.serverUrl
) {
await firstValueFrom(
playlistsService.clearPlaylistRecentlyViewed(
playlist._id
)
)
)
);
}
})
);
patchState(store, { recentItems: [] });
} catch (error) {
@@ -0,0 +1,78 @@
import { DatabaseService } from './database-electron.service';
describe('DatabaseService browser guards', () => {
const originalElectron = window.electron;
let service: DatabaseService;
let consoleErrorSpy: jest.SpyInstance;
beforeEach(() => {
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
value: undefined,
});
consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
service = new DatabaseService();
});
afterEach(() => {
consoleErrorSpy.mockRestore();
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
value: originalElectron,
});
});
it('treats app state persistence as unavailable without the Electron bridge', async () => {
await expect(service.getAppState('xtream-key')).resolves.toBeNull();
await expect(
service.setAppState('xtream-key', 'completed')
).resolves.toBe(false);
await expect(
service.getXtreamImportStatus('playlist-1', 'movie')
).resolves.toBe('idle');
await expect(
service.setXtreamImportStatus('playlist-1', 'movie', 'completed')
).resolves.toBe(false);
await expect(
service.getContentByXtreamId(20229, 'playlist-1', 'movie')
).resolves.toBeNull();
await expect(service.getGlobalRecentlyViewed()).resolves.toEqual([]);
await expect(service.getGlobalFavorites()).resolves.toEqual([]);
await expect(service.getAllGlobalFavorites()).resolves.toEqual([]);
await expect(
service.clearGlobalRecentlyViewed()
).resolves.toBeUndefined();
await expect(service.addToFavorites(20229, 'playlist-1')).resolves.toBe(
false
);
await expect(
service.removeFromFavorites(20229, 'playlist-1')
).resolves.toBe(false);
await expect(service.isFavorite(20229, 'playlist-1')).resolves.toBe(
false
);
await expect(service.getFavorites('playlist-1')).resolves.toEqual([]);
await expect(service.getRecentItems('playlist-1')).resolves.toEqual([]);
await expect(service.addRecentItem(20229, 'playlist-1')).resolves.toBe(
false
);
await expect(
service.clearPlaylistRecentItems('playlist-1')
).resolves.toBe(false);
await expect(
service.removeRecentItem(20229, 'playlist-1')
).resolves.toBe(false);
await expect(
service.removeRecentItemsBatch([
{ contentId: 20229, playlistId: 'playlist-1' },
])
).resolves.toBe(false);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
});
@@ -157,11 +157,11 @@ export class DatabaseService {
}
supportsDbOperationEvents(): boolean {
return typeof window.electron.onDbOperationEvent === 'function';
return typeof window.electron?.onDbOperationEvent === 'function';
}
supportsDbOperationCancellation(): boolean {
return typeof window.electron.dbCancelOperation === 'function';
return typeof window.electron?.dbCancelOperation === 'function';
}
async cancelOperation(operationId: string): Promise<boolean> {
@@ -496,6 +496,10 @@ export class DatabaseService {
playlistId: string,
type: 'live' | 'movie' | 'series'
): Promise<boolean> {
if (typeof window.electron?.dbClearXtreamImportCache !== 'function') {
return false;
}
try {
await window.electron.dbClearXtreamImportCache(playlistId, type);
return true;
@@ -506,6 +510,10 @@ export class DatabaseService {
}
async getAppState(key: string): Promise<string | null> {
if (typeof window.electron?.dbGetAppState !== 'function') {
return null;
}
try {
return await window.electron.dbGetAppState(key);
} catch (error) {
@@ -515,6 +523,10 @@ export class DatabaseService {
}
async setAppState(key: string, value: string): Promise<boolean> {
if (typeof window.electron?.dbSetAppState !== 'function') {
return false;
}
try {
await window.electron.dbSetAppState(key, value);
return true;
@@ -615,6 +627,10 @@ export class DatabaseService {
* Get recently viewed items
*/
async getGlobalRecentlyViewed(): Promise<GlobalRecentItem[]> {
if (typeof window.electron?.dbGetRecentlyViewed !== 'function') {
return [];
}
try {
const items = await window.electron.dbGetRecentlyViewed();
return items || [];
@@ -628,6 +644,10 @@ export class DatabaseService {
* Get global favorites across all playlists
*/
async getGlobalFavorites(): Promise<GlobalFavoriteItem[]> {
if (typeof window.electron?.dbGetGlobalFavorites !== 'function') {
return [];
}
try {
const items = await window.electron.dbGetGlobalFavorites();
return items || [];
@@ -641,6 +661,10 @@ export class DatabaseService {
* Get global favorites across all playlists (all content types)
*/
async getAllGlobalFavorites(): Promise<GlobalFavoriteItem[]> {
if (typeof window.electron?.dbGetAllGlobalFavorites !== 'function') {
return [];
}
try {
const items = await window.electron.dbGetAllGlobalFavorites();
return items || [];
@@ -654,6 +678,10 @@ export class DatabaseService {
* Clear recently viewed items
*/
async clearGlobalRecentlyViewed(): Promise<void> {
if (typeof window.electron?.dbClearRecentlyViewed !== 'function') {
return;
}
try {
await window.electron.dbClearRecentlyViewed();
} catch (error) {
@@ -694,6 +722,10 @@ export class DatabaseService {
playlistId: string,
backdropUrl?: string
): Promise<boolean> {
if (typeof window.electron?.dbAddFavorite !== 'function') {
return false;
}
try {
await window.electron.dbAddFavorite(
contentId,
@@ -714,6 +746,10 @@ export class DatabaseService {
contentId: number,
playlistId: string
): Promise<boolean> {
if (typeof window.electron?.dbRemoveFavorite !== 'function') {
return false;
}
try {
await window.electron.dbRemoveFavorite(contentId, playlistId);
return true;
@@ -727,6 +763,10 @@ export class DatabaseService {
* Check if content is favorited
*/
async isFavorite(contentId: number, playlistId: string): Promise<boolean> {
if (typeof window.electron?.dbIsFavorite !== 'function') {
return false;
}
try {
return await window.electron.dbIsFavorite(contentId, playlistId);
} catch (error) {
@@ -739,6 +779,10 @@ export class DatabaseService {
* Get all favorites for a playlist
*/
async getFavorites(playlistId: string): Promise<XtreamContent[]> {
if (typeof window.electron?.dbGetFavorites !== 'function') {
return [];
}
try {
return await window.electron.dbGetFavorites(playlistId);
} catch (error) {
@@ -751,6 +795,10 @@ export class DatabaseService {
* Get recently viewed items for a specific playlist
*/
async getRecentItems(playlistId: string): Promise<XtreamContent[]> {
if (typeof window.electron?.dbGetRecentItems !== 'function') {
return [];
}
try {
return await window.electron.dbGetRecentItems(playlistId);
} catch (error) {
@@ -767,6 +815,10 @@ export class DatabaseService {
playlistId: string,
backdropUrl?: string
): Promise<boolean> {
if (typeof window.electron?.dbAddRecentItem !== 'function') {
return false;
}
try {
await window.electron.dbAddRecentItem(
contentId,
@@ -813,6 +865,10 @@ export class DatabaseService {
* Clear recently viewed for a specific playlist
*/
async clearPlaylistRecentItems(playlistId: string): Promise<boolean> {
if (typeof window.electron?.dbClearPlaylistRecentItems !== 'function') {
return false;
}
try {
await window.electron.dbClearPlaylistRecentItems(playlistId);
return true;
@@ -829,6 +885,10 @@ export class DatabaseService {
contentId: number,
playlistId: string
): Promise<boolean> {
if (typeof window.electron?.dbRemoveRecentItem !== 'function') {
return false;
}
try {
await window.electron.dbRemoveRecentItem(contentId, playlistId);
return true;
@@ -844,6 +904,10 @@ export class DatabaseService {
if (items.length === 0) {
return true;
}
if (typeof window.electron?.dbRemoveRecentItemsBatch !== 'function') {
return false;
}
try {
await window.electron.dbRemoveRecentItemsBatch(items);
return true;
@@ -861,6 +925,10 @@ export class DatabaseService {
playlistId: string,
contentType?: 'live' | 'movie' | 'series'
): Promise<XtreamContent | null> {
if (typeof window.electron?.dbGetContentByXtreamId !== 'function') {
return null;
}
try {
return await window.electron.dbGetContentByXtreamId(
xtreamId,
@@ -2,16 +2,38 @@ 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 {
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { of } from 'rxjs';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
import { DashboardDataService } from './dashboard-data.service';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import {
DashboardDataService,
DashboardFavoriteItem,
GlobalRecentItem,
} from './dashboard-data.service';
describe('DashboardDataService', () => {
let service: DashboardDataService;
const playlistsLoadedSignal = signal(true);
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',
@@ -68,6 +90,12 @@ describe('DashboardDataService', () => {
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',
@@ -138,6 +166,14 @@ describe('DashboardDataService', () => {
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();
TestBed.configureTestingModule({
@@ -145,6 +181,10 @@ describe('DashboardDataService', () => {
DashboardDataService,
{ provide: Store, useValue: storeMock },
{ provide: DatabaseService, useValue: dbServiceMock },
{
provide: XTREAM_DATA_SOURCE,
useValue: xtreamDataSourceMock,
},
{
provide: PlaylistsService,
useValue: playlistsServiceMock,
@@ -324,6 +364,139 @@ describe('DashboardDataService', () => {
).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',
serverUrl: 'https://one.example.com',
},
{
_id: 'xtream-2',
title: 'Xtream Two',
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',
serverUrl: 'https://xtream.example.com',
},
{
_id: 'stalker-1',
title: 'Stalker Playlist',
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
@@ -404,6 +577,35 @@ describe('DashboardDataService', () => {
);
});
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',
} satisfies DashboardFavoriteItem;
await service.removeGlobalFavorite(item);
expect(xtreamDataSourceMock.removeFavorite).toHaveBeenCalledWith(
51,
'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([
@@ -528,6 +730,110 @@ describe('DashboardDataService', () => {
).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',
serverUrl: 'https://one.example.com',
},
{
_id: 'xtream-2',
title: 'Xtream Two',
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',
serverUrl: 'https://xtream.example.com',
},
{
_id: 'stalker-1',
title: 'Stalker Playlist',
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([
{
@@ -736,4 +1042,34 @@ describe('DashboardDataService', () => {
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(
91,
'xtream-1'
);
expect(dbServiceMock.removeRecentItem).not.toHaveBeenCalled();
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith(
'xtream-1'
);
});
});
@@ -20,6 +20,10 @@ import {
GlobalRecentlyAddedKind,
PlaylistsService,
} from '@iptvnator/services';
import {
XTREAM_DATA_SOURCE,
XtreamContentItem,
} from '@iptvnator/portal/xtream/data-access';
import {
buildPlaylistRecentItems,
Channel,
@@ -64,6 +68,7 @@ export type DashboardRecentlyAddedFilterKind = GlobalRecentlyAddedKind;
export class DashboardDataService {
private readonly store = inject(Store);
private readonly dbService = inject(DatabaseService);
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
private readonly playlistsService = inject(PlaylistsService);
private readonly ngZone = inject(NgZone);
private readonly translate = inject(TranslateService);
@@ -298,7 +303,10 @@ export class DashboardDataService {
}
if (!window.electron) {
this.xtreamGlobalRecentItems.set([]);
const recentItems = await this.loadPwaXtreamGlobalRecentItems();
this.ngZone.run(() =>
this.xtreamGlobalRecentItems.set(recentItems)
);
this.globalRecentDbLoadedState.set(true);
this.finishInitialGlobalRecentLoadIfReady();
return;
@@ -400,7 +408,8 @@ export class DashboardDataService {
private async reloadXtreamGlobalFavorites(): Promise<void> {
if (!window.electron) {
this.xtreamGlobalFavorites.set([]);
const favorites = await this.loadPwaXtreamGlobalFavorites();
this.ngZone.run(() => this.xtreamGlobalFavorites.set(favorites));
this.finishInitialGlobalFavoritesLoadIfReady();
return;
}
@@ -422,6 +431,95 @@ export class DashboardDataService {
}
}
private async loadPwaXtreamGlobalRecentItems(): Promise<
GlobalRecentItem[]
> {
const nested = await Promise.all(
this.getXtreamPlaylists().map(async (playlist) => {
const rows = await this.xtreamDataSource.getRecentItems(
playlist._id
);
return rows.map((item) =>
this.mapPwaXtreamRecentItem(item, playlist)
);
})
);
return nested.reduce<GlobalRecentItem[]>(
(items, playlistItems) => items.concat(playlistItems),
[]
);
}
private async loadPwaXtreamGlobalFavorites(): Promise<
DashboardFavoriteItem[]
> {
const nested = await Promise.all(
this.getXtreamPlaylists().map(async (playlist) => {
const rows = await this.xtreamDataSource.getFavorites(
playlist._id
);
return rows.map((item) =>
this.mapPwaXtreamFavoriteItem(item, playlist)
);
})
);
return nested.reduce<DashboardFavoriteItem[]>(
(items, playlistItems) => items.concat(playlistItems),
[]
);
}
private getXtreamPlaylists(): PlaylistMeta[] {
return this.playlists().filter(
(playlist) => !!playlist.serverUrl && !playlist.macAddress
);
}
private mapPwaXtreamRecentItem(
item: XtreamContentItem,
playlist: PlaylistMeta
): GlobalRecentItem {
return {
id: item.id,
title: item.title,
type: this.normalizeXtreamActivityType(item.type),
playlist_id: playlist._id,
playlist_name: playlist.title || 'Xtream',
viewed_at: item.viewed_at ?? '',
category_id: item.category_id,
xtream_id: item.xtream_id,
poster_url: item.poster_url,
backdrop_url: item.backdrop_url ?? undefined,
source: 'xtream',
};
}
private mapPwaXtreamFavoriteItem(
item: XtreamContentItem,
playlist: PlaylistMeta
): DashboardFavoriteItem {
return {
id: item.id,
title: item.title,
type: this.normalizeXtreamActivityType(item.type),
playlist_id: playlist._id,
playlist_name: playlist.title || 'Xtream',
added_at: item.added_at || item.added || new Date(0).toISOString(),
category_id: item.category_id,
xtream_id: item.xtream_id,
poster_url: item.poster_url,
backdrop_url: item.backdrop_url ?? undefined,
source: 'xtream',
};
}
private normalizeXtreamActivityType(type: string): PortalActivityType {
if (type === 'live' || type === 'series') {
return type;
}
return 'movie';
}
private async refreshPlaylistBackedGlobalFavorites(): Promise<void> {
await this.reloadM3uGlobalFavorites();
this.finishInitialGlobalFavoritesLoadIfReady();
@@ -585,10 +683,17 @@ export class DashboardDataService {
async removeGlobalRecentItem(item: GlobalRecentItem): Promise<void> {
if (item.source === 'xtream') {
await this.dbService.removeRecentItem(
item.id as number,
item.playlist_id
);
if (window.electron) {
await this.dbService.removeRecentItem(
item.id as number,
item.playlist_id
);
} else {
await this.xtreamDataSource.removeRecentItem(
item.id as number,
item.playlist_id
);
}
await this.reloadGlobalRecentItems();
return;
}
@@ -739,10 +844,17 @@ export class DashboardDataService {
async removeGlobalFavorite(item: DashboardFavoriteItem): Promise<void> {
if (item.source === 'xtream') {
await this.dbService.removeFromFavorites(
item.id as number,
item.playlist_id
);
if (window.electron) {
await this.dbService.removeFromFavorites(
item.id as number,
item.playlist_id
);
} else {
await this.xtreamDataSource.removeFavorite(
item.id as number,
item.playlist_id
);
}
await this.reloadGlobalFavorites();
return;
}
@@ -875,8 +987,7 @@ export class DashboardDataService {
const matchedFavoriteId =
channelIdFavoritePosition !== undefined &&
(channelUrlFavoritePosition === undefined ||
channelIdFavoritePosition <=
channelUrlFavoritePosition)
channelIdFavoritePosition <= channelUrlFavoritePosition)
? channelId
: channelUrlFavoritePosition !== undefined
? channelUrl