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:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-02 13:24:16 +02:00
1 parent 8f9e78ff90
commit a6186a46c8
35 files changed
+912 -35

No files matched your search

+10
View File
@@ -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.
+1
View File
@@ -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**:
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+19
View File
@@ -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: {
+48 -11
View File
@@ -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;
}
/**
@@ -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';