Files
iptvnator/libs/shared/interfaces/src/lib/xtream-portal.utils.ts
T
4grayandClaude Fable 5 a6186a46c8 feat(dashboard): subscription-expiry warning badge on source cards (#1342)
* 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>
2026-08-02 13:24:16 +02:00

144 lines
3.7 KiB
TypeScript

export type XtreamPortalStatusType =
'active' | 'inactive' | 'expired' | 'unavailable';
export interface XtreamPortalStatusResponseLike {
user_info?: {
auth?: boolean | number | string | null;
exp_date?: number | string | null;
status?: string | null;
} | null;
}
export interface XtreamCredentialsFromUrl {
password: string;
username: string;
}
const XTREAM_API_ENDPOINT_PATTERN = /\/(?:get|player_api)\.php$/i;
// Some Xtream panels challenge generic Node HTTP clients while allowing
// established IPTV players. Keep API, probe, and download requests aligned.
export const XTREAM_CLIENT_USER_AGENT = 'VLC/3.0.18 LibVLC/3.0.18';
export function normalizeXtreamServerUrl(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
throw new Error('Xtream URL is required');
}
const url = new URL(trimmed);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Only http and https Xtream URLs are supported');
}
if (url.username || url.password) {
throw new Error('URL credentials are not supported');
}
const pathWithoutTrailingSlash = url.pathname.replace(/\/+$/, '');
const basePath = pathWithoutTrailingSlash.replace(
XTREAM_API_ENDPOINT_PATTERN,
''
);
return `${url.origin}${basePath}`;
}
export function extractXtreamCredentialsFromUrl(
value: string
): XtreamCredentialsFromUrl | null {
let url: URL;
try {
url = new URL(value.trim());
} catch {
return null;
}
const username = url.searchParams.get('username')?.trim() ?? '';
const password = url.searchParams.get('password')?.trim() ?? '';
if (!username || !password) {
return null;
}
return { username, password };
}
export function resolveXtreamPortalStatus(
response: XtreamPortalStatusResponseLike | null | undefined,
now = new Date()
): XtreamPortalStatusType {
const userInfo = response?.user_info;
if (!userInfo) {
return 'unavailable';
}
const auth = normalizeAuthValue(userInfo.auth);
if (auth === false) {
return 'inactive';
}
const normalizedStatus = userInfo.status?.trim().toLowerCase() ?? '';
if (normalizedStatus === 'expired') {
return 'expired';
}
const isActive =
normalizedStatus === 'active' ||
(auth === true && normalizedStatus.length === 0);
if (!isActive) {
return normalizedStatus ? 'inactive' : 'unavailable';
}
const expirationTimestamp = parseXtreamExpiration(userInfo.exp_date);
if (
expirationTimestamp !== null &&
expirationTimestamp * 1000 < now.getTime()
) {
return 'expired';
}
return 'active';
}
/**
* Reads the account expiration from an Xtream `get_account_info`-style
* response as unix seconds. Returns null when the portal doesn't report one
* (unlimited accounts answer with empty/zero/absent `exp_date`).
*/
export function resolveXtreamPortalExpiration(
response: XtreamPortalStatusResponseLike | null | undefined
): number | null {
return parseXtreamExpiration(response?.user_info?.exp_date);
}
function normalizeAuthValue(
value: boolean | number | string | null | undefined
): boolean | null {
if (value === true || value === 1 || value === '1') {
return true;
}
if (value === false || value === 0 || value === '0') {
return false;
}
return null;
}
function parseXtreamExpiration(
value: number | string | null | undefined
): number | null {
if (value === null || value === undefined || value === '') {
return null;
}
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return parsed;
}