mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
feat(playlist): auto-detect import method that parses pasted provider messages
Adds a deterministic parser (detectProviderImportCandidates in libs/shared/interfaces) that scans a pasted provider message for URLs, MAC addresses, and labeled credentials/identity fields (username, password, serial, device IDs, signatures), classifies the source as Xtream, Stalker, or an M3U link/body, and returns ranked candidates. Every extracted value is a verbatim substring or a canonical form from an existing shared normalizer; detection only proposes — the existing forms and their behavioral probes (portal discovery, connection test) stay authoritative. The Add playlist dialog gains a sixth "Auto-detect" method with a textarea that runs the parser on every edit and renders candidate cards; picking one switches to the matching import form with the fields prefilled. Detection runs fully locally; pasted text never leaves the app. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JE1De2dfyRZMYh4H2iGXb4
This commit is contained in:
35 files changed
+1883
-48
No files matched your search
@@ -35,6 +35,7 @@ export * from './lib/portal-activity-item.interface';
|
||||
export * from './lib/portal-debug.interface';
|
||||
export * from './lib/playlist-display-label.util';
|
||||
export * from './lib/portal-playback.interface';
|
||||
export * from './lib/provider-import-detection.util';
|
||||
export * from './lib/provider-overview.util';
|
||||
export * from './lib/random-id.util';
|
||||
export * from './lib/security-policy-error.utils';
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import {
|
||||
detectProviderImportCandidates,
|
||||
ProviderImportCandidate,
|
||||
} from './provider-import-detection.util';
|
||||
|
||||
describe('detectProviderImportCandidates', () => {
|
||||
const only = (
|
||||
candidates: ProviderImportCandidate[],
|
||||
kind: ProviderImportCandidate['kind']
|
||||
) => candidates.filter((candidate) => candidate.kind === kind);
|
||||
|
||||
it('returns nothing for empty or blank input', () => {
|
||||
expect(detectProviderImportCandidates('')).toEqual([]);
|
||||
expect(detectProviderImportCandidates(' \n\t ')).toEqual([]);
|
||||
expect(
|
||||
detectProviderImportCandidates(undefined as unknown as string)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns nothing for prose without credentials or links', () => {
|
||||
expect(
|
||||
detectProviderImportCandidates(
|
||||
'Hello! Thanks for your purchase, enjoy the service.'
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
describe('xtream detection', () => {
|
||||
it('detects a get.php URL and mines the credentials from its query', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'Your playlist: http://tv.example.com:8080/get.php?username=alice&password=s3cret&type=m3u_plus&output=ts'
|
||||
);
|
||||
|
||||
const xtream = only(candidates, 'xtream');
|
||||
expect(xtream).toHaveLength(1);
|
||||
expect(xtream[0]).toMatchObject({
|
||||
confidence: 'high',
|
||||
serverUrl: 'http://tv.example.com:8080',
|
||||
username: 'alice',
|
||||
password: 's3cret',
|
||||
suggestedTitle: 'tv.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('offers a get.php link with credentials as a low-ranked plain M3U too', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'http://tv.example.com:8080/get.php?username=alice&password=s3cret&type=m3u_plus'
|
||||
);
|
||||
|
||||
const m3u = only(candidates, 'm3u-url');
|
||||
expect(m3u).toHaveLength(1);
|
||||
expect(m3u[0].confidence).toBe('low');
|
||||
expect(m3u[0].url).toBe(
|
||||
'http://tv.example.com:8080/get.php?username=alice&password=s3cret&type=m3u_plus'
|
||||
);
|
||||
});
|
||||
|
||||
it('combines a bare player_api.php URL with labeled credentials', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'Server: http://panel.example.org/player_api.php',
|
||||
'Username: bob',
|
||||
'Password: hunter2',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const xtream = only(candidates, 'xtream');
|
||||
expect(xtream).toHaveLength(1);
|
||||
expect(xtream[0]).toMatchObject({
|
||||
confidence: 'high',
|
||||
serverUrl: 'http://panel.example.org',
|
||||
username: 'bob',
|
||||
password: 'hunter2',
|
||||
});
|
||||
});
|
||||
|
||||
it('leans xtream for labeled username+password with a labeled host and port', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'Host: super.tv',
|
||||
'Port: 8080',
|
||||
'User: carol',
|
||||
'Pass: pa55w0rd',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const xtream = only(candidates, 'xtream');
|
||||
expect(xtream).toHaveLength(1);
|
||||
expect(xtream[0]).toMatchObject({
|
||||
confidence: 'medium',
|
||||
serverUrl: 'http://super.tv:8080',
|
||||
username: 'carol',
|
||||
password: 'pa55w0rd',
|
||||
suggestedTitle: 'super.tv',
|
||||
});
|
||||
});
|
||||
|
||||
it('still proposes xtream from a credential pair with no server at all', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'login: dave\nпароль: qwerty123'
|
||||
);
|
||||
|
||||
const xtream = only(candidates, 'xtream');
|
||||
expect(xtream).toHaveLength(1);
|
||||
expect(xtream[0].confidence).toBe('low');
|
||||
expect(xtream[0].username).toBe('dave');
|
||||
expect(xtream[0].password).toBe('qwerty123');
|
||||
expect(xtream[0].serverUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('understands Russian labels', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'Сервер: http://ru.example.net:2500',
|
||||
'Логин: ivan',
|
||||
'Пароль: secret42',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const xtream = only(candidates, 'xtream');
|
||||
expect(xtream).toHaveLength(1);
|
||||
expect(xtream[0]).toMatchObject({
|
||||
serverUrl: 'http://ru.example.net:2500',
|
||||
username: 'ivan',
|
||||
password: 'secret42',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats credentials in the query of a generic URL as xtream', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'http://portal.example.io/api?username=eve&password=pw&foo=bar'
|
||||
);
|
||||
|
||||
const xtream = only(candidates, 'xtream');
|
||||
expect(xtream).toHaveLength(1);
|
||||
expect(xtream[0].username).toBe('eve');
|
||||
expect(xtream[0].password).toBe('pw');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stalker detection', () => {
|
||||
it('pairs a MAC with a portal-shaped URL at high confidence', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'Portal: http://stb.example.com/stalker_portal/c/',
|
||||
'MAC: 00:1A:79:12:34:56',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker).toHaveLength(1);
|
||||
expect(stalker[0]).toMatchObject({
|
||||
confidence: 'high',
|
||||
portalUrl: 'http://stb.example.com/stalker_portal/c/',
|
||||
macAddress: '00:1A:79:12:34:56',
|
||||
suggestedTitle: 'stb.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts /c/ and portal.php shaped URLs as portals', () => {
|
||||
for (const url of [
|
||||
'http://p.example.com/c/',
|
||||
'http://p.example.com/portal.php',
|
||||
'http://p.example.com/server/load.php',
|
||||
]) {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
`${url}\n00-1A-79-AB-CD-EF`
|
||||
);
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker[0]?.portalUrl).toBe(url);
|
||||
expect(stalker[0]?.confidence).toBe('high');
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to a generic URL at medium confidence', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'http://somehost.example.net/tv\nmac: 00:1a:79:aa:bb:cc'
|
||||
);
|
||||
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker).toHaveLength(1);
|
||||
expect(stalker[0].confidence).toBe('medium');
|
||||
expect(stalker[0].portalUrl).toBe('http://somehost.example.net/tv');
|
||||
expect(stalker[0].macAddress).toBe('00:1A:79:AA:BB:CC');
|
||||
});
|
||||
|
||||
it('proposes a low-confidence stalker candidate for a lone MAC', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'Ваш MAC 00:1A:79:00:11:22 активирован'
|
||||
);
|
||||
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker).toHaveLength(1);
|
||||
expect(stalker[0].confidence).toBe('low');
|
||||
expect(stalker[0].portalUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('recognizes a bare Infomir MAC but not a bare foreign 12-hex run', () => {
|
||||
const infomir = detectProviderImportCandidates('mac 001a79deadbe');
|
||||
expect(only(infomir, 'stalker')).toHaveLength(1);
|
||||
expect(only(infomir, 'stalker')[0].macAddress).toBe(
|
||||
'00:1A:79:DE:AD:BE'
|
||||
);
|
||||
|
||||
const foreign = detectProviderImportCandidates(
|
||||
'token aabbccddeeff issued'
|
||||
);
|
||||
expect(only(foreign, 'stalker')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts a labeled non-Infomir MAC', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'MAC: A0:B1:C2:D3:E4:F5\nhttp://p.example.com/c/'
|
||||
);
|
||||
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker).toHaveLength(1);
|
||||
expect(stalker[0].macAddress).toBe('A0:B1:C2:D3:E4:F5');
|
||||
});
|
||||
|
||||
it('captures serial, device IDs and signatures', () => {
|
||||
const deviceId1 = 'a'.repeat(64);
|
||||
const deviceId2 = 'b'.repeat(64);
|
||||
const signature1 = 'c'.repeat(64);
|
||||
const signature2 = 'd'.repeat(64);
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'Portal: http://stb.example.com/c/',
|
||||
'MAC: 00:1A:79:12:34:56',
|
||||
'Serial Number: 0123456789AB',
|
||||
`Device ID: ${deviceId1}`,
|
||||
`Device ID 2: ${deviceId2}`,
|
||||
`Signature: ${signature1}`,
|
||||
`Signature 2: ${signature2}`,
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker[0]).toMatchObject({
|
||||
serialNumber: '0123456789AB',
|
||||
deviceId1,
|
||||
deviceId2,
|
||||
signature1,
|
||||
signature2,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-hex device IDs and signatures instead of guessing', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'MAC: 00:1A:79:12:34:56',
|
||||
'Device ID: see-attachment',
|
||||
'Signature: n/a',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker[0].deviceId1).toBeUndefined();
|
||||
expect(stalker[0].signature1).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not read a MAC out of a 64-hex device ID blob', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
`device id: ${'001a79'.repeat(10)}aabb`
|
||||
);
|
||||
|
||||
expect(only(candidates, 'stalker')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('produces one candidate per MAC when several are handed out', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'http://multi.example.com/c/',
|
||||
'00:1A:79:11:11:11',
|
||||
'00:1A:79:22:22:22',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker).toHaveLength(2);
|
||||
expect(stalker.map((candidate) => candidate.macAddress)).toEqual([
|
||||
'00:1A:79:11:11:11',
|
||||
'00:1A:79:22:22:22',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives labeled credentials to the stalker candidate, not a competing xtream guess', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'Portal: http://stb.example.com/c/',
|
||||
'MAC: 00:1A:79:12:34:56',
|
||||
'Login: stbuser',
|
||||
'Password: stbpass',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
expect(only(candidates, 'xtream')).toHaveLength(0);
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker[0].username).toBe('stbuser');
|
||||
expect(stalker[0].password).toBe('stbpass');
|
||||
});
|
||||
});
|
||||
|
||||
describe('m3u detection', () => {
|
||||
it('detects .m3u and .m3u8 links at high confidence', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'List 1: https://lists.example.com/main.m3u\nList 2: https://lists.example.com/backup.m3u8'
|
||||
);
|
||||
|
||||
const m3u = only(candidates, 'm3u-url');
|
||||
expect(m3u).toHaveLength(2);
|
||||
expect(m3u.every((c) => c.confidence === 'high')).toBe(true);
|
||||
expect(m3u[0].url).toBe('https://lists.example.com/main.m3u');
|
||||
expect(m3u[0].suggestedTitle).toBe('lists.example.com');
|
||||
});
|
||||
|
||||
it('strips trailing punctuation from a link found in prose', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'Your playlist (https://lists.example.com/main.m3u).'
|
||||
);
|
||||
|
||||
expect(only(candidates, 'm3u-url')[0].url).toBe(
|
||||
'https://lists.example.com/main.m3u'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not turn an .m3u link with query credentials into an xtream server', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'https://lists.example.com/files/list.m3u8?username=u&password=p'
|
||||
);
|
||||
|
||||
expect(only(candidates, 'xtream')).toHaveLength(0);
|
||||
expect(only(candidates, 'm3u-url')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('routes a pasted playlist body to the raw-text import', () => {
|
||||
const body =
|
||||
'#EXTM3U\n#EXTINF:-1 tvg-id="one",Channel One\nhttp://streams.example.com/1.ts';
|
||||
const candidates = detectProviderImportCandidates(body);
|
||||
|
||||
expect(candidates).toEqual([
|
||||
{ kind: 'm3u-text', confidence: 'high', text: body },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed messages and ranking', () => {
|
||||
it('surfaces every source found in a combined provider message', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'Xtream: http://x.example.com:8080/get.php?username=u1&password=p1',
|
||||
'Backup M3U: http://x.example.com:8080/files/backup.m3u',
|
||||
'Stalker portal: http://s.example.com/c/',
|
||||
'MAC: 00:1A:79:99:88:77',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
expect(only(candidates, 'stalker')).toHaveLength(1);
|
||||
expect(only(candidates, 'xtream')).toHaveLength(1);
|
||||
expect(only(candidates, 'm3u-url').length).toBeGreaterThanOrEqual(1);
|
||||
// High-confidence candidates sort ahead of low-confidence ones.
|
||||
const ranks = candidates.map((candidate) => candidate.confidence);
|
||||
const firstLow = ranks.indexOf('low');
|
||||
const lastHigh = ranks.lastIndexOf('high');
|
||||
expect(firstLow === -1 || lastHigh < firstLow).toBe(true);
|
||||
});
|
||||
|
||||
it('dedupes an URL pasted twice', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'https://lists.example.com/main.m3u\nhttps://lists.example.com/main.m3u'
|
||||
);
|
||||
|
||||
expect(only(candidates, 'm3u-url')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('caps the number of returned candidates', () => {
|
||||
const urls = Array.from(
|
||||
{ length: 12 },
|
||||
(_, index) => `https://lists.example.com/list-${index}.m3u`
|
||||
).join('\n');
|
||||
|
||||
expect(
|
||||
detectProviderImportCandidates(urls).length
|
||||
).toBeLessThanOrEqual(6);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import {
|
||||
DetectedUrl,
|
||||
extractLabeledFields,
|
||||
extractMacAddresses,
|
||||
extractUrls,
|
||||
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 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.
|
||||
* 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;
|
||||
const MAX_CANDIDATES = 6;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
if (EXTM3U_HEADER_PATTERN.test(raw)) {
|
||||
return [{ kind: 'm3u-text', confidence: 'high', text: raw }];
|
||||
}
|
||||
|
||||
const urls = extractUrls(raw);
|
||||
const labeled = extractLabeledFields(raw, urls);
|
||||
const macs = extractMacAddresses(raw, 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.
|
||||
const portal = pickStalkerPortalUrl(urls, labeled);
|
||||
for (const macAddress of macs) {
|
||||
candidates.push(
|
||||
compact({
|
||||
kind: 'stalker',
|
||||
confidence: portal?.confidence ?? 'low',
|
||||
portalUrl: portal?.url,
|
||||
macAddress,
|
||||
serialNumber: labeled.serialNumber,
|
||||
deviceId1: labeled.deviceId1,
|
||||
deviceId2: labeled.deviceId2,
|
||||
signature1: labeled.signature1,
|
||||
signature2: labeled.signature2,
|
||||
username: labeled.username,
|
||||
password: labeled.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),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 ?? '',
|
||||
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]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { normalizeStalkerMacAddress } from './stalker-mac-address.util';
|
||||
import { isFullStalkerPortalUrl } from './stalker-portal-mode.util';
|
||||
|
||||
/**
|
||||
* Low-level scanners for the provider-message auto-detect
|
||||
* (`provider-import-detection.util.ts` assembles their findings into
|
||||
* candidates). Everything here returns verbatim substrings of the input, or
|
||||
* canonical forms produced by the existing shared normalizers.
|
||||
*/
|
||||
|
||||
export type UrlRole = 'xtream-api' | 'm3u' | 'stalker' | 'generic';
|
||||
|
||||
export interface DetectedUrl {
|
||||
/** Exact substring as found, trailing punctuation stripped. */
|
||||
raw: string;
|
||||
parsed: URL;
|
||||
role: UrlRole;
|
||||
}
|
||||
|
||||
export interface LabeledFields {
|
||||
username?: string;
|
||||
password?: string;
|
||||
/** Raw value of a server/portal/host/url label — URL- or host-shaped. */
|
||||
host?: string;
|
||||
port?: string;
|
||||
macAddress?: string;
|
||||
serialNumber?: string;
|
||||
deviceId1?: string;
|
||||
deviceId2?: string;
|
||||
signature1?: string;
|
||||
signature2?: string;
|
||||
}
|
||||
|
||||
const URL_PATTERN = /https?:\/\/[^\s<>"'`]+/gi;
|
||||
const TRAILING_PUNCTUATION = /[),.;:!?\]»›]+$/;
|
||||
const M3U_PATH_PATTERN = /\.m3u8?$/i;
|
||||
const XTREAM_API_PATH_PATTERN = /\/(?:get|player_api|panel_api)\.php$/i;
|
||||
const STALKER_PATH_PATTERN = /\/(?:portal\.php|c\/?)$/i;
|
||||
|
||||
// Uniform-separator MAC (00:1A:79:AA:BB:CC, hyphens or dots alike). The
|
||||
// backreference rejects mixed separators, and because `\b` never sits between
|
||||
// two word characters a MAC-shaped window inside a longer hex blob (a 64-hex
|
||||
// device ID) cannot match.
|
||||
const SEPARATED_MAC_PATTERN = /\b[0-9A-F]{2}([:.-])[0-9A-F]{2}(?:\1[0-9A-F]{2}){4}\b/gi;
|
||||
// A bare 12-hex run is only trusted as a MAC when it carries Infomir's OUI —
|
||||
// any 12 hex digits would false-positive on ids, tokens and hashes.
|
||||
const BARE_INFOMIR_MAC_PATTERN = /\b001A79[0-9A-F]{6}\b/gi;
|
||||
|
||||
/** Hex-shaped identity values (device IDs / signatures are SHA-256 hex). */
|
||||
const HEX_IDENTITY_PATTERN = /^[0-9A-Fa-f]{16,64}$/;
|
||||
const HOSTISH_PATTERN = /^[a-z0-9][a-z0-9.-]*(?::\d{2,5})?$/i;
|
||||
|
||||
const WRAPPING_CHARS = /^["'`«<([]+|["'`»>)\]]+$/g;
|
||||
|
||||
const MAX_URLS = 8;
|
||||
const MAX_MACS = 4;
|
||||
|
||||
interface FieldMatcher {
|
||||
field: keyof LabeledFields;
|
||||
pattern: RegExp;
|
||||
validate?: (value: string) => boolean;
|
||||
}
|
||||
|
||||
// Order matters only for readability — each matcher targets its own field and
|
||||
// the numbered variants ("device id 2") are structured so the unnumbered
|
||||
// pattern cannot swallow them (its optional `1` never matches a literal `2`,
|
||||
// and the required `:`/`=` separator then fails on the digit).
|
||||
//
|
||||
// All matchers require an explicit `:` or `=` separator. Provider messages
|
||||
// overwhelmingly use "Label: value"; a bare "login example" in prose is
|
||||
// exactly the false positive the separator rule exists to reject. The label
|
||||
// vocabulary is deliberately small (English + Russian) until a corpus of
|
||||
// real provider messages justifies growing it.
|
||||
const FIELD_MATCHERS: FieldMatcher[] = [
|
||||
{
|
||||
field: 'deviceId2',
|
||||
pattern:
|
||||
/(?<![\p{L}\p{N}_])device[\s_-]*id[\s_-]*2\s*[:=]\s*([^\s,;|]+)/iu,
|
||||
validate: (value) => HEX_IDENTITY_PATTERN.test(value),
|
||||
},
|
||||
{
|
||||
field: 'deviceId1',
|
||||
pattern:
|
||||
/(?<![\p{L}\p{N}_])device[\s_-]*id[\s_-]*1?\s*[:=]\s*([^\s,;|]+)/iu,
|
||||
validate: (value) => HEX_IDENTITY_PATTERN.test(value),
|
||||
},
|
||||
{
|
||||
field: 'signature2',
|
||||
pattern:
|
||||
/(?<![\p{L}\p{N}_])sig(?:nature)?[\s_-]*2\s*[:=]\s*([^\s,;|]+)/iu,
|
||||
validate: (value) => HEX_IDENTITY_PATTERN.test(value),
|
||||
},
|
||||
{
|
||||
field: 'signature1',
|
||||
pattern:
|
||||
/(?<![\p{L}\p{N}_])sig(?:nature)?[\s_-]*1?\s*[:=]\s*([^\s,;|]+)/iu,
|
||||
validate: (value) => HEX_IDENTITY_PATTERN.test(value),
|
||||
},
|
||||
{
|
||||
field: 'serialNumber',
|
||||
pattern:
|
||||
/(?<![\p{L}\p{N}_])(?:serial[\s_-]*(?:number|no)?|s\/n|sn|серийный(?:[\s_-]*номер)?)\s*[:=]\s*([^\s,;|]+)/iu,
|
||||
validate: (value) => value.length <= 64,
|
||||
},
|
||||
{
|
||||
field: 'macAddress',
|
||||
pattern:
|
||||
/(?<![\p{L}\p{N}_])(?:mac(?:[\s_-]*address)?|мак(?:[\s_-]*адрес)?)\s*[:=]\s*([0-9A-Fa-f:. -]{12,23})/iu,
|
||||
validate: (value) => normalizeStalkerMacAddress(value) !== null,
|
||||
},
|
||||
{
|
||||
field: 'username',
|
||||
pattern:
|
||||
/(?<![\p{L}\p{N}_])(?:user[\s_-]*name|login|user|логин|пользователь)\s*[:=]\s*([^\s,;|]+)/iu,
|
||||
},
|
||||
{
|
||||
field: 'password',
|
||||
pattern:
|
||||
/(?<![\p{L}\p{N}_])(?:pass[\s_-]*word|pass|pwd|пароль)\s*[:=]\s*([^\s,;|]+)/iu,
|
||||
},
|
||||
{
|
||||
field: 'host',
|
||||
pattern:
|
||||
/(?<![\p{L}\p{N}_])(?:server|portal|host|dns|url|сервер|портал|хост|адрес)(?:[\s_-]*(?:url|address|адрес))?\s*[:=]\s*([^\s,;|]+)/iu,
|
||||
},
|
||||
{
|
||||
field: 'port',
|
||||
pattern: /(?<![\p{L}\p{N}_])(?:port|порт)\s*[:=]\s*(\d{2,5})\b/iu,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* `String.prototype.matchAll` equivalent — the web build's TS lib target
|
||||
* predates it. Works on a fresh RegExp copy so the module-level patterns
|
||||
* never carry `lastIndex` state between calls.
|
||||
*/
|
||||
function allMatches(pattern: RegExp, text: string): RegExpExecArray[] {
|
||||
const matcher = new RegExp(pattern.source, pattern.flags);
|
||||
const matches: RegExpExecArray[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = matcher.exec(text)) !== null) {
|
||||
matches.push(match);
|
||||
if (match[0] === '') {
|
||||
matcher.lastIndex += 1;
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function extractUrls(text: string): DetectedUrl[] {
|
||||
const detected: DetectedUrl[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const match of allMatches(URL_PATTERN, text)) {
|
||||
const raw = match[0].replace(TRAILING_PUNCTUATION, '');
|
||||
if (seen.has(raw)) {
|
||||
continue;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
seen.add(raw);
|
||||
detected.push({ raw, parsed, role: classifyUrl(raw, parsed) });
|
||||
if (detected.length >= MAX_URLS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return detected;
|
||||
}
|
||||
|
||||
function classifyUrl(raw: string, parsed: URL): UrlRole {
|
||||
if (M3U_PATH_PATTERN.test(parsed.pathname)) {
|
||||
return 'm3u';
|
||||
}
|
||||
if (XTREAM_API_PATH_PATTERN.test(parsed.pathname)) {
|
||||
return 'xtream-api';
|
||||
}
|
||||
if (
|
||||
isFullStalkerPortalUrl(raw) ||
|
||||
STALKER_PATH_PATTERN.test(parsed.pathname)
|
||||
) {
|
||||
return 'stalker';
|
||||
}
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
export function extractLabeledFields(
|
||||
text: string,
|
||||
urls: DetectedUrl[]
|
||||
): LabeledFields {
|
||||
// Query strings ARE label-shaped (`?username=u&password=p`), so labeled
|
||||
// extraction must never look inside a URL: credentials that ride in a
|
||||
// query are mined by `extractXtreamCredentialsFromUrl` with proper URL
|
||||
// decoding, and reading them as plain labels would truncate at `&`. Every
|
||||
// matcher therefore runs against a copy with URL spans blanked out — with
|
||||
// one exception: the host/server label legitimately points AT a URL
|
||||
// ("Portal: http://…"), so it reads the raw text, accepting only matches
|
||||
// whose label starts outside every URL span (`&url=…` inside a query
|
||||
// stays rejected).
|
||||
const spans = urlSpans(text, urls);
|
||||
const masked = maskSpans(text, spans);
|
||||
const fields: LabeledFields = {};
|
||||
for (const { field, pattern, validate } of FIELD_MATCHERS) {
|
||||
if (fields[field] !== undefined) {
|
||||
continue;
|
||||
}
|
||||
const match = pattern.exec(field === 'host' ? text : masked);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
if (field === 'host' && isInsideSpan(spans, match.index)) {
|
||||
continue;
|
||||
}
|
||||
const value = stripWrapping(match[1] ?? '');
|
||||
if (!value || (validate && !validate(value))) {
|
||||
continue;
|
||||
}
|
||||
fields[field] = value;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function extractMacAddresses(
|
||||
text: string,
|
||||
labeledMac: string | undefined
|
||||
): string[] {
|
||||
const macs: string[] = [];
|
||||
const push = (value: string | null) => {
|
||||
if (value && !macs.includes(value)) {
|
||||
macs.push(value);
|
||||
}
|
||||
};
|
||||
push(normalizeStalkerMacAddress(labeledMac));
|
||||
for (const match of allMatches(SEPARATED_MAC_PATTERN, text)) {
|
||||
push(normalizeStalkerMacAddress(match[0]));
|
||||
}
|
||||
for (const match of allMatches(BARE_INFOMIR_MAC_PATTERN, text)) {
|
||||
push(normalizeStalkerMacAddress(match[0]));
|
||||
}
|
||||
return macs.slice(0, MAX_MACS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a labeled server/host value into an http URL. A scheme-less host is
|
||||
* completed with `http://` and a separately labeled port — the one place a
|
||||
* value is assembled rather than found verbatim, and it lands in an editable
|
||||
* form field, never on the wire directly.
|
||||
*/
|
||||
export function labeledHostUrl(labeled: LabeledFields): string | undefined {
|
||||
const host = labeled.host;
|
||||
if (!host) {
|
||||
return undefined;
|
||||
}
|
||||
if (/^https?:\/\//i.test(host)) {
|
||||
return host;
|
||||
}
|
||||
if (!HOSTISH_PATTERN.test(host) || !host.includes('.')) {
|
||||
return undefined;
|
||||
}
|
||||
const portSuffix =
|
||||
host.includes(':') || !labeled.port ? '' : `:${labeled.port}`;
|
||||
return `http://${host}${portSuffix}`;
|
||||
}
|
||||
|
||||
function urlSpans(
|
||||
text: string,
|
||||
urls: DetectedUrl[]
|
||||
): Array<[number, number]> {
|
||||
const spans: Array<[number, number]> = [];
|
||||
for (const url of urls) {
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const index = text.indexOf(url.raw, from);
|
||||
if (index === -1) {
|
||||
break;
|
||||
}
|
||||
spans.push([index, index + url.raw.length]);
|
||||
from = index + url.raw.length;
|
||||
}
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
function maskSpans(text: string, spans: Array<[number, number]>): string {
|
||||
if (spans.length === 0) {
|
||||
return text;
|
||||
}
|
||||
const chars = text.split('');
|
||||
for (const [start, end] of spans) {
|
||||
for (let index = start; index < end; index += 1) {
|
||||
chars[index] = ' ';
|
||||
}
|
||||
}
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
function isInsideSpan(
|
||||
spans: Array<[number, number]>,
|
||||
index: number
|
||||
): boolean {
|
||||
return spans.some(([start, end]) => index > start && index < end);
|
||||
}
|
||||
|
||||
function stripWrapping(value: string): string {
|
||||
return value.trim().replace(WRAPPING_CHARS, '').trim();
|
||||
}
|
||||
Reference in new issue
Block a user