Files
iptvnator/libs/portal/shared/util/src/lib/series-quick-start.ts
T
MahdiHrmandClaude Fable 5 643dee1be3 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>
2026-07-18 15:49:22 +02:00

226 lines
6.5 KiB
TypeScript

import { PlaybackPositionData, XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import {
isPortalPlaybackInProgress,
isPortalPlaybackWatched,
} from './portal-playback-positions';
export const SERIES_QUICK_START_ACTION_KIND = {
PlayFirst: 'play-first',
PlayRecent: 'play-recent',
Resume: 'resume',
PlayNext: 'play-next',
Completed: 'completed',
} as const;
export type SeriesQuickStartActionKind =
(typeof SERIES_QUICK_START_ACTION_KIND)[keyof typeof SERIES_QUICK_START_ACTION_KIND];
export interface SeriesQuickStartAction {
kind: SeriesQuickStartActionKind;
labelKey: string;
labelParams?: Record<string, number>;
episodeLabel: string;
icon: string;
episode: XtreamSerieEpisode;
position: PlaybackPositionData | null;
disabled: boolean;
}
interface SeriesQuickStartRequest {
seasons: Record<string, XtreamSerieEpisode[]>;
playbackPositions: Map<number, PlaybackPositionData>;
}
interface OrderedEpisode {
episode: XtreamSerieEpisode;
position: PlaybackPositionData | null;
order: number;
}
const naturalCollator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
export function getSeriesQuickStartAction(
request: SeriesQuickStartRequest
): SeriesQuickStartAction | null {
const orderedEpisodes = getOrderedEpisodes(request);
if (orderedEpisodes.length === 0) {
return null;
}
const startedEpisodes = orderedEpisodes
.filter(
({ position }) =>
position !== null && !isPortalPlaybackWatched(position)
)
.sort(comparePlaybackPositionRecency);
const latestStartedEpisode = startedEpisodes[startedEpisodes.length - 1];
if (latestStartedEpisode?.position) {
const shouldResume = isPortalPlaybackInProgress(
latestStartedEpisode.position
);
return createQuickStartAction({
kind: shouldResume
? SERIES_QUICK_START_ACTION_KIND.Resume
: SERIES_QUICK_START_ACTION_KIND.PlayRecent,
labelKey: shouldResume
? 'XTREAM.RESUME_EPISODE'
: 'XTREAM.PLAY_EPISODE',
...(!shouldResume
? {
labelParams: {
episode: Number(
latestStartedEpisode.episode.episode_num
),
},
}
: {}),
icon: 'play_arrow',
episode: latestStartedEpisode.episode,
position: latestStartedEpisode.position,
disabled: false,
});
}
const firstUnwatchedEpisode = orderedEpisodes.find(
({ position }) => !isPortalPlaybackWatched(position)
);
if (firstUnwatchedEpisode) {
const hasWatchedEpisode = orderedEpisodes.some(({ position }) =>
isPortalPlaybackWatched(position)
);
return createQuickStartAction({
kind: hasWatchedEpisode
? SERIES_QUICK_START_ACTION_KIND.PlayNext
: SERIES_QUICK_START_ACTION_KIND.PlayFirst,
labelKey: hasWatchedEpisode
? 'XTREAM.PLAY_NEXT_EPISODE'
: 'XTREAM.PLAY_FIRST_EPISODE',
icon: 'play_arrow',
episode: firstUnwatchedEpisode.episode,
position: firstUnwatchedEpisode.position,
disabled: false,
});
}
const finalEpisode = orderedEpisodes[orderedEpisodes.length - 1];
if (!finalEpisode) {
return null;
}
return createQuickStartAction({
kind: SERIES_QUICK_START_ACTION_KIND.Completed,
labelKey: 'XTREAM.SERIES_WATCHED',
icon: 'check_circle',
episode: finalEpisode.episode,
position: finalEpisode.position,
disabled: true,
});
}
function createQuickStartAction(
action: Omit<SeriesQuickStartAction, 'episodeLabel'>
): SeriesQuickStartAction {
return {
...action,
episodeLabel: getEpisodeLabel(action.episode),
};
}
function getOrderedEpisodes(
request: SeriesQuickStartRequest
): OrderedEpisode[] {
const orderedEpisodes: OrderedEpisode[] = [];
Object.entries(request.seasons)
.sort(([seasonA], [seasonB]) =>
naturalCollator.compare(seasonA, seasonB)
)
.forEach(([, episodes]) => {
[...episodes].sort(compareEpisodes).forEach((episode) => {
orderedEpisodes.push({
episode,
position:
request.playbackPositions.get(Number(episode.id)) ??
null,
order: orderedEpisodes.length,
});
});
});
return orderedEpisodes;
}
function compareEpisodes(
episodeA: XtreamSerieEpisode,
episodeB: XtreamSerieEpisode
): number {
const episodeNumberDelta =
Number(episodeA.episode_num) - Number(episodeB.episode_num);
if (Number.isFinite(episodeNumberDelta) && episodeNumberDelta !== 0) {
return episodeNumberDelta;
}
return naturalCollator.compare(episodeA.title ?? '', episodeB.title ?? '');
}
function comparePlaybackPositionRecency(
episodeA: OrderedEpisode,
episodeB: OrderedEpisode
): number {
const timeA = getTimestamp(episodeA.position?.updatedAt);
const timeB = getTimestamp(episodeB.position?.updatedAt);
if (timeA !== timeB) {
return timeA - timeB;
}
return episodeA.order - episodeB.order;
}
function getTimestamp(value: string | undefined): number {
if (!value) {
return 0;
}
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : 0;
}
function getEpisodeLabel(episode: XtreamSerieEpisode): string {
const episodeCode = formatSeriesEpisodeCode(
episode.season,
episode.episode_num
);
const title = episode.title.trim();
return title ? `${episodeCode} · ${title}` : episodeCode;
}
export function formatSeriesEpisodeCode(
seasonNumber: number,
episodeNumber: number
): string {
const safeSeasonNumber = getPositiveInteger(seasonNumber) ?? 1;
const safeEpisodeNumber = getPositiveInteger(episodeNumber) ?? 1;
return `S${padEpisodePart(safeSeasonNumber)}E${padEpisodePart(
safeEpisodeNumber
)}`;
}
function getPositiveInteger(value: number): number | null {
return Number.isInteger(value) && value > 0 ? value : null;
}
function padEpisodePart(value: number): string {
return value < 10 ? `0${value}` : String(value);
}