fix(pwa): restore xtream rail and vod navigation

This commit is contained in:
4gray committed 2026-05-21 14:18:11 +03:00
1 parent 89df1ada0a
commit 6829c667bd
7 files changed
+335 -38

No files matched your search

+26
View File
@@ -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,