Files
iptvnator/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts
T
4grayandClaude Opus 5 4e575a810e 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>
2026-09-20 22:19:47 +02:00

234 lines
7.6 KiB
TypeScript

import {
PlaylistMeta,
PlaybackPositionData,
PortalActivityType,
PortalAddedItem,
PortalFavoriteItem,
PortalRecentItem,
resolvePortalActivityWatchKind,
} from '@iptvnator/shared/interfaces';
import {
buildStalkerDetailNavigationTarget,
buildStalkerStateItem,
buildXtreamNavigationTarget,
getGlobalFavoriteNavigation,
getRecentItemNavigation,
isPortalPlaybackWatched,
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;
}
/**
* Detail-only navigation state: never carries a series resume target, so the
* handoff opens the detail page without auto-playing. Used by the Continue
* Watching cards' default click (movie-like behavior, issue #1441); the hero
* CTA and the cards' explicit Resume action keep the resume handoff. The
* position still matters here: a legacy episode-keyed recent row needs its
* identity rewritten to the parent series the position names, or the detail
* would target the episode id.
*/
export function getRecentItemDetailNavigationState(
item: PortalRecentItem,
playbackPosition?: PlaybackPositionData | null
): WorkspaceNavigationTarget['state'] {
return getRecentItemNavigation(
item,
buildRecentSeriesIdentityTarget(item, playbackPosition),
{ resumeIdentityOnly: true }
).state;
}
/**
* Full navigation target carrying the one-shot resume handoff, or null when
* 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(
item: PortalRecentItem,
playbackPosition?: PlaybackPositionData | null
): WorkspaceNavigationTarget | null {
const resumeTarget = buildRecentSeriesResumeTarget(item, playbackPosition);
return resumeTarget ? getRecentItemNavigation(item, resumeTarget) : null;
}
export function buildRecentSeriesResumeTarget(
item: PortalRecentItem,
playbackPosition?: PlaybackPositionData | null
): SeriesResumeTarget | null {
// A watched row is a completion marker (natural finish or a manual/bulk
// "mark watched"), not resumable progress — auto-playing it would start
// the episode at its end. Detail-only handoff lets the series page's
// quick-start pick the first unwatched episode instead.
if (isPortalPlaybackWatched(playbackPosition)) {
return null;
}
return buildRecentSeriesIdentityTarget(item, playbackPosition);
}
/**
* Series coordinates named by an episode position, without the watched
* guard: even a finished episode still identifies its parent series, which
* the detail-only click needs for the episode-keyed-row identity rewrite.
*/
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 (
resolvePortalActivityWatchKind(item) !== 'series' ||
(item.source !== 'xtream' && item.source !== 'stalker') ||
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;
}