mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
feat(dashboard): warn on source cards when a portal subscription expires soon
Dashboard source cards now carry a passive expiry chip: amber "Expires in N d" within 7 days of the subscription lapsing, error-toned "Expired" once it has. Account details stay behind the card's ⋮ → Account info. Xtream expirations ride on the playlist switcher's cached PortalStatusService check — checkPortalStatusDetails() now surfaces the parsed exp_date from the same round-trip, so the dashboard adds no extra portal calls. Stalker expirations come from the stalkerAccountInfo snapshot persisted at import; it lives in the playlist payload (meta rows carry payload: null), so each Stalker source costs one full-playlist read memoized on the playlist's update timestamp. New i18n keys added to all 19 locales via the i18n-fill merger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6c065124ed
commit
e7d32d7c61
34 files changed
+849
-30
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,50 @@
|
||||
/** Warn this many days before a portal subscription lapses. */
|
||||
export const SOURCE_EXPIRY_WARNING_DAYS = 7;
|
||||
|
||||
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,28 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// Subscription-expiry chip on portal source cards. Mirrors the episode
|
||||
// chip's geometry; colors follow the switcher's status palette (amber =
|
||||
// expiring soon, red = 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: color-mix(in srgb, #ff9800 16%, transparent);
|
||||
color: #e68a00;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rail__card-expiry--expired {
|
||||
background: color-mix(in srgb, #f44336 14%, transparent);
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,9 +43,11 @@ import {
|
||||
DashboardDataService,
|
||||
DashboardFavoriteItem,
|
||||
DashboardRecentlyAddedItem,
|
||||
DashboardSourceExpiryService,
|
||||
DashboardTrendingItem,
|
||||
DashboardTrendingService,
|
||||
GlobalRecentItem,
|
||||
resolveSourceExpiryBadge,
|
||||
} from '@iptvnator/workspace/dashboard/data-access';
|
||||
import type { DashboardHeroTmdbExtras } from './dashboard-hero-tmdb.service';
|
||||
import { DashboardHeroTmdbService } from './dashboard-hero-tmdb.service';
|
||||
@@ -121,6 +123,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);
|
||||
@@ -357,6 +360,7 @@ export class WorkspaceDashboardRailsComponent {
|
||||
playlist,
|
||||
this.playlistRefreshAction.canRefresh(playlist)
|
||||
),
|
||||
expiryBadge: this.buildSourceExpiryBadge(playlist._id),
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -410,6 +414,14 @@ 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.
|
||||
effect(() => {
|
||||
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 +609,30 @@ export class WorkspaceDashboardRailsComponent {
|
||||
};
|
||||
}
|
||||
|
||||
private buildSourceExpiryBadge(
|
||||
playlistId: string
|
||||
): DashboardRailCard['expiryBadge'] {
|
||||
const badge = resolveSourceExpiryBadge(
|
||||
this.sourceExpiry.facts().get(playlistId),
|
||||
Date.now()
|
||||
);
|
||||
if (!badge) {
|
||||
return null;
|
||||
}
|
||||
return badge.kind === 'expired'
|
||||
? {
|
||||
kind: 'expired',
|
||||
label: this.t('WORKSPACE.DASHBOARD.SOURCE_EXPIRED'),
|
||||
}
|
||||
: {
|
||||
kind: 'expiring',
|
||||
label: this.translate.instant(
|
||||
'WORKSPACE.DASHBOARD.SOURCE_EXPIRES_IN_DAYS',
|
||||
{ days: badge.daysLeft }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private typeIcon(type: 'live' | 'movie' | 'series'): string {
|
||||
if (type === 'live') return 'live_tv';
|
||||
if (type === 'movie') return 'movie';
|
||||
|
||||
Reference in new issue
Block a user