mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
Adds an "Auto-detect" method to the Add playlist dialog: paste the message a provider sent — links, Xtream credentials, a MAC address with device identity — and a deterministic parser recognizes the source(s) and prefills the matching import form. - detectProviderImportCandidates (libs/shared/interfaces) extracts URLs, MAC addresses and labeled fields, classifies each finding as Xtream, Stalker or an M3U link/body, and returns ranked candidates. Pure and synchronous. - Built against a corpus of 19 real reseller handouts kept verbatim in the spec: Unicode "font" labels, arrow/dingbat separators, separator-less hex serials, dual device IDs, multi-MAC lists, bare three-line handouts, and a guard so a parental PIN is never read as the account password. - Detection only proposes: the target form's own validation and behavioral probes remain the sole path into the store, and no pasted text leaves the app. Passwords are masked on candidate cards, including query and HTTP Basic userinfo forms. - Covered by parser, component and dialog unit tests plus two web E2E specs for the paste → pick → prefilled form workflow; i18n for all 19 languages.
147 lines
4.0 KiB
TypeScript
147 lines
4.0 KiB
TypeScript
export type XtreamPortalStatusType =
|
|
'active' | 'inactive' | 'expired' | 'unavailable';
|
|
|
|
export interface XtreamPortalStatusResponseLike {
|
|
user_info?: {
|
|
auth?: boolean | number | string | null;
|
|
exp_date?: number | string | null;
|
|
status?: string | null;
|
|
} | null;
|
|
}
|
|
|
|
export interface XtreamCredentialsFromUrl {
|
|
password: string;
|
|
username: string;
|
|
}
|
|
|
|
// panel_api.php is the legacy admin-flavored endpoint some panels hand out;
|
|
// it identifies an Xtream server just like the other two and must be
|
|
// stripped the same way, or the transport would append /player_api.php to it.
|
|
const XTREAM_API_ENDPOINT_PATTERN = /\/(?:get|player_api|panel_api)\.php$/i;
|
|
|
|
// Some Xtream panels challenge generic Node HTTP clients while allowing
|
|
// established IPTV players. Keep API, probe, and download requests aligned.
|
|
export const XTREAM_CLIENT_USER_AGENT = 'VLC/3.0.18 LibVLC/3.0.18';
|
|
|
|
export function normalizeXtreamServerUrl(value: string): string {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
throw new Error('Xtream URL is required');
|
|
}
|
|
|
|
const url = new URL(trimmed);
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
throw new Error('Only http and https Xtream URLs are supported');
|
|
}
|
|
|
|
if (url.username || url.password) {
|
|
throw new Error('URL credentials are not supported');
|
|
}
|
|
|
|
const pathWithoutTrailingSlash = url.pathname.replace(/\/+$/, '');
|
|
const basePath = pathWithoutTrailingSlash.replace(
|
|
XTREAM_API_ENDPOINT_PATTERN,
|
|
''
|
|
);
|
|
|
|
return `${url.origin}${basePath}`;
|
|
}
|
|
|
|
export function extractXtreamCredentialsFromUrl(
|
|
value: string
|
|
): XtreamCredentialsFromUrl | null {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(value.trim());
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const username = url.searchParams.get('username')?.trim() ?? '';
|
|
const password = url.searchParams.get('password')?.trim() ?? '';
|
|
|
|
if (!username || !password) {
|
|
return null;
|
|
}
|
|
|
|
return { username, password };
|
|
}
|
|
|
|
export function resolveXtreamPortalStatus(
|
|
response: XtreamPortalStatusResponseLike | null | undefined,
|
|
now = new Date()
|
|
): XtreamPortalStatusType {
|
|
const userInfo = response?.user_info;
|
|
if (!userInfo) {
|
|
return 'unavailable';
|
|
}
|
|
|
|
const auth = normalizeAuthValue(userInfo.auth);
|
|
if (auth === false) {
|
|
return 'inactive';
|
|
}
|
|
|
|
const normalizedStatus = userInfo.status?.trim().toLowerCase() ?? '';
|
|
if (normalizedStatus === 'expired') {
|
|
return 'expired';
|
|
}
|
|
|
|
const isActive =
|
|
normalizedStatus === 'active' ||
|
|
(auth === true && normalizedStatus.length === 0);
|
|
|
|
if (!isActive) {
|
|
return normalizedStatus ? 'inactive' : 'unavailable';
|
|
}
|
|
|
|
const expirationTimestamp = parseXtreamExpiration(userInfo.exp_date);
|
|
if (
|
|
expirationTimestamp !== null &&
|
|
expirationTimestamp * 1000 < now.getTime()
|
|
) {
|
|
return 'expired';
|
|
}
|
|
|
|
return 'active';
|
|
}
|
|
|
|
/**
|
|
* Reads the account expiration from an Xtream `get_account_info`-style
|
|
* response as unix seconds. Returns null when the portal doesn't report one
|
|
* (unlimited accounts answer with empty/zero/absent `exp_date`).
|
|
*/
|
|
export function resolveXtreamPortalExpiration(
|
|
response: XtreamPortalStatusResponseLike | null | undefined
|
|
): number | null {
|
|
return parseXtreamExpiration(response?.user_info?.exp_date);
|
|
}
|
|
|
|
function normalizeAuthValue(
|
|
value: boolean | number | string | null | undefined
|
|
): boolean | null {
|
|
if (value === true || value === 1 || value === '1') {
|
|
return true;
|
|
}
|
|
|
|
if (value === false || value === 0 || value === '0') {
|
|
return false;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function parseXtreamExpiration(
|
|
value: number | string | null | undefined
|
|
): number | null {
|
|
if (value === null || value === undefined || value === '') {
|
|
return null;
|
|
}
|
|
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return parsed;
|
|
}
|