diff --git a/.changes/import-stalker-unlimited-expiry.md b/.changes/import-stalker-unlimited-expiry.md new file mode 100644 index 000000000..3e5fd151f --- /dev/null +++ b/.changes/import-stalker-unlimited-expiry.md @@ -0,0 +1,6 @@ +--- +type: fix +area: import +--- + +Adding a Stalker portal with an unlimited subscription no longer announces a bogus 1970 expiry date, and a portal that reports its expiry as a date shows that date instead of "Invalid Date". diff --git a/apps/website/src/content/blog/stalker-portal-setup-guide.mdx b/apps/website/src/content/blog/stalker-portal-setup-guide.mdx index ed194a65b..5701f61a1 100644 --- a/apps/website/src/content/blog/stalker-portal-setup-guide.mdx +++ b/apps/website/src/content/blog/stalker-portal-setup-guide.mdx @@ -94,8 +94,9 @@ setups, so IPTVnator no longer guesses: that address, and if a provider migrates the portal, IPTVnator re-probes once on the first failure and repairs the stored connection. -On a full portal the profile also carries your subscription facts, and IPTVnator confirms -the import with "Portal validated" and the expiry date. A portal that cannot be reached +On a full portal the profile also carries your subscription facts. When they include an +expiry date, IPTVnator confirms the import with "Portal validated" and that date; an account +without a fixed expiry is added without that notice. A portal that cannot be reached during the import is still added when the address looks like a panel, with a notice that it was added without validation, so a temporarily offline provider does not block you; the next successful request completes the setup. diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts index 8b4e0b34f..fc96814f9 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts @@ -449,6 +449,56 @@ describe('StalkerPortalImportComponent identity handling', () => { ); }); + // Portals encode an unlimited account as -1, "0" or a zero date, and + // the wire value is often a string despite the API's number type. + it.each([-1, '0', '-1', '0000-00-00'])( + 'announces no expiry for the unlimited sentinel %p', + async (expireDate) => { + portalDiscovery.discover.mockResolvedValue({ + status: 'resolved', + portalUrl: + 'https://portal.example.com/stalker_portal/server/load.php', + isFullStalkerPortal: true, + token: 'token-1', + accountInfo: { login: 'demo', expire_date: expireDate }, + }); + component.form.patchValue({ + title: 'Unlimited Portal', + macAddress: '00:1A:79:AA:BB:CC', + portalUrl: 'https://portal.example.com/stalker_portal/c', + }); + + await component.addPlaylist(); + + expect(snackBar.open).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledTimes(1); + } + ); + + it('announces a date-string expiry as that calendar day', async () => { + const translate = TestBed.inject(TranslateService); + portalDiscovery.discover.mockResolvedValue({ + status: 'resolved', + portalUrl: + 'https://portal.example.com/stalker_portal/server/load.php', + isFullStalkerPortal: true, + token: 'token-1', + accountInfo: { login: 'demo', expire_date: '2030-01-15' }, + }); + component.form.patchValue({ + title: 'Dated Portal', + macAddress: '00:1A:79:AA:BB:CC', + portalUrl: 'https://portal.example.com/stalker_portal/c', + }); + + await component.addPlaylist(); + + expect(translate.instant).toHaveBeenCalledWith( + 'HOME.STALKER_PORTAL.VALIDATED_EXPIRES', + { date: new Date(2030, 0, 15).toLocaleDateString() } + ); + }); + it('drops a refusal once the user edits the form or retries', async () => { portalDiscovery.discover.mockResolvedValue({ status: 'auth-rejected', diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index 8cd65d5fd..1a19d0403 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -28,6 +28,7 @@ import { PlaylistActions } from '@iptvnator/m3u-state'; import { legacyTransformStalkerPortalUrl, normalizeStalkerPortalInputUrl, + parseStalkerDate, STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS, StalkerPortalDiscoveryService, normalizeStalkerPortalIdentity, @@ -462,10 +463,13 @@ export class StalkerPortalImportComponent { }; } - if (stalkerAccountInfo?.expireDate) { - const expireDate = new Date( - stalkerAccountInfo.expireDate * 1000 - ); + // The raw value may be a date string or an "unlimited" + // sentinel (-1, "0"), so read it like every other consumer. + const expiresAt = parseStalkerDate( + stalkerAccountInfo?.expireDate + ); + if (expiresAt) { + const expireDate = new Date(expiresAt * 1000); this.snackBar.open( this.translate.instant( 'HOME.STALKER_PORTAL.VALIDATED_EXPIRES', diff --git a/tools/nx/check-font-weights.mjs b/tools/nx/check-font-weights.mjs index 2f26d2b26..50355caa0 100644 --- a/tools/nx/check-font-weights.mjs +++ b/tools/nx/check-font-weights.mjs @@ -757,6 +757,11 @@ function capOnly(term) { return Boolean(term.cap) && !offScale; } +/** What a finding reports (see `describeFinding`): one key, one message. */ +function findingKey({ file, line, name, value, cap, computed }) { + return JSON.stringify([file, line, name, value, cap, computed]); +} + /** A string literal in code, whole: its quote and its text. */ const STRING_LITERAL = /^\s*(['"`])([\s\S]*)\1\s*$/; @@ -940,6 +945,8 @@ export function scanWeights(file, written, modules = {}) { return { ...running, findings: union(running.findings, after.findings), + // A variable a weight reads is followed from either reading. + references: union(running.references, after.references), deferred: union(running.deferred, after.deferred), // A call meets the families of either reading. includeCalls: new Map( @@ -2362,7 +2369,13 @@ export function findIndirectWeights(scans) { ); } } - return findings; + // A definition several references reach (two rules reading one Sass + // variable, or both readings of a keyframe) is reported once. + return [ + ...new Map( + findings.map((finding) => [findingKey(finding), finding]) + ).values(), + ]; } /** @@ -2448,13 +2461,11 @@ export function findWorkspaceWeights(sources) { ...scans.flatMap((scan) => scan.findings), ...findIndirectWeights(scans), ]; - const keyOf = ({ file, line, name, value, cap, computed }) => - JSON.stringify([file, line, name, value, cap, computed]); - const seen = new Set(all.filter((f) => !f.landed).map(keyOf)); + const seen = new Set(all.filter((f) => !f.landed).map(findingKey)); const findings = []; for (const { landed, ...finding } of all) { if (landed) { - const key = keyOf(finding); + const key = findingKey(finding); if (seen.has(key)) continue; seen.add(key); } diff --git a/tools/nx/check-font-weights.test.mjs b/tools/nx/check-font-weights.test.mjs index 79eecf32b..a06503085 100644 --- a/tools/nx/check-font-weights.test.mjs +++ b/tools/nx/check-font-weights.test.mjs @@ -197,6 +197,27 @@ test('follows variables a weight refers to, through chains and files', () => { ); }); +test('reports a definition once however many references reach it', () => { + const mono = "'JetBrains Mono'"; + for (const [source, expected] of [ + ['$w: 650; .a { font-weight: $w; } .b { font-weight: $w; }', '650'], + [ + '$w: calc(400 + 100); .a { font-weight: $w; } .b { font-weight: $w; }', + 'calc(400 + 100)', + ], + [ + `$w: calc(400 + 100); .a { font-family: ${mono}; font-weight: $w; } .b { font-weight: $w; }`, + 'calc(400 + 100)', + ], + ]) { + assert.deepEqual( + offScale('libs/a.scss', source), + [`1 $w: ${expected}`], + source + ); + } +}); + test('checks Angular style bindings and literal DOM writes', () => { const template = [ '
a
', @@ -4939,6 +4960,63 @@ test('reads a keyframe where a rule runs it, over its own', () => { } }); +test('follows a weight variable in either reading of a keyframe that does not hold', () => { + const mono = "'JetBrains Mono'"; + const roboto = '@keyframes k { to { font-family: Roboto; } }'; + const report = (body) => + findOffScaleWeights('libs/s11/g.scss', body).findings.map( + ({ name, value, cap }) => + `${name}: ${value}${cap ? ` cap ${cap}` : ''}` + ); + for (const [source, expected] of [ + // Mono once it has run: the variable meets the cap where it is set. + [ + `$w: 700; ${roboto} .x { font-family: ${mono}; animation: k 1ms; font-weight: $w; }`, + ['$w: 700 cap 500'], + ], + [ + `:root { --w: 700; } ${roboto} .x { font-family: ${mono}; animation: k 1ms; font-weight: var(--w); }`, + ['--w: 700 cap 500'], + ], + [ + `$w: 700; ${roboto} .x { animation: k 1ms; font: $w 12px/1 ${mono}; }`, + ['$w: 700 cap 500'], + ], + // A computed weight is one finding, whichever reading reaches it. + [ + `$w: calc(400 + 100); ${roboto} .x { font-family: ${mono}; animation: k 1ms; font-weight: $w; }`, + ['$w: calc(400 + 100)'], + ], + // Mono only while it runs. + [ + `$w: 700; .x { font-family: Roboto; animation: k 1ms; font-weight: $w; } @keyframes k { to { font-family: ${mono}; } }`, + ['$w: 700 cap 500'], + ], + // One that holds its frame keeps Roboto after it. + [ + `$w: 700; ${roboto} .x { font-family: ${mono}; animation: k 1ms forwards; font-weight: $w; }`, + [], + ], + ]) { + assert.deepEqual(report(source), expected, source); + } + // And where another module's mixin runs it. + for (const [run, expected] of [ + ['k 1ms', ['libs/s11/c.scss:2 $w: 700']], + ['k 1ms forwards', []], + ]) { + const face = `@mixin face { font-family: ${mono}; animation: ${run}; }`; + assert.deepEqual( + workspace({ + 'libs/s11/_type.scss': `${roboto}\n${face}`, + 'libs/s11/c.scss': `@use 'type';\n$w: 700;\n.x { @include type.face; font-weight: $w; }`, + }), + expected, + run + ); + } +}); + test('reads an `@at-root` rule where Sass writes it out', () => { const mono = "'JetBrains Mono'"; const media = '@media (min-width: 1px)';