feat(xtream): resume the latest series episode (#1187)

Dashboard Continue Watching now carries the exact saved season/episode into
Xtream series details and starts it at the persisted offset. Successful
external MPV/VLC launches persist the launched episode and retarget the
series CTA to "Play episode N". Recent-history rows keyed by an episode id
resolve their parent series before navigation.

Includes maintainer follow-ups: no zero-offset resume on failed position
loads, seriesXtreamId-gated resume targets for legacy rows, and patch
coverage raised from 76.7% to 93.9%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
MahdiHrmandClaude Fable 5 authored and GitHub committed 2026-07-18 15:49:22 +02:00
1 parent d61fd5db19
commit 643dee1be3
45 files changed
+1735 -88

No files matched your search

@@ -969,6 +969,9 @@ describe('DashboardDataService', () => {
const series = vodItems.find(
(item) => item.title === 'Color Orchard S02E04'
);
if (!series) {
throw new Error('expected the series recent item');
}
// Movie: vod position with 60% watched.
const moviePos = service.getPlaybackPositionForItem(movie!);
@@ -977,13 +980,91 @@ describe('DashboardDataService', () => {
// Series: episode position keyed by the recent item's xtream_id (the
// episode id, not the series id).
const seriesPos = service.getPlaybackPositionForItem(series!);
const seriesPos = service.getPlaybackPositionForItem(series);
expect(seriesPos?.contentType).toBe('episode');
expect(seriesPos?.positionSeconds).toBe(720);
// Season/episode metadata travels with the position so cards can
// render an "S2 · E5" badge without an extra round-trip.
expect(seriesPos?.seasonNumber).toBe(2);
expect(seriesPos?.episodeNumber).toBe(4);
expect(service.getRecentItemNavigationState(series)).toEqual({
openCollectionDetailItem: {
item: expect.objectContaining({
contentType: 'series',
xtreamId: 900,
}),
seriesResume: {
seriesXtreamId: 900,
contentXtreamId: 909,
seasonNumber: 2,
episodeNumber: 4,
},
},
});
});
it('keeps legacy episode-keyed recents detail-only when the position row lacks the parent series id', async () => {
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
{
id: 103,
category_id: 19,
title: 'Legacy Orchard S02E04',
rating: '8.1',
viewed_at: '2026-04-22T10:00:00.000Z',
poster_url: 'https://example.com/legacy-orchard.png',
xtream_id: 909,
type: 'series',
playlist_id: 'xtream-L',
playlist_name: 'Xtream Legacy',
},
]);
playlistsSignal.set([
...playlistsSignal(),
{
_id: 'xtream-L',
title: 'Xtream Legacy',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
serverUrl: 'https://legacy.example.com',
},
]);
// Rows saved before seriesXtreamId existed: keyed by the episode id
// with no pointer back to the parent series.
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue([
{
contentXtreamId: 909,
contentType: 'episode',
seasonNumber: 2,
episodeNumber: 4,
positionSeconds: 720,
durationSeconds: 1800,
playlistId: 'xtream-L',
} as PlaybackPositionData,
]);
await service.reloadGlobalRecentItems();
await service.reloadPlaybackPositions();
const series = service
.globalRecentVodItems()
.find((item) => item.title === 'Legacy Orchard S02E04');
if (!series) {
throw new Error('expected the series recent item');
}
// The episode id must not be promoted to a series id: no resume
// target, and the detail item keeps its original identifier.
expect(service.getRecentItemNavigationState(series)).toEqual({
openCollectionDetailItem: {
item: expect.objectContaining({
contentType: 'series',
xtreamId: 909,
}),
},
});
});
it('resolves the latest episode position for series whose recent_items row carries the series id', async () => {
@@ -1077,6 +1158,21 @@ describe('DashboardDataService', () => {
expect(position?.contentType).toBe('episode');
expect(position?.seasonNumber).toBe(3);
expect(position?.episodeNumber).toBe(7);
expect(service.getRecentItemNavigationState(series)).toEqual({
openCollectionDetailItem: {
item: expect.objectContaining({
contentType: 'series',
sourceType: 'xtream',
xtreamId: 4000,
}),
seriesResume: {
seriesXtreamId: 4000,
contentXtreamId: 4007,
seasonNumber: 3,
episodeNumber: 7,
},
},
});
expect(failOnLinearScan).not.toHaveBeenCalled();
});
@@ -893,7 +893,10 @@ export class DashboardDataService {
getRecentItemNavigationState(
item: GlobalRecentItem
): WorkspaceNavigationTarget['state'] {
return getRecentItemNavigationStateUtil(item);
return getRecentItemNavigationStateUtil(
item,
this.getPlaybackPositionForItem(item)
);
}
async removeGlobalRecentItem(item: GlobalRecentItem): Promise<void> {
@@ -1,5 +1,6 @@
import {
PlaylistMeta,
PlaybackPositionData,
PortalActivityType,
PortalAddedItem,
PortalFavoriteItem,
@@ -12,6 +13,7 @@ import {
getGlobalFavoriteNavigation,
getRecentItemNavigation,
WorkspaceNavigationTarget,
type SeriesResumeTarget,
} from '@iptvnator/portal/shared/util';
/**
@@ -58,9 +60,54 @@ export function getRecentItemLink(item: PortalRecentItem): string[] {
}
export function getRecentItemNavigationState(
item: PortalRecentItem
item: PortalRecentItem,
playbackPosition?: PlaybackPositionData | null
): WorkspaceNavigationTarget['state'] {
return getRecentItemNavigation(item).state;
return getRecentItemNavigation(
item,
buildRecentSeriesResumeTarget(item, playbackPosition)
).state;
}
function buildRecentSeriesResumeTarget(
item: PortalRecentItem,
playbackPosition?: PlaybackPositionData | null
): SeriesResumeTarget | null {
if (
item.type !== 'series' ||
item.source !== 'xtream' ||
playbackPosition?.contentType !== 'episode'
) {
return null;
}
// Episode-keyed recent rows make item.xtream_id an episode id, so only a
// position row that names its parent series can produce a resume target;
// legacy rows without seriesXtreamId stay detail-only.
const seriesXtreamId = Number(playbackPosition.seriesXtreamId);
const contentXtreamId = Number(playbackPosition.contentXtreamId);
const seasonNumber = Number(playbackPosition.seasonNumber);
const episodeNumber = Number(playbackPosition.episodeNumber);
if (
!Number.isInteger(seriesXtreamId) ||
seriesXtreamId <= 0 ||
!Number.isInteger(contentXtreamId) ||
contentXtreamId <= 0 ||
!Number.isInteger(seasonNumber) ||
seasonNumber < 0 ||
!Number.isInteger(episodeNumber) ||
episodeNumber < 0
) {
return null;
}
return {
seriesXtreamId,
contentXtreamId,
seasonNumber,
episodeNumber,
};
}
export function getGlobalFavoriteLink(item: PortalFavoriteItem): string[] {