mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
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:
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;
|
||||
|
||||
Reference in new issue
Block a user