mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
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:
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[] {
|
||||
|
||||
Reference in new issue
Block a user