fix(playlist): keep IPv6 brackets, honor written default ports, collision-safe dedupe keys

- Cleanup of prose punctuation and bracket quotes now loses to parseability:
  ']' closes both a quote and an IPv6 authority, so a strip that breaks the
  address is rolled back and 'Portal: http://[2001:db8::1]' stays importable.
- The explicit-port check reads the raw authority instead of parsed.port,
  which the WHATWG parser blanks for a protocol default — a written ':80' is
  the user's choice and a labeled port no longer overwrites it.
- Candidate dedupe keys are JSON-serialized, so two accounts whose
  credentials contain the former separator no longer collapse into one.

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-16 08:36:30 +00:00
1 parent 36f340c907
commit 2cea665689
3 files changed
+114 -10

No files matched your search

@@ -331,6 +331,45 @@ describe('detectProviderImportCandidates', () => {
});
});
it('keeps an explicitly written default port over a labeled one', () => {
// The WHATWG parser blanks `:80`, but the user wrote it.
const candidates = detectProviderImportCandidates(
[
'Server: http://primary.example.org:80/player_api.php',
'Port: 8080',
'User: alice',
'Pass: s3cret',
].join('\n')
);
// `:80` is http's default, so the shared normalizer collapses it
// away — what matters is that the labeled 8080 never replaced it.
expect(only(candidates, 'xtream')[0].serverUrl).toBe(
'http://primary.example.org'
);
});
it('keeps distinct accounts whose credentials contain the key separator', () => {
const candidates = detectProviderImportCandidates(
[
'http://panel.example.org/get.php?username=a%7Cb&password=c',
'http://panel.example.org/get.php?username=a&password=b%7Cc',
].join('\n')
);
const xtream = only(candidates, 'xtream');
expect(xtream).toHaveLength(2);
expect(
xtream.map((candidate) => [
candidate.username,
candidate.password,
])
).toEqual([
['a|b', 'c'],
['a', 'b|c'],
]);
});
it('applies a labeled port to an IPv6 endpoint', () => {
// The address is full of colons; none of them is a port.
const candidates = detectProviderImportCandidates(
@@ -480,6 +519,20 @@ describe('detectProviderImportCandidates', () => {
expect(stalker[0].macAddress).toBe('00:1A:79:AA:BB:CC');
});
it('keeps the closing bracket of a root IPv6 portal URL', () => {
// `]` closes the authority here; stripping it as prose would
// leave an address that cannot be parsed or imported.
const candidates = detectProviderImportCandidates(
['Portal: http://[2001:db8::1]', 'MAC: 00:1A:79:12:34:56'].join(
'\n'
)
);
const stalker = only(candidates, 'stalker');
expect(stalker).toHaveLength(1);
expect(stalker[0].portalUrl).toBe('http://[2001:db8::1]');
});
it('proposes a low-confidence stalker candidate for a lone MAC', () => {
const candidates = detectProviderImportCandidates(
'Ваш MAC 00:1A:79:00:11:22 активирован'
@@ -317,13 +317,27 @@ function portalInstallationKey(
* Applied only when the URL itself states no explicit port, so a written
* `:80` is never overridden by the label.
*/
/**
* Whether the URL states a port itself. Read off the RAW authority rather
* than `parsed.port`, which the WHATWG parser blanks for a protocol's default
* (`http://host:80` reports no port) — a written `:80` is still the user's
* explicit choice and must not be overwritten by a labeled one. An IPv6
* literal's own colons are skipped by cutting the bracketed address first.
*/
function hasExplicitPort(raw: string): boolean {
const afterScheme = raw.slice(raw.indexOf('://') + 3);
const authority = afterScheme.split(/[/?#]/, 1)[0];
const afterAddress = authority.startsWith('[')
? authority.slice(authority.indexOf(']') + 1)
: authority;
return /:\d+$/.test(afterAddress);
}
function completeWithLabeledPort(
url: DetectedUrl,
port: string | undefined
): string {
// `parsed.port` rather than a colon in the authority: an IPv6 literal
// (`http://[2001:db8::1]/…`) is full of colons that are address, not port.
if (!port || url.parsed.port !== '') {
if (!port || hasExplicitPort(url.raw)) {
return url.raw;
}
const { protocol, hostname, pathname, search } = url.parsed;
@@ -416,17 +430,20 @@ function dedupe(
): ProviderImportCandidate[] {
const seen = new Set<string>();
return candidates.filter((candidate) => {
const key = [
// JSON rather than a joined string: a credential may legitimately
// contain the separator (URL-encoded in the query), and two different
// accounts must never collapse into one key because of it. Same
// server + username with a DIFFERENT password is a distinct account
// (e.g. a rotation message), so the password is part of the key.
const key = JSON.stringify([
candidate.kind,
candidate.url ?? '',
candidate.serverUrl ?? '',
candidate.username ?? '',
// Same server + username with a DIFFERENT password is a distinct
// account (e.g. a rotation message) — never collapse it away.
candidate.password ?? '',
candidate.portalUrl ?? '',
candidate.macAddress ?? '',
].join('|');
]);
if (seen.has(key)) {
return false;
}
@@ -37,6 +37,31 @@ export interface LabeledFields {
// account entirely.
const URL_PATTERN = /https?:\/\/[^\s<>"'`|]+/gi;
const TRAILING_PUNCTUATION = /[),.;:!?\]»›]+$/;
/**
* Strips sentence punctuation a URL picked up from prose ("see http://x/y.")
* — unless doing so breaks the address itself. `]` is both a closing quote in
* prose and the structural end of an IPv6 authority (`http://[2001:db8::1]`),
* and only parsing can tell the two apart: the stripped form wins whenever it
* still parses, otherwise the original stands. Values with no scheme parse
* either way, so they keep the plain stripped form.
*/
function stripTrailingProsePunctuation(value: string): string {
const stripped = value.replace(TRAILING_PUNCTUATION, '');
if (stripped === value || parsesAsUrl(stripped) || !parsesAsUrl(value)) {
return stripped;
}
return value;
}
function parsesAsUrl(value: string): boolean {
try {
new URL(value);
return true;
} catch {
return false;
}
}
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;
@@ -289,7 +314,7 @@ export function extractUrls(text: string): DetectedUrl[] {
detected.length < MAX_URLS &&
(match = matcher.exec(text)) !== null
) {
const raw = match[0].replace(TRAILING_PUNCTUATION, '');
const raw = stripTrailingProsePunctuation(match[0]);
if (seen.has(raw)) {
continue;
}
@@ -423,7 +448,9 @@ export function labeledHostUrl(labeled: LabeledFields): string | undefined {
// value takes precedence over the scanned one, so leaving `Server:
// http://host!` uncleaned here would hand the forms a hostname DNS can
// never resolve while the sanitized scanned URL sat right beside it.
const host = labeled.host?.replace(TRAILING_PUNCTUATION, '');
const host = labeled.host
? stripTrailingProsePunctuation(labeled.host)
: undefined;
if (!host) {
return undefined;
}
@@ -518,5 +545,12 @@ function firstMatchOutsideSpans(
}
function stripWrapping(value: string): string {
return value.trim().replace(WRAPPING_CHARS, '').trim();
const trimmed = value.trim();
const stripped = trimmed.replace(WRAPPING_CHARS, '').trim();
// Same hazard as the prose-punctuation cleanup: `]` closes both a bracket
// quote and an IPv6 authority, so a strip that breaks the address loses.
if (stripped === trimmed || parsesAsUrl(stripped) || !parsesAsUrl(trimmed)) {
return stripped;
}
return trimmed;
}