mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
* feat(dashboard): warn on source cards when a portal subscription expires soon Dashboard source cards now carry a passive expiry chip: amber "Expires in N d" within 7 days of the subscription lapsing, error-toned "Expired" once it has. Account details stay behind the card's ⋮ → Account info. Xtream expirations ride on the playlist switcher's cached PortalStatusService check — checkPortalStatusDetails() now surfaces the parsed exp_date from the same round-trip, so the dashboard adds no extra portal calls. Stalker expirations come from the stalkerAccountInfo snapshot persisted at import; it lives in the playlist payload (meta rows carry payload: null), so each Stalker source costs one full-playlist read memoized on the playlist's update timestamp. New i18n keys added to all 19 locales via the i18n-fill merger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(dashboard): address review feedback on expiry badges - Recompute expiry badges on a minute tick so a dashboard left open crosses day-countdown and expiration boundaries (Greptile P1 / Codex P2) - Gate the expiry refresh on the recent-sources rail setting so hidden rails cost no portal checks or playlist reads (Codex P2) - Move chip colors to theme-aware tokens in m3-theme.scss; both themes now hold >= 4.5:1 small-text contrast (light warn 5.3:1, light expired 5.4:1, dark warn 7.4:1, dark expired 6.0:1) (Codex P2) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(dashboard): make expiry-badge labels depend on the language signal sourceCards previously relied on getPlaylistProvider's indirect language read; the translate.instant() labels now read languageTick explicitly (mirroring trendingCards). Also shift the minute tick by one so the interval's first 0 differs from initialValue — the signal equality check was swallowing the first heartbeat, delaying it to two minutes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
144 lines
4.6 KiB
TypeScript
144 lines
4.6 KiB
TypeScript
import {
|
|
extractXtreamCredentialsFromUrl,
|
|
normalizeXtreamServerUrl,
|
|
resolveXtreamPortalExpiration,
|
|
resolveXtreamPortalStatus,
|
|
} from './xtream-portal.utils';
|
|
|
|
describe('xtream portal utilities', () => {
|
|
describe('normalizeXtreamServerUrl', () => {
|
|
it.each([
|
|
[' https://example.com/ ', 'https://example.com'],
|
|
['https://example.com/base/', 'https://example.com/base'],
|
|
[
|
|
'https://example.com/get.php?username=user&password=pass&type=m3u_plus',
|
|
'https://example.com',
|
|
],
|
|
[
|
|
'https://example.com/base/player_api.php?username=user&password=pass',
|
|
'https://example.com/base',
|
|
],
|
|
])('normalizes %s to %s', (input, expected) => {
|
|
expect(normalizeXtreamServerUrl(input)).toBe(expected);
|
|
});
|
|
|
|
it('rejects non-http portal URLs', () => {
|
|
expect(() =>
|
|
normalizeXtreamServerUrl('file://example.com/portal')
|
|
).toThrow('Only http and https Xtream URLs are supported');
|
|
});
|
|
});
|
|
|
|
describe('extractXtreamCredentialsFromUrl', () => {
|
|
it('extracts and trims credentials from a full Xtream playlist URL', () => {
|
|
expect(
|
|
extractXtreamCredentialsFromUrl(
|
|
'https://example.com/get.php?username=%20user%20&password=%20pass%20&type=m3u_plus'
|
|
)
|
|
).toEqual({
|
|
username: 'user',
|
|
password: 'pass',
|
|
});
|
|
});
|
|
|
|
it('returns null when the URL has no usable credentials', () => {
|
|
expect(
|
|
extractXtreamCredentialsFromUrl('https://example.com/get.php')
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('resolveXtreamPortalStatus', () => {
|
|
const now = new Date('2026-06-14T00:00:00.000Z');
|
|
|
|
it('accepts active status case-insensitively and treats exp_date 0 as unlimited', () => {
|
|
expect(
|
|
resolveXtreamPortalStatus(
|
|
{
|
|
user_info: {
|
|
auth: 1,
|
|
status: 'active',
|
|
exp_date: '0',
|
|
},
|
|
},
|
|
now
|
|
)
|
|
).toBe('active');
|
|
});
|
|
|
|
it('uses auth=1 as active when status is missing', () => {
|
|
expect(
|
|
resolveXtreamPortalStatus(
|
|
{
|
|
user_info: {
|
|
auth: '1',
|
|
exp_date: String(now.getTime() / 1000 + 3600),
|
|
},
|
|
},
|
|
now
|
|
)
|
|
).toBe('active');
|
|
});
|
|
|
|
it('marks explicit auth failure as inactive', () => {
|
|
expect(
|
|
resolveXtreamPortalStatus({
|
|
user_info: {
|
|
auth: 0,
|
|
},
|
|
})
|
|
).toBe('inactive');
|
|
});
|
|
|
|
it('marks active accounts with past expiration as expired', () => {
|
|
expect(
|
|
resolveXtreamPortalStatus(
|
|
{
|
|
user_info: {
|
|
auth: 1,
|
|
status: 'Active',
|
|
exp_date: String(now.getTime() / 1000 - 3600),
|
|
},
|
|
},
|
|
now
|
|
)
|
|
).toBe('expired');
|
|
});
|
|
});
|
|
|
|
describe('resolveXtreamPortalExpiration', () => {
|
|
it('parses numeric-string exp_date into unix seconds', () => {
|
|
expect(
|
|
resolveXtreamPortalExpiration({
|
|
user_info: { exp_date: '1790000000' },
|
|
})
|
|
).toBe(1_790_000_000);
|
|
});
|
|
|
|
it('treats unlimited and absent expirations as null', () => {
|
|
expect(
|
|
resolveXtreamPortalExpiration({
|
|
user_info: { exp_date: '0' },
|
|
})
|
|
).toBeNull();
|
|
expect(
|
|
resolveXtreamPortalExpiration({
|
|
user_info: { exp_date: '' },
|
|
})
|
|
).toBeNull();
|
|
expect(
|
|
resolveXtreamPortalExpiration({ user_info: {} })
|
|
).toBeNull();
|
|
expect(resolveXtreamPortalExpiration(null)).toBeNull();
|
|
});
|
|
|
|
it('rejects non-numeric exp_date values', () => {
|
|
expect(
|
|
resolveXtreamPortalExpiration({
|
|
user_info: { exp_date: 'never' },
|
|
})
|
|
).toBeNull();
|
|
});
|
|
});
|
|
});
|