mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge pull request #961 from 4gray/agent/pwa-provider-target-flow
fix(docker): exclude agent worktrees from build context
This commit is contained in:
24 files changed
+3030
-324
No files matched your search
@@ -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
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 */
|
||||
});
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
+122
@@ -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',
|
||||
|
||||
+120
-14
@@ -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'>
|
||||
|
||||
+185
@@ -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
|
||||
|
||||
Reference in new issue
Block a user