feat: enhance Xtream data models and services with EPG support, including new fields for content and improved API handling

This commit is contained in:
4gray committed 2026-04-05 19:04:40 +02:00
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',
],
};
+8
View File
@@ -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;
}