mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(pwa): restore xtream rail and vod navigation
This commit is contained in:
1 parent
89df1ada0a
commit
6829c667bd
7 files changed
+335
-38
No files matched your search
@@ -133,11 +133,37 @@ 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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
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('normalizes Xtream API stream identifiers for PWA catalog navigation', async () => {
|
||||
apiService.getStreams.mockImplementation(
|
||||
(_credentials: XtreamCredentials, type: string) => {
|
||||
switch (type) {
|
||||
case 'live':
|
||||
return Promise.resolve([
|
||||
{
|
||||
stream_id: 101,
|
||||
name: 'News Live',
|
||||
stream_icon: 'live.png',
|
||||
category_id: '10',
|
||||
added: '1',
|
||||
},
|
||||
]);
|
||||
case 'movie':
|
||||
return Promise.resolve([
|
||||
{
|
||||
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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -37,9 +37,15 @@ const STORAGE_KEYS = {
|
||||
interface XtreamCachedContentItem {
|
||||
readonly added?: string;
|
||||
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,6 +53,7 @@ interface XtreamCachedContentItem {
|
||||
readonly title?: string;
|
||||
readonly type?: string;
|
||||
readonly viewed_at?: string;
|
||||
readonly xtream_id?: number | string;
|
||||
}
|
||||
|
||||
interface StoredRecentItem {
|
||||
@@ -219,7 +226,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 +245,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 +285,73 @@ 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 id = Number(item.id);
|
||||
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: Number.isFinite(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;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -326,7 +399,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
const content = this.contentCache.get(cacheKey) || [];
|
||||
|
||||
for (const item of content) {
|
||||
const itemId = item.stream_id || item.series_id || item.id;
|
||||
const itemId = this.getItemIdentity(item);
|
||||
if (playlistFavorites.includes(itemId)) {
|
||||
results.push(item);
|
||||
}
|
||||
@@ -343,6 +416,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
// is electron-only. Accept the param for interface parity.
|
||||
_backdropUrl?: string
|
||||
): Promise<void> {
|
||||
void _backdropUrl;
|
||||
const allFavorites = this.getFavoritesFromStorage();
|
||||
if (!allFavorites[playlistId]) {
|
||||
allFavorites[playlistId] = [];
|
||||
@@ -533,7 +607,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
const content = this.contentCache.get(cacheKey) || [];
|
||||
|
||||
for (const item of content) {
|
||||
const itemId = item.stream_id || item.series_id || item.id;
|
||||
const itemId = this.getItemIdentity(item);
|
||||
const recentEntry = playlistRecent.find((r) => r.id === itemId);
|
||||
if (recentEntry) {
|
||||
results.push({
|
||||
@@ -559,6 +633,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
playlistId: string,
|
||||
_backdropUrl?: string
|
||||
): Promise<void> {
|
||||
void _backdropUrl;
|
||||
const allRecent = this.getRecentItemsFromStorage();
|
||||
if (!allRecent[playlistId]) {
|
||||
allRecent[playlistId] = [];
|
||||
@@ -640,8 +715,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;
|
||||
});
|
||||
|
||||
@@ -667,8 +741,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;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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();
|
||||
|
||||
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;
|
||||
@@ -861,6 +873,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,
|
||||
|
||||
Reference in new issue
Block a user