mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
feat(dashboard): subscription-expiry warning badge on source cards (#1342)
* feat(dashboard): warn on source cards when a portal subscription expires soon Dashboard source cards now carry a passive expiry chip: amber "Expires in N d" within 7 days of the subscription lapsing, error-toned "Expired" once it has. Account details stay behind the card's ⋮ → Account info. Xtream expirations ride on the playlist switcher's cached PortalStatusService check — checkPortalStatusDetails() now surfaces the parsed exp_date from the same round-trip, so the dashboard adds no extra portal calls. Stalker expirations come from the stalkerAccountInfo snapshot persisted at import; it lives in the playlist payload (meta rows carry payload: null), so each Stalker source costs one full-playlist read memoized on the playlist's update timestamp. New i18n keys added to all 19 locales via the i18n-fill merger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(dashboard): address review feedback on expiry badges - Recompute expiry badges on a minute tick so a dashboard left open crosses day-countdown and expiration boundaries (Greptile P1 / Codex P2) - Gate the expiry refresh on the recent-sources rail setting so hidden rails cost no portal checks or playlist reads (Codex P2) - Move chip colors to theme-aware tokens in m3-theme.scss; both themes now hold >= 4.5:1 small-text contrast (light warn 5.3:1, light expired 5.4:1, dark warn 7.4:1, dark expired 6.0:1) (Codex P2) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(dashboard): make expiry-badge labels depend on the language signal sourceCards previously relied on getPlaylistProvider's indirect language read; the translate.instant() labels now read languageTick explicitly (mirroring trendingCards). Also shift the minute tick by one so the interval's first 0 differs from initialValue — the signal equality check was swallowing the first heartbeat, delaying it to two minutes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8f9e78ff90
commit
a6186a46c8
35 files changed
+912
-35
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user