feat(dashboard): say where you left off instead of which provider it came from (#1646)

Every dashboard title carried a "Xtream · Series" / "Stalker · Movie" subtitle. Provider kind and content kind are the app's own taxonomy, not a property of the title, and they are identical on every card in a rail — so the one line that could tell two cards apart said nothing.

The hero now shows the source name alone, through `playlistDisplayLabel` (a stored playlist name is routinely the pasted URL with credentials, or a MAC). Continue Watching cards show what actually varies: the "S1·E5" chip plus "12 min left". Favorites keep the title alone, Recently Added keeps the source name, and a meta row with nothing in it is no longer rendered.

Stalker shows filed under Movies had no badge, no progress and no resume. An embedded-VOD row announces its episodes through a `series[]` array and carries no `is_series` flag, so `extractStalkerItemType` reports `movie` on purpose — the item must keep routing to the VOD catalog — while its progress is a set of episode positions keyed by the parent id, which the dashboard was looking up as a single `vod` row and never finding.

Split the two questions: `PortalActivityItem.watch_kind` records the progress model when it differs from the routing type, and every reader that has to choose goes through `resolvePortalActivityWatchKind` instead of `type`. That makes the resume handoff reachable for Stalker, so wire it through `STALKER_SERIES_RESUME_TARGET`: consumed once after the series positions are read, hydrating a lazy Ministra season first with a bounded two-attempt retry, and playing nothing at all when the position read failed rather than restarting the episode from zero.

Also fixes the global-recent route template, which bound `[seriesResume]` only on its Xtream branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 authored and GitHub committed 2026-09-20 22:19:47 +02:00
1 parent 954c8ad65e
commit 4e575a810e
26 files changed
+1514 -160

No files matched your search

@@ -1066,6 +1066,103 @@ describe('DashboardDataService', () => {
);
});
it('resolves episode progress for a Stalker embedded-VOD show stored without is_series', async () => {
// Real stored shape (RUcolor-style portal): the recent entry keeps
// the `series[]` episode array and a numeric VOD category, but no
// `is_series` flag. It must keep routing as a movie while its
// progress, badge, and resume handoff come from the episode rows
// saved under the parent id.
playlistsSignal.set([
...createDefaultPlaylists(),
{
_id: 'stalker-embedded',
title: 'RUcolor.tv',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
macAddress: '00:11:22:33:44:55',
recentlyViewed: [
{
id: '17572',
title: 'Fake (10 episodes)',
category_id: '7',
cmd: '/media/17572.mpg',
series: [1, 2, 3, 4, 5, 6, 7, 8],
added_at: '2026-09-19T16:13:50.000Z',
},
],
},
]);
playbackPositionsMock.getAllPlaybackPositions.mockImplementation(
async (playlistId: string) =>
playlistId === 'stalker-embedded'
? [
{
playlistId,
contentXtreamId: 1750797719,
contentType: 'episode',
seriesXtreamId: 17572,
seasonNumber: 1,
episodeNumber: 2,
positionSeconds: 2673,
durationSeconds: 2761,
updatedAt: '2026-09-17T10:55:35.000Z',
},
{
playlistId,
contentXtreamId: 1750797722,
contentType: 'episode',
seriesXtreamId: 17572,
seasonNumber: 1,
episodeNumber: 5,
positionSeconds: 2306,
durationSeconds: 2920,
updatedAt: '2026-09-18T21:45:56.000Z',
},
]
: []
);
await service.reloadPlaybackPositions();
const item = service
.globalRecentItems()
.find((recent) => recent.playlist_id === 'stalker-embedded');
if (!item) {
throw new Error('expected the Stalker embedded-series recent item');
}
expect(item.type).toBe('movie');
expect(item.watch_kind).toBe('series');
expect(service.getPlaybackPositionForItem(item)).toEqual(
expect.objectContaining({
contentType: 'episode',
seasonNumber: 1,
episodeNumber: 5,
positionSeconds: 2306,
})
);
expect(service.getRecentItemResumeNavigation(item)).toEqual(
expect.objectContaining({
link: ['/workspace', 'global-recent'],
state: {
openCollectionDetailItem: expect.objectContaining({
item: expect.objectContaining({
sourceType: 'stalker',
contentType: 'movie',
}),
seriesResume: {
seriesXtreamId: 17572,
contentXtreamId: 1750797722,
seasonNumber: 1,
episodeNumber: 5,
},
}),
},
})
);
});
it('keeps legacy episode-keyed recents detail-only when the position row lacks the parent series id', async () => {
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
{
@@ -36,11 +36,11 @@ import {
PortalActivityType,
PortalFavoriteItem,
PortalRecentItem,
resolvePortalActivityWatchKind,
stalkerItemMatchesId,
} from '@iptvnator/shared/interfaces';
import {
buildStalkerFavoriteItems,
getActivityTypeLabelKey,
mapDbFavoriteToItem,
mapDbRecentlyAddedToItem,
mapDbRecentToItem,
@@ -293,7 +293,11 @@ export class DashboardDataService {
getPlaybackPositionForItem(
item: PortalActivityItem
): PlaybackPositionData | null {
if (item.type !== 'movie' && item.type !== 'series') {
// The progress model, not the routing type: a Stalker embedded-VOD
// row routes as a movie but tracks episodes under its parent id,
// and a `vod` lookup for it finds nothing.
const watchKind = resolvePortalActivityWatchKind(item);
if (!watchKind) {
return null;
}
const xtreamId =
@@ -304,7 +308,7 @@ export class DashboardDataService {
return null;
}
if (item.type === 'movie') {
if (watchKind === 'movie') {
const key = playbackPositionMapKey(
item.playlist_id,
xtreamId,
@@ -883,11 +887,6 @@ export class DashboardDataService {
return this.translateText('WORKSPACE.DASHBOARD.PROVIDER');
}
getRecentItemTypeLabel(item: GlobalRecentItem): string {
this.languageTick();
return this.translateText(getActivityTypeLabelKey(item.type));
}
getRecentItemLink(item: GlobalRecentItem): string[] {
return getRecentItemLinkUtil(item);
}
@@ -1002,48 +1001,6 @@ export class DashboardDataService {
}
}
getFavoriteItemProviderLabel(item: DashboardFavoriteItem): string {
return this.getActivityItemProviderLabel(item);
}
getRecentlyAddedItemProviderLabel(
item: DashboardRecentlyAddedItem
): string {
return this.getActivityItemProviderLabel(item);
}
private getActivityItemProviderLabel(
item: Pick<PortalActivityItem, 'source'>
): string {
this.languageTick();
if (item.source === 'stalker') {
return this.translateText('WORKSPACE.DASHBOARD.STALKER');
}
if (item.source === 'xtream') {
return this.translateText('WORKSPACE.DASHBOARD.XTREAM');
}
if (item.source === 'm3u') {
return this.translateText('WORKSPACE.DASHBOARD.M3U');
}
return this.translateText('WORKSPACE.DASHBOARD.PROVIDER');
}
getFavoriteItemTypeLabel(item: DashboardFavoriteItem): string {
return this.getActivityItemTypeLabel(item);
}
getRecentlyAddedItemTypeLabel(item: DashboardRecentlyAddedItem): string {
return this.getActivityItemTypeLabel(item);
}
private getActivityItemTypeLabel(
item: Pick<PortalActivityItem, 'type'>
): string {
this.languageTick();
return this.translateText(getActivityTypeLabelKey(item.type));
}
getGlobalFavoriteLink(item: DashboardFavoriteItem): string[] {
return getGlobalFavoriteLinkUtil(item);
}
@@ -16,6 +16,7 @@ import {
extractStalkerItemTitle,
extractStalkerItemTmdbHints,
extractStalkerItemType,
isStalkerSeriesItem,
normalizeStalkerDate,
} from '@iptvnator/shared/interfaces';
@@ -25,12 +26,6 @@ export function normalizeActivityType(value: string): PortalActivityType {
return value === 'live' || value === 'series' ? value : 'movie';
}
export function getActivityTypeLabelKey(type: PortalActivityType): string {
if (type === 'live') return 'WORKSPACE.DASHBOARD.TYPE_LIVE';
if (type === 'series') return 'WORKSPACE.DASHBOARD.TYPE_SERIES';
return 'WORKSPACE.DASHBOARD.TYPE_MOVIE';
}
// ────── Xtream DB → ViewModel ──────
/**
@@ -146,6 +141,11 @@ export function buildStalkerFavoriteItems(
backdrop_url: extractStalkerItemTmdbHints(raw).backdropUrl,
source: 'stalker' as const,
stalker_item: item,
// Same split as the recent-items mapper: routing type
// vs. per-episode progress model.
...(isStalkerSeriesItem(raw)
? { watch_kind: 'series' as const }
: {}),
} as PortalFavoriteItem;
});
@@ -37,7 +37,13 @@ function episodePosition(
type RecentNavigationState = {
openCollectionDetailItem?: {
item?: { uid?: string; xtreamId?: number };
item?: {
uid?: string;
xtreamId?: number;
sourceType?: string;
contentType?: string;
stalkerId?: string;
};
seriesResume?: unknown;
};
};
@@ -124,13 +130,64 @@ describe('getRecentItemResumeNavigation', () => {
).toBeNull();
});
it('returns null for non-Xtream sources and missing positions', () => {
it('returns null for M3U sources and missing positions', () => {
expect(
getRecentItemResumeNavigation(
{ ...recentSeries, source: 'stalker' },
{ ...recentSeries, source: 'm3u' },
episodePosition()
)
).toBeNull();
expect(getRecentItemResumeNavigation(recentSeries, null)).toBeNull();
});
it('resumes a Stalker embedded-VOD show that routes as a movie', () => {
// The stored row lives in the VOD catalog (`type: 'movie'` keeps it
// routing there) but tracks per-episode progress under its parent id.
const stalkerShow: PortalRecentItem = {
id: '17572',
title: 'Fake (10 episodes)',
type: 'movie',
watch_kind: 'series',
source: 'stalker',
playlist_id: 'stalker-R',
category_id: '7',
xtream_id: '17572',
viewed_at: '2026-09-19T16:13:50.000Z',
stalker_item: { id: '17572', series: [1, 2, 3] } as never,
};
const navigation = getRecentItemResumeNavigation(
stalkerShow,
episodePosition({
playlistId: 'stalker-R',
seriesXtreamId: 17572,
contentXtreamId: 1750797722,
seasonNumber: 1,
episodeNumber: 5,
})
);
expect(navigation?.link).toEqual(['/workspace', 'global-recent']);
const state = navigation?.state as RecentNavigationState;
expect(state.openCollectionDetailItem?.item).toEqual(
expect.objectContaining({
sourceType: 'stalker',
contentType: 'movie',
stalkerId: '17572',
})
);
expect(state.openCollectionDetailItem?.seriesResume).toEqual({
seriesXtreamId: 17572,
contentXtreamId: 1750797722,
seasonNumber: 1,
episodeNumber: 5,
});
// Without the watch kind the same row is a plain movie: no handoff.
expect(
getRecentItemResumeNavigation(
{ ...stalkerShow, watch_kind: undefined },
episodePosition({ seriesXtreamId: 17572 })
)
).toBeNull();
});
});
@@ -5,6 +5,7 @@ import {
PortalAddedItem,
PortalFavoriteItem,
PortalRecentItem,
resolvePortalActivityWatchKind,
} from '@iptvnator/shared/interfaces';
import {
buildStalkerDetailNavigationTarget,
@@ -92,7 +93,7 @@ export function getRecentItemDetailNavigationState(
/**
* Full navigation target carrying the one-shot resume handoff, or null when
* the item/position cannot produce one (non-Xtream, non-series, watched or
* the item/position cannot produce one (M3U, non-series, watched or
* coordinate-less rows). Powers the card's explicit "Resume episode" action.
*/
export function getRecentItemResumeNavigation(
@@ -127,9 +128,12 @@ function buildRecentSeriesIdentityTarget(
item: PortalRecentItem,
playbackPosition?: PlaybackPositionData | null
): SeriesResumeTarget | null {
// Watch kind, not `type`: a Stalker embedded-VOD row routes as a movie
// yet resumes an episode (the detail decides the mode from the stored
// item, so the collection item keeps its routing type).
if (
item.type !== 'series' ||
item.source !== 'xtream' ||
resolvePortalActivityWatchKind(item) !== 'series' ||
(item.source !== 'xtream' && item.source !== 'stalker') ||
playbackPosition?.contentType !== 'episode'
) {
return null;