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
This commit is contained in:
Claude committed 2026-08-15 19:53:41 +00:00
1 parent 526e8abc3e
commit 6e81d7034d
3 files changed
+74 -6

No files matched your search

@@ -315,6 +315,20 @@ describe('detectProviderImportCandidates', () => {
expect(xtream[0].serverUrl).toBe('http://panel.example.io:8080');
});
it('strips panel_api.php from the derived server URL', () => {
const candidates = detectProviderImportCandidates(
[
'http://panel.example.org/panel_api.php',
'User: alice',
'Pass: s3cret',
].join('\n')
);
const xtream = only(candidates, 'xtream');
expect(xtream).toHaveLength(1);
expect(xtream[0].serverUrl).toBe('http://panel.example.org');
});
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'
@@ -699,6 +713,42 @@ describe('detectProviderImportCandidates', () => {
}
});
it('treats tenant installs under different base paths as different panels', () => {
const candidates = detectProviderImportCandidates(
[
'http://panel.example.com/a/stalker_portal/c/',
'MAC: 00:1A:79:AA:AA:AA',
'http://panel.example.com/b/stalker_portal/c/',
'MAC: 00:1A:79:BB:BB:BB',
].join('\n')
);
const stalker = only(candidates, 'stalker');
expect(stalker).toHaveLength(2);
for (const candidate of stalker) {
expect(candidate.portalUrl).toBeUndefined();
}
});
it('keeps alternate endpoints of one install unambiguous for a MAC list', () => {
// /c/ and portal.php are two doors into the same installation —
// a MAC list next to both must still get the shared portal.
const candidates = detectProviderImportCandidates(
[
'http://panel.example.com/c/',
'http://panel.example.com/portal.php',
'MAC: 00:1A:79:AA:AA:AA',
'MAC: 00:1A:79:BB:BB:BB',
].join('\n')
);
const stalker = only(candidates, 'stalker');
expect(stalker).toHaveLength(2);
for (const candidate of stalker) {
expect(candidate.portalUrl).toBe('http://panel.example.com/c/');
}
});
it('treats same-host portals on different ports as different panels', () => {
const candidates = detectProviderImportCandidates(
[
@@ -135,13 +135,9 @@ export function detectProviderImportCandidates(
: labeledHostUrl(labeled) !== undefined
? []
: urls.filter((url) => url.role === 'generic');
// Compared by ORIGIN, not hostname: two panels on one DNS name but
// different ports are different panels, while URL normalization drops a
// default port, so the Real/Panel `:80`-vs-bare pairs of scanner dumps
// still compare equal.
const portalAmbiguous =
macs.length > 1 &&
new Set(portalPool.map((url) => url.parsed.origin)).size > 1;
new Set(portalPool.map(portalInstallationKey)).size > 1;
const portal = portalAmbiguous
? null
: pickStalkerPortalUrl(urls, labeled);
@@ -276,6 +272,25 @@ export function detectProviderImportCandidates(
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
@@ -14,7 +14,10 @@ export interface XtreamCredentialsFromUrl {
username: string;
}
const XTREAM_API_ENDPOINT_PATTERN = /\/(?:get|player_api)\.php$/i;
// panel_api.php is the legacy admin-flavored endpoint some panels hand out;
// it identifies an Xtream server just like the other two and must be
// stripped the same way, or the transport would append /player_api.php to it.
const XTREAM_API_ENDPOINT_PATTERN = /\/(?:get|player_api|panel_api)\.php$/i;
// Some Xtream panels challenge generic Node HTTP clients while allowing
// established IPTV players. Keep API, probe, and download requests aligned.