mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
feat: enhance Xtream data models and services with EPG support, including new fields for content and improved API handling
This commit is contained in:
1 parent
fb04b0b0f5
commit
12eca90e4a
19 files changed
+1120
-30
No files matched your search
@@ -0,0 +1,21 @@
|
||||
export default {
|
||||
displayName: 'portal-stalker-feature',
|
||||
preset: '../../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory: '../../../../coverage/libs/portal/stalker/feature',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
};
|
||||
@@ -6,6 +6,14 @@
|
||||
"projectType": "library",
|
||||
"tags": ["scope:portal", "domain:stalker", "type:feature"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/portal/stalker/feature/jest.config.ts",
|
||||
"tsConfig": "libs/portal/stalker/feature/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Subject, of } from 'rxjs';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
import { StalkerWorkspaceRouteSession } from './stalker-workspace-route-session.service';
|
||||
|
||||
const PLAYLIST_ID = 'stalker-1';
|
||||
const ACTIVE_PLAYLIST: PlaylistMeta = {
|
||||
_id: PLAYLIST_ID,
|
||||
filename: 'stalker.m3u',
|
||||
macAddress: '00:1A:79:12:34:56',
|
||||
portalUrl: 'http://localhost/stalker_portal/server/load.php',
|
||||
title: 'Test Stalker',
|
||||
} as PlaylistMeta;
|
||||
|
||||
async function flushEffects(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function getStalkerSectionFromUrl(url: string): string | null {
|
||||
const match = url.match(
|
||||
/^\/workspace\/stalker\/[^/]+\/([^/?]+)(?:\/|$)/
|
||||
);
|
||||
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
describe('StalkerWorkspaceRouteSession', () => {
|
||||
const routerEvents = new Subject<NavigationEnd>();
|
||||
const activePlaylist = signal<PlaylistMeta | null>(ACTIVE_PLAYLIST);
|
||||
const selectedContentType = signal<'vod' | 'itv' | 'series'>('vod');
|
||||
|
||||
const playlistContext = {
|
||||
activePlaylist,
|
||||
syncFromUrl: jest.fn(),
|
||||
};
|
||||
|
||||
const stalkerStore = {
|
||||
resetCategories: jest.fn(),
|
||||
setSelectedCategory: jest.fn(),
|
||||
clearSelectedItem: jest.fn(),
|
||||
setCurrentPlaylist: jest.fn().mockResolvedValue(undefined),
|
||||
setSelectedContentType: jest.fn((type: 'vod' | 'itv' | 'series') => {
|
||||
selectedContentType.set(type);
|
||||
}),
|
||||
setSearchPhrase: jest.fn(),
|
||||
};
|
||||
|
||||
const playlistsService = {
|
||||
getPlaylistById: jest.fn(() => of(ACTIVE_PLAYLIST)),
|
||||
};
|
||||
|
||||
const router = {
|
||||
url: `/workspace/stalker/${PLAYLIST_ID}/vod`,
|
||||
events: routerEvents.asObservable(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
router.url = `/workspace/stalker/${PLAYLIST_ID}/vod`;
|
||||
activePlaylist.set(ACTIVE_PLAYLIST);
|
||||
selectedContentType.set('vod');
|
||||
|
||||
playlistContext.syncFromUrl.mockImplementation((url: string) => ({
|
||||
inWorkspace: true,
|
||||
provider: 'stalker',
|
||||
playlistId: PLAYLIST_ID,
|
||||
section: getStalkerSectionFromUrl(url) as
|
||||
| 'favorites'
|
||||
| 'itv'
|
||||
| 'recent'
|
||||
| 'search'
|
||||
| 'series'
|
||||
| 'vod'
|
||||
| null,
|
||||
}));
|
||||
|
||||
stalkerStore.resetCategories.mockClear();
|
||||
stalkerStore.setSelectedCategory.mockClear();
|
||||
stalkerStore.clearSelectedItem.mockClear();
|
||||
stalkerStore.setCurrentPlaylist.mockClear();
|
||||
stalkerStore.setSelectedContentType.mockClear();
|
||||
stalkerStore.setSearchPhrase.mockClear();
|
||||
playlistsService.getPlaylistById.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
StalkerWorkspaceRouteSession,
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
useValue: playlistContext,
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: playlistsService,
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: router,
|
||||
},
|
||||
{
|
||||
provide: StalkerStore,
|
||||
useValue: stalkerStore,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the itv route selection after playlist bootstrap', async () => {
|
||||
router.url = `/workspace/stalker/${PLAYLIST_ID}/itv`;
|
||||
|
||||
TestBed.inject(StalkerWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
|
||||
expect(stalkerStore.resetCategories).toHaveBeenCalled();
|
||||
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith(
|
||||
ACTIVE_PLAYLIST
|
||||
);
|
||||
expect(stalkerStore.setSelectedContentType).toHaveBeenCalledWith('itv');
|
||||
expect(selectedContentType()).toBe('itv');
|
||||
expect(
|
||||
stalkerStore.setSelectedContentType.mock.invocationCallOrder[0]
|
||||
).toBeGreaterThan(
|
||||
stalkerStore.setCurrentPlaylist.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -45,6 +45,10 @@ export interface XtreamContentItem {
|
||||
rating: string;
|
||||
added: string;
|
||||
poster_url: string;
|
||||
epg_channel_id?: string | null;
|
||||
tv_archive?: number | null;
|
||||
tv_archive_duration?: number | null;
|
||||
direct_source?: string | null;
|
||||
xtream_id: number;
|
||||
type: string;
|
||||
added_at?: string;
|
||||
@@ -57,7 +61,6 @@ export interface XtreamContentItem {
|
||||
stream_id?: number;
|
||||
stream_icon?: string;
|
||||
custom_sid?: string;
|
||||
direct_source?: string;
|
||||
rating_imdb?: string;
|
||||
|
||||
// Global search result fields
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DataService } from 'services';
|
||||
import { XTREAM_REQUEST } from 'shared-interfaces';
|
||||
import { XtreamApiService, XtreamCredentials } from './xtream-api.service';
|
||||
|
||||
describe('XtreamApiService', () => {
|
||||
let service: XtreamApiService;
|
||||
let dataService: {
|
||||
sendIpcEvent: jest.Mock<Promise<unknown>, [string, unknown]>;
|
||||
};
|
||||
|
||||
const credentials: XtreamCredentials = {
|
||||
serverUrl: 'http://demo.example',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
dataService = {
|
||||
sendIpcEvent: jest.fn(),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
XtreamApiService,
|
||||
{ provide: DataService, useValue: dataService },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(XtreamApiService);
|
||||
});
|
||||
|
||||
it('falls back to the legacy full-epg action and normalizes the response', async () => {
|
||||
dataService.sendIpcEvent.mockImplementation(
|
||||
async (_type: string, payload: unknown) => {
|
||||
const action = (
|
||||
payload as {
|
||||
params: { action: string };
|
||||
}
|
||||
).params.action;
|
||||
|
||||
if (action === 'get_simple_data_table') {
|
||||
return { payload: { epg_listings: [] } };
|
||||
}
|
||||
|
||||
return {
|
||||
payload: {
|
||||
epg_listings: [
|
||||
{
|
||||
id: 'later',
|
||||
epg_id: 'channel-101.mock',
|
||||
title: Buffer.from('Later Show').toString(
|
||||
'base64'
|
||||
),
|
||||
description: Buffer.from(
|
||||
'Later description'
|
||||
).toString('base64'),
|
||||
start: '2026-04-04 11:00:00',
|
||||
end: '2026-04-04 11:30:00',
|
||||
start_timestamp: '1775300400',
|
||||
stop_timestamp: '1775302200',
|
||||
channel_id: 'channel-101.mock',
|
||||
},
|
||||
{
|
||||
id: 'current',
|
||||
epg_id: 'channel-101.mock',
|
||||
title: Buffer.from('Current Show').toString(
|
||||
'base64'
|
||||
),
|
||||
description: Buffer.from(
|
||||
'Current description'
|
||||
).toString('base64'),
|
||||
start: '2026-04-04 10:30:00',
|
||||
end: '2026-04-04 11:00:00',
|
||||
start_timestamp: '1775298600',
|
||||
stop_timestamp: '1775300400',
|
||||
channel_id: 'channel-101.mock',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const items = await service.getFullEpg(credentials, 101);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
XTREAM_REQUEST,
|
||||
expect.objectContaining({
|
||||
url: 'http://demo.example',
|
||||
params: expect.objectContaining({
|
||||
action: 'get_simple_data_table',
|
||||
stream_id: '101',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(dataService.sendIpcEvent).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
XTREAM_REQUEST,
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
action: 'get_simple_date_table',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items.map((item) => item.title)).toEqual([
|
||||
'Current Show',
|
||||
'Later Show',
|
||||
]);
|
||||
expect(items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
description: 'Current description',
|
||||
start: '2026-04-04T10:30:00.000Z',
|
||||
stop: '2026-04-04T11:00:00.000Z',
|
||||
start_timestamp: '1775298600',
|
||||
stop_timestamp: '1775300400',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes short epg items from unix timestamps for display', async () => {
|
||||
const startTimestamp = Math.floor(
|
||||
Date.parse('2026-04-05T05:30:00.000Z') / 1000
|
||||
);
|
||||
const stopTimestamp = Math.floor(
|
||||
Date.parse('2026-04-05T06:00:00.000Z') / 1000
|
||||
);
|
||||
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
payload: {
|
||||
epg_listings: [
|
||||
{
|
||||
id: 'current',
|
||||
epg_id: 'channel-101.mock',
|
||||
title: Buffer.from('Current Show').toString('base64'),
|
||||
description: Buffer.from(
|
||||
'Current description'
|
||||
).toString('base64'),
|
||||
start: '2026-04-05 03:00:00',
|
||||
end: '2026-04-05 03:30:00',
|
||||
start_timestamp: String(startTimestamp),
|
||||
stop_timestamp: String(stopTimestamp),
|
||||
channel_id: 'channel-101.mock',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const items = await service.getShortEpg(credentials, 101, 4);
|
||||
|
||||
expect(items).toEqual([
|
||||
expect.objectContaining({
|
||||
title: 'Current Show',
|
||||
description: 'Current description',
|
||||
start: '2026-04-05T05:30:00.000Z',
|
||||
stop: '2026-04-05T06:00:00.000Z',
|
||||
start_timestamp: String(startTimestamp),
|
||||
stop_timestamp: String(stopTimestamp),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to parsed short epg date strings when unix timestamps are absent', async () => {
|
||||
const rawStart = '2026-04-05 03:00:00';
|
||||
const rawStop = '2026-04-05 03:30:00';
|
||||
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
payload: {
|
||||
epg_listings: [
|
||||
{
|
||||
id: 'current',
|
||||
epg_id: 'channel-101.mock',
|
||||
title: Buffer.from('Current Show').toString('base64'),
|
||||
description: Buffer.from(
|
||||
'Current description'
|
||||
).toString('base64'),
|
||||
start: rawStart,
|
||||
end: rawStop,
|
||||
channel_id: 'channel-101.mock',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const items = await service.getShortEpg(credentials, 101, 4);
|
||||
|
||||
expect(items).toEqual([
|
||||
expect.objectContaining({
|
||||
start: new Date(rawStart.replace(' ', 'T')).toISOString(),
|
||||
stop: new Date(rawStop.replace(' ', 'T')).toISOString(),
|
||||
start_timestamp: '',
|
||||
stop_timestamp: '',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes short and full epg items consistently for the same timestamps', async () => {
|
||||
const startTimestamp = Math.floor(
|
||||
Date.parse('2026-04-05T05:30:00.000Z') / 1000
|
||||
);
|
||||
const stopTimestamp = Math.floor(
|
||||
Date.parse('2026-04-05T06:00:00.000Z') / 1000
|
||||
);
|
||||
|
||||
dataService.sendIpcEvent.mockImplementation(
|
||||
async (_type: string, payload: unknown) => {
|
||||
const action = (
|
||||
payload as {
|
||||
params: { action: string };
|
||||
}
|
||||
).params.action;
|
||||
|
||||
const listing = {
|
||||
id: 'current',
|
||||
epg_id: 'channel-101.mock',
|
||||
title: Buffer.from('Current Show').toString('base64'),
|
||||
description: Buffer.from('Current description').toString(
|
||||
'base64'
|
||||
),
|
||||
start: '2026-04-05 03:00:00',
|
||||
end: '2026-04-05 03:30:00',
|
||||
start_timestamp: String(startTimestamp),
|
||||
stop_timestamp: String(stopTimestamp),
|
||||
channel_id: 'channel-101.mock',
|
||||
};
|
||||
|
||||
if (action === 'get_short_epg') {
|
||||
return { payload: { epg_listings: [listing] } };
|
||||
}
|
||||
|
||||
return {
|
||||
payload: {
|
||||
epg_listings: [listing],
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const shortItems = await service.getShortEpg(credentials, 101, 4);
|
||||
const fullItems = await service.getFullEpg(credentials, 101);
|
||||
|
||||
expect(shortItems[0].start).toBe(fullItems[0].start);
|
||||
expect(shortItems[0].stop).toBe(fullItems[0].stop);
|
||||
expect(shortItems[0].start_timestamp).toBe(fullItems[0].start_timestamp);
|
||||
expect(shortItems[0].stop_timestamp).toBe(fullItems[0].stop_timestamp);
|
||||
});
|
||||
});
|
||||
@@ -42,12 +42,17 @@ export interface XtreamRequestOptions {
|
||||
* Raw EPG listing from API (before decoding)
|
||||
*/
|
||||
interface RawEpgListing {
|
||||
title: string;
|
||||
description: string;
|
||||
start: string;
|
||||
end: string;
|
||||
start_timestamp: string;
|
||||
stop_timestamp: string;
|
||||
id?: string;
|
||||
epg_id?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
stop?: string;
|
||||
start_timestamp?: string;
|
||||
stop_timestamp?: string;
|
||||
channel_id?: string;
|
||||
lang?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -55,7 +60,7 @@ interface RawEpgListing {
|
||||
* EPG API response
|
||||
*/
|
||||
interface EpgResponse {
|
||||
epg_listings?: RawEpgListing[];
|
||||
epg_listings?: RawEpgListing[] | Record<string, RawEpgListing>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -265,15 +270,49 @@ export class XtreamApiService {
|
||||
options
|
||||
);
|
||||
|
||||
if (!response?.epg_listings || !Array.isArray(response.epg_listings)) {
|
||||
return [];
|
||||
return this.normalizeShortEpgItems(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full EPG schedule for a stream.
|
||||
* Uses the documented endpoint with a fallback for older typoed panels.
|
||||
*/
|
||||
async getFullEpg(
|
||||
credentials: XtreamCredentials,
|
||||
streamId: number,
|
||||
options?: XtreamRequestOptions
|
||||
): Promise<EpgItem[]> {
|
||||
try {
|
||||
const response: EpgResponse = await this.sendRequest(
|
||||
credentials.serverUrl,
|
||||
{
|
||||
action: XtreamCodeActions.GetSimpleDataTable,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
stream_id: streamId,
|
||||
},
|
||||
options
|
||||
);
|
||||
const items = this.normalizeFullEpgItems(response);
|
||||
if (items.length > 0) {
|
||||
return items;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the legacy typo endpoint below.
|
||||
}
|
||||
|
||||
return response.epg_listings.map((item) => ({
|
||||
...item,
|
||||
title: this.decodeBase64Unicode(item.title).trim(),
|
||||
description: this.decodeBase64Unicode(item.description).trim(),
|
||||
})) as EpgItem[];
|
||||
const fallbackResponse: EpgResponse = await this.sendRequest(
|
||||
credentials.serverUrl,
|
||||
{
|
||||
action: XtreamCodeActions.GetSimpleDateTable,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
stream_id: streamId,
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
return this.normalizeFullEpgItems(fallbackResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -295,6 +334,142 @@ export class XtreamApiService {
|
||||
}
|
||||
}
|
||||
|
||||
private getEpgListings(response: EpgResponse | null | undefined): RawEpgListing[] {
|
||||
const listings = response?.epg_listings;
|
||||
if (!listings) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(listings)) {
|
||||
return listings;
|
||||
}
|
||||
|
||||
return Object.values(listings);
|
||||
}
|
||||
|
||||
private normalizeShortEpgItems(response: EpgResponse): EpgItem[] {
|
||||
return this.getEpgListings(response)
|
||||
.map((item, index) => {
|
||||
const startTimestamp = this.parseUnixTimestamp(
|
||||
item.start_timestamp
|
||||
);
|
||||
const stopTimestamp = this.parseUnixTimestamp(
|
||||
item.stop_timestamp
|
||||
);
|
||||
const normalizedStart =
|
||||
this.toIsoString(startTimestamp) ??
|
||||
this.normalizeDateString(item.start);
|
||||
const normalizedStop =
|
||||
this.toIsoString(stopTimestamp) ??
|
||||
this.normalizeDateString(item.stop ?? item.end);
|
||||
|
||||
return {
|
||||
id: String(item.id ?? index),
|
||||
epg_id: String(item.epg_id ?? ''),
|
||||
title: this.decodeBase64Unicode(
|
||||
String(item.title ?? '')
|
||||
).trim(),
|
||||
description: this.decodeBase64Unicode(
|
||||
String(item.description ?? '')
|
||||
).trim(),
|
||||
lang: String(item.lang ?? ''),
|
||||
start: normalizedStart,
|
||||
end: normalizedStop,
|
||||
stop: normalizedStop,
|
||||
channel_id: String(item.channel_id ?? ''),
|
||||
start_timestamp: String(startTimestamp ?? ''),
|
||||
stop_timestamp: String(stopTimestamp ?? ''),
|
||||
} satisfies EpgItem;
|
||||
})
|
||||
.filter((item) => Boolean(item.start) && Boolean(item.stop))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
this.getEpgItemTimestampMs(left.start, left.start_timestamp) -
|
||||
this.getEpgItemTimestampMs(
|
||||
right.start,
|
||||
right.start_timestamp
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeFullEpgItems(response: EpgResponse): EpgItem[] {
|
||||
return this.getEpgListings(response)
|
||||
.map((item, index) => {
|
||||
const startTimestamp = this.parseUnixTimestamp(
|
||||
item.start_timestamp
|
||||
);
|
||||
const stopTimestamp = this.parseUnixTimestamp(
|
||||
item.stop_timestamp
|
||||
);
|
||||
const normalizedStart =
|
||||
this.toIsoString(startTimestamp) ??
|
||||
this.normalizeDateString(item.start);
|
||||
const normalizedStop =
|
||||
this.toIsoString(stopTimestamp) ??
|
||||
this.normalizeDateString(item.stop ?? item.end);
|
||||
|
||||
return {
|
||||
id: String(item.id ?? index),
|
||||
epg_id: String(item.epg_id ?? item.channel_id ?? ''),
|
||||
title: this.decodeBase64Unicode(
|
||||
String(item.title ?? '')
|
||||
).trim(),
|
||||
description: this.decodeBase64Unicode(
|
||||
String(item.description ?? '')
|
||||
).trim(),
|
||||
lang: String(item.lang ?? ''),
|
||||
start: normalizedStart,
|
||||
end: normalizedStop,
|
||||
stop: normalizedStop,
|
||||
channel_id: String(item.channel_id ?? ''),
|
||||
start_timestamp: String(startTimestamp ?? ''),
|
||||
stop_timestamp: String(stopTimestamp ?? ''),
|
||||
} satisfies EpgItem;
|
||||
})
|
||||
.filter((item) => Boolean(item.start) && Boolean(item.stop))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
this.getEpgItemTimestampMs(left.start, left.start_timestamp) -
|
||||
this.getEpgItemTimestampMs(
|
||||
right.start,
|
||||
right.start_timestamp
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private parseUnixTimestamp(value: unknown): number | null {
|
||||
const parsed = Number.parseInt(String(value ?? ''), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
private toIsoString(timestamp: number | null): string | null {
|
||||
return timestamp ? new Date(timestamp * 1000).toISOString() : null;
|
||||
}
|
||||
|
||||
private normalizeDateString(value: unknown): string {
|
||||
const rawValue = String(value ?? '').trim();
|
||||
if (!rawValue) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parsed = Date.parse(rawValue.replace(' ', 'T'));
|
||||
return Number.isFinite(parsed)
|
||||
? new Date(parsed).toISOString()
|
||||
: rawValue;
|
||||
}
|
||||
|
||||
private getEpgItemTimestampMs(
|
||||
isoValue: string,
|
||||
unixTimestampValue: string
|
||||
): number {
|
||||
const unixTimestamp = this.parseUnixTimestamp(unixTimestampValue);
|
||||
if (unixTimestamp) {
|
||||
return unixTimestamp * 1000;
|
||||
}
|
||||
|
||||
return Date.parse(isoValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send request via IPC to avoid CORS issues
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DatabaseService, SettingsStore } from 'services';
|
||||
import { XtreamCredentials } from './xtream-api.service';
|
||||
import { XtreamUrlService } from './xtream-url.service';
|
||||
|
||||
describe('XtreamUrlService', () => {
|
||||
let service: XtreamUrlService;
|
||||
let databaseService: {
|
||||
getAppState: jest.Mock<Promise<string | null>, [string]>;
|
||||
setAppState: jest.Mock<Promise<void>, [string, string]>;
|
||||
};
|
||||
|
||||
const credentials: XtreamCredentials = {
|
||||
serverUrl: 'http://demo.example',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
};
|
||||
const originalElectron = window.electron;
|
||||
|
||||
beforeEach(() => {
|
||||
databaseService = {
|
||||
getAppState: jest.fn().mockResolvedValue(null),
|
||||
setAppState: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
XtreamUrlService,
|
||||
{ provide: DatabaseService, useValue: databaseService },
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
streamFormat: signal('ts'),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(XtreamUrlService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.electron = originalElectron;
|
||||
});
|
||||
|
||||
it('detects the legacy catchup scheme once and then uses the cached result', async () => {
|
||||
const xtreamProbeUrl = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ status: 404 })
|
||||
.mockResolvedValueOnce({ status: 302 });
|
||||
window.electron = {
|
||||
xtreamProbeUrl,
|
||||
} as typeof window.electron;
|
||||
|
||||
const firstUrl = await service.resolveCatchupUrl(
|
||||
'playlist-1',
|
||||
credentials,
|
||||
101,
|
||||
1775296800,
|
||||
1775300400
|
||||
);
|
||||
const secondUrl = await service.resolveCatchupUrl(
|
||||
'playlist-1',
|
||||
credentials,
|
||||
101,
|
||||
1775296800,
|
||||
1775300400
|
||||
);
|
||||
|
||||
expect(firstUrl).toContain('/streaming/timeshift.php?');
|
||||
expect(secondUrl).toBe(firstUrl);
|
||||
expect(xtreamProbeUrl).toHaveBeenCalledTimes(2);
|
||||
expect(databaseService.setAppState).toHaveBeenCalledWith(
|
||||
'xtream-catchup-scheme:playlist-1',
|
||||
'legacy'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
XtreamSerieEpisode,
|
||||
XtreamVodDetails,
|
||||
} from 'shared-interfaces';
|
||||
import { SettingsStore } from 'services';
|
||||
import { DatabaseService, SettingsStore } from 'services';
|
||||
import { XtreamCredentials } from './xtream-api.service';
|
||||
|
||||
/**
|
||||
@@ -30,13 +30,30 @@ type XtreamVodStreamLike = XtreamVodDetails & {
|
||||
readonly stream_id?: number;
|
||||
};
|
||||
|
||||
type XtreamCatchupScheme = 'rest' | 'legacy';
|
||||
|
||||
type XtreamProbeApi = {
|
||||
xtreamProbeUrl?: (
|
||||
url: string,
|
||||
method?: 'GET' | 'HEAD'
|
||||
) => Promise<{ status: number }>;
|
||||
};
|
||||
|
||||
const XTREAM_CATCHUP_SCHEME_KEY_PREFIX = 'xtream-catchup-scheme:';
|
||||
|
||||
/**
|
||||
* Service for constructing Xtream stream URLs.
|
||||
* Handles URL construction for live streams, VOD, and series episodes.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class XtreamUrlService {
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly catchupSchemeCache = new Map<string, XtreamCatchupScheme>();
|
||||
private readonly catchupSchemeRequests = new Map<
|
||||
string,
|
||||
Promise<XtreamCatchupScheme>
|
||||
>();
|
||||
|
||||
/**
|
||||
* Construct live stream URL
|
||||
@@ -77,4 +94,166 @@ export class XtreamUrlService {
|
||||
): string {
|
||||
return `${credentials.serverUrl}/series/${credentials.username}/${credentials.password}/${episode.id}.${episode.container_extension}`;
|
||||
}
|
||||
|
||||
constructCatchupUrl(
|
||||
credentials: XtreamCredentials,
|
||||
streamId: number,
|
||||
startTimestamp: number,
|
||||
stopTimestamp: number,
|
||||
scheme: XtreamCatchupScheme
|
||||
): string {
|
||||
const durationMinutes = Math.max(
|
||||
1,
|
||||
Math.round((stopTimestamp - startTimestamp) / 60)
|
||||
);
|
||||
const timeString = this.formatCatchupStartTime(startTimestamp);
|
||||
|
||||
if (scheme === 'legacy') {
|
||||
const params = new URLSearchParams({
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
stream: String(streamId),
|
||||
start: timeString,
|
||||
duration: String(durationMinutes),
|
||||
});
|
||||
return `${credentials.serverUrl}/streaming/timeshift.php?${params.toString()}`;
|
||||
}
|
||||
|
||||
return `${credentials.serverUrl}/timeshift/${credentials.username}/${credentials.password}/${durationMinutes}/${timeString}/${streamId}.ts`;
|
||||
}
|
||||
|
||||
async resolveCatchupUrl(
|
||||
playlistId: string,
|
||||
credentials: XtreamCredentials,
|
||||
streamId: number,
|
||||
startTimestamp: number,
|
||||
stopTimestamp: number
|
||||
): Promise<string> {
|
||||
const scheme = await this.getCatchupScheme(
|
||||
playlistId,
|
||||
credentials,
|
||||
streamId,
|
||||
startTimestamp,
|
||||
stopTimestamp
|
||||
);
|
||||
|
||||
return this.constructCatchupUrl(
|
||||
credentials,
|
||||
streamId,
|
||||
startTimestamp,
|
||||
stopTimestamp,
|
||||
scheme
|
||||
);
|
||||
}
|
||||
|
||||
private async getCatchupScheme(
|
||||
playlistId: string,
|
||||
credentials: XtreamCredentials,
|
||||
streamId: number,
|
||||
startTimestamp: number,
|
||||
stopTimestamp: number
|
||||
): Promise<XtreamCatchupScheme> {
|
||||
const cacheKey = `${XTREAM_CATCHUP_SCHEME_KEY_PREFIX}${playlistId}`;
|
||||
const cached = this.catchupSchemeCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const persisted = await this.databaseService.getAppState(cacheKey);
|
||||
if (persisted === 'rest' || persisted === 'legacy') {
|
||||
this.catchupSchemeCache.set(cacheKey, persisted);
|
||||
return persisted;
|
||||
}
|
||||
|
||||
const inFlightRequest = this.catchupSchemeRequests.get(cacheKey);
|
||||
if (inFlightRequest) {
|
||||
return inFlightRequest;
|
||||
}
|
||||
|
||||
const request = this.detectCatchupScheme(
|
||||
cacheKey,
|
||||
credentials,
|
||||
streamId,
|
||||
startTimestamp,
|
||||
stopTimestamp
|
||||
).finally(() => {
|
||||
this.catchupSchemeRequests.delete(cacheKey);
|
||||
});
|
||||
|
||||
this.catchupSchemeRequests.set(cacheKey, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
private async detectCatchupScheme(
|
||||
cacheKey: string,
|
||||
credentials: XtreamCredentials,
|
||||
streamId: number,
|
||||
startTimestamp: number,
|
||||
stopTimestamp: number
|
||||
): Promise<XtreamCatchupScheme> {
|
||||
const restUrl = this.constructCatchupUrl(
|
||||
credentials,
|
||||
streamId,
|
||||
startTimestamp,
|
||||
stopTimestamp,
|
||||
'rest'
|
||||
);
|
||||
const legacyUrl = this.constructCatchupUrl(
|
||||
credentials,
|
||||
streamId,
|
||||
startTimestamp,
|
||||
stopTimestamp,
|
||||
'legacy'
|
||||
);
|
||||
|
||||
const restStatus = await this.probeCatchupUrl(restUrl);
|
||||
let detectedScheme: XtreamCatchupScheme;
|
||||
|
||||
if (this.isAcceptedCatchupProbeStatus(restStatus)) {
|
||||
detectedScheme = 'rest';
|
||||
} else {
|
||||
const legacyStatus = await this.probeCatchupUrl(legacyUrl);
|
||||
detectedScheme = this.isAcceptedCatchupProbeStatus(legacyStatus)
|
||||
? 'legacy'
|
||||
: restStatus === 404
|
||||
? 'legacy'
|
||||
: 'rest';
|
||||
}
|
||||
|
||||
this.catchupSchemeCache.set(cacheKey, detectedScheme);
|
||||
await this.databaseService.setAppState(cacheKey, detectedScheme);
|
||||
return detectedScheme;
|
||||
}
|
||||
|
||||
private async probeCatchupUrl(url: string): Promise<number> {
|
||||
const probeUrl = (window.electron as XtreamProbeApi | undefined)
|
||||
?.xtreamProbeUrl;
|
||||
|
||||
if (typeof probeUrl !== 'function') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await probeUrl(url, 'HEAD');
|
||||
return Number(result?.status ?? 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private isAcceptedCatchupProbeStatus(status: number): boolean {
|
||||
return (
|
||||
(status >= 200 && status < 400) ||
|
||||
status === 401 ||
|
||||
status === 403 ||
|
||||
status === 405
|
||||
);
|
||||
}
|
||||
|
||||
private formatCatchupStartTime(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}:${pad(date.getHours())}-${pad(date.getMinutes())}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { signalStore, withState } from '@ngrx/signals';
|
||||
import { DataService } from 'services';
|
||||
import { EpgItem } from 'shared-interfaces';
|
||||
import { XtreamApiService } from '../../services/xtream-api.service';
|
||||
import { withEpg } from './with-epg.feature';
|
||||
|
||||
jest.mock('@iptvnator/portal/shared/util', () => ({
|
||||
createLogger: () => ({
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const PLAYLIST = {
|
||||
id: 'playlist-1',
|
||||
serverUrl: 'http://demo.example',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
};
|
||||
|
||||
const TestEpgStore = signalStore(
|
||||
withState({
|
||||
currentPlaylist: PLAYLIST,
|
||||
selectedItem: { xtream_id: 101 },
|
||||
}),
|
||||
withEpg()
|
||||
);
|
||||
|
||||
function buildProgram(
|
||||
title: string,
|
||||
startTimestamp: number,
|
||||
stopTimestamp: number
|
||||
): EpgItem {
|
||||
return {
|
||||
id: title,
|
||||
epg_id: title,
|
||||
title,
|
||||
description: `${title} description`,
|
||||
lang: 'en',
|
||||
start: new Date(startTimestamp * 1000).toISOString(),
|
||||
stop: new Date(stopTimestamp * 1000).toISOString(),
|
||||
end: new Date(stopTimestamp * 1000).toISOString(),
|
||||
channel_id: 'channel-101',
|
||||
start_timestamp: String(startTimestamp),
|
||||
stop_timestamp: String(stopTimestamp),
|
||||
};
|
||||
}
|
||||
|
||||
describe('withEpg', () => {
|
||||
let store: InstanceType<typeof TestEpgStore>;
|
||||
let xtreamApiService: {
|
||||
getFullEpg: jest.Mock<Promise<EpgItem[]>, unknown[]>;
|
||||
getShortEpg: jest.Mock<Promise<EpgItem[]>, unknown[]>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
xtreamApiService = {
|
||||
getFullEpg: jest.fn(),
|
||||
getShortEpg: jest.fn(),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TestEpgStore,
|
||||
{
|
||||
provide: DataService,
|
||||
useValue: { isElectron: true },
|
||||
},
|
||||
{
|
||||
provide: XtreamApiService,
|
||||
useValue: xtreamApiService,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
store = TestBed.inject(TestEpgStore);
|
||||
});
|
||||
|
||||
it('loads the full electron epg and derives the current program from timestamps', async () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const programs = [
|
||||
buildProgram('Past Show', now - 7200, now - 3600),
|
||||
buildProgram('Current Show', now - 300, now + 1800),
|
||||
buildProgram('Next Show', now + 1800, now + 3600),
|
||||
];
|
||||
xtreamApiService.getFullEpg.mockResolvedValue(programs);
|
||||
|
||||
const result = await store.loadEpg();
|
||||
|
||||
expect(xtreamApiService.getFullEpg).toHaveBeenCalledWith(
|
||||
{
|
||||
serverUrl: 'http://demo.example',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
},
|
||||
101,
|
||||
{
|
||||
suppressErrorLog: true,
|
||||
}
|
||||
);
|
||||
expect(result).toEqual(programs);
|
||||
expect(store.epgItems()).toEqual(programs);
|
||||
expect(store.currentEpgItem()).toEqual(programs[1]);
|
||||
expect(store.isLoadingEpg()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,13 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { computed, inject } from '@angular/core';
|
||||
import {
|
||||
patchState,
|
||||
signalStoreFeature,
|
||||
withComputed,
|
||||
withMethods,
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { EpgItem } from 'shared-interfaces';
|
||||
import { DataService } from 'services';
|
||||
import {
|
||||
XtreamApiService,
|
||||
XtreamCredentials,
|
||||
@@ -49,9 +51,40 @@ export function withEpg() {
|
||||
|
||||
return signalStoreFeature(
|
||||
withState<EpgState>(initialEpgState),
|
||||
withComputed((store) => ({
|
||||
currentEpgItem: computed(() => {
|
||||
const now = Date.now();
|
||||
const items = [...store.epgItems()].sort(
|
||||
(left, right) =>
|
||||
getEpgTimestampMs(
|
||||
left.start,
|
||||
left.start_timestamp
|
||||
) -
|
||||
getEpgTimestampMs(
|
||||
right.start,
|
||||
right.start_timestamp
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
items.find((item) => {
|
||||
const start = getEpgTimestampMs(
|
||||
item.start,
|
||||
item.start_timestamp
|
||||
);
|
||||
const stop = getEpgTimestampMs(
|
||||
item.stop ?? item.end,
|
||||
item.stop_timestamp
|
||||
);
|
||||
return now >= start && now < stop;
|
||||
}) ?? null
|
||||
);
|
||||
}),
|
||||
})),
|
||||
|
||||
withMethods((store) => {
|
||||
const apiService = inject(XtreamApiService);
|
||||
const dataService = inject(DataService);
|
||||
|
||||
/**
|
||||
* Helper to get credentials from parent store
|
||||
@@ -91,17 +124,25 @@ export function withEpg() {
|
||||
return [];
|
||||
}
|
||||
|
||||
patchState(store, { isLoadingEpg: true });
|
||||
patchState(store, { epgItems: [], isLoadingEpg: true });
|
||||
|
||||
try {
|
||||
const epgItems = await apiService.getShortEpg(
|
||||
credentials,
|
||||
selectedItem.xtream_id,
|
||||
10,
|
||||
{
|
||||
suppressErrorLog: true,
|
||||
}
|
||||
);
|
||||
const epgItems = dataService.isElectron
|
||||
? await apiService.getFullEpg(
|
||||
credentials,
|
||||
selectedItem.xtream_id,
|
||||
{
|
||||
suppressErrorLog: true,
|
||||
}
|
||||
)
|
||||
: await apiService.getShortEpg(
|
||||
credentials,
|
||||
selectedItem.xtream_id,
|
||||
10,
|
||||
{
|
||||
suppressErrorLog: true,
|
||||
}
|
||||
);
|
||||
|
||||
patchState(store, {
|
||||
epgItems,
|
||||
@@ -153,3 +194,15 @@ export function withEpg() {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function getEpgTimestampMs(
|
||||
dateValue: string | undefined,
|
||||
unixTimestampValue: string | undefined
|
||||
): number {
|
||||
const unixTimestamp = Number.parseInt(String(unixTimestampValue ?? ''), 10);
|
||||
if (Number.isFinite(unixTimestamp) && unixTimestamp > 0) {
|
||||
return unixTimestamp * 1000;
|
||||
}
|
||||
|
||||
return Date.parse(String(dateValue ?? ''));
|
||||
}
|
||||
@@ -37,6 +37,14 @@ async function flushEffects(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function getXtreamSectionFromUrl(url: string): string | null {
|
||||
const match = url.match(
|
||||
/^\/workspace\/xtreams\/[^/]+\/([^/?]+)(?:\/|$)/
|
||||
);
|
||||
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
describe('XtreamWorkspaceRouteSession', () => {
|
||||
const routerEvents = new Subject<NavigationEnd>();
|
||||
const routeProvider = signal<'xtreams' | null>('xtreams');
|
||||
@@ -45,6 +53,7 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
const currentPlaylist = signal<XtreamPlaylistData | null>(XTREAM_PLAYLIST);
|
||||
const playlistId = signal<string | null>(PLAYLIST_ID);
|
||||
const portalStatus = signal<PortalStatusType>('active');
|
||||
const selectedContentType = signal<'live' | 'vod' | 'series'>('vod');
|
||||
const contentInitBlockReason =
|
||||
signal<XtreamContentInitBlockReason | null>(null);
|
||||
|
||||
@@ -62,6 +71,7 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
resetStore: jest.fn((nextPlaylistId?: string) => {
|
||||
playlistId.set(nextPlaylistId ?? null);
|
||||
currentPlaylist.set(null);
|
||||
selectedContentType.set('vod');
|
||||
}),
|
||||
setCurrentPlaylist: jest.fn((playlist: XtreamPlaylistData | null) => {
|
||||
currentPlaylist.set(playlist);
|
||||
@@ -70,7 +80,11 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
checkPortalStatus: jest.fn(),
|
||||
contentInitBlockReason,
|
||||
initializeContent: jest.fn().mockResolvedValue(undefined),
|
||||
setSelectedContentType: jest.fn(),
|
||||
setSelectedContentType: jest.fn(
|
||||
(type: 'live' | 'vod' | 'series') => {
|
||||
selectedContentType.set(type);
|
||||
}
|
||||
),
|
||||
setContentInitBlockReason: jest.fn(
|
||||
(reason: XtreamContentInitBlockReason | null) => {
|
||||
contentInitBlockReason.set(reason);
|
||||
@@ -91,13 +105,21 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
currentPlaylist.set(XTREAM_PLAYLIST);
|
||||
playlistId.set(PLAYLIST_ID);
|
||||
portalStatus.set('active');
|
||||
selectedContentType.set('vod');
|
||||
contentInitBlockReason.set(null);
|
||||
|
||||
playlistContext.syncFromUrl.mockImplementation((url: string) => ({
|
||||
inWorkspace: true,
|
||||
provider: 'xtreams',
|
||||
playlistId: PLAYLIST_ID,
|
||||
section: url.endsWith('/favorites') ? 'favorites' : 'vod',
|
||||
section: getXtreamSectionFromUrl(url) as
|
||||
| 'favorites'
|
||||
| 'live'
|
||||
| 'recently-added'
|
||||
| 'search'
|
||||
| 'series'
|
||||
| 'vod'
|
||||
| null,
|
||||
}));
|
||||
|
||||
xtreamStore.resetStore.mockClear();
|
||||
@@ -142,6 +164,24 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
expect(xtreamStore.initializeContent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reapplies a live route section after resetStore restores the default selection', async () => {
|
||||
router.url = `/workspace/xtreams/${PLAYLIST_ID}/live`;
|
||||
xtreamStore.checkPortalStatus.mockImplementation(async () => {
|
||||
portalStatus.set('active');
|
||||
return 'active';
|
||||
});
|
||||
|
||||
TestBed.inject(XtreamWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
|
||||
expect(xtreamStore.resetStore).toHaveBeenCalledWith(PLAYLIST_ID);
|
||||
expect(xtreamStore.setSelectedContentType).toHaveBeenCalledWith('live');
|
||||
expect(selectedContentType()).toBe('live');
|
||||
expect(
|
||||
xtreamStore.setSelectedContentType.mock.invocationCallOrder[0]
|
||||
).toBeGreaterThan(xtreamStore.resetStore.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it.each(['expired', 'inactive', 'unavailable'] as const)(
|
||||
'blocks %s portals before import-driven initialization starts',
|
||||
async (status) => {
|
||||
|
||||
@@ -138,7 +138,8 @@ export class XtreamWorkspaceRouteSession {
|
||||
routeContext.provider === 'xtreams'
|
||||
? routeContext.playlistId
|
||||
: null;
|
||||
const section = this.syncRouteState(routeContext.section);
|
||||
const routeSection =
|
||||
routeContext.provider === 'xtreams' ? routeContext.section : null;
|
||||
const routePlaylist =
|
||||
routeContext.provider === 'xtreams'
|
||||
? toXtreamPlaylistData(this.playlistContext.activePlaylist())
|
||||
@@ -179,6 +180,8 @@ export class XtreamWorkspaceRouteSession {
|
||||
}
|
||||
}
|
||||
|
||||
const section = this.syncRouteState(routeSection);
|
||||
|
||||
if (isImportDrivenSection(section) && portalStatus !== 'active') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ export interface XtreamContent {
|
||||
rating: string;
|
||||
added: string;
|
||||
poster_url: string;
|
||||
epg_channel_id?: string | null;
|
||||
tv_archive?: number | null;
|
||||
tv_archive_duration?: number | null;
|
||||
direct_source?: string | null;
|
||||
xtream_id: number;
|
||||
type: string;
|
||||
added_at?: string;
|
||||
@@ -60,6 +64,10 @@ type XtreamContentStream =
|
||||
cover?: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
epg_channel_id?: string;
|
||||
tv_archive?: string | number;
|
||||
tv_archive_duration?: string | number;
|
||||
direct_source?: string;
|
||||
series_id?: string | number;
|
||||
stream_id?: string | number;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface EpgProgram {
|
||||
title: string;
|
||||
desc: string | null;
|
||||
category: string | null;
|
||||
startTimestamp?: number | null;
|
||||
stopTimestamp?: number | null;
|
||||
date?: string;
|
||||
episodeNum?: string | null;
|
||||
iconUrl?: string | null;
|
||||
|
||||
@@ -9,4 +9,6 @@ export enum XtreamCodeActions {
|
||||
GetVodCategories = 'get_vod_categories',
|
||||
GetVodInfo = 'get_vod_info',
|
||||
GetShortEpg = 'get_short_epg',
|
||||
GetSimpleDataTable = 'get_simple_data_table',
|
||||
GetSimpleDateTable = 'get_simple_date_table',
|
||||
}
|
||||
@@ -8,6 +8,9 @@ export interface XtreamItem {
|
||||
category_id: string;
|
||||
custom_sid: string;
|
||||
direct_source: string;
|
||||
epg_channel_id?: string;
|
||||
tv_archive?: number;
|
||||
tv_archive_duration?: number;
|
||||
rating_imdb?: string;
|
||||
xtream_id?: number;
|
||||
type?: 'movie' | 'series' | 'live';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { XtreamItem } from './xtream-item.interface';
|
||||
|
||||
export interface XtreamLiveStream extends XtreamItem {
|
||||
stream_type: 'live';
|
||||
epg_channel_id?: number;
|
||||
epg_channel_id?: string;
|
||||
tv_archive: number;
|
||||
tv_archive_duration: number;
|
||||
}
|
||||
Reference in new issue
Block a user