import { inject, Injectable } from '@angular/core'; import { DataService } from 'services'; import { EpgItem, XtreamCategory, XtreamCodeActions, XtreamLiveStream, XtreamSerieDetails, XtreamSerieItem, XtreamVodDetails, XtreamVodStream, XTREAM_REQUEST, } from 'shared-interfaces'; import { XtreamAccountInfo } from '../account-info/account-info.interface'; /** * Xtream API credentials */ export interface XtreamCredentials { serverUrl: string; username: string; password: string; } /** * Category type mapping for API calls */ export type CategoryType = 'live' | 'vod' | 'series'; /** * Stream type mapping for API calls */ export type StreamType = 'live' | 'movie' | 'series'; export interface XtreamRequestOptions { requestId?: string; sessionId?: string; suppressErrorLog?: boolean; } /** * Raw EPG listing from API (before decoding) */ interface RawEpgListing { 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; } /** * EPG API response */ interface EpgResponse { epg_listings?: RawEpgListing[] | Record; } /** * Service for all Xtream Codes API interactions. * Extracts API communication logic from the XtreamStore. */ @Injectable({ providedIn: 'root' }) export class XtreamApiService { private readonly dataService = inject(DataService); async cancelSession(sessionId: string): Promise { if (!sessionId || typeof window.electron?.xtreamCancelSession !== 'function') { return false; } try { const result = await window.electron.xtreamCancelSession(sessionId); return result.success; } catch (error) { console.error('Failed to cancel Xtream session:', error); return false; } } /** * Get account/portal status */ async getAccountInfo( credentials: XtreamCredentials, options?: XtreamRequestOptions ): Promise { return this.sendRequest( credentials.serverUrl, { username: credentials.username, password: credentials.password, action: XtreamCodeActions.GetAccountInfo, }, options ); } /** * Get categories by type */ async getCategories( credentials: XtreamCredentials, type: CategoryType, options?: XtreamRequestOptions ): Promise { const actionMap: Record = { live: XtreamCodeActions.GetLiveCategories, vod: XtreamCodeActions.GetVodCategories, series: XtreamCodeActions.GetSeriesCategories, }; const response = await this.sendRequest( credentials.serverUrl, { action: actionMap[type], username: credentials.username, password: credentials.password, }, options ); return Array.isArray(response) ? response : []; } /** * Get live streams */ async getLiveStreams( credentials: XtreamCredentials, options?: XtreamRequestOptions ): Promise { const response = await this.sendRequest( credentials.serverUrl, { action: XtreamCodeActions.GetLiveStreams, username: credentials.username, password: credentials.password, }, options ); return Array.isArray(response) ? response : []; } /** * Get VOD streams */ async getVodStreams( credentials: XtreamCredentials, options?: XtreamRequestOptions ): Promise { const response = await this.sendRequest( credentials.serverUrl, { action: XtreamCodeActions.GetVodStreams, username: credentials.username, password: credentials.password, }, options ); return Array.isArray(response) ? response : []; } /** * Get series items */ async getSeriesStreams( credentials: XtreamCredentials, options?: XtreamRequestOptions ): Promise { const response = await this.sendRequest( credentials.serverUrl, { action: XtreamCodeActions.GetSeries, username: credentials.username, password: credentials.password, }, options ); return Array.isArray(response) ? response : []; } /** * Get streams by type (unified method) */ async getStreams( credentials: XtreamCredentials, type: StreamType, options?: XtreamRequestOptions ): Promise { switch (type) { case 'live': return this.getLiveStreams(credentials, options); case 'movie': return this.getVodStreams(credentials, options); case 'series': return this.getSeriesStreams(credentials, options); } } /** * Get VOD details */ async getVodInfo( credentials: XtreamCredentials, vodId: string | number, options?: XtreamRequestOptions ): Promise { return this.sendRequest( credentials.serverUrl, { action: XtreamCodeActions.GetVodInfo, username: credentials.username, password: credentials.password, vod_id: vodId, }, options ); } /** * Get series details */ async getSeriesInfo( credentials: XtreamCredentials, seriesId: string | number, options?: XtreamRequestOptions ): Promise { return this.sendRequest( credentials.serverUrl, { action: XtreamCodeActions.GetSeriesInfo, username: credentials.username, password: credentials.password, series_id: seriesId, }, options ); } /** * Get short EPG for a stream * Returns decoded EPG items (title and description are base64 decoded) */ async getShortEpg( credentials: XtreamCredentials, streamId: number, limit = 10, options?: XtreamRequestOptions ): Promise { const response: EpgResponse = await this.sendRequest( credentials.serverUrl, { action: XtreamCodeActions.GetShortEpg, username: credentials.username, password: credentials.password, stream_id: streamId, limit, }, options ); 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. } 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); } /** * Decode base64 unicode string (used for EPG data) */ private decodeBase64Unicode(str: string): string { try { return decodeURIComponent( Array.prototype.map .call(atob(str), (c: string) => { return ( '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) ); }) .join('') ); } catch { return str; } } 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 */ private async sendRequest( url: string, params: Record, options?: XtreamRequestOptions ): Promise { const serializedParams: Record = {}; Object.entries(params).forEach(([key, value]) => { serializedParams[key] = String(value); }); const response = (await this.dataService.sendIpcEvent(XTREAM_REQUEST, { url, params: serializedParams, requestId: options?.requestId, sessionId: options?.sessionId, suppressErrorLog: options?.suppressErrorLog, })) as { message?: string; payload?: unknown; type?: string; }; // The IPC layer catches errors and returns { type: 'ERROR', message, status } // instead of rejecting. Convert that back into a thrown error so callers // can handle it with .catch() / try-catch. if (response?.type === 'ERROR' || (!response?.payload && response?.message)) { throw new Error(response?.message ?? 'Request failed'); } return response?.payload as TResponse; } }