508 lines
15 KiB
TypeScript
508 lines
15 KiB
TypeScript
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<string, RawEpgListing>;
|
|
}
|
|
|
|
/**
|
|
* 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<boolean> {
|
|
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<XtreamAccountInfo> {
|
|
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<XtreamCategory[]> {
|
|
const actionMap: Record<CategoryType, XtreamCodeActions> = {
|
|
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<XtreamLiveStream[]> {
|
|
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<XtreamVodStream[]> {
|
|
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<XtreamSerieItem[]> {
|
|
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<XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[]> {
|
|
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<XtreamVodDetails> {
|
|
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<XtreamSerieDetails> {
|
|
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<EpgItem[]> {
|
|
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<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.
|
|
}
|
|
|
|
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<TResponse>(
|
|
url: string,
|
|
params: Record<string, string | number>,
|
|
options?: XtreamRequestOptions
|
|
): Promise<TResponse> {
|
|
const serializedParams: Record<string, string> = {};
|
|
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;
|
|
}
|
|
}
|