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>
This commit is contained in:
4grayandClaude Opus 5 authored and GitHub committed 2026-09-20 22:19:47 +02:00
1 parent 954c8ad65e
commit 4e575a810e
26 files changed
+1514 -160

No files matched your search

@@ -0,0 +1,92 @@
import { buildPlaylistRecentItems } from './playlist-recently-viewed.utils';
import { resolvePortalActivityWatchKind } from './portal-activity-item.interface';
import type { PlaylistMeta } from './playlist-meta.type';
const labels = { stalker: 'Stalker', m3u: 'M3U' };
function stalkerPlaylist(recentlyViewed: unknown[]): PlaylistMeta {
return {
_id: 'stalker-1',
title: 'Portal',
count: 0,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
macAddress: '00:1A:79:00:00:01',
recentlyViewed,
} as unknown as PlaylistMeta;
}
describe('buildPlaylistRecentItems (Stalker watch kind)', () => {
it('keeps embedded-VOD shows routing as movies while tracking them as series', () => {
// Real stored shape: no `is_series`, numeric VOD category, and the
// `series[]` episode array the portal answered with.
const [item] = buildPlaylistRecentItems(
[
stalkerPlaylist([
{
id: '17572',
title: 'Fake (10 episodes)',
category_id: '7',
series: [1, 2, 3],
added_at: '2026-09-19T16:13:50.000Z',
},
]),
],
labels
);
expect(item.type).toBe('movie');
expect(item.watch_kind).toBe('series');
expect(resolvePortalActivityWatchKind(item)).toBe('series');
});
it('marks lazy Ministra is_series rows as series-tracked too', () => {
const [item] = buildPlaylistRecentItems(
[
stalkerPlaylist([
{
id: '50001',
title: 'Flagged',
category_id: 'vod',
is_series: '1',
added_at: '2026-09-19T16:13:50.000Z',
},
]),
],
labels
);
expect(item.type).toBe('series');
expect(item.watch_kind).toBe('series');
});
it('leaves plain movies and live channels without a watch kind override', () => {
const items = buildPlaylistRecentItems(
[
stalkerPlaylist([
{
id: '900',
title: 'A Movie',
category_id: '7',
series: [],
added_at: '2026-09-19T16:13:50.000Z',
},
{
id: '712',
title: 'Channel',
category_id: 'itv',
added_at: '2026-09-19T14:49:22.000Z',
},
]),
],
labels
);
expect(items.map((item) => item.watch_kind)).toEqual([
undefined,
undefined,
]);
expect(resolvePortalActivityWatchKind(items[0])).toBe('movie');
expect(resolvePortalActivityWatchKind(items[1])).toBeNull();
});
});
@@ -6,6 +6,7 @@ import {
extractStalkerItemPoster,
extractStalkerItemTitle,
extractStalkerItemType,
isStalkerSeriesItem,
normalizeStalkerDate,
} from './stalker-item.normalizer';
import {
@@ -59,6 +60,10 @@ function mapStalkerPlaylistRecentItems(
backdrop_url: extractStalkerItemTmdbHints(item).backdropUrl,
source: 'stalker',
stalker_item: rawItem,
// `type` stays the routing verdict: an embedded-VOD row
// (a `series[]` array, no flag) routes as a movie. Either
// shape tracks progress per episode, keyed by this parent id.
...(isStalkerSeriesItem(item) ? { watch_kind: 'series' } : {}),
});
return acc;
@@ -47,6 +47,43 @@ export interface PortalActivityItem {
source?: 'xtream' | 'stalker' | 'm3u';
/** Original stalker item for navigation state; undefined for xtream. */
stalker_item?: StalkerPortalItem;
/**
* How the item's watch progress is tracked, when that differs from the
* catalog section `type` routes it to.
*
* The case that needs it is a Stalker embedded-VOD row: its stored entry
* announces episodes through a `series[]` array, but `extractStalkerItemType`
* is deliberately blind to that array so the item keeps routing to the VOD
* catalog, and it therefore reports `type: 'movie'` while its progress is a
* set of EPISODE positions keyed by the parent id. (A lazy Ministra
* `is_series` row already reports `series` — the flag is read — so there
* this field only restates the type.)
*
* Readers that decide between a movie position and a series position go
* through `resolvePortalActivityWatchKind`, never `type` alone. Absent
* means "same as `type`".
*/
watch_kind?: PortalActivityWatchKind;
}
/** Progress model of a VOD activity item: one position, or per-episode positions. */
export type PortalActivityWatchKind = 'movie' | 'series';
/**
* The progress model a dashboard/collection reader should use for an item:
* the explicit `watch_kind` when a mapper recorded one, else the catalog
* `type`. Live items have no progress and resolve to `null`.
*/
export function resolvePortalActivityWatchKind(
item: Pick<PortalActivityItem, 'type' | 'watch_kind'>
): PortalActivityWatchKind | null {
if (item.watch_kind === 'movie' || item.watch_kind === 'series') {
return item.watch_kind;
}
if (item.type === 'movie' || item.type === 'series') {
return item.type;
}
return null;
}
/** A recently-viewed item with a `viewed_at` timestamp. */