mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* fix(downloads): resume interrupted Xtream VOD transfers * fix(downloads): validate partials before resuming * fix(downloads): propagate headers to episode transfers
320 lines
10 KiB
TypeScript
320 lines
10 KiB
TypeScript
/**
|
|
* This module handles all Xtream Codes API related IPC communications
|
|
* between the frontend and the electron backend.
|
|
*/
|
|
|
|
import axios, { AxiosRequestConfig } from 'axios';
|
|
import { ipcMain } from 'electron';
|
|
import {
|
|
PortalDebugEvent,
|
|
XTREAM_CANCEL_SESSION,
|
|
XTREAM_CLIENT_USER_AGENT,
|
|
XTREAM_MAIN_PERFORMANCE_PHASE,
|
|
normalizeXtreamServerUrl,
|
|
} from '@iptvnator/shared/interfaces';
|
|
import { redactSensitiveData } from '@iptvnator/shared/logging';
|
|
import { emitPortalDebugEvent } from './portal-debug.events';
|
|
import { requestWithValidatedRedirects } from '../util/validated-axios';
|
|
import {
|
|
createXtreamMainPerformanceCaptureForRequest,
|
|
createXtreamMeasuredTransformResponse,
|
|
} from './xtream-performance';
|
|
import { cancelXtreamSessionRequests } from './xtream-session-cancellation';
|
|
|
|
export default class XtreamEvents {
|
|
static bootstrapXtreamEvents(): Electron.IpcMain {
|
|
return ipcMain;
|
|
}
|
|
}
|
|
|
|
function formatXtreamError(
|
|
error: unknown,
|
|
requestUrl: string,
|
|
action?: string
|
|
) {
|
|
let parsedUrl: URL | null = null;
|
|
try {
|
|
parsedUrl = new URL(requestUrl);
|
|
} catch {
|
|
parsedUrl = null;
|
|
}
|
|
const base = {
|
|
action,
|
|
host: parsedUrl?.host ?? 'unknown',
|
|
pathname: parsedUrl?.pathname ?? requestUrl,
|
|
};
|
|
|
|
if (axios.isAxiosError(error)) {
|
|
return {
|
|
...base,
|
|
type: 'AxiosError',
|
|
code: error.code,
|
|
status: error.response?.status,
|
|
message: error.message,
|
|
syscall: (error as NodeJS.ErrnoException).syscall,
|
|
hostname: (error as any).hostname,
|
|
};
|
|
}
|
|
|
|
if (error && typeof error === 'object') {
|
|
const errObj = error as Record<string, unknown>;
|
|
return {
|
|
...base,
|
|
type: 'ErrorObject',
|
|
status: errObj.status,
|
|
message: errObj.message,
|
|
};
|
|
}
|
|
|
|
return {
|
|
...base,
|
|
type: 'UnknownError',
|
|
message: String(error),
|
|
};
|
|
}
|
|
|
|
function buildXtreamApiUrl(url: string, params: Record<string, string>): URL {
|
|
const baseUrl = normalizeXtreamServerUrl(url);
|
|
const apiUrl = new URL(`${baseUrl}/player_api.php`);
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
apiUrl.searchParams.append(
|
|
key,
|
|
key === 'username' || key === 'password' ? value.trim() : value
|
|
);
|
|
});
|
|
|
|
return apiUrl;
|
|
}
|
|
|
|
/**
|
|
* Handle Xtream Codes API requests
|
|
*/
|
|
ipcMain.handle(
|
|
'XTREAM_REQUEST',
|
|
async (
|
|
event,
|
|
payload: {
|
|
url: string;
|
|
params: Record<string, string>;
|
|
requestId?: string;
|
|
sessionId?: string;
|
|
suppressErrorLog?: boolean;
|
|
}
|
|
) => {
|
|
const startedAt = Date.now();
|
|
const performanceCapture = createXtreamMainPerformanceCaptureForRequest(
|
|
payload.requestId
|
|
);
|
|
let activeRequestKey: string | null = null;
|
|
let requestUrlForLog = payload.url;
|
|
try {
|
|
const { url, params, requestId, sessionId } = payload;
|
|
|
|
// Build URL with query parameters
|
|
// Xtream API endpoint is always at /player_api.php
|
|
const apiUrl = buildXtreamApiUrl(url, params);
|
|
requestUrlForLog = apiUrl.toString();
|
|
|
|
const controller = new AbortController();
|
|
if (requestId || sessionId) {
|
|
activeRequestKey = requestId ?? crypto.randomUUID();
|
|
activeXtreamRequests.set(activeRequestKey, {
|
|
controller,
|
|
sessionId,
|
|
});
|
|
}
|
|
|
|
// Configure axios request
|
|
const config: AxiosRequestConfig = {
|
|
method: 'GET',
|
|
url: apiUrl.toString(),
|
|
headers: {
|
|
'User-Agent': XTREAM_CLIENT_USER_AGENT,
|
|
Accept: 'application/json',
|
|
},
|
|
timeout: 30000, // 30 seconds timeout for Xtream API
|
|
validateStatus: (status) => status < 500, // Don't throw on 4xx errors
|
|
signal: controller.signal,
|
|
};
|
|
if (performanceCapture) {
|
|
config.transformResponse =
|
|
createXtreamMeasuredTransformResponse(
|
|
performanceCapture,
|
|
axios.defaults.transformResponse
|
|
);
|
|
}
|
|
|
|
const response = performanceCapture
|
|
? await performanceCapture.measureAsync(
|
|
XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
|
|
() =>
|
|
requestWithValidatedRedirects<unknown>(
|
|
apiUrl.toString(),
|
|
config,
|
|
{ allowPrivateNetworks: true }
|
|
)
|
|
)
|
|
: await requestWithValidatedRedirects<unknown>(
|
|
apiUrl.toString(),
|
|
config,
|
|
{ allowPrivateNetworks: true }
|
|
);
|
|
// Check if response is successful
|
|
if (response.status >= 400) {
|
|
throw {
|
|
message: `HTTP Error: ${response.statusText}`,
|
|
status: response.status,
|
|
};
|
|
}
|
|
|
|
if (requestId) {
|
|
const debugEvent: PortalDebugEvent = {
|
|
requestId,
|
|
provider: 'xtream',
|
|
operation: params.action ?? 'unknown',
|
|
transport: 'electron-main',
|
|
startedAt: new Date(startedAt).toISOString(),
|
|
durationMs: Date.now() - startedAt,
|
|
status: 'success',
|
|
request: {
|
|
method: config.method ?? 'GET',
|
|
url: apiUrl.toString(),
|
|
headers: config.headers,
|
|
timeout: config.timeout,
|
|
params,
|
|
},
|
|
response: response.data,
|
|
};
|
|
emitPortalDebugEvent(debugEvent);
|
|
}
|
|
|
|
// Xtream API returns JSON data
|
|
const result = {
|
|
payload: response.data,
|
|
action: params.action,
|
|
};
|
|
return performanceCapture
|
|
? performanceCapture.measure(
|
|
XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY,
|
|
() => result
|
|
)
|
|
: result;
|
|
} catch (error) {
|
|
const requestId = payload.requestId;
|
|
if (requestId) {
|
|
const apiUrl = (() => {
|
|
try {
|
|
return buildXtreamApiUrl(
|
|
payload.url,
|
|
payload.params ?? {}
|
|
).toString();
|
|
} catch {
|
|
return requestUrlForLog;
|
|
}
|
|
})();
|
|
|
|
const debugEvent: PortalDebugEvent = {
|
|
requestId,
|
|
provider: 'xtream',
|
|
operation: payload.params?.action ?? 'unknown',
|
|
transport: 'electron-main',
|
|
startedAt: new Date(startedAt).toISOString(),
|
|
durationMs: Date.now() - startedAt,
|
|
status: 'error',
|
|
request: {
|
|
method: 'GET',
|
|
url: apiUrl,
|
|
headers: {
|
|
'User-Agent': XTREAM_CLIENT_USER_AGENT,
|
|
Accept: 'application/json',
|
|
},
|
|
timeout: 30000,
|
|
params: payload.params,
|
|
},
|
|
error,
|
|
};
|
|
emitPortalDebugEvent(debugEvent);
|
|
}
|
|
|
|
if (!payload.suppressErrorLog) {
|
|
console.error(
|
|
'[XTREAM_REQUEST] Failed',
|
|
redactSensitiveData(
|
|
formatXtreamError(
|
|
error,
|
|
requestUrlForLog,
|
|
payload.params?.action
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
// Format error response
|
|
if (axios.isAxiosError(error)) {
|
|
if (error.code === 'ERR_CANCELED') {
|
|
throw {
|
|
type: 'ERROR',
|
|
name: 'AbortError',
|
|
message: 'Xtream request cancelled',
|
|
status: 499,
|
|
};
|
|
}
|
|
const errorResponse = {
|
|
type: 'ERROR',
|
|
message:
|
|
error.response?.data?.message ||
|
|
error.message ||
|
|
'Failed to fetch data from Xtream server',
|
|
status: error.response?.status || 500,
|
|
};
|
|
throw errorResponse;
|
|
} else if (
|
|
error &&
|
|
typeof error === 'object' &&
|
|
'message' in error
|
|
) {
|
|
throw error;
|
|
} else {
|
|
throw {
|
|
type: 'ERROR',
|
|
message: 'An unknown error occurred',
|
|
status: 500,
|
|
};
|
|
}
|
|
} finally {
|
|
if (activeRequestKey) {
|
|
activeXtreamRequests.delete(activeRequestKey);
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
ipcMain.handle(
|
|
XTREAM_CANCEL_SESSION,
|
|
async (
|
|
_event,
|
|
sessionId: string
|
|
): Promise<{ success: boolean; cancelled: number }> => {
|
|
const capture = createXtreamMainPerformanceCaptureForRequest();
|
|
return capture
|
|
? capture.measure(
|
|
XTREAM_MAIN_PERFORMANCE_PHASE.CANCEL_SESSION,
|
|
() =>
|
|
cancelXtreamSessionRequests(
|
|
activeXtreamRequests.values(),
|
|
sessionId
|
|
)
|
|
)
|
|
: cancelXtreamSessionRequests(
|
|
activeXtreamRequests.values(),
|
|
sessionId
|
|
);
|
|
}
|
|
);
|
|
type ActiveXtreamRequest = {
|
|
controller: AbortController;
|
|
sessionId?: string;
|
|
};
|
|
|
|
const activeXtreamRequests = new Map<string, ActiveXtreamRequest>();
|