mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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>
This commit is contained in:
1 parent
8f9e78ff90
commit
a6186a46c8
35 files changed
+912
-35
No files matched your search
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: feature
|
||||
area: dashboard
|
||||
---
|
||||
|
||||
Dashboard source cards now warn when a portal subscription is about to lapse:
|
||||
an amber "Expires in N d" chip appears within a week of the expiry date, and
|
||||
an "Expired" chip once it has passed. Xtream expirations come from the same
|
||||
cached status check the playlist switcher uses; Stalker portals reuse the
|
||||
account info saved at import.
|
||||
@@ -1027,6 +1027,7 @@ engine` (restart required) or
|
||||
- Both portal types expose an account-info dialog through the same entry points: header playlist switcher (bottom section for the active playlist + per-row ⋮ menu), dashboard source card ⋮ menu, and the command palette. Gates use the shared predicates in `libs/shared/interfaces/src/lib/portal-account-playlist.utils.ts`; `WorkspaceShellHeaderService.openAccountInfoFor()` picks the dialog by playlist type.
|
||||
- Xtream: `AccountInfoComponent` (`libs/portal/xtream/feature/src/lib/account-info/`), queries `get_account_info` live.
|
||||
- Stalker: `StalkerAccountInfoComponent` (`libs/portal/stalker/feature/src/lib/stalker-account-info/`), cached-first — renders the import-time `stalkerAccountInfo` snapshot instantly, then `StalkerAccountInfoService` refreshes (full portals: handshake+`get_profile`; `portal.php`: best-effort `account_info/get_main_info`, nested `js.account_info` envelope or flat fields). Details: `docs/architecture/stalker-portal.md` ("Account Info Dialog").
|
||||
- Dashboard source cards carry a passive subscription-expiry chip (amber within 7 days, error-toned once expired); account details remain behind ⋮ → Account info. `DashboardSourceExpiryService` (`libs/workspace/dashboard/data-access/`) gathers the facts: Xtream from `PortalStatusService.checkPortalStatusDetails()` (the switcher's cached status check, now carrying `exp_date`), Stalker from the persisted `stalkerAccountInfo` snapshot — it lives in the playlist payload, not on meta rows, so each Stalker source costs one memoized full-playlist read.
|
||||
|
||||
**Favorites and Recently Viewed**:
|
||||
|
||||
|
||||
@@ -1213,7 +1213,9 @@
|
||||
"SEASON_EPISODE_BADGE": "م{{season}}·ح{{episode}}",
|
||||
"NOT_YET_SYNCED": "لم تتم المزامنة بعد",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "ينتهي خلال {{days}} يوم",
|
||||
"SOURCE_EXPIRED": "منتهي"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1213,7 +1213,9 @@
|
||||
"SEASON_EPISODE_BADGE": "م{{season}}·ح{{episode}}",
|
||||
"NOT_YET_SYNCED": "مازال ما تزامنش",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "كتسالي ف {{days}} يوم",
|
||||
"SOURCE_EXPIRED": "سالات"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1213,7 +1213,9 @@
|
||||
"SEASON_EPISODE_BADGE": "С{{season}}·Э{{episode}}",
|
||||
"NOT_YET_SYNCED": "Яшчэ не сінхранізавана",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Скончыцца праз {{days}} дз.",
|
||||
"SOURCE_EXPIRED": "Скончыўся"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1213,7 +1213,9 @@
|
||||
"SEASON_EPISODE_BADGE": "St{{season}} · F{{episode}}",
|
||||
"NOT_YET_SYNCED": "Noch nicht synchronisiert",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Zuletzt angesehenes Live-TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorisierte Filme & Serien"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorisierte Filme & Serien",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Läuft in {{days}} T. ab",
|
||||
"SOURCE_EXPIRED": "Abgelaufen"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1213,7 +1213,9 @@
|
||||
"SEASON_EPISODE_BADGE": "Σ{{season}}·Ε{{episode}}",
|
||||
"NOT_YET_SYNCED": "Δεν έχει συγχρονιστεί ακόμα",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Λήγει σε {{days}} ημ.",
|
||||
"SOURCE_EXPIRED": "Έληξε"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1213,7 +1213,9 @@
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Not yet synced",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Expires in {{days}} d",
|
||||
"SOURCE_EXPIRED": "Expired"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1213,7 +1213,9 @@
|
||||
"SEASON_EPISODE_BADGE": "T{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Aún no sincronizado",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Caduca en {{days}} d",
|
||||
"SOURCE_EXPIRED": "Caducada"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1213,7 +1213,9 @@
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Pas encore synchronisé",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Expire dans {{days}} j",
|
||||
"SOURCE_EXPIRED": "Expiré"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1213,7 +1213,9 @@
|
||||
"SEASON_EPISODE_BADGE": "É{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Még nincs szinkronizálva",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Legutóbb nézett élő csatornák",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Kedvenc filmek és sorozatok"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Kedvenc filmek és sorozatok",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "{{days}} nap múlva lejár",
|
||||
"SOURCE_EXPIRED": "Lejárt"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1213,7 +1213,9 @@
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Non ancora sincronizzato",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Scade tra {{days}} g",
|
||||
"SOURCE_EXPIRED": "Scaduto"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1213,7 +1213,9 @@
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}・E{{episode}}",
|
||||
"NOT_YET_SYNCED": "未同期",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "あと{{days}}日で期限切れ",
|
||||
"SOURCE_EXPIRED": "期限切れ"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1213,7 +1213,9 @@
|
||||
"SEASON_EPISODE_BADGE": "시즌{{season}} {{episode}}화",
|
||||
"NOT_YET_SYNCED": "아직 동기화되지 않음",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "{{days}}일 후 만료",
|
||||
"SOURCE_EXPIRED": "만료됨"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1213,7 +1213,9 @@
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·A{{episode}}",
|
||||
"NOT_YET_SYNCED": "Nog niet gesynchroniseerd",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Verloopt over {{days}} d",
|
||||
"SOURCE_EXPIRED": "Verlopen"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1213,7 +1213,9 @@
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·O{{episode}}",
|
||||
"NOT_YET_SYNCED": "Jeszcze niezsynchronizowane",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Wygasa za {{days}} dn.",
|
||||
"SOURCE_EXPIRED": "Wygasło"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1213,7 +1213,9 @@
|
||||
"SEASON_EPISODE_BADGE": "T{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Ainda não sincronizado",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Expira em {{days}} d",
|
||||
"SOURCE_EXPIRED": "Expirada"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1213,7 +1213,9 @@
|
||||
"SEASON_EPISODE_BADGE": "С{{season}}·Э{{episode}}",
|
||||
"NOT_YET_SYNCED": "Еще не синхронизировано",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Недавно просмотренное прямое ТВ",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Избранные фильмы и сериалы"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Избранные фильмы и сериалы",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Истекает через {{days}} дн.",
|
||||
"SOURCE_EXPIRED": "Истёк"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1213,7 +1213,9 @@
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·B{{episode}}",
|
||||
"NOT_YET_SYNCED": "Henüz eşitlenmedi",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "{{days}} gün içinde sona erer",
|
||||
"SOURCE_EXPIRED": "Süresi doldu"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1213,7 +1213,9 @@
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "尚未同步",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "{{days}} 天后到期",
|
||||
"SOURCE_EXPIRED": "已过期"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1213,7 +1213,9 @@
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "尚未同步",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "{{days}} 天後到期",
|
||||
"SOURCE_EXPIRED": "已過期"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -112,6 +112,14 @@ html {
|
||||
--app-provider-stalker: #10b981;
|
||||
--app-provider-m3u: #d97706;
|
||||
|
||||
// Subscription-expiry chips (dashboard source cards). Text/surface pairs
|
||||
// are picked per theme to keep >= 4.5:1 small-text contrast against the
|
||||
// composited chip background.
|
||||
--app-expiry-warn-text: #8a5300;
|
||||
--app-expiry-warn-surface: color-mix(in srgb, #d97706 16%, transparent);
|
||||
--app-expiry-expired-text: #b3261e;
|
||||
--app-expiry-expired-surface: color-mix(in srgb, #f44336 14%, transparent);
|
||||
|
||||
// Hero
|
||||
--app-accent-gradient: linear-gradient(
|
||||
135deg,
|
||||
@@ -188,6 +196,17 @@ html {
|
||||
--app-provider-stalker: #34d399;
|
||||
--app-provider-m3u: #fbbf24;
|
||||
|
||||
// Subscription-expiry chips — brighter text for the dark surfaces;
|
||||
// expired matches the --app-live-color accent family.
|
||||
--app-expiry-warn-text: #fbbf24;
|
||||
--app-expiry-warn-surface: color-mix(in srgb, #fbbf24 16%, transparent);
|
||||
--app-expiry-expired-text: #ff8f8f;
|
||||
--app-expiry-expired-surface: color-mix(
|
||||
in srgb,
|
||||
#ff8f8f 16%,
|
||||
transparent
|
||||
);
|
||||
|
||||
// Hero section
|
||||
--app-accent-gradient: linear-gradient(
|
||||
135deg,
|
||||
|
||||
@@ -188,6 +188,55 @@ describe('PortalStatusService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('returns expiry details from the same round-trip and serves repeats from the cache', async () => {
|
||||
const expiresAtSeconds = Math.floor(Date.now() / 1000) + 3 * 86_400;
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
payload: {
|
||||
user_info: {
|
||||
auth: 1,
|
||||
status: 'Active',
|
||||
exp_date: String(expiresAtSeconds),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const details = await service.checkPortalStatusDetails(
|
||||
'http://example.com',
|
||||
'user',
|
||||
'pass'
|
||||
);
|
||||
|
||||
expect(details).toEqual({ status: 'active', expiresAtSeconds });
|
||||
|
||||
const cached = await service.checkPortalStatusDetails(
|
||||
'http://example.com',
|
||||
'user',
|
||||
'pass'
|
||||
);
|
||||
expect(cached).toEqual(details);
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports a null expiry for unlimited accounts', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
payload: {
|
||||
user_info: {
|
||||
auth: 1,
|
||||
status: 'Active',
|
||||
exp_date: '0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.checkPortalStatusDetails(
|
||||
'http://example.com',
|
||||
'user',
|
||||
'pass'
|
||||
)
|
||||
).resolves.toEqual({ status: 'active', expiresAtSeconds: null });
|
||||
});
|
||||
|
||||
it('treats lowercase active status and exp_date 0 as active', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
payload: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
normalizeXtreamServerUrl,
|
||||
resolveXtreamPortalExpiration,
|
||||
resolveXtreamPortalStatus,
|
||||
XtreamPortalStatusResponseLike,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
@@ -13,12 +14,24 @@ export type PortalStatus =
|
||||
| 'unavailable'
|
||||
| 'checking';
|
||||
|
||||
/**
|
||||
* Status plus the parsed account expiration from the same round-trip the
|
||||
* status check already makes — consumers that care about "expires soon"
|
||||
* (dashboard source cards) share the cache instead of issuing their own
|
||||
* `get_account_info` calls.
|
||||
*/
|
||||
export interface PortalStatusDetails {
|
||||
status: PortalStatus;
|
||||
/** Unix seconds; null when the portal doesn't report an expiration. */
|
||||
expiresAtSeconds: number | null;
|
||||
}
|
||||
|
||||
interface XtreamPortalStatusResponse {
|
||||
payload?: XtreamPortalStatusResponseLike;
|
||||
}
|
||||
|
||||
interface PortalStatusCacheEntry {
|
||||
status: PortalStatus;
|
||||
details: PortalStatusDetails;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@@ -57,7 +70,7 @@ export class PortalStatusService {
|
||||
* playlist-item + switcher menu open) share a single network round-trip
|
||||
* instead of racing each other.
|
||||
*/
|
||||
private readonly inFlight = new Map<string, Promise<PortalStatus>>();
|
||||
private readonly inFlight = new Map<string, Promise<PortalStatusDetails>>();
|
||||
|
||||
/**
|
||||
* Checks the status of an Xtream Code portal
|
||||
@@ -75,13 +88,32 @@ export class PortalStatusService {
|
||||
password: string,
|
||||
options?: CheckPortalStatusOptions
|
||||
): Promise<PortalStatus> {
|
||||
const details = await this.checkPortalStatusDetails(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
options
|
||||
);
|
||||
return details.status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same check as {@link checkPortalStatus} (shared cache, shared in-flight
|
||||
* dedup) but returns the account expiration alongside the status.
|
||||
*/
|
||||
async checkPortalStatusDetails(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
options?: CheckPortalStatusOptions
|
||||
): Promise<PortalStatusDetails> {
|
||||
const connection = this.normalizeConnection(
|
||||
serverUrl,
|
||||
username,
|
||||
password
|
||||
);
|
||||
if (!connection) {
|
||||
return 'unavailable';
|
||||
return { status: 'unavailable', expiresAtSeconds: null };
|
||||
}
|
||||
|
||||
const cacheKey = this.buildCacheKey(
|
||||
@@ -96,7 +128,7 @@ export class PortalStatusService {
|
||||
cached &&
|
||||
Date.now() - cached.timestamp < PORTAL_STATUS_CACHE_TTL_MS
|
||||
) {
|
||||
return cached.status;
|
||||
return cached.details;
|
||||
}
|
||||
|
||||
const pending = this.inFlight.get(cacheKey);
|
||||
@@ -110,12 +142,12 @@ export class PortalStatusService {
|
||||
connection.username,
|
||||
connection.password
|
||||
)
|
||||
.then((status) => {
|
||||
.then((details) => {
|
||||
this.cache.set(cacheKey, {
|
||||
status,
|
||||
details,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return status;
|
||||
return details;
|
||||
})
|
||||
.finally(() => {
|
||||
this.inFlight.delete(cacheKey);
|
||||
@@ -159,7 +191,7 @@ export class PortalStatusService {
|
||||
if (Date.now() - cached.timestamp >= PORTAL_STATUS_CACHE_TTL_MS) {
|
||||
return null;
|
||||
}
|
||||
return cached.status;
|
||||
return cached.details.status;
|
||||
}
|
||||
|
||||
/** Clear the entire cache. Useful for log-out or debug flows. */
|
||||
@@ -205,7 +237,7 @@ export class PortalStatusService {
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<PortalStatus> {
|
||||
): Promise<PortalStatusDetails> {
|
||||
for (const action of XTREAM_STATUS_ACTIONS) {
|
||||
try {
|
||||
const response =
|
||||
@@ -223,14 +255,19 @@ export class PortalStatusService {
|
||||
);
|
||||
const status = resolveXtreamPortalStatus(response?.payload);
|
||||
if (status !== 'unavailable') {
|
||||
return status;
|
||||
return {
|
||||
status,
|
||||
expiresAtSeconds: resolveXtreamPortalExpiration(
|
||||
response?.payload
|
||||
),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Try the next Xtream account-info action variant.
|
||||
}
|
||||
}
|
||||
|
||||
return 'unavailable';
|
||||
return { status: 'unavailable', expiresAtSeconds: null };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
extractXtreamCredentialsFromUrl,
|
||||
normalizeXtreamServerUrl,
|
||||
resolveXtreamPortalExpiration,
|
||||
resolveXtreamPortalStatus,
|
||||
} from './xtream-portal.utils';
|
||||
|
||||
@@ -104,4 +105,39 @@ describe('xtream portal utilities', () => {
|
||||
).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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -102,6 +102,17 @@ export function resolveXtreamPortalStatus(
|
||||
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 {
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './lib/dashboard-data.service';
|
||||
export * from './lib/dashboard-source-expiry.service';
|
||||
export * from './lib/dashboard-source-expiry.util';
|
||||
export * from './lib/dashboard-trending.service';
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Injectable, NgZone, inject, signal } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { normalizeStoredStalkerAccountInfo } from '@iptvnator/portal/stalker/data-access';
|
||||
import { PlaylistsService, PortalStatusService } from '@iptvnator/services';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
isStalkerAccountPlaylist,
|
||||
isXtreamAccountPlaylist,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { SourceExpiryFacts } from './dashboard-source-expiry.util';
|
||||
|
||||
/**
|
||||
* Collects subscription-expiry facts for the dashboard's source cards.
|
||||
*
|
||||
* Xtream expirations ride on the same cached `PortalStatusService` check the
|
||||
* playlist switcher makes (30s TTL + in-flight dedup), so the dashboard adds
|
||||
* no portal round-trips beyond that shared check. Stalker expirations come
|
||||
* from the `stalkerAccountInfo` snapshot persisted at import — it lives in
|
||||
* the playlist payload (not on the meta rows), so each Stalker source costs
|
||||
* one full-playlist read, memoized until the playlist is refreshed.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DashboardSourceExpiryService {
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly portalStatusService = inject(PortalStatusService);
|
||||
|
||||
private readonly factsState = signal<ReadonlyMap<string, SourceExpiryFacts>>(
|
||||
new Map()
|
||||
);
|
||||
/** Facts per playlist id; entries land as each source's lookup resolves. */
|
||||
readonly facts = this.factsState.asReadonly();
|
||||
|
||||
/**
|
||||
* Stalker snapshots only change on re-import/refresh; fingerprinting on
|
||||
* the playlist's update timestamp keeps dashboard revisits IPC-free.
|
||||
*/
|
||||
private readonly stalkerFactsCache = new Map<
|
||||
string,
|
||||
{ fingerprint: string; facts: SourceExpiryFacts | null }
|
||||
>();
|
||||
|
||||
private refreshRound = 0;
|
||||
|
||||
/** Refreshes facts for the given sources; prunes ids no longer present. */
|
||||
async refresh(playlists: readonly PlaylistMeta[]): Promise<void> {
|
||||
const round = ++this.refreshRound;
|
||||
const validIds = new Set(playlists.map((playlist) => playlist._id));
|
||||
|
||||
this.applyIfCurrent(round, (map) => {
|
||||
let next: Map<string, SourceExpiryFacts> | null = null;
|
||||
for (const id of map.keys()) {
|
||||
if (!validIds.has(id)) {
|
||||
next ??= new Map(map);
|
||||
next.delete(id);
|
||||
}
|
||||
}
|
||||
return next ?? map;
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
playlists.map(async (playlist) => {
|
||||
const facts = await this.loadFacts(playlist);
|
||||
this.applyIfCurrent(round, (map) => {
|
||||
if ((map.get(playlist._id) ?? null) === facts) {
|
||||
return map;
|
||||
}
|
||||
const next = new Map(map);
|
||||
if (facts === null) {
|
||||
next.delete(playlist._id);
|
||||
} else {
|
||||
next.set(playlist._id, facts);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async loadFacts(
|
||||
playlist: PlaylistMeta
|
||||
): Promise<SourceExpiryFacts | null> {
|
||||
if (isXtreamAccountPlaylist(playlist)) {
|
||||
return this.loadXtreamFacts(playlist);
|
||||
}
|
||||
if (isStalkerAccountPlaylist(playlist)) {
|
||||
return this.loadStalkerFacts(playlist);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async loadXtreamFacts(
|
||||
playlist: PlaylistMeta & {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
): Promise<SourceExpiryFacts | null> {
|
||||
try {
|
||||
const details =
|
||||
await this.portalStatusService.checkPortalStatusDetails(
|
||||
playlist.serverUrl,
|
||||
playlist.username,
|
||||
playlist.password
|
||||
);
|
||||
// An unreachable portal is unknown, not expired — no badge.
|
||||
if (details.status === 'unavailable') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
expiresAtSeconds: details.expiresAtSeconds,
|
||||
reportedExpired: details.status === 'expired',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadStalkerFacts(
|
||||
playlist: PlaylistMeta
|
||||
): Promise<SourceExpiryFacts | null> {
|
||||
const fingerprint = String(
|
||||
playlist.updateDate ?? playlist.importDate ?? ''
|
||||
);
|
||||
const cached = this.stalkerFactsCache.get(playlist._id);
|
||||
if (cached && cached.fingerprint === fingerprint) {
|
||||
return cached.facts;
|
||||
}
|
||||
|
||||
let facts: SourceExpiryFacts | null;
|
||||
try {
|
||||
const fullPlaylist = await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(playlist._id)
|
||||
);
|
||||
const snapshot = normalizeStoredStalkerAccountInfo(
|
||||
fullPlaylist?.stalkerAccountInfo
|
||||
);
|
||||
facts =
|
||||
snapshot?.expireDate !== undefined
|
||||
? {
|
||||
expiresAtSeconds: snapshot.expireDate,
|
||||
reportedExpired: false,
|
||||
}
|
||||
: null;
|
||||
} catch {
|
||||
// Don't cache failures — the next refresh should retry the read.
|
||||
return null;
|
||||
}
|
||||
|
||||
this.stalkerFactsCache.set(playlist._id, { fingerprint, facts });
|
||||
return facts;
|
||||
}
|
||||
|
||||
private applyIfCurrent(
|
||||
round: number,
|
||||
update: (
|
||||
map: ReadonlyMap<string, SourceExpiryFacts>
|
||||
) => ReadonlyMap<string, SourceExpiryFacts>
|
||||
): void {
|
||||
if (round !== this.refreshRound) {
|
||||
return;
|
||||
}
|
||||
this.ngZone.run(() => {
|
||||
this.factsState.update(update);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { PlaylistsService, PortalStatusService } from '@iptvnator/services';
|
||||
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { DashboardSourceExpiryService } from './dashboard-source-expiry.service';
|
||||
import {
|
||||
SOURCE_EXPIRY_WARNING_DAYS,
|
||||
resolveSourceExpiryBadge,
|
||||
} from './dashboard-source-expiry.util';
|
||||
|
||||
const DAY_SECONDS = 86_400;
|
||||
|
||||
describe('resolveSourceExpiryBadge', () => {
|
||||
const nowMs = Date.UTC(2026, 7, 1, 12, 0, 0);
|
||||
const nowSeconds = nowMs / 1000;
|
||||
|
||||
it('returns null without facts', () => {
|
||||
expect(resolveSourceExpiryBadge(null, nowMs)).toBeNull();
|
||||
expect(resolveSourceExpiryBadge(undefined, nowMs)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns expired when the portal reports an expired account without a timestamp', () => {
|
||||
expect(
|
||||
resolveSourceExpiryBadge(
|
||||
{ expiresAtSeconds: null, reportedExpired: true },
|
||||
nowMs
|
||||
)
|
||||
).toEqual({ kind: 'expired' });
|
||||
});
|
||||
|
||||
it('returns expired for timestamps in the past', () => {
|
||||
expect(
|
||||
resolveSourceExpiryBadge(
|
||||
{ expiresAtSeconds: nowSeconds - 60, reportedExpired: false },
|
||||
nowMs
|
||||
)
|
||||
).toEqual({ kind: 'expired' });
|
||||
});
|
||||
|
||||
it('warns with ceil-ed days inside the warning window', () => {
|
||||
expect(
|
||||
resolveSourceExpiryBadge(
|
||||
{
|
||||
expiresAtSeconds: nowSeconds + 2.5 * DAY_SECONDS,
|
||||
reportedExpired: false,
|
||||
},
|
||||
nowMs
|
||||
)
|
||||
).toEqual({ kind: 'expiring', daysLeft: 3 });
|
||||
});
|
||||
|
||||
it('includes the boundary day but not one hour past it', () => {
|
||||
expect(
|
||||
resolveSourceExpiryBadge(
|
||||
{
|
||||
expiresAtSeconds:
|
||||
nowSeconds + SOURCE_EXPIRY_WARNING_DAYS * DAY_SECONDS,
|
||||
reportedExpired: false,
|
||||
},
|
||||
nowMs
|
||||
)
|
||||
).toEqual({ kind: 'expiring', daysLeft: SOURCE_EXPIRY_WARNING_DAYS });
|
||||
|
||||
expect(
|
||||
resolveSourceExpiryBadge(
|
||||
{
|
||||
expiresAtSeconds:
|
||||
nowSeconds +
|
||||
SOURCE_EXPIRY_WARNING_DAYS * DAY_SECONDS +
|
||||
3600,
|
||||
reportedExpired: false,
|
||||
},
|
||||
nowMs
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('treats unknown and sentinel expirations as no badge', () => {
|
||||
expect(
|
||||
resolveSourceExpiryBadge(
|
||||
{ expiresAtSeconds: null, reportedExpired: false },
|
||||
nowMs
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveSourceExpiryBadge(
|
||||
{ expiresAtSeconds: 0, reportedExpired: false },
|
||||
nowMs
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardSourceExpiryService', () => {
|
||||
let service: DashboardSourceExpiryService;
|
||||
let portalStatusService: { checkPortalStatusDetails: jest.Mock };
|
||||
let playlistsService: { getPlaylistById: jest.Mock };
|
||||
|
||||
const xtreamPlaylist = {
|
||||
_id: 'xtream-1',
|
||||
title: 'Xtream',
|
||||
serverUrl: 'https://provider.example.test',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
} as PlaylistMeta;
|
||||
|
||||
const stalkerPlaylist = {
|
||||
_id: 'stalker-1',
|
||||
title: 'Stalker',
|
||||
portalUrl: 'https://stalker.example.test/portal.php',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
updateDate: 1_750_000_000_000,
|
||||
} as PlaylistMeta;
|
||||
|
||||
const m3uPlaylist = {
|
||||
_id: 'm3u-1',
|
||||
title: 'M3U',
|
||||
url: 'https://example.com/playlist.m3u',
|
||||
} as PlaylistMeta;
|
||||
|
||||
beforeEach(() => {
|
||||
portalStatusService = { checkPortalStatusDetails: jest.fn() };
|
||||
playlistsService = { getPlaylistById: jest.fn() };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DashboardSourceExpiryService,
|
||||
{
|
||||
provide: PortalStatusService,
|
||||
useValue: portalStatusService,
|
||||
},
|
||||
{ provide: PlaylistsService, useValue: playlistsService },
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(DashboardSourceExpiryService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
it('collects Xtream facts from the shared status details', async () => {
|
||||
const expiresAtSeconds = Math.floor(Date.now() / 1000) + 3 * DAY_SECONDS;
|
||||
portalStatusService.checkPortalStatusDetails.mockResolvedValue({
|
||||
status: 'active',
|
||||
expiresAtSeconds,
|
||||
});
|
||||
|
||||
await service.refresh([xtreamPlaylist]);
|
||||
|
||||
expect(service.facts().get('xtream-1')).toEqual({
|
||||
expiresAtSeconds,
|
||||
reportedExpired: false,
|
||||
});
|
||||
expect(
|
||||
portalStatusService.checkPortalStatusDetails
|
||||
).toHaveBeenCalledWith(
|
||||
'https://provider.example.test',
|
||||
'demo',
|
||||
'secret'
|
||||
);
|
||||
});
|
||||
|
||||
it('marks Xtream sources the portal reports as expired', async () => {
|
||||
portalStatusService.checkPortalStatusDetails.mockResolvedValue({
|
||||
status: 'expired',
|
||||
expiresAtSeconds: null,
|
||||
});
|
||||
|
||||
await service.refresh([xtreamPlaylist]);
|
||||
|
||||
expect(service.facts().get('xtream-1')).toEqual({
|
||||
expiresAtSeconds: null,
|
||||
reportedExpired: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('records no facts for unreachable portals or failed checks', async () => {
|
||||
portalStatusService.checkPortalStatusDetails.mockResolvedValueOnce({
|
||||
status: 'unavailable',
|
||||
expiresAtSeconds: null,
|
||||
});
|
||||
await service.refresh([xtreamPlaylist]);
|
||||
expect(service.facts().has('xtream-1')).toBe(false);
|
||||
|
||||
portalStatusService.checkPortalStatusDetails.mockRejectedValueOnce(
|
||||
new Error('offline')
|
||||
);
|
||||
await service.refresh([xtreamPlaylist]);
|
||||
expect(service.facts().has('xtream-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('reads Stalker expirations from the stored snapshot and memoizes the read', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
// Stored verbatim at import — a date string despite the
|
||||
// declared number type; the normalizer must parse it.
|
||||
stalkerAccountInfo: { expireDate: '2027-10-01' },
|
||||
})
|
||||
);
|
||||
|
||||
await service.refresh([stalkerPlaylist]);
|
||||
|
||||
const facts = service.facts().get('stalker-1');
|
||||
expect(facts?.reportedExpired).toBe(false);
|
||||
expect(facts?.expiresAtSeconds).toBe(
|
||||
Math.round(new Date(2027, 9, 1).getTime() / 1000)
|
||||
);
|
||||
|
||||
await service.refresh([stalkerPlaylist]);
|
||||
expect(playlistsService.getPlaylistById).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-reads the Stalker snapshot after a playlist refresh changes the fingerprint', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
stalkerAccountInfo: { expireDate: '2027-10-01' },
|
||||
})
|
||||
);
|
||||
|
||||
await service.refresh([stalkerPlaylist]);
|
||||
await service.refresh([
|
||||
{ ...stalkerPlaylist, updateDate: 1_760_000_000_000 },
|
||||
]);
|
||||
|
||||
expect(playlistsService.getPlaylistById).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('records no facts when the snapshot is absent or unlimited', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
stalkerAccountInfo: { expireDate: '-1' },
|
||||
})
|
||||
);
|
||||
|
||||
await service.refresh([stalkerPlaylist]);
|
||||
|
||||
expect(service.facts().has('stalker-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not cache failed Stalker reads', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValueOnce(
|
||||
throwError(() => new Error('db down'))
|
||||
);
|
||||
await service.refresh([stalkerPlaylist]);
|
||||
expect(service.facts().has('stalker-1')).toBe(false);
|
||||
|
||||
playlistsService.getPlaylistById.mockReturnValueOnce(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
stalkerAccountInfo: { expireDate: '2027-10-01' },
|
||||
})
|
||||
);
|
||||
await service.refresh([stalkerPlaylist]);
|
||||
expect(service.facts().has('stalker-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores M3U playlists and prunes removed sources', async () => {
|
||||
portalStatusService.checkPortalStatusDetails.mockResolvedValue({
|
||||
status: 'active',
|
||||
expiresAtSeconds: Math.floor(Date.now() / 1000) + DAY_SECONDS,
|
||||
});
|
||||
|
||||
await service.refresh([xtreamPlaylist, m3uPlaylist]);
|
||||
expect(service.facts().has('xtream-1')).toBe(true);
|
||||
expect(service.facts().has('m3u-1')).toBe(false);
|
||||
|
||||
await service.refresh([m3uPlaylist]);
|
||||
expect(service.facts().has('xtream-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/** Warn this many days before a portal subscription lapses. */
|
||||
export const SOURCE_EXPIRY_WARNING_DAYS = 7;
|
||||
|
||||
/**
|
||||
* How often badge consumers should re-evaluate {@link resolveSourceExpiryBadge}
|
||||
* against the wall clock. The badge state only moves at day granularity, but a
|
||||
* dashboard left open must still cross day-countdown and expiration
|
||||
* boundaries without a remount; a minute tick is imperceptibly cheap.
|
||||
*/
|
||||
export const SOURCE_EXPIRY_TICK_MS = 60_000;
|
||||
|
||||
const SECONDS_PER_DAY = 86_400;
|
||||
|
||||
/**
|
||||
* What a source's account facts say about its subscription lifetime.
|
||||
* `reportedExpired` covers portals that answer with an expired account
|
||||
* status without a usable timestamp; `expiresAtSeconds` is unix seconds
|
||||
* when the portal reports one.
|
||||
*/
|
||||
export interface SourceExpiryFacts {
|
||||
expiresAtSeconds: number | null;
|
||||
reportedExpired: boolean;
|
||||
}
|
||||
|
||||
export type SourceExpiryBadge =
|
||||
| { kind: 'expired' }
|
||||
| { kind: 'expiring'; daysLeft: number };
|
||||
|
||||
/**
|
||||
* Decides whether a source card should carry an expiry badge. Returns null
|
||||
* for unknown expirations and for subscriptions further out than
|
||||
* `warningDays` — the badge is a warning, not a countdown.
|
||||
*/
|
||||
export function resolveSourceExpiryBadge(
|
||||
facts: SourceExpiryFacts | null | undefined,
|
||||
nowMs: number,
|
||||
warningDays: number = SOURCE_EXPIRY_WARNING_DAYS
|
||||
): SourceExpiryBadge | null {
|
||||
if (!facts) {
|
||||
return null;
|
||||
}
|
||||
if (facts.reportedExpired) {
|
||||
return { kind: 'expired' };
|
||||
}
|
||||
|
||||
const expiresAt = facts.expiresAtSeconds;
|
||||
if (expiresAt === null || expiresAt <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const secondsLeft = expiresAt - nowMs / 1000;
|
||||
if (secondsLeft <= 0) {
|
||||
return { kind: 'expired' };
|
||||
}
|
||||
|
||||
const daysLeft = Math.ceil(secondsLeft / SECONDS_PER_DAY);
|
||||
return daysLeft <= warningDays ? { kind: 'expiring', daysLeft } : null;
|
||||
}
|
||||
@@ -204,6 +204,20 @@
|
||||
card.subtitle
|
||||
}}</span>
|
||||
}
|
||||
@if (card.expiryBadge; as expiry) {
|
||||
<span
|
||||
class="rail__card-expiry"
|
||||
[class.rail__card-expiry--expired]="
|
||||
expiry.kind === 'expired'
|
||||
"
|
||||
[attr.data-test-id]="
|
||||
testId()
|
||||
? testId() + '-card-expiry'
|
||||
: null
|
||||
"
|
||||
>{{ expiry.label }}</span
|
||||
>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -375,6 +375,35 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// Subscription-expiry chip on portal source cards. Mirrors the episode
|
||||
// chip's geometry; the theme-aware token pairs (m3-theme.scss) keep the
|
||||
// small bold text at >= 4.5:1 contrast in both themes (amber = expiring
|
||||
// soon, error tone = already expired).
|
||||
.rail__card-expiry {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(
|
||||
--app-expiry-warn-surface,
|
||||
color-mix(in srgb, #d97706 16%, transparent)
|
||||
);
|
||||
color: var(--app-expiry-warn-text, #8a5300);
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rail__card-expiry--expired {
|
||||
background: var(
|
||||
--app-expiry-expired-surface,
|
||||
color-mix(in srgb, #f44336 14%, transparent)
|
||||
);
|
||||
color: var(--app-expiry-expired-text, #b3261e);
|
||||
}
|
||||
|
||||
.rail__card-subtitle {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
DashboardRailCard,
|
||||
@@ -69,4 +71,67 @@ describe('DashboardRailComponent', () => {
|
||||
'US | CNN'
|
||||
);
|
||||
});
|
||||
|
||||
describe('expiry badge rendering', () => {
|
||||
beforeEach(() => {
|
||||
// jsdom has no ResizeObserver; the component observes its track
|
||||
// element after view init.
|
||||
(
|
||||
globalThis as unknown as { ResizeObserver: unknown }
|
||||
).ResizeObserver = class {
|
||||
observe = jest.fn();
|
||||
unobserve = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
};
|
||||
});
|
||||
|
||||
const renderCards = async (items: DashboardRailCard[]) => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DashboardRailComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: { stripCountryPrefix: signal(false) },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(DashboardRailComponent);
|
||||
fixture.componentRef.setInput('label', 'Sources');
|
||||
fixture.componentRef.setInput('items', items);
|
||||
fixture.detectChanges();
|
||||
return fixture.nativeElement as HTMLElement;
|
||||
};
|
||||
|
||||
it('renders the chip only for cards with a badge and tones expired ones', async () => {
|
||||
const element = await renderCards([
|
||||
card({
|
||||
id: 'expiring',
|
||||
subtitle: 'Xtream',
|
||||
expiryBadge: {
|
||||
kind: 'expiring',
|
||||
label: 'Expires in 3 d',
|
||||
},
|
||||
}),
|
||||
card({
|
||||
id: 'expired',
|
||||
subtitle: 'Stalker',
|
||||
expiryBadge: { kind: 'expired', label: 'Expired' },
|
||||
}),
|
||||
card({ id: 'plain', subtitle: 'M3U' }),
|
||||
]);
|
||||
|
||||
const chips = element.querySelectorAll('.rail__card-expiry');
|
||||
expect(chips).toHaveLength(2);
|
||||
expect(chips[0].textContent?.trim()).toBe('Expires in 3 d');
|
||||
expect(
|
||||
chips[0].classList.contains('rail__card-expiry--expired')
|
||||
).toBe(false);
|
||||
expect(chips[1].textContent?.trim()).toBe('Expired');
|
||||
expect(
|
||||
chips[1].classList.contains('rail__card-expiry--expired')
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,13 @@ export interface DashboardRailCard {
|
||||
* user can see which episode they were on without opening the show.
|
||||
*/
|
||||
episodeBadge?: string | null;
|
||||
|
||||
/**
|
||||
* Subscription-expiry warning for portal source cards: a quiet amber
|
||||
* chip when the account expires soon, an error-toned one once it has.
|
||||
* The chip is passive — account details stay behind ⋮ → Account info.
|
||||
*/
|
||||
expiryBadge?: { kind: 'expiring' | 'expired'; label: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+65
-5
@@ -8,7 +8,7 @@ import {
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { interval, of, startWith, switchMap } from 'rxjs';
|
||||
import { interval, map, of, startWith, switchMap } from 'rxjs';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import {
|
||||
type EpgProgram,
|
||||
@@ -43,9 +43,12 @@ import {
|
||||
DashboardDataService,
|
||||
DashboardFavoriteItem,
|
||||
DashboardRecentlyAddedItem,
|
||||
DashboardSourceExpiryService,
|
||||
DashboardTrendingItem,
|
||||
DashboardTrendingService,
|
||||
GlobalRecentItem,
|
||||
resolveSourceExpiryBadge,
|
||||
SOURCE_EXPIRY_TICK_MS,
|
||||
} from '@iptvnator/workspace/dashboard/data-access';
|
||||
import type { DashboardHeroTmdbExtras } from './dashboard-hero-tmdb.service';
|
||||
import { DashboardHeroTmdbService } from './dashboard-hero-tmdb.service';
|
||||
@@ -121,6 +124,7 @@ export class WorkspaceDashboardRailsComponent {
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly heroTmdb = inject(DashboardHeroTmdbService);
|
||||
private readonly sourceExpiry = inject(DashboardSourceExpiryService);
|
||||
readonly trendingService = inject(DashboardTrendingService);
|
||||
|
||||
readonly hasPlaylists = computed(() => this.data.playlists().length > 0);
|
||||
@@ -339,8 +343,23 @@ export class WorkspaceDashboardRailsComponent {
|
||||
.map((item) => this.toTrendingCard(item));
|
||||
});
|
||||
|
||||
readonly sourceCards = computed<DashboardRailCard[]>(() =>
|
||||
this.data.recentPlaylists().map((playlist) => ({
|
||||
// Minute heartbeat for the expiry badges: resolveSourceExpiryBadge reads
|
||||
// the wall clock, so without a reactive tick a dashboard left open would
|
||||
// never cross a day-countdown or expiration boundary. interval() emits
|
||||
// 0 first — shifted by one so it differs from initialValue, otherwise
|
||||
// the signal's equality check would swallow the first tick.
|
||||
private readonly sourceExpiryTick = toSignal(
|
||||
interval(SOURCE_EXPIRY_TICK_MS).pipe(map((tick) => tick + 1)),
|
||||
{ initialValue: 0 }
|
||||
);
|
||||
|
||||
readonly sourceCards = computed<DashboardRailCard[]>(() => {
|
||||
// Explicit language dependency for the translate.instant() calls
|
||||
// below (title fallback, expiry-badge labels). getPlaylistProvider
|
||||
// also reads the data service's language tick, but the labels must
|
||||
// not rely on that indirection surviving refactors.
|
||||
this.languageTick();
|
||||
return this.data.recentPlaylists().map((playlist) => ({
|
||||
id: playlist._id,
|
||||
title:
|
||||
playlist.title ||
|
||||
@@ -357,8 +376,9 @@ export class WorkspaceDashboardRailsComponent {
|
||||
playlist,
|
||||
this.playlistRefreshAction.canRefresh(playlist)
|
||||
),
|
||||
}))
|
||||
);
|
||||
expiryBadge: this.buildSourceExpiryBadge(playlist._id),
|
||||
}));
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Re-entering the dashboard should pick up any DB-backed recent/favorite
|
||||
@@ -410,6 +430,19 @@ export class WorkspaceDashboardRailsComponent {
|
||||
});
|
||||
});
|
||||
|
||||
// Subscription-expiry badges for the source cards. Xtream rides the
|
||||
// shared PortalStatusService cache; Stalker reads the import-time
|
||||
// snapshot (memoized per playlist), so this stays cheap on re-entry.
|
||||
// Gated on the sources rail being enabled — with the rail hidden no
|
||||
// badge can render, so the status checks would be pure waste.
|
||||
effect(() => {
|
||||
if (!this.dashboardRails().recentSources) {
|
||||
return;
|
||||
}
|
||||
const playlists = this.data.recentPlaylists();
|
||||
untracked(() => void this.sourceExpiry.refresh(playlists));
|
||||
});
|
||||
|
||||
// Trending rail: needs the TMDB opt-in and the Electron DB worker.
|
||||
// Deferred until the dashboard's own recent/favorites data is in so
|
||||
// the batched title match never competes for the worker at startup.
|
||||
@@ -597,6 +630,33 @@ export class WorkspaceDashboardRailsComponent {
|
||||
};
|
||||
}
|
||||
|
||||
private buildSourceExpiryBadge(
|
||||
playlistId: string
|
||||
): DashboardRailCard['expiryBadge'] {
|
||||
// Reactive read: ties the wall-clock evaluation below to the minute
|
||||
// tick (this method only runs inside the sourceCards computed).
|
||||
this.sourceExpiryTick();
|
||||
const badge = resolveSourceExpiryBadge(
|
||||
this.sourceExpiry.facts().get(playlistId),
|
||||
Date.now()
|
||||
);
|
||||
if (!badge) {
|
||||
return null;
|
||||
}
|
||||
return badge.kind === 'expired'
|
||||
? {
|
||||
kind: 'expired',
|
||||
label: this.t('WORKSPACE.DASHBOARD.SOURCE_EXPIRED'),
|
||||
}
|
||||
: {
|
||||
kind: 'expiring',
|
||||
label: this.translate.instant(
|
||||
'WORKSPACE.DASHBOARD.SOURCE_EXPIRES_IN_DAYS',
|
||||
{ days: badge.daysLeft }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private typeIcon(type: 'live' | 'movie' | 'series'): string {
|
||||
if (type === 'live') return 'live_tv';
|
||||
if (type === 'movie') return 'movie';
|
||||
|
||||
Reference in new issue
Block a user