fix(dashboard): address PR review feedback

This commit is contained in:
4gray committed 2026-05-22 00:59:17 +03:00
1 parent 7083bd2af4
commit 2ecb5c28f4
30 files changed
+340 -104

No files matched your search

@@ -242,6 +242,7 @@ describe('DashboardDataService', () => {
playlist_id: 'm3u-1',
source: 'm3u',
poster_url: 'https://example.com/logo-1.png',
epg_lookup_key: 'tvg-1',
}),
expect.objectContaining({
id: 'https://example.com/stream-2.m3u8',
@@ -250,6 +251,7 @@ describe('DashboardDataService', () => {
playlist_id: 'm3u-1',
source: 'm3u',
poster_url: 'https://example.com/logo-2.png',
epg_lookup_key: 'tvg-2',
}),
])
);
@@ -436,6 +438,7 @@ describe('DashboardDataService', () => {
playlist_id: 'm3u-1',
source: 'm3u',
xtream_id: 'https://example.com/stream-1.m3u8',
epg_lookup_key: 'tvg-1',
}),
])
);
@@ -696,10 +699,27 @@ describe('DashboardDataService', () => {
const series = service
.globalRecentVodItems()
.find((item) => item.title === 'Shadow Bay');
const position = service.getPlaybackPositionForItem(series!);
const positionsMap = service.playbackPositions$();
const failOnLinearScan = jest.fn(() => {
throw new Error('series lookup should not scan all positions');
});
(
positionsMap as unknown as {
values: () => IterableIterator<PlaybackPositionData>;
}
).values =
failOnLinearScan as unknown as () => IterableIterator<PlaybackPositionData>;
expect(series).toBeDefined();
if (!series) {
throw new Error('expected Shadow Bay recent item');
}
const position = service.getPlaybackPositionForItem(series);
expect(position?.contentType).toBe('episode');
expect(position?.seasonNumber).toBe(3);
expect(position?.episodeNumber).toBe(7);
expect(failOnLinearScan).not.toHaveBeenCalled();
});
it('exposes a live-only slice of global favorites so the dashboard can promote favorited channels into the Live rail', async () => {
@@ -65,6 +65,28 @@ function playbackPositionMapKey(
return `${playlistId}::${contentXtreamId}::${contentType}`;
}
function seriesPlaybackPositionMapKey(
playlistId: string,
seriesXtreamId: number
): string {
return `${playlistId}::${seriesXtreamId}`;
}
function newestPlaybackPosition(
current: PlaybackPositionData | null | undefined,
candidate: PlaybackPositionData | null | undefined
): PlaybackPositionData | null {
if (!current) {
return candidate ?? null;
}
if (!candidate) {
return current;
}
return (candidate.updatedAt ?? '') > (current.updatedAt ?? '')
? candidate
: current;
}
/** @deprecated Use {@link PortalRecentItem} from `@iptvnator/shared/interfaces` instead. */
export type GlobalRecentItem = PortalRecentItem;
/** @deprecated Use {@link PortalFavoriteItem} from `@iptvnator/shared/interfaces` instead. */
@@ -239,6 +261,9 @@ export class DashboardDataService {
private readonly playbackPositionsMap = signal<
Map<string, PlaybackPositionData>
>(new Map());
private readonly playbackPositionsBySeriesMap = signal<
Map<string, PlaybackPositionData>
>(new Map());
readonly playbackPositions$ = this.playbackPositionsMap.asReadonly();
@@ -267,29 +292,17 @@ export class DashboardDataService {
// Series recent_items rows carry either the series id (the series
// landing page writes the series itself) or, for direct-play flows,
// the episode id. Match both shapes and prefer the most recently
// updated episode position so the card can show resume progress and
// a "S2 · E5" badge regardless of how the row was originally saved.
let best: PlaybackPositionData | null = null;
for (const position of this.playbackPositionsMap().values()) {
if (position.contentType !== 'episode') continue;
if (
position.playlistId &&
position.playlistId !== item.playlist_id
) {
continue;
}
const matchesEpisode = position.contentXtreamId === xtreamId;
const matchesSeries = position.seriesXtreamId === xtreamId;
if (!matchesEpisode && !matchesSeries) continue;
if (
!best ||
(position.updatedAt ?? '') > (best.updatedAt ?? '')
) {
best = position;
}
}
return best;
// the episode id. Match both shapes through keyed maps so card renders
// do not scan every saved playback position.
const episodePosition =
this.playbackPositionsMap().get(
playbackPositionMapKey(item.playlist_id, xtreamId, 'episode')
) ?? null;
const seriesPosition =
this.playbackPositionsBySeriesMap().get(
seriesPlaybackPositionMapKey(item.playlist_id, xtreamId)
) ?? null;
return newestPlaybackPosition(episodePosition, seriesPosition);
}
/**
@@ -307,10 +320,12 @@ export class DashboardDataService {
}
if (playlistIds.size === 0) {
this.playbackPositionsMap.set(new Map());
this.playbackPositionsBySeriesMap.set(new Map());
return;
}
const next = new Map<string, PlaybackPositionData>();
const nextBySeries = new Map<string, PlaybackPositionData>();
for (const playlistId of playlistIds) {
try {
const positions =
@@ -326,6 +341,22 @@ export class DashboardDataService {
),
position
);
if (
position.contentType === 'episode' &&
Number.isFinite(position.seriesXtreamId)
) {
const seriesKey = seriesPlaybackPositionMapKey(
playlistId,
position.seriesXtreamId as number
);
nextBySeries.set(
seriesKey,
newestPlaybackPosition(
nextBySeries.get(seriesKey),
position
) as PlaybackPositionData
);
}
}
} catch (err) {
console.warn(
@@ -336,7 +367,10 @@ export class DashboardDataService {
}
}
this.ngZone.run(() => this.playbackPositionsMap.set(next));
this.ngZone.run(() => {
this.playbackPositionsMap.set(next);
this.playbackPositionsBySeriesMap.set(nextBySeries);
});
}
readonly stalkerGlobalFavorites = computed<DashboardFavoriteItem[]>(() => {
@@ -1041,6 +1075,11 @@ export class DashboardDataService {
category_id: 'live',
xtream_id: matchedFavoriteId,
poster_url: channel.tvg?.logo || undefined,
epg_lookup_key:
channel.tvg?.id?.trim() ||
channel.tvg?.name?.trim() ||
channel.name?.trim() ||
undefined,
source: 'm3u',
});
return acc;
@@ -110,7 +110,8 @@
<span
class="rail__channel-progress"
[class.rail__channel-progress--idle]="
card.nowPlayingProgress == null
card.nowPlayingProgress === null ||
card.nowPlayingProgress === undefined
"
>
<i
@@ -173,7 +174,7 @@
</div>
}
@if (card.watchProgress != null) {
@if (card.watchProgress !== null && card.watchProgress !== undefined) {
<span
class="rail__art-progress"
aria-hidden="true"
@@ -35,6 +35,7 @@ export interface DashboardRailCard {
link: string[];
state?: Record<string, unknown>;
actions?: DashboardRailAction[];
epgLookupKey?: string;
/**
* Optional EPG enrichment shown by the 'channel' rail layout. Populated
@@ -76,7 +76,7 @@
<h1 class="hero__title">{{ item.title }}</h1>
<p class="hero__subtitle">{{ item.subtitle }}</p>
@if (item.watchProgress != null) {
@if (item.watchProgress !== null && item.watchProgress !== undefined) {
<span class="hero__progress" aria-hidden="true">
<span
class="hero__progress-bar"
@@ -1,12 +1,17 @@
import type { EpgProgram, PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
buildLiveEpgLookupKeys,
buildPlaybackPositionReloadKey,
buildDashboardSourceActions,
calcEpgProgress,
formatEpgTimeRange,
formatRemainingLabel,
getLiveEpgProgramForCard,
liveRailTitleKeyForSource,
playbackProgressPercent,
resolveDashboardHeroArtwork,
} from './workspace-dashboard-rails.component';
import type { DashboardRailCard } from './dashboard-rail.component';
describe('buildDashboardSourceActions', () => {
const basePlaylist = {
@@ -201,6 +206,83 @@ describe('EPG enrichment helpers', () => {
});
});
describe('Live rail helpers', () => {
const channelCard = (
overrides: Partial<DashboardRailCard> = {}
): DashboardRailCard => ({
id: 'card-1',
title: 'Display Channel',
subtitle: 'M3U · Live',
icon: 'live_tv',
contentType: 'live',
link: ['/workspace', 'playlists', 'm3u-1'],
...overrides,
});
it('uses explicit EPG lookup keys before falling back to card titles', () => {
expect(
buildLiveEpgLookupKeys([
channelCard({
title: 'Das Erste HD',
epgLookupKey: 'ard.de',
}),
channelCard({
id: 'card-2',
title: 'Das Erste HD',
epgLookupKey: 'ard.de',
}),
channelCard({
id: 'card-3',
title: 'Fallback News',
}),
])
).toEqual(['ard.de', 'Fallback News']);
});
it('reads EPG programs by explicit lookup key instead of display title', () => {
const program = { title: 'Tagesschau' } as EpgProgram;
const wrongProgram = { title: 'Wrong channel' } as EpgProgram;
const card = channelCard({
title: 'Das Erste HD',
epgLookupKey: 'ard.de',
});
expect(
getLiveEpgProgramForCard(
card,
new Map<string, EpgProgram | null>([
['Das Erste HD', wrongProgram],
['ard.de', program],
])
)
).toBe(program);
});
it('uses honest, semantically named title keys for favorite and recent live rails', () => {
expect(liveRailTitleKeyForSource('favorites')).toBe(
'WORKSPACE.DASHBOARD.LIVE_FAVORITES'
);
expect(liveRailTitleKeyForSource('recent')).toBe(
'WORKSPACE.DASHBOARD.LIVE_RECENT'
);
});
it('builds a stable playback-position reload key from VOD and series items only', () => {
const first = buildPlaybackPositionReloadKey([
{ playlist_id: 'b', xtream_id: 20, type: 'series' },
{ playlist_id: 'live', xtream_id: 'stream-url', type: 'live' },
{ playlist_id: 'a', xtream_id: 10, type: 'movie' },
]);
const second = buildPlaybackPositionReloadKey([
{ playlist_id: 'a', xtream_id: 10, type: 'movie' },
{ playlist_id: 'b', xtream_id: 20, type: 'series' },
]);
expect(first).toBe(second);
expect(first).toBe('a::movie::10|b::series::20');
});
});
describe('playback-position helpers', () => {
describe('playbackProgressPercent', () => {
it('returns null when there is no position or no duration', () => {
@@ -5,6 +5,7 @@ import {
effect,
inject,
signal,
untracked,
} from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { interval, of, startWith, switchMap } from 'rxjs';
@@ -258,6 +259,56 @@ export function calcEpgProgress(
return Math.max(0, Math.min(100, ratio * 100));
}
function liveEpgLookupKeyForCard(card: DashboardRailCard): string {
return card.epgLookupKey?.trim() || card.title.trim();
}
export function buildLiveEpgLookupKeys(
cards: readonly DashboardRailCard[]
): string[] {
const seen = new Set<string>();
const keys: string[] = [];
for (const card of cards) {
const key = liveEpgLookupKeyForCard(card);
if (!key || seen.has(key)) continue;
seen.add(key);
keys.push(key);
}
return keys;
}
export function getLiveEpgProgramForCard(
card: DashboardRailCard,
epgMap: ReadonlyMap<string, EpgProgram | null>
): EpgProgram | null {
const key = liveEpgLookupKeyForCard(card);
const program = epgMap.get(key);
if (program) {
return program;
}
const titleKey = card.title.trim();
return key !== titleKey ? (epgMap.get(titleKey) ?? null) : null;
}
export function liveRailTitleKeyForSource(
source: 'favorites' | 'recent'
): string {
return source === 'favorites'
? 'WORKSPACE.DASHBOARD.LIVE_FAVORITES'
: 'WORKSPACE.DASHBOARD.LIVE_RECENT';
}
export function buildPlaybackPositionReloadKey(
items: readonly Pick<GlobalRecentItem, 'playlist_id' | 'type' | 'xtream_id'>[]
): string {
return items
.filter((item) => item.type === 'movie' || item.type === 'series')
.map((item) => `${item.playlist_id}::${item.type}::${item.xtream_id}`)
.sort()
.join('|');
}
// ── Playback-position helpers (used by both hero + Continue Watching cards)
// — kept as plain functions so they're easy to unit-test without mounting the
// component or the data service.
@@ -431,9 +482,7 @@ export class WorkspaceDashboardRailsComponent {
// we're rendering favorites, "Continue with live TV" when we're falling
// back to recently-watched. Always honest about what the user is seeing.
readonly liveRailTitleKey = computed(() =>
this.liveRailSource() === 'favorites'
? 'WORKSPACE.DASHBOARD.LIVE_RECENT'
: 'WORKSPACE.DASHBOARD.LIVE_CONTINUE'
liveRailTitleKeyForSource(this.liveRailSource())
);
readonly liveRailTotalCount = computed(() =>
@@ -442,22 +491,17 @@ export class WorkspaceDashboardRailsComponent {
: this.data.globalRecentLiveItems().length
);
// Best-effort EPG lookup keyed by the channel's display name. This works
// for M3U sources whose XMLTV channel ids resolve through the existing
// `resolveChannelEpgLookupKey()` (tvg-id → tvg-name → name) chain.
// Best-effort EPG lookup keyed by the app-wide M3U XMLTV chain
// (tvg-id -> tvg-name -> name), with the card title as a final fallback.
// Xtream/Stalker live items often have no XMLTV side-channel and will
// simply return null — the card renders without the program row.
private readonly liveChannelLookupKeys = computed(() => {
const seen = new Set<string>();
const keys: string[] = [];
for (const card of this.liveOnFavoritesCards()) {
const key = card.title.trim();
if (!key || seen.has(key)) continue;
seen.add(key);
keys.push(key);
}
return keys;
});
private readonly liveChannelLookupKeys = computed(() =>
buildLiveEpgLookupKeys(this.liveOnFavoritesCards())
);
private readonly playbackPositionReloadKey = computed(() =>
buildPlaybackPositionReloadKey(this.data.globalRecentVodItems())
);
// Re-fetch on rail change AND on a 30s heartbeat so the progress bar
// catches the boundary between programs without a full page revisit.
@@ -486,7 +530,7 @@ export class WorkspaceDashboardRailsComponent {
// 30s ticks even if the program identity is unchanged.
const now = Date.now();
return this.liveOnFavoritesCards().map((card) => {
const program = epgMap.get(card.title.trim()) ?? null;
const program = getLiveEpgProgramForCard(card, epgMap);
if (!program) {
return card;
}
@@ -552,19 +596,11 @@ export class WorkspaceDashboardRailsComponent {
void this.data.reloadXtreamRecentlyAddedItems(RAIL_ITEM_LIMIT);
});
// Reload playback positions whenever the set of VOD/series recent
// items changes (new playlists with watch history, removed playlist,
// newly tracked content). Using just the count + the playlist ids as
// the dependency keeps unrelated tick churn out of the IPC path.
// Reload playback positions when the VOD/series recent set changes.
// The primitive key keeps live-only recent churn out of the IPC path.
effect(() => {
const playlistKeys = new Set<string>();
for (const item of this.data.globalRecentVodItems()) {
playlistKeys.add(item.playlist_id);
}
void this.data.reloadPlaybackPositions();
// Sort so the effect dep is stable across re-renders for the
// same set; otherwise the computed identity flips every tick.
void [...playlistKeys].sort().join('|');
this.playbackPositionReloadKey();
untracked(() => void this.data.reloadPlaybackPositions());
});
}
@@ -634,6 +670,7 @@ export class WorkspaceDashboardRailsComponent {
imageUrl: item.poster_url,
icon: this.typeIcon(item.type),
contentType: item.type,
epgLookupKey: item.epg_lookup_key,
link: this.data.getRecentItemLink(item),
state: this.data.getRecentItemNavigationState(item),
watchProgress,
@@ -649,6 +686,7 @@ export class WorkspaceDashboardRailsComponent {
imageUrl: item.poster_url,
icon: this.typeIcon(item.type),
contentType: item.type,
epgLookupKey: item.epg_lookup_key,
link: this.data.getGlobalFavoriteLink(item),
state: this.data.getGlobalFavoriteNavigationState(item),
};