mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -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
3.7 KiB
TypeScript
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;
|
|
}
|