From 12eca90e4a8ced27c43fd252bba0d71e16113995 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 5 Apr 2026 18:46:29 +0200 Subject: [PATCH] feat: enhance Xtream data models and services with EPG support, including new fields for content and improved API handling --- libs/portal/stalker/feature/jest.config.ts | 21 ++ libs/portal/stalker/feature/project.json | 8 + ...er-workspace-route-session.service.spec.ts | 131 +++++++++ libs/portal/stalker/feature/src/test-setup.ts | 6 + .../portal/stalker/feature/tsconfig.spec.json | 17 ++ .../xtream-data-source.interface.ts | 5 +- .../lib/services/xtream-api.service.spec.ts | 251 ++++++++++++++++++ .../src/lib/services/xtream-api.service.ts | 203 +++++++++++++- .../lib/services/xtream-url.service.spec.ts | 79 ++++++ .../src/lib/services/xtream-url.service.ts | 181 ++++++++++++- .../stores/features/with-epg.feature.spec.ts | 109 ++++++++ .../lib/stores/features/with-epg.feature.ts | 73 ++++- ...am-workspace-route-session.service.spec.ts | 44 ++- .../xtream-workspace-route-session.service.ts | 5 +- .../src/lib/database-electron.service.ts | 8 + .../interfaces/src/lib/epg-program.model.ts | 2 + .../interfaces/src/lib/xtream-code-actions.ts | 2 + .../src/lib/xtream-item.interface.ts | 3 + .../src/lib/xtream-live-stream.interface.ts | 2 +- 19 files changed, 1120 insertions(+), 30 deletions(-) create mode 100644 libs/portal/stalker/feature/jest.config.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts create mode 100644 libs/portal/stalker/feature/src/test-setup.ts create mode 100644 libs/portal/stalker/feature/tsconfig.spec.json create mode 100644 libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts create mode 100644 libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts create mode 100644 libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts diff --git a/libs/portal/stalker/feature/jest.config.ts b/libs/portal/stalker/feature/jest.config.ts new file mode 100644 index 000000000..12455654a --- /dev/null +++ b/libs/portal/stalker/feature/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'portal-stalker-feature', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/portal/stalker/feature', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/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', + ], +}; diff --git a/libs/portal/stalker/feature/project.json b/libs/portal/stalker/feature/project.json index 62a74dce7..820cd6c30 100644 --- a/libs/portal/stalker/feature/project.json +++ b/libs/portal/stalker/feature/project.json @@ -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" } diff --git a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts new file mode 100644 index 000000000..7cbd8a748 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts @@ -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 { + 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(); + const activePlaylist = signal(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] + ); + }); +}); diff --git a/libs/portal/stalker/feature/src/test-setup.ts b/libs/portal/stalker/feature/src/test-setup.ts new file mode 100644 index 000000000..0d88648e3 --- /dev/null +++ b/libs/portal/stalker/feature/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/libs/portal/stalker/feature/tsconfig.spec.json b/libs/portal/stalker/feature/tsconfig.spec.json new file mode 100644 index 000000000..c0bb248be --- /dev/null +++ b/libs/portal/stalker/feature/tsconfig.spec.json @@ -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" + ] +} diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index 66652c060..aa650c3e5 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.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 diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts new file mode 100644 index 000000000..598616b62 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts @@ -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, [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); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts index c62225b0f..48ac17abd 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts @@ -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; } /** @@ -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 { + 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 */ diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts new file mode 100644 index 000000000..0fc4da7a5 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts @@ -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, [string]>; + setAppState: jest.Mock, [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' + ); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts index a617a8f53..22196085a 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts @@ -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(); + private readonly catchupSchemeRequests = new Map< + string, + Promise + >(); /** * 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 { + 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 { + 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 { + 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 { + 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())}`; + } } diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts new file mode 100644 index 000000000..5b08585e0 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts @@ -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; + let xtreamApiService: { + getFullEpg: jest.Mock, unknown[]>; + getShortEpg: jest.Mock, 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); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts index f7afcf6c3..a4adcfc2c 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts @@ -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(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 ?? '')); +} diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts index 6ca8ca9a8..75f5fa894 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts @@ -37,6 +37,14 @@ async function flushEffects(): Promise { 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(); const routeProvider = signal<'xtreams' | null>('xtreams'); @@ -45,6 +53,7 @@ describe('XtreamWorkspaceRouteSession', () => { const currentPlaylist = signal(XTREAM_PLAYLIST); const playlistId = signal(PLAYLIST_ID); const portalStatus = signal('active'); + const selectedContentType = signal<'live' | 'vod' | 'series'>('vod'); const contentInitBlockReason = signal(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) => { diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts index 6efb3f4d0..18a6c4e19 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts @@ -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; } diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index bf62ce1d2..684e65674 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -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; } diff --git a/libs/shared/interfaces/src/lib/epg-program.model.ts b/libs/shared/interfaces/src/lib/epg-program.model.ts index 3a41c0b84..63ce10057 100644 --- a/libs/shared/interfaces/src/lib/epg-program.model.ts +++ b/libs/shared/interfaces/src/lib/epg-program.model.ts @@ -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; diff --git a/libs/shared/interfaces/src/lib/xtream-code-actions.ts b/libs/shared/interfaces/src/lib/xtream-code-actions.ts index 06d08a15f..795d72234 100644 --- a/libs/shared/interfaces/src/lib/xtream-code-actions.ts +++ b/libs/shared/interfaces/src/lib/xtream-code-actions.ts @@ -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', } diff --git a/libs/shared/interfaces/src/lib/xtream-item.interface.ts b/libs/shared/interfaces/src/lib/xtream-item.interface.ts index 969cd1e28..8d4d9d6b9 100644 --- a/libs/shared/interfaces/src/lib/xtream-item.interface.ts +++ b/libs/shared/interfaces/src/lib/xtream-item.interface.ts @@ -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'; diff --git a/libs/shared/interfaces/src/lib/xtream-live-stream.interface.ts b/libs/shared/interfaces/src/lib/xtream-live-stream.interface.ts index eafbc345a..1c1339849 100644 --- a/libs/shared/interfaces/src/lib/xtream-live-stream.interface.ts +++ b/libs/shared/interfaces/src/lib/xtream-live-stream.interface.ts @@ -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; }