fix(playlist): omit ambiguous portals for multi-MAC pastes, align scanner and candidate caps

- Several MACs meeting portal-shaped URLs on different hosts now yield
  MAC-only candidates: there is no deterministic owner for either portal, and
  prefilling half the accounts with the wrong panel only fails later at
  authentication. Same-host Real/Panel pairs and one portal shared by a MAC
  list stay unambiguous.
- The URL scanner cap is raised to match the candidate cap (16), so a
  nine-link handout is no longer truncated to eight before assembly.

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 10:30:24 +00:00
1 parent 98c3b442f0
commit 1d599322f4
3 files changed
+48 -9

No files matched your search

@@ -678,6 +678,27 @@ describe('detectProviderImportCandidates', () => {
}
});
it('omits the portal when several MACs meet portals on different hosts', () => {
// Two panels + two MACs in one paste: no deterministic owner for
// either portal, so the candidates carry the MAC alone instead of
// prefilling half of them with the wrong panel.
const candidates = detectProviderImportCandidates(
[
'http://panel-a.example.com/c/',
'MAC: 00:1A:79:AA:AA:AA',
'http://panel-b.example.com/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();
expect(candidate.confidence).toBe('low');
}
});
it('surfaces every MAC of a long multi-account list', () => {
const macs = Array.from(
{ length: 6 },
@@ -779,15 +800,18 @@ describe('detectProviderImportCandidates', () => {
expect(only(candidates, 'm3u-url')).toHaveLength(1);
});
it('caps the number of returned candidates', () => {
const urls = Array.from(
it('surfaces every link of a long list and caps only pathological pastes', () => {
const twelve = Array.from(
{ length: 12 },
(_, index) => `https://lists.example.com/list-${index}.m3u`
).join('\n');
expect(detectProviderImportCandidates(twelve)).toHaveLength(12);
expect(
detectProviderImportCandidates(urls).length
).toBeLessThanOrEqual(16);
const twenty = Array.from(
{ length: 20 },
(_, index) => `https://lists.example.com/list-${index}.m3u`
).join('\n');
expect(detectProviderImportCandidates(twenty)).toHaveLength(16);
});
});
});
@@ -115,7 +115,21 @@ export function detectProviderImportCandidates(
// label belongs to — and a device ID submitted with the wrong MAC is
// pinned by the portal permanently. Multi-MAC candidates therefore carry
// portal + MAC only, and the user supplies identity per account.
const portal = pickStalkerPortalUrl(urls, labeled);
// Several MACs meeting portal-shaped URLs on DIFFERENT hosts is two
// accounts from two panels in one paste — there is no deterministic way
// to say which MAC belongs to which portal, so those candidates carry the
// MAC alone instead of prefilling half of them with the wrong panel.
// Same-host URL pairs (the Real/Panel lines of scanner dumps) stay
// unambiguous, as does one portal shared by a whole MAC list.
const stalkerHosts = new Set(
urls
.filter((url) => url.role === 'stalker')
.map((url) => url.parsed.hostname)
);
const portalAmbiguous = macs.length > 1 && stalkerHosts.size > 1;
const portal = portalAmbiguous
? null
: pickStalkerPortalUrl(urls, labeled);
const identity: LabeledFields = macs.length === 1 ? labeled : {};
for (const macAddress of macs) {
candidates.push(
@@ -52,9 +52,10 @@ const HOSTISH_PATTERN = /^[a-z0-9][a-z0-9.-]*(?::\d{2,5})?$/i;
const WRAPPING_CHARS = /^["'`«<([]+|["'`»>)\]]+$/g;
const MAX_URLS = 8;
// High enough that a real multi-account handout is never silently truncated;
// only a pathological paste (hex dumps, logs) hits it.
// Both caps guard against pathological pastes only, and stay aligned with
// the assembler's candidate cap so a real multi-link or multi-account
// handout is never truncated by the scanner before assembly even sees it.
const MAX_URLS = 16;
const MAX_MACS = 12;
interface FieldMatcher {