mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
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>
176 lines
5.1 KiB
TypeScript
176 lines
5.1 KiB
TypeScript
import {
|
|
PlaylistMeta,
|
|
PlaybackPositionData,
|
|
PortalActivityType,
|
|
PortalAddedItem,
|
|
PortalFavoriteItem,
|
|
PortalRecentItem,
|
|
} from '@iptvnator/shared/interfaces';
|
|
import {
|
|
buildStalkerDetailNavigationTarget,
|
|
buildStalkerStateItem,
|
|
buildXtreamNavigationTarget,
|
|
getGlobalFavoriteNavigation,
|
|
getRecentItemNavigation,
|
|
WorkspaceNavigationTarget,
|
|
type SeriesResumeTarget,
|
|
} from '@iptvnator/portal/shared/util';
|
|
|
|
/**
|
|
* Pure navigation/link helpers for dashboard items.
|
|
*
|
|
* Extracted from `DashboardDataService` so the routing logic can be unit-tested
|
|
* in isolation and the service stays a thin facade. None of these functions
|
|
* touch component/service state — they map a dashboard item to a router link
|
|
* (and optional navigation state) using the shared portal navigation builders.
|
|
*/
|
|
|
|
export type DashboardContentKind = 'all' | 'channels' | 'vod' | 'series';
|
|
|
|
export function isTypeInKind(
|
|
type: PortalActivityType,
|
|
kind: DashboardContentKind
|
|
): boolean {
|
|
if (kind === 'all') {
|
|
return true;
|
|
}
|
|
if (kind === 'channels') {
|
|
return type === 'live';
|
|
}
|
|
if (kind === 'vod') {
|
|
return type === 'movie';
|
|
}
|
|
return type === 'series';
|
|
}
|
|
|
|
export function getPlaylistLink(playlist: PlaylistMeta): string[] {
|
|
if (playlist.serverUrl) {
|
|
return ['/workspace', 'xtreams', playlist._id, 'vod'];
|
|
}
|
|
|
|
if (playlist.macAddress) {
|
|
return ['/workspace', 'stalker', playlist._id, 'vod'];
|
|
}
|
|
|
|
return ['/workspace', 'playlists', playlist._id];
|
|
}
|
|
|
|
export function getRecentItemLink(item: PortalRecentItem): string[] {
|
|
return getRecentItemNavigation(item).link;
|
|
}
|
|
|
|
export function getRecentItemNavigationState(
|
|
item: PortalRecentItem,
|
|
playbackPosition?: PlaybackPositionData | null
|
|
): WorkspaceNavigationTarget['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[] {
|
|
return getGlobalFavoriteNavigation(item).link;
|
|
}
|
|
|
|
export function getGlobalFavoriteNavigationState(
|
|
item: PortalFavoriteItem
|
|
): WorkspaceNavigationTarget['state'] {
|
|
return getGlobalFavoriteNavigation(item).state;
|
|
}
|
|
|
|
export function getRecentlyAddedLink(item: PortalAddedItem): string[] {
|
|
if (item.source === 'stalker' && item.type !== 'live') {
|
|
return buildStalkerDetailNavigationTarget({
|
|
playlistId: item.playlist_id,
|
|
type: item.type,
|
|
categoryId: item.category_id,
|
|
item: buildStalkerStateItem(item.stalker_item, {
|
|
id: item.id,
|
|
title: item.title,
|
|
type: item.type,
|
|
category_id: item.category_id,
|
|
poster_url: item.poster_url,
|
|
}),
|
|
}).link;
|
|
}
|
|
|
|
return buildXtreamNavigationTarget({
|
|
playlistId: item.playlist_id,
|
|
type: item.type,
|
|
categoryId: item.category_id,
|
|
itemId: item.xtream_id,
|
|
title: item.title,
|
|
imageUrl: item.poster_url,
|
|
}).link;
|
|
}
|
|
|
|
export function getRecentlyAddedNavigationState(
|
|
item: PortalAddedItem
|
|
): WorkspaceNavigationTarget['state'] {
|
|
if (item.source === 'stalker' && item.type !== 'live') {
|
|
return buildStalkerDetailNavigationTarget({
|
|
playlistId: item.playlist_id,
|
|
type: item.type,
|
|
categoryId: item.category_id,
|
|
item: buildStalkerStateItem(item.stalker_item, {
|
|
id: item.id,
|
|
title: item.title,
|
|
type: item.type,
|
|
category_id: item.category_id,
|
|
poster_url: item.poster_url,
|
|
}),
|
|
}).state;
|
|
}
|
|
|
|
return buildXtreamNavigationTarget({
|
|
playlistId: item.playlist_id,
|
|
type: item.type,
|
|
categoryId: item.category_id,
|
|
itemId: item.xtream_id,
|
|
title: item.title,
|
|
imageUrl: item.poster_url,
|
|
}).state;
|
|
}
|