diff --git a/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts b/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts index d4fed438a..d5f415239 100644 --- a/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts +++ b/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts @@ -218,6 +218,103 @@ describe('detectProviderImportCandidates', () => { ).toEqual([]); }); + it('reads an email-shaped username and ignores stats lines (corpus #1)', () => { + const candidates = detectProviderImportCandidates( + [ + 'http://lordstreams.live:80', + 'User=mszala@comcast.net', + 'Pass=0323910031', + '30602 channels', + 'Validity 27/12/26', + ].join('\n') + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0]).toMatchObject({ + serverUrl: 'http://lordstreams.live', + username: 'mszala@comcast.net', + password: '0323910031', + }); + }); + + it('reads the bare "URL, token, token" handout shape (corpus #2)', () => { + const candidates = detectProviderImportCandidates( + 'http://alltvmx.com\nVictoria89\nVictoria89' + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0]).toMatchObject({ + confidence: 'low', + serverUrl: 'http://alltvmx.com', + username: 'Victoria89', + password: 'Victoria89', + }); + }); + + it('does not read prose around a lone URL as bare credentials', () => { + expect( + detectProviderImportCandidates( + 'check out http://example.com\nsee the attached guide\nthanks again' + ) + ).toEqual([]); + }); + + it('handles indented labels and numeric credentials (corpus #4)', () => { + const candidates = detectProviderImportCandidates( + 'http://eliplay.onl:8089\nUser: 111\n Pass: 111' + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0]).toMatchObject({ + serverUrl: 'http://eliplay.onl:8089', + username: '111', + password: '111', + }); + }); + + it('prefers the get.php line of a full panel dump (corpus #5)', () => { + const candidates = detectProviderImportCandidates( + [ + '𝙋𝙀𝙧𝙩𝙖𝙑 : http://31.43.191.125', + 'β€’ π˜Ύπ™§π™šπ™–π™©π™šπ™™ : 09/02/2026', + 'β€’ 𝙀𝙭π™₯ : 10/02/2027', + 'β€’ π™π™¨π™šπ™§ : vip6001770656088', + 'β€’ 𝙋𝙖𝙨𝙨 : c394e34a0504', + 'β€’ π˜Ύπ™€π™£π™£ : 1', + 'β€’ π™ˆπ™–π™­π˜Ύπ™€π™£π™£ : 1', + 'β€’ 𝙋𝙀𝙧𝙩 : 8080', + 'β€’ π™Žπ™©π™–π™©π™ͺ𝙨 : # OK βœ…', + 'β€’ πŒπŸ‘π” : http://31.43.191.125:8080/get.php?username=vip6001770656088&password=c394e34a0504&type=m3u', + ].join('\n') + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0]).toMatchObject({ + confidence: 'high', + serverUrl: 'http://31.43.191.125:8080', + username: 'vip6001770656088', + password: 'c394e34a0504', + }); + }); + + it('completes a port-less labeled portal URL with the labeled port', () => { + const candidates = detectProviderImportCandidates( + [ + 'Portal : http://panel.example.io', + 'Port : 8080', + 'User : u1', + 'Pass : p1', + ].join('\n') + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream[0].serverUrl).toBe('http://panel.example.io:8080'); + }); + 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' @@ -426,6 +523,135 @@ describe('detectProviderImportCandidates', () => { }); }); + it('reads a scanner dump with Real/Panel URLs and bold-italic labels (corpus #3)', () => { + const candidates = detectProviderImportCandidates( + [ + 'β”œβ—‹ 𝑹𝒆𝒂𝒍 ➀ http://main.light-ott.net:80/c/', + 'β”œβ—‹ 𝑷𝒂𝒏𝒆𝒍 ➀ http://main.light-ott.net/c/', + 'β”œβ—‹ 𝑴𝒂𝒄 ➀ 00:1A:79:f5:cd:04', + 'β”œβ—‹ 𝑷𝒂𝒏𝒆𝒍 π‘»π’šπ’‘π’† ➀ portal.php', + 'β”œβ—‹ 𝑬𝒙𝒑 ➀ July 26, 2027, 12:41 pm', + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(1); + expect(stalker[0]).toMatchObject({ + confidence: 'high', + portalUrl: 'http://main.light-ott.net:80/c/', + macAddress: '00:1A:79:F5:CD:04', + }); + }); + + it('produces one candidate per MAC of a multi-account list (corpus #6)', () => { + const candidates = detectProviderImportCandidates( + [ + 'http://mag.tivi-one-iptv.net/c/', + '00:1A:79:61:31:34 13/01/2027', + '00:1A:79:9C:E8:78 31/12/2027 (513 Days)', + '00:1A:79:A0:E3:66 18/08/2027 (378 Days)', + '00:1A:79:AD:85:3E 16/08/2027 (377 Days)', + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(4); + expect( + stalker.every( + (candidate) => + candidate.portalUrl === 'http://mag.tivi-one-iptv.net/c/' + ) + ).toBe(true); + expect(stalker.map((candidate) => candidate.macAddress)).toEqual([ + '00:1A:79:61:31:34', + '00:1A:79:9C:E8:78', + '00:1A:79:A0:E3:66', + '00:1A:79:AD:85:3E', + ]); + }); + + it('reads separator-less SN and a decorated ΒΉπŸ’₯Β² dual device ID (corpus #8)', () => { + const deviceHex = + 'AD8D35940A3FA14EA07451780224114F276D0F3BB4427E5351436F60ED6BF0BA'; + const candidates = detectProviderImportCandidates( + [ + 'http://tv.saartv.cc/stalker_portal/c/', + 'Mac 00:1a:79:01:07:06', + '𝐄𝐗𝐏 πƒπšπ­πž 2026-08-07', + 'πŸ”₯ πŸ†…πŸ…ΏοΈπŸ…½ Toronto', + '𝐒𝐍 38415545307A3', + `πƒπžπ―π’πœπžπˆπƒ ΒΉπŸ’₯Β² ${deviceHex}`, + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(1); + expect(stalker[0]).toMatchObject({ + portalUrl: 'http://tv.saartv.cc/stalker_portal/c/', + macAddress: '00:1A:79:01:07:06', + serialNumber: '38415545307A3', + deviceId1: deviceHex, + deviceId2: deviceHex, + }); + }); + + it('ignores scanner footers and status prose around a portal (corpus #9)', () => { + const candidates = detectProviderImportCandidates( + [ + 'β”œβ—‹ 𝑺𝒄𝒂𝒏𝒏𝒆𝒅 π‘©π’š ➀ β˜…β˜†β˜… π“œπ“²π“±π“ͺ𝓳𝓡𝓸 π“˜π“Ώπ“²π“¬ β˜…β˜†β˜…', + 'β”œβ—‹ 𝑺𝒄𝒂𝒏 𝑫𝒂𝒕𝒆 / π‘»π’Šπ’Žπ’† ➀ 11.08.2026. β€’ 11:38:54', + 'β”œβ—‹ 𝑹𝒆𝒂𝒍 ➀ http://kamariato.com:80', + 'β”œβ—‹ 𝑷𝒂𝒏𝒆𝒍 ➀ http://kamariato.com:80/c/', + 'β”œβ—‹ 𝑴𝒂𝒄 ➀ 00:1A:79:f1:6a:9f', + 'β”œβ—‹ 𝑷𝒂𝒏𝒆𝒍 π‘»π’šπ’‘π’† ➀ portal.php', + 'β”œβ—‹ 𝑬𝒙𝒑 ➀ July 12, 2026, 12:00 am -30 Days', + 'β”œβ—‹ 𝑽𝒑𝒏 ➀ π‘π‘œ 𝐢𝑙𝑖𝑒𝑛𝑑 𝐼𝑃 π΄π‘‘π‘‘π‘Ÿπ‘’π‘ π‘ ', + 'β”œβ—‹ π‘°π’Žπ’‚π’ˆπ’† ➀ πΌπ‘›π‘£π‘Žπ‘™π‘–π‘‘ 𝑂𝑝𝑝𝑠', + 'β””β”€β”€βž’ 𝑴𝑨π‘ͺ 𝑷𝑹𝑢 𝑨𝑻𝑳𝑨𝑺', + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(1); + expect(stalker[0]).toMatchObject({ + portalUrl: 'http://kamariato.com:80/c/', + macAddress: '00:1A:79:F1:6A:9F', + }); + expect(only(candidates, 'xtream')).toHaveLength(0); + }); + + it('reads a [+]-prefixed dump with serial, device ID and signature (corpus #11)', () => { + const candidates = detectProviderImportCandidates( + [ + '[+] Panel: http://mag.jee-ott.xyz:80/c', + '[+] Real: mag.jee-ott.xyz', + '[+] Mac: 00:1B:79:48:3A:90', + '[+] Exp.: February 27, 2027, 7:33 pm', + '[+] ᴍᴀᴄ κœ±α΄›α΄€α΄›α΄œκœ±: ᴍᴀᴄ ᴏꜰꜰπŸ₯Ί', + '[+] ᴍ3ᴜ κœ±α΄›α΄€α΄›α΄œκœ±: ᴏꜰꜰʟΙͺΙ΄α΄‡πŸ˜”', + '[+] α΄„Κœα΄€Ι΄Ι΄α΄‡ΚŸCategories: 684', + '[+] m3u_Url: ', + '', + '[+] Serial: 862E1CDB82415', + '[+] Device ID: FA9CBD1857D1D8A57BF1A03542B928CB90779C7CE2673CAC85A5FD4F8B7756E5', + '[+] Signature: A48D3EDCCC09B580CD19A07C8B0A9C44F1D00377CEE9913EE2ADCD858776FD67', + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(1); + expect(stalker[0]).toMatchObject({ + portalUrl: 'http://mag.jee-ott.xyz:80/c', + macAddress: '00:1B:79:48:3A:90', + serialNumber: '862E1CDB82415', + deviceId1: + 'FA9CBD1857D1D8A57BF1A03542B928CB90779C7CE2673CAC85A5FD4F8B7756E5', + signature1: + 'A48D3EDCCC09B580CD19A07C8B0A9C44F1D00377CEE9913EE2ADCD858776FD67', + }); + expect(stalker[0].deviceId2).toBeUndefined(); + }); + it('gives labeled credentials to the stalker candidate, not a competing xtream guess', () => { const candidates = detectProviderImportCandidates( [ diff --git a/libs/shared/interfaces/src/lib/provider-import-detection.util.ts b/libs/shared/interfaces/src/lib/provider-import-detection.util.ts index f4697b6bf..b033d581a 100644 --- a/libs/shared/interfaces/src/lib/provider-import-detection.util.ts +++ b/libs/shared/interfaces/src/lib/provider-import-detection.util.ts @@ -191,6 +191,36 @@ export function detectProviderImportCandidates( ); } + // ── 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') { @@ -226,6 +256,32 @@ function pickStalkerPortalUrl( 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); diff --git a/libs/shared/interfaces/src/lib/provider-import-scan.util.ts b/libs/shared/interfaces/src/lib/provider-import-scan.util.ts index d598e5a1f..95868132d 100644 --- a/libs/shared/interfaces/src/lib/provider-import-scan.util.ts +++ b/libs/shared/interfaces/src/lib/provider-import-scan.util.ts @@ -208,6 +208,16 @@ const FIELD_MATCHERS: FieldMatcher[] = [ ), validate: (value) => value.length <= 64, }, + { + field: 'serialNumber', + // Separator-less handout ("SN 38415545307A3"): allowed only because + // the hex-shaped value cannot be mistaken for prose. + pattern: new RegExp( + LABEL_LOOKBEHIND + + String.raw`(?:serial|s[\s._/-]?n)\s+([0-9A-Fa-f]{8,64})\b`, + 'iu' + ), + }, { field: 'macAddress', // Turkish panels label it "MAC ADRESΔ°" β€” the trailing Δ°/Δ±/i is listed @@ -306,11 +316,23 @@ function classifyUrl(raw: string, parsed: URL): UrlRole { return 'generic'; } -/** "DEVICE ID=> 1&2 " β€” the 1&2 marker may sit before or after the separator. */ +/** + * The "both device IDs" marker: 1 and 2 joined by any symbol run ("1&2", + * "1/2", "1πŸ’₯2" β€” superscript ΒΉΒ² are already folded to 1/2 by NFKC) or by + * plain whitespace. Bare "12" stays a number, not a marker. + */ +const DUAL_MARKER = String.raw`1(?:\s*[^\p{L}\p{N}\s]+\s*|\s+)2`; + +/** + * "DEVICE ID=> 1&2 " β€” the marker may sit before or after the + * separator, and the separator itself may be plain whitespace ("DeviceID + * 1πŸ’₯2 "), which is safe here only because the 16–64-hex value shape + * cannot be prose. + */ const DUAL_DEVICE_ID_PATTERN = new RegExp( LABEL_LOOKBEHIND + String.raw`device[\s_-]*ids?` + - `(?:[\\s_-]*1\\s*[&/+]\\s*2${LABEL_SEPARATOR}|${LABEL_SEPARATOR}1\\s*[&/+]\\s*2\\s+)` + + `(?:[\\s_-]*${DUAL_MARKER}(?:${LABEL_SEPARATOR}|\\s+)|(?:${LABEL_SEPARATOR}|\\s+)${DUAL_MARKER}\\s+)` + String.raw`([0-9A-Fa-f]{16,64})\b`, 'iu' ); @@ -391,6 +413,19 @@ export function labeledHostUrl(labeled: LabeledFields): string | undefined { return undefined; } if (/^https?:\/\//i.test(host)) { + // Panels often hand out "Portal: http://host" and "Port: 8080" as two + // separate lines β€” a separately labeled port completes a port-less URL. + if (labeled.port) { + try { + const url = new URL(host); + if (!url.port) { + url.port = labeled.port; + return url.toString(); + } + } catch { + return host; + } + } return host; } if (!HOSTISH_PATTERN.test(host) || !host.includes('.')) {