From a6186a46c80aaf7651672e9350ad9bbfd2155a32 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:24:16 +0200 Subject: [PATCH] feat(dashboard): subscription-expiry warning badge on source cards (#1342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- .changes/dashboard-source-expiry-badge.md | 10 + CLAUDE.md | 1 + apps/web/src/assets/i18n/ar.json | 4 +- apps/web/src/assets/i18n/ary.json | 4 +- apps/web/src/assets/i18n/by.json | 4 +- apps/web/src/assets/i18n/de.json | 4 +- apps/web/src/assets/i18n/el.json | 4 +- apps/web/src/assets/i18n/en.json | 4 +- apps/web/src/assets/i18n/es.json | 4 +- apps/web/src/assets/i18n/fr.json | 4 +- apps/web/src/assets/i18n/hu.json | 4 +- apps/web/src/assets/i18n/it.json | 4 +- apps/web/src/assets/i18n/ja.json | 4 +- apps/web/src/assets/i18n/ko.json | 4 +- apps/web/src/assets/i18n/nl.json | 4 +- apps/web/src/assets/i18n/pl.json | 4 +- apps/web/src/assets/i18n/pt.json | 4 +- apps/web/src/assets/i18n/ru.json | 4 +- apps/web/src/assets/i18n/tr.json | 4 +- apps/web/src/assets/i18n/zh.json | 4 +- apps/web/src/assets/i18n/zhtw.json | 4 +- apps/web/src/m3-theme.scss | 19 ++ .../src/lib/portal-status.service.spec.ts | 49 ++++ .../services/src/lib/portal-status.service.ts | 59 +++- .../src/lib/xtream-portal.utils.spec.ts | 36 +++ .../interfaces/src/lib/xtream-portal.utils.ts | 11 + .../dashboard/data-access/src/index.ts | 2 + .../lib/dashboard-source-expiry.service.ts | 167 +++++++++++ .../src/lib/dashboard-source-expiry.spec.ts | 274 ++++++++++++++++++ .../src/lib/dashboard-source-expiry.util.ts | 58 ++++ .../lib/rails/dashboard-rail.component.html | 14 + .../lib/rails/dashboard-rail.component.scss | 29 ++ .../rails/dashboard-rail.component.spec.ts | 65 +++++ .../src/lib/rails/dashboard-rail.component.ts | 7 + .../workspace-dashboard-rails.component.ts | 70 ++++- 35 files changed, 912 insertions(+), 35 deletions(-) create mode 100644 .changes/dashboard-source-expiry-badge.md create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.service.ts create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.spec.ts create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.util.ts diff --git a/.changes/dashboard-source-expiry-badge.md b/.changes/dashboard-source-expiry-badge.md new file mode 100644 index 000000000..7ea44e806 --- /dev/null +++ b/.changes/dashboard-source-expiry-badge.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index a2d95db04..491e6ce4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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**: diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index b10bf92f0..e1705d5c7 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 64feda99f..058bab158 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index aea483e55..91e0a2b59 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 2b164fc0c..473d2c5cf 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index f50be139b..6b5b6278c 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index c908f2fa0..85f744542 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 6959a7fe2..9c40aa2da 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index d9d4c4ff3..ab787e7b4 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index d83846d65..32d8b4df3 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index b0f143440..a42a6f76c 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index d1e9885da..814ea5fdf 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 631c938ff..13e3a950c 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index af5250773..9d7d3066e 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 1ed478cd3..dcbcd69c4 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 5e9370751..42fb7a57b 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index f18e817aa..09b6e33fb 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 9972b8cdd..361923064 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index dd8e65302..b405219f5 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 0fe2c5d58..5e1818cb6 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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", diff --git a/apps/web/src/m3-theme.scss b/apps/web/src/m3-theme.scss index 4f8ac3325..235010a23 100644 --- a/apps/web/src/m3-theme.scss +++ b/apps/web/src/m3-theme.scss @@ -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, diff --git a/libs/services/src/lib/portal-status.service.spec.ts b/libs/services/src/lib/portal-status.service.spec.ts index 3f0f8f2a3..4541ffe5a 100644 --- a/libs/services/src/lib/portal-status.service.spec.ts +++ b/libs/services/src/lib/portal-status.service.spec.ts @@ -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: { diff --git a/libs/services/src/lib/portal-status.service.ts b/libs/services/src/lib/portal-status.service.ts index f53a80e83..7074af1dc 100644 --- a/libs/services/src/lib/portal-status.service.ts +++ b/libs/services/src/lib/portal-status.service.ts @@ -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>(); + private readonly inFlight = new Map>(); /** * Checks the status of an Xtream Code portal @@ -75,13 +88,32 @@ export class PortalStatusService { password: string, options?: CheckPortalStatusOptions ): Promise { + 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 { 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 { + ): Promise { 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 }; } /** diff --git a/libs/shared/interfaces/src/lib/xtream-portal.utils.spec.ts b/libs/shared/interfaces/src/lib/xtream-portal.utils.spec.ts index cde4acd1b..2c34368a8 100644 --- a/libs/shared/interfaces/src/lib/xtream-portal.utils.spec.ts +++ b/libs/shared/interfaces/src/lib/xtream-portal.utils.spec.ts @@ -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(); + }); + }); }); diff --git a/libs/shared/interfaces/src/lib/xtream-portal.utils.ts b/libs/shared/interfaces/src/lib/xtream-portal.utils.ts index 3eaf73a67..d158b0a8a 100644 --- a/libs/shared/interfaces/src/lib/xtream-portal.utils.ts +++ b/libs/shared/interfaces/src/lib/xtream-portal.utils.ts @@ -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 { diff --git a/libs/workspace/dashboard/data-access/src/index.ts b/libs/workspace/dashboard/data-access/src/index.ts index fe59f79f0..f97ea519d 100644 --- a/libs/workspace/dashboard/data-access/src/index.ts +++ b/libs/workspace/dashboard/data-access/src/index.ts @@ -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'; diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.service.ts new file mode 100644 index 000000000..5d372aaa0 --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.service.ts @@ -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>( + 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 { + const round = ++this.refreshRound; + const validIds = new Set(playlists.map((playlist) => playlist._id)); + + this.applyIfCurrent(round, (map) => { + let next: Map | 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 { + 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 { + 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 { + 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 + ) => ReadonlyMap + ): void { + if (round !== this.refreshRound) { + return; + } + this.ngZone.run(() => { + this.factsState.update(update); + }); + } +} diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.spec.ts new file mode 100644 index 000000000..cd9184dfd --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.spec.ts @@ -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); + }); +}); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.util.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.util.ts new file mode 100644 index 000000000..0e1627099 --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.util.ts @@ -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; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html index 632dfa7e8..cfe91881b 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html @@ -204,6 +204,20 @@ card.subtitle }} } + @if (card.expiryBadge; as expiry) { + {{ expiry.label }} + } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss index b459f7d29..b7fb55c6b 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss @@ -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; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts index 6e6f39a7b..506825dd7 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts @@ -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); + }); + }); }); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts index 9a3cbaa9f..e3eaaa589 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts @@ -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; } /** diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index ee5f7230f..810728126 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -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(() => - 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(() => { + // 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';