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 708a8f7ae..bd18485ad 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 @@ -315,6 +315,36 @@ describe('detectProviderImportCandidates', () => { expect(xtream[0].serverUrl).toBe('http://panel.example.io:8080'); }); + it('completes a port-less API URL with the separately labeled port', () => { + const candidates = detectProviderImportCandidates( + [ + 'Server: http://panel.example.org/player_api.php', + 'Port: 8080', + 'User: alice', + 'Pass: s3cret', + ].join('\n') + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0].serverUrl).toBe('http://panel.example.org:8080'); + }); + + it('does not let a labeled port override an explicit one', () => { + const candidates = detectProviderImportCandidates( + [ + 'Server: http://panel.example.org:2500/player_api.php', + 'Port: 8080', + 'User: alice', + 'Pass: s3cret', + ].join('\n') + ); + + expect(only(candidates, 'xtream')[0].serverUrl).toBe( + 'http://panel.example.org:2500' + ); + }); + it('strips panel_api.php from the derived server URL', () => { const candidates = detectProviderImportCandidates( [ @@ -713,6 +743,20 @@ describe('detectProviderImportCandidates', () => { } }); + it('completes a port-less shaped portal with the separately labeled port', () => { + const candidates = detectProviderImportCandidates( + [ + 'Portal: http://stb.example.com/c/', + 'Port: 8080', + 'MAC: 00:1A:79:12:34:56', + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(1); + expect(stalker[0].portalUrl).toBe('http://stb.example.com:8080/c/'); + }); + it('treats tenant installs under different base paths as different panels', () => { 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 77efa4021..12c650e7c 100644 --- a/libs/shared/interfaces/src/lib/provider-import-detection.util.ts +++ b/libs/shared/interfaces/src/lib/provider-import-detection.util.ts @@ -178,11 +178,14 @@ export function detectProviderImportCandidates( const password = creds?.password ?? (macs.length === 0 ? labeled.password : undefined); + // A separately labeled "Port:" completes a port-less API URL, same + // as the labeled-host fallback already does. + const completedRaw = completeWithLabeledPort(url, labeled.port); candidates.push( compact({ kind: 'xtream', confidence: username && password ? 'high' : 'medium', - serverUrl: safeXtreamServerUrl(url.raw), + serverUrl: safeXtreamServerUrl(completedRaw), username, password, suggestedTitle: url.parsed.hostname, @@ -195,7 +198,7 @@ export function detectProviderImportCandidates( candidates.push({ kind: 'm3u-url', confidence: 'low', - url: url.raw, + url: completedRaw, suggestedTitle: url.parsed.hostname, }); } @@ -291,13 +294,33 @@ function portalInstallationKey(url: DetectedUrl): string { return `${url.parsed.origin}${base}`; } +/** + * Completes a found URL with a separately labeled port — "Portal: http://x/…" + * plus "Port: 8080" on its own line is a real handout shape (corpus #5). + * Applied only when the URL itself states no explicit port, so a written + * `:80` is never overridden by the label. + */ +function completeWithLabeledPort( + url: DetectedUrl, + port: string | undefined +): string { + if (!port || /^https?:\/\/[^/]*:\d+/i.test(url.raw)) { + return url.raw; + } + const { protocol, hostname, pathname, search } = url.parsed; + return `${protocol}//${hostname}:${port}${pathname}${search}`; +} + 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' }; + return { + url: completeWithLabeledPort(shaped, labeled.port), + confidence: 'high', + }; } const labeledUrl = labeledHostUrl(labeled); if (labeledUrl) { @@ -305,7 +328,10 @@ function pickStalkerPortalUrl( } const generic = urls.find((url) => url.role === 'generic'); if (generic) { - return { url: generic.raw, confidence: 'medium' }; + return { + url: completeWithLabeledPort(generic, labeled.port), + confidence: 'medium', + }; } return null; }