Files
iptvnator/libs/shared/interfaces/src/lib/provider-import-detection.util.ts
T
Claude 6e81d7034d fix(playlist): strip panel_api.php endpoints, path-aware portal ambiguity for tenant installs
- normalizeXtreamServerUrl strips panel_api.php like the other API
  endpoints, so a panel_api.php handout (pasted or auto-detected) prefills
  the server base instead of a URL the transport would double-suffix.
- The multi-MAC portal ambiguity key is origin plus the installation base
  path: tenant installs sharing one origin (/a/stalker_portal/c/ vs /b/...)
  no longer collapse, while alternate endpoints of one install (/c/,
  portal.php, server/load.php, Real/Panel pairs) still compare equal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JE1De2dfyRZMYh4H2iGXb4
2026-08-15 19:53:41 +00:00

408 lines
15 KiB
TypeScript

import {
DetectedUrl,
extractLabeledFields,
extractMacAddresses,
extractUrls,
foldDecorativeAlphabets,
LabeledFields,
labeledHostUrl,
} from './provider-import-scan.util';
import {
extractXtreamCredentialsFromUrl,
normalizeXtreamServerUrl,
} from './xtream-portal.utils';
/**
* Deterministic detection of playlist sources inside a pasted provider
* message ("here are your credentials" mails, reseller chat messages, etc.).
*
* Design constraints, in order:
*
* 1. **Every value is a verbatim substring of the NFKC-normalized input**
* (or a canonical form produced by an existing shared normalizer such as
* `normalizeStalkerMacAddress`). Nothing here invents, completes, or
* "fixes" a credential — a password that is almost right is wrong. NFKC
* is applied once up front because reseller messages dress labels in
* Unicode math alphabets (`𝚄𝚂𝙴𝚁➤`, `𝙿𝙰𝚂𝚂➤`) to slip past chat spam
* filters; the folding turns them back into the ASCII they denote, and
* real credentials/URLs are ASCII already, so values pass through it
* unchanged. The one deliberate exception is `labeledHostUrl`, which
* completes a scheme-less labeled host with `http://` and a separately
* labeled port — an assembled value, landing in an editable form field.
* 2. **Detection proposes, it never decides.** The output prefills the
* existing import forms; classification authority stays with the
* behavioral probes those forms already run (portal discovery, Xtream
* status check). URL-shape guessing shipped broken Stalker configs three
* times (#850/#686/#755) — this util must not become a fourth copy.
* 3. Heuristics are ranked by strength: a MAC address is the strongest
* signal (→ Stalker), a `get.php`/`player_api.php` URL is next
* (→ Xtream, credentials ride in the query), a labeled
* username+password pair without a MAC leans Xtream, and `.m3u`
* extensions mean a plain playlist URL.
*
* The low-level scanners live in `provider-import-scan.util.ts`.
*/
export type ProviderImportKind = 'xtream' | 'stalker' | 'm3u-url' | 'm3u-text';
export type ProviderImportConfidence = 'high' | 'medium' | 'low';
export interface ProviderImportCandidate {
kind: ProviderImportKind;
confidence: ProviderImportConfidence;
/** Suggested playlist title — the hostname of the detected source. */
suggestedTitle?: string;
/** `m3u-url`: the playlist link exactly as found in the text. */
url?: string;
/** `m3u-text`: the pasted playlist body itself. */
text?: string;
/** `xtream`: server base URL (API suffix stripped). */
serverUrl?: string;
username?: string;
password?: string;
/** `stalker` */
portalUrl?: string;
macAddress?: string;
serialNumber?: string;
deviceId1?: string;
deviceId2?: string;
signature1?: string;
signature2?: string;
}
const GET_PHP_PATH_PATTERN = /\/get\.php$/i;
const EXTM3U_HEADER_PATTERN = /^\s*#EXTM3U/i;
// DOM-safety guard against pathological pastes, not a business limit: sized
// above the scanners' combined caps (64 MACs + 16 URLs), so every candidate
// a real multi-account handout produces survives the final slice.
const MAX_CANDIDATES = 96;
/**
* Scans a pasted provider message and returns ranked import candidates.
* Pure and synchronous — safe to run on every keystroke.
*/
export function detectProviderImportCandidates(
text: string
): ProviderImportCandidate[] {
const raw = typeof text === 'string' ? text : '';
if (!raw.trim()) {
return [];
}
// A raw playlist body pasted wholesale: every URL inside is a channel
// stream, not a source, so URL mining would only produce noise. Route the
// whole paste to the raw-text import instead — the ORIGINAL bytes, since
// a playlist body is content, not decoration to be folded.
if (EXTM3U_HEADER_PATTERN.test(raw)) {
return [{ kind: 'm3u-text', confidence: 'high', text: raw }];
}
// All scanning runs on one consistently normalized string so label
// positions, URL spans and masking agree with each other. NFKC folds the
// math alphabets; the extra pass folds the squared/negative-circled/
// regional-indicator "font" letters and circled digits NFKC leaves alone.
// Both only touch decorative glyphs — ASCII credentials and URLs, hence
// every emitted value, pass through unchanged.
const normalized = foldDecorativeAlphabets(raw.normalize('NFKC'));
const urls = extractUrls(normalized);
const labeled = extractLabeledFields(normalized, urls);
const macs = extractMacAddresses(normalized, labeled.macAddress);
const candidates: ProviderImportCandidate[] = [];
// ── Stalker: a MAC address is the strongest signal there is. Labeled
// credentials attach here (Stalker portals can require a login too)
// rather than seeding a competing Xtream guess.
//
// Identity fields attach ONLY when the message names exactly one account:
// labeled extraction is first-match-wins over the whole text, so with
// several MACs there is no reliable way to tell whose serial/device ID a
// label belongs to — and a device ID submitted with the wrong MAC is
// pinned by the portal permanently. Multi-MAC candidates therefore carry
// portal + MAC only, and the user supplies identity per account.
// Several MACs meeting portal candidates on DIFFERENT hosts is two
// accounts from two panels in one paste — there is no deterministic way
// to say which MAC belongs to which portal, so those candidates carry the
// MAC alone instead of prefilling half of them with the wrong panel.
// The ambiguity is judged over the POOL `pickStalkerPortalUrl` would
// actually draw from: portal-shaped URLs when any exist, otherwise the
// generic-URL fallback — a guard on the shaped pool alone would let two
// root-URL panels slip through the fallback door. A single labeled
// "Portal:" line, same-host Real/Panel pairs, and one portal shared by a
// whole MAC list stay unambiguous.
const shapedPortals = urls.filter((url) => url.role === 'stalker');
const portalPool =
shapedPortals.length > 0
? shapedPortals
: labeledHostUrl(labeled) !== undefined
? []
: urls.filter((url) => url.role === 'generic');
const portalAmbiguous =
macs.length > 1 &&
new Set(portalPool.map(portalInstallationKey)).size > 1;
const portal = portalAmbiguous
? null
: pickStalkerPortalUrl(urls, labeled);
const identity: LabeledFields = macs.length === 1 ? labeled : {};
for (const macAddress of macs) {
candidates.push(
compact({
kind: 'stalker',
confidence: portal?.confidence ?? 'low',
portalUrl: portal?.url,
macAddress,
serialNumber: identity.serialNumber,
deviceId1: identity.deviceId1,
deviceId2: identity.deviceId2,
signature1: identity.signature1,
signature2: identity.signature2,
username: identity.username,
password: identity.password,
suggestedTitle: hostnameOf(portal?.url),
})
);
}
// ── Xtream from URLs: an API-shaped path, or credentials in the query of
// any non-playlist URL. A `.m3u` link with query credentials stays an M3U
// candidate — its path would corrupt the derived server URL.
for (const url of urls) {
if (url.role === 'm3u' || url.role === 'stalker') {
continue;
}
const creds = extractXtreamCredentialsFromUrl(url.raw);
if (url.role !== 'xtream-api' && !creds) {
continue;
}
const username =
creds?.username ??
(macs.length === 0 ? labeled.username : undefined);
const password =
creds?.password ??
(macs.length === 0 ? labeled.password : undefined);
candidates.push(
compact({
kind: 'xtream',
confidence: username && password ? 'high' : 'medium',
serverUrl: safeXtreamServerUrl(url.raw),
username,
password,
suggestedTitle: url.parsed.hostname,
})
);
// `get.php` doubles as the M3U download endpoint, so the same link is
// honestly offered as a plain playlist too — ranked low because the
// Xtream import unlocks VOD/series where the flat M3U does not.
if (creds && GET_PHP_PATH_PATTERN.test(url.parsed.pathname)) {
candidates.push({
kind: 'm3u-url',
confidence: 'low',
url: url.raw,
suggestedTitle: url.parsed.hostname,
});
}
}
// ── Xtream from labeled credentials alone (username + password with no
// MAC in sight leans Xtream), attached to the best available server URL.
if (
macs.length === 0 &&
labeled.username &&
labeled.password &&
!candidates.some((candidate) => candidate.kind === 'xtream')
) {
const serverUrl =
labeledHostUrl(labeled) ??
urls.find((url) => url.role === 'generic')?.raw;
candidates.push(
compact({
kind: 'xtream',
confidence: serverUrl ? 'medium' : 'low',
serverUrl: serverUrl
? safeXtreamServerUrl(serverUrl)
: undefined,
username: labeled.username,
password: labeled.password,
suggestedTitle: hostnameOf(serverUrl),
})
);
}
// ── Bare handout: exactly "URL, token, token" and nothing else readable.
// "http://host\nVictoria89\nVictoria89" carries no labels at all — the
// shape IS the message (server, then username, then password, in reading
// order). Deliberately the last resort and ranked low: it only fires when
// no label, MAC or API URL produced anything, the single URL is generic,
// and the message contains nothing beyond those three lines, so prose
// cannot be misread as credentials.
if (
macs.length === 0 &&
!labeled.username &&
!labeled.password &&
candidates.length === 0 &&
urls.length === 1 &&
urls[0].role === 'generic'
) {
const tokens = bareCredentialTokens(normalized, urls[0]);
if (tokens) {
candidates.push(
compact({
kind: 'xtream',
confidence: 'low',
serverUrl: safeXtreamServerUrl(urls[0].raw),
username: tokens[0],
password: tokens[1],
suggestedTitle: urls[0].parsed.hostname,
})
);
}
}
// ── Plain playlist links.
for (const url of urls) {
if (url.role !== 'm3u') {
continue;
}
candidates.push({
kind: 'm3u-url',
confidence: 'high',
url: url.raw,
suggestedTitle: url.parsed.hostname,
});
}
return sortByConfidence(dedupe(candidates)).slice(0, MAX_CANDIDATES);
}
/**
* Ambiguity key for a portal candidate: origin plus the installation's base
* path. Origin (not hostname) separates panels on different ports while URL
* normalization drops default ports, so the Real/Panel `:80`-vs-bare pairs
* of scanner dumps compare equal. The base path separates tenant installs
* sharing one origin (`/a/stalker_portal/c/` vs `/b/…`), while the known
* endpoint suffixes of ONE install (`/c/`, `portal.php`, `server/load.php`)
* are stripped so its alternate endpoints reduce to the same key.
*/
function portalInstallationKey(url: DetectedUrl): string {
const base = url.parsed.pathname
.replace(/\/(?:stalker_portal\/)?c\/?$/i, '')
.replace(/\/stalker_portal\/?$/i, '')
.replace(/\/portal\.php$/i, '')
.replace(/\/(?:stalker_portal\/)?server\/load\.php$/i, '')
.replace(/\/+$/, '');
return `${url.parsed.origin}${base}`;
}
function pickStalkerPortalUrl(
urls: DetectedUrl[],
labeled: LabeledFields
): { url: string; confidence: ProviderImportConfidence } | null {
const shaped = urls.find((url) => url.role === 'stalker');
if (shaped) {
return { url: shaped.raw, confidence: 'high' };
}
const labeledUrl = labeledHostUrl(labeled);
if (labeledUrl) {
return { url: labeledUrl, confidence: 'high' };
}
const generic = urls.find((url) => url.role === 'generic');
if (generic) {
return { url: generic.raw, confidence: 'medium' };
}
return null;
}
/**
* For the bare "URL, token, token" shape: returns the two credential-shaped
* tokens when the message consists of exactly three non-empty lines — the
* URL and two single tokens — and nothing else.
*/
function bareCredentialTokens(
text: string,
url: DetectedUrl
): [string, string] | null {
const lines = text
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (lines.length !== 3) {
return null;
}
const tokens = lines.filter((line) => !line.includes(url.raw));
if (tokens.length !== 2) {
return null;
}
const credentialShaped = /^[A-Za-z0-9._@-]{3,64}$/;
return credentialShaped.test(tokens[0]) && credentialShaped.test(tokens[1])
? [tokens[0], tokens[1]]
: null;
}
function safeXtreamServerUrl(raw: string): string | undefined {
try {
return normalizeXtreamServerUrl(raw);
} catch {
return undefined;
}
}
function hostnameOf(url: string | undefined): string | undefined {
if (!url) {
return undefined;
}
try {
return new URL(url).hostname;
} catch {
return undefined;
}
}
/** Drops undefined/empty fields so candidates stay clean to render and diff. */
function compact(candidate: ProviderImportCandidate): ProviderImportCandidate {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(candidate)) {
if (value !== undefined && value !== '') {
result[key] = value;
}
}
return result as unknown as ProviderImportCandidate;
}
function dedupe(
candidates: ProviderImportCandidate[]
): ProviderImportCandidate[] {
const seen = new Set<string>();
return candidates.filter((candidate) => {
const key = [
candidate.kind,
candidate.url ?? '',
candidate.serverUrl ?? '',
candidate.username ?? '',
// Same server + username with a DIFFERENT password is a distinct
// account (e.g. a rotation message) — never collapse it away.
candidate.password ?? '',
candidate.portalUrl ?? '',
candidate.macAddress ?? '',
].join('|');
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
const CONFIDENCE_RANK: Record<ProviderImportConfidence, number> = {
high: 0,
medium: 1,
low: 2,
};
function sortByConfidence(
candidates: ProviderImportCandidate[]
): ProviderImportCandidate[] {
// Array.prototype.sort is stable, so equal-confidence candidates keep
// their assembly order: Stalker (strongest signal) → Xtream → M3U.
return [...candidates].sort(
(a, b) => CONFIDENCE_RANK[a.confidence] - CONFIDENCE_RANK[b.confidence]
);
}