fix(playlist): apply separately labeled ports to detected API and portal URLs

'Portal: http://host/…' plus 'Port: 8080' on its own line is a real handout
shape; the labeled port now completes port-less Xtream API URLs, shaped
Stalker portals, and the generic-URL fallback alike. A URL stating its own
port explicitly is never overridden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JE1De2dfyRZMYh4H2iGXb4
This commit is contained in:
Claude committed 2026-08-15 20:21:21 +00:00
1 parent 6e81d7034d
commit f3723eb8e3
2 files changed
+74 -4

No files matched your search

@@ -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(
[
@@ -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;
}