mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
feat(dashboard): say where you left off instead of which provider it came from
Every dashboard title carried a "Xtream · Series" / "Stalker · Movie" subtitle. Provider kind and content kind are this 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 told the user 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. Their stored rows carry a `series[]` episode array (or a Ministra `is_series` flag) while living in the VOD catalog, so `extractStalkerItemType` reports `movie` on purpose: the detail must route there. But their progress is a set of EPISODE positions keyed by the parent id, and the dashboard was looking them up as a single `vod` row and finding nothing. Split the two questions. `PortalActivityItem.watch_kind` records the progress model when it differs from the routing type, the Stalker mappers set it from `isStalkerSeriesItem`, and every reader that has to choose — position lookup, the S·E badge, the resume and mark-watched actions — goes through `resolvePortalActivityWatchKind` instead of `type`. That makes the resume handoff reachable for Stalker, so wire it: a new `STALKER_SERIES_RESUME_TARGET` token, provided by the collection detail and consumed once by `StalkerSeriesViewComponent` after the series positions are read (a failed read plays nothing, rather than restarting the episode from zero). A lazy Ministra season the target lives in is hydrated first, and an episode matched by season/episode coordinates instead of a tracking id still resumes at the offset the card displayed. The global-recent route template bound `[seriesResume]` only on its Xtream branch, so the Stalker detail never received the target. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
15d964ef88
commit
f430dec851
26 files changed
+1254
-160
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: feature
|
||||
area: dashboard
|
||||
---
|
||||
|
||||
The dashboard no longer labels every title with "Xtream · Series" or "Stalker · Movie": the hero shows only the source name, and Continue Watching cards show the episode you are on plus the minutes left. Stalker shows filed under Movies now get that S·E badge, the progress bar, and a working "Resume episode" that opens the show at the saved episode.
|
||||
@@ -1574,7 +1574,7 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the
|
||||
- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons; the dropdown's menu rows and closed trigger carry a 28×42 season thumbnail from the same `seasonPosters` map when that season has one — no placeholder when it does not, a failed image is dropped — while the pill row deliberately stays text-only) with auto-selection (playing episode's season → resume season → earliest season with unwatched episodes → latest non-empty season; Stalker lazy-VOD series with unhydrated seasons fall back to the first season, and a session's own watched-toggle echo never re-resolves the selection) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream, provider-first with URL-only junk filtered by `sanitizeProviderOverview` and a TMDB season-overview fallback stored as `tmdb_season_overviews` by the lazy season enrichment) or TMDB (Stalker). The tabs sit in a season card with the selected season's own **season cover** on the left (`SeasonContainerComponent.seasonPosters`, keyed like the descriptions; TMDB-first: `tmdb_season_posters` written by the same lazy season enrichment as a `w342` `tmdbSeasonPosterUrl`, then Xtream's provider `seasons[].cover_big`/`cover` when it is a trimmed http(s) URL other than the show poster — `buildSeasonPosters` in `serial-details/season-posters.util.ts`; Stalker is TMDB-only via `StalkerSeriesTmdbSeasonsService.posters()`). Sized by `--season-cover-width`; the column is not rendered for one-season items, seasons without a poster, or a failed image, so those cases are today's markup. The hero poster never follows the season. The fullscreen episode panel shows the same poster as a season strip (poster + name + episode count, `PORTALS.EPISODE_COUNT_ONE/OTHER`) above its tabs, fed through `PortalInlinePlayerComponent.seasonPosters` and `FullscreenEpisodePanelSeason.posterUrl`, under the same gates, and hands the same map to its tabs' dropdown
|
||||
- The season header hosts a bulk watched toggle next to "Download season" (both portals): marking writes full-progress position rows for the unwatched episodes only — skipping the episode currently playing/launching, whose position ticks would overwrite the row — and a fully watched season flips the action to unwatch-all (`buildSeasonWatchToggleRequest` in `libs/ui/components/.../season-watch-toggle.util.ts`). Xtream persists via the batch IPC `DB_SAVE/CLEAR_PLAYBACK_POSITIONS_BATCH` (one SQLite transaction; the PWA data source rewrites its localStorage blob once) and refreshes `XtreamStore.loadAllPositions` after any toggle so catalog progress badges follow; Stalker loops the serialized position-mutation queue (legacy-row reconciliation, one coalesced reload) and reports direction-specific partial failures. A batch resolving after navigation neither mutates the new page's state nor shows its snackbar. A series-level counterpart sits in a `⋮` menu at the end of the header row (`SeasonWatchPresenter` owns both scopes' state math; `buildSeriesWatchToggleRequest` flattens every loaded season; the direction is always the one the label advertised). It reuses the same host machinery per portal (Xtream: scope-parameterized `SerialDetailsSeasonWatchService`; Stalker: shared `runWatchToggleBatch` core). Stalker lazy-VOD hydrates unloaded seasons sequentially first (abort with zero writes on a failed fetch; a well-formed EMPTY portal answer marks the season loaded-and-empty via `VodSeriesSeasonVm.episodesLoaded` rather than eternally pending, while `fetchVodSeriesEpisodes` rejects malformed envelopes and answers without recognizable episodes; `loadEpisodesForSeason` is single-flight per season so concurrent callers join one request), re-runs the position reconcile synchronously so hydrated episodes' legacy rows are cleaned, then rebuilds the request keeping the clicked direction — the `hasUnloadedSeasons` container input blocks the unwatch verdict and the count label until everything is loaded. Contract: `docs/architecture/embedded-inline-playback.md`
|
||||
- Movies get the same manual toggle in the detail action row (Xtream: icon square after Favorite, `VodDetailsWatchedService`; Stalker: labelled button in the shared `app-vod-details`, both the routed catalog detail and the collection inline detail wire it). Both portals go through one helper, `createVodWatchedToggle` in `@iptvnator/portal/shared/util`: marking writes a full-progress `vod` position row (stored duration → provider `duration_secs` → 1 s fallback, since Stalker states no runtime), unmarking deletes the row and so forgets the resume point, both through the rejecting `*OrThrow` persistence boundary so the row on screen changes only after a confirmed write. The toggle is disabled while the movie plays inline or in an external session (the ~15 s position tick would overwrite the row), acts only on the route copy's row (a pinned multi-source alternative keeps its own), and a completion landing after navigation refreshes the catalog badges but neither patches the new page nor shows its snackbar. A watched copy shows Play, never "Resume" from its final seconds. Catalog cards on both portals derive their corner badge from one shared `PortalWatchState` (`resolvePortalWatchState` / `watchStateFromProgressPercent`, 90% threshold; `resolvePortalSeriesWatchState` reports a series as at most `in-progress`, because the list payload never carries the episode total).
|
||||
- The dashboard hero CTA and the Continue Watching cards' explicit "Resume episode" ⋮ action for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Continue Watching cards' DEFAULT click is detail-only (movie-like, issue #1441), and their ⋮ menu (`buildDashboardContinueWatchingActions`) also offers "Mark as Watched" (maxes out the existing position row via `DashboardDataService.markRecentItemWatched`) and "Remove from history". Ordinary global-recent grid clicks remain detail-only.
|
||||
- The dashboard hero CTA and the Continue Watching cards' explicit "Resume episode" ⋮ action for a series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Xtream consumes it in `SerialDetailsPlaybackService` (`XTREAM_SERIES_RESUME_TARGET`); Stalker in `StalkerSeriesViewComponent` (`STALKER_SERIES_RESUME_TARGET`, provided by `StalkerCollectionDetailComponent`; a lazy Ministra season the target lives in is hydrated first, and an episode matched by coordinates rather than tracking id still resumes at the row's offset). "Series" here is the item's WATCH kind, not its routing type: a Stalker embedded-VOD `series[]` or lazy `is_series` row keeps `type: 'movie'` (it must route to the VOD catalog) while the mappers set `watch_kind: 'series'` (`isStalkerSeriesItem`), and every dashboard reader — position lookup, S·E badge, resume/mark-watched actions — goes through `resolvePortalActivityWatchKind` (`libs/shared/interfaces`). Hero and card subtitles no longer name the provider kind or content kind: the hero shows only the source name (`playlistDisplayLabel`, since stored names carry URLs/MACs), Continue Watching cards show the S·E chip plus "N min left", favorites cards show the title alone, and Recently Added keeps the source name. Continue Watching cards' DEFAULT click is detail-only (movie-like, issue #1441), and their ⋮ menu (`buildDashboardContinueWatchingActions`) also offers "Mark as Watched" (maxes out the existing position row via `DashboardDataService.markRecentItemWatched`) and "Remove from history". Ordinary global-recent grid clicks remain detail-only.
|
||||
- See `docs/architecture/embedded-inline-playback.md` ("Two-State Detail Layout")
|
||||
|
||||
**VOD Multi-Source** (alternative sources for a movie):
|
||||
|
||||
@@ -17,9 +17,22 @@ class MockXtreamCollectionDetailComponent {
|
||||
readonly closeRequested = output<void>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-collection-detail',
|
||||
template: '',
|
||||
})
|
||||
class MockStalkerCollectionDetailComponent {
|
||||
readonly item = input<UnifiedCollectionItem | null>(null);
|
||||
readonly seriesResume = input<SeriesResumeTarget | null>(null);
|
||||
readonly closeRequested = output<void>();
|
||||
}
|
||||
|
||||
jest.unstable_mockModule('@iptvnator/portal/xtream/feature', () => ({
|
||||
XtreamCollectionDetailComponent: MockXtreamCollectionDetailComponent,
|
||||
}));
|
||||
jest.unstable_mockModule('@iptvnator/portal/stalker/feature', () => ({
|
||||
StalkerCollectionDetailComponent: MockStalkerCollectionDetailComponent,
|
||||
}));
|
||||
|
||||
@Component({
|
||||
imports: [GlobalCollectionDetailHostComponent],
|
||||
@@ -123,6 +136,38 @@ describe('GlobalCollectionDetailHostComponent', () => {
|
||||
expect(instance.seriesResume()).toEqual(seriesResume);
|
||||
});
|
||||
|
||||
it('creates the Stalker detail with the series resume target attached', async () => {
|
||||
// A Stalker embedded-VOD show routes as a movie; the resume target
|
||||
// still has to reach the detail so the series view can consume it.
|
||||
const seriesResume = {
|
||||
seriesXtreamId: 17672,
|
||||
contentXtreamId: 1879880442,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 6,
|
||||
};
|
||||
fixture.componentInstance.seriesResume.set(seriesResume);
|
||||
fixture.componentInstance.item.set({
|
||||
uid: 'stalker::stalker-1::17672',
|
||||
name: 'Old School',
|
||||
contentType: 'movie',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Portal',
|
||||
stalkerId: '17672',
|
||||
categoryId: '7',
|
||||
});
|
||||
await stabilize();
|
||||
|
||||
const detail = fixture.debugElement.query(
|
||||
By.directive(MockStalkerCollectionDetailComponent)
|
||||
);
|
||||
expect(detail).not.toBeNull();
|
||||
const instance =
|
||||
detail?.componentInstance as MockStalkerCollectionDetailComponent;
|
||||
expect(instance.item()?.stalkerId).toBe('17672');
|
||||
expect(instance.seriesResume()).toEqual(seriesResume);
|
||||
});
|
||||
|
||||
it('re-emits close requests and clears the detail with the item', async () => {
|
||||
fixture.componentInstance.item.set(xtreamSeriesItem);
|
||||
await stabilize();
|
||||
|
||||
@@ -113,9 +113,9 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
|
||||
environmentInjector: this.environmentInjector,
|
||||
});
|
||||
componentRef.setInput('item', item);
|
||||
if (item.sourceType === 'xtream') {
|
||||
componentRef.setInput('seriesResume', seriesResume);
|
||||
}
|
||||
// Both portal details accept the one-shot resume handoff (Xtream
|
||||
// series, Stalker embedded-VOD / lazy is_series shows).
|
||||
componentRef.setInput('seriesResume', seriesResume);
|
||||
this.subscribeToClose(componentRef.instance);
|
||||
}
|
||||
|
||||
@@ -196,6 +196,7 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
|
||||
} @else if (item.sourceType === 'stalker') {
|
||||
<app-global-collection-detail-host
|
||||
[item]="item"
|
||||
[seriesResume]="seriesResume"
|
||||
(closeRequested)="close()"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -74,13 +74,21 @@ with a return handler keep Back available.
|
||||
Stalker movies/series into the matching global collection route with detail
|
||||
pre-opened.
|
||||
- The dashboard hero CTA and the Continue Watching cards' explicit "Resume
|
||||
episode" ⋮ action for Xtream series carry a one-shot season/episode resume
|
||||
target. The collection-owned Xtream detail consumes it after its episode
|
||||
positions load and starts that exact episode; the cards' default click is
|
||||
detail-only (movie-like), as is opening the series from the collection grid
|
||||
itself. If the positions load fails, the target stays unconsumed and the
|
||||
handoff degrades to detail-only rather than starting the episode at offset
|
||||
zero. Continue Watching cards also expose "Mark as Watched" (maxes out the
|
||||
episode" ⋮ action for Xtream and Stalker series carry a one-shot
|
||||
season/episode resume target. The collection-owned detail consumes it after
|
||||
its episode positions load and starts that exact episode; the cards' default
|
||||
click is detail-only (movie-like), as is opening the series from the
|
||||
collection grid itself. If the positions load fails, the target stays
|
||||
unconsumed and the handoff degrades to detail-only rather than starting the
|
||||
episode at offset zero. Xtream reads it from `XTREAM_SERIES_RESUME_TARGET`;
|
||||
Stalker from `STALKER_SERIES_RESUME_TARGET`, provided by
|
||||
`StalkerCollectionDetailComponent` and consumed by
|
||||
`StalkerSeriesViewComponent` (`stalker-series-resume.ts`), which first
|
||||
hydrates the lazy Ministra season the target lives in. A Stalker
|
||||
embedded-VOD `series[]` or lazy `is_series` row keeps its routing
|
||||
`contentType: 'movie'` in the handoff state — the detail resolves the mode
|
||||
from the stored row — so `getOpenCollectionDetailItemState` keeps a resume
|
||||
target for any non-live Stalker item, not only `contentType: 'series'`. Continue Watching cards also expose "Mark as Watched" (maxes out the
|
||||
tracked position row) and "Remove from history" in the same ⋮ menu.
|
||||
- Ready Download Manager cards open one of the three focused
|
||||
`downloads/:downloadId` routes. These local details hide the workspace
|
||||
@@ -227,9 +235,10 @@ Dashboard behavior to preserve:
|
||||
Xtream movie/series items into `/workspace/global-favorites` or
|
||||
`/workspace/global-recent` with collection detail pre-opened from navigation
|
||||
state.
|
||||
- When an Xtream series recent has a saved episode position, the dashboard hero
|
||||
and Continue Watching card should include that exact series/episode target in
|
||||
the navigation state. It is a one-shot playback request and must not leak into
|
||||
- When a series recent (Xtream, or a Stalker row whose `watch_kind` is
|
||||
`series`) has a saved episode position, the dashboard hero and Continue
|
||||
Watching card should include that exact series/episode target in the
|
||||
navigation state. It is a one-shot playback request and must not leak into
|
||||
normal favorites, search, category, or collection-grid navigation. Only
|
||||
position rows that name their parent `seriesXtreamId` produce a target:
|
||||
episode-keyed recents make `item.xtream_id` an episode id, so legacy rows
|
||||
|
||||
@@ -113,17 +113,25 @@ Render rules:
|
||||
2. `continueWatchingCards` — maps `globalRecentVodItems()` to movie/series
|
||||
cover cards. Portal playback positions are bulk-loaded per playlist so
|
||||
hero and cards can show progress, remaining time, and series season/
|
||||
episode badges. This includes Stalker VOD activity normalized to series
|
||||
through `is_series`. Series lookup uses keyed maps for both direct
|
||||
episode ids and parent series ids; card renders must not scan the full
|
||||
playback-position map. The badge uses saved `seasonNumber` /
|
||||
episode badges. Whether an item is looked up as a movie (one `vod`
|
||||
row) or a series (episode rows under the parent id) is its WATCH
|
||||
kind, `resolvePortalActivityWatchKind`, not its routing `type`: a
|
||||
Stalker embedded-VOD `series[]` or lazy `is_series` row routes as a
|
||||
movie but is mapped with `watch_kind: 'series'`. Series lookup uses
|
||||
keyed maps for both direct episode ids and parent series ids; card
|
||||
renders must not scan the full playback-position map. The badge uses saved `seasonNumber` /
|
||||
`episodeNumber` metadata and does not infer it from provider payloads;
|
||||
legacy rows without that metadata remain badge-less until replay.
|
||||
Dashboard-originated Xtream series clicks also carry that exact episode
|
||||
target through the global-recent inline-detail handoff. Once the series
|
||||
metadata and playback positions load, the detail player consumes the
|
||||
target once and resumes the saved episode. Opening the same item normally
|
||||
from the global recent grid remains a detail-only action.
|
||||
Dashboard-originated Xtream and Stalker series clicks also carry that
|
||||
exact episode target through the global-recent inline-detail handoff.
|
||||
Once the series metadata and playback positions load, the detail player
|
||||
consumes the target once and resumes the saved episode. Opening the same
|
||||
item normally from the global recent grid remains a detail-only action.
|
||||
Continue Watching cards carry no provider/content-kind subtitle: their
|
||||
meta row is the S·E chip plus a "N min left" label (`remainingLabel`,
|
||||
from `formatRemainingLabel`), and the row is not rendered when both are
|
||||
absent. The hero subtitle is the source name alone, through
|
||||
`playlistDisplayLabel`.
|
||||
3. `liveFavoriteCardsEnriched` and `recentLiveCardsEnriched` — two
|
||||
independent rails (`dashboard-live-favorites-rail` and
|
||||
`dashboard-recent-live-rail`); there is no fallback from one to the
|
||||
@@ -136,8 +144,9 @@ Render rules:
|
||||
which calls `getGlobalRecentlyAdded('all', limit, 'xtream')` with the
|
||||
DB-level `playlists.type = 'xtream'` filter. The rail is Electron-only
|
||||
(PWA returns `[]`) and auto-hides when empty, so users without Xtream
|
||||
playlists never see it. Cards carry a `playlist_name · type` subtitle
|
||||
so users can tell which provider each item came from. Driven by an
|
||||
playlists never see it. Cards carry the source name as their subtitle
|
||||
(`playlistDisplayLabel`) so users can tell where each item was added;
|
||||
the content kind is not repeated on every card. Driven by an
|
||||
effect that re-runs whenever the Xtream playlist count changes, but the
|
||||
first run waits for `globalFavoritesLoaded()` so the slower
|
||||
recently-added DB query does not block the live favorites rail on
|
||||
@@ -217,9 +226,10 @@ The welcome state is rendered via the existing
|
||||
rails have data.
|
||||
5. Navigation from a rail card must deep-link into the appropriate workspace
|
||||
route without switching the active playlist in the header switcher.
|
||||
6. Xtream series hero/Continue Watching clicks with a saved episode position
|
||||
must resume that exact episode while preserving the collection-owned detail
|
||||
and Back behavior. Do not apply autoplay to ordinary collection-grid clicks.
|
||||
6. Xtream and Stalker series hero/Continue Watching clicks with a saved episode
|
||||
position must resume that exact episode while preserving the
|
||||
collection-owned detail and Back behavior. Do not apply autoplay to
|
||||
ordinary collection-grid clicks.
|
||||
7. `Recently Used Sources` reflects recent source usage across all provider
|
||||
types, not just recent imports.
|
||||
8. The live rail title key must match the rendered source: favorites use
|
||||
|
||||
@@ -258,6 +258,33 @@ describe('workspace-portal-navigation', () => {
|
||||
xtreamId: 55,
|
||||
})
|
||||
).toBeUndefined();
|
||||
|
||||
// …except for a Stalker VOD row, which may be an embedded-VOD or
|
||||
// lazy is_series show that routes as a movie; the detail decides.
|
||||
expect(
|
||||
readResume(validResume, {
|
||||
uid: 'stalker::stalker-1::17572',
|
||||
name: 'Fake (10 episodes)',
|
||||
contentType: 'movie',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Portal',
|
||||
stalkerId: '17572',
|
||||
categoryId: '7',
|
||||
})
|
||||
).toEqual(validResume);
|
||||
expect(
|
||||
readResume(validResume, {
|
||||
uid: 'stalker::stalker-1::712',
|
||||
name: 'Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Portal',
|
||||
stalkerId: '712',
|
||||
categoryId: 'itv',
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits the resume key when building detail state without a valid target', () => {
|
||||
|
||||
@@ -88,12 +88,13 @@ export function getRecentItemNavigation(
|
||||
xtreamId: seriesResume.seriesXtreamId,
|
||||
}
|
||||
: collectionItem;
|
||||
// The caller builds `seriesResume` only for items whose progress is
|
||||
// per episode (see `resolvePortalActivityWatchKind`); a Stalker
|
||||
// embedded-VOD series arrives here with the routing type `movie`.
|
||||
return buildGlobalCollectionDetailNavigationTarget(
|
||||
'recent',
|
||||
detailItem,
|
||||
item.type === 'series' && !options?.resumeIdentityOnly
|
||||
? seriesResume
|
||||
: null
|
||||
!options?.resumeIdentityOnly ? seriesResume : null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -287,12 +288,17 @@ export function getOpenCollectionDetailItemState(
|
||||
candidate?.['seriesResume']
|
||||
);
|
||||
|
||||
// An Xtream series, or any Stalker VOD detail: a Stalker item whose
|
||||
// routing type is `movie` may still be an embedded-VOD / lazy
|
||||
// `is_series` show, and only the detail (reading the stored row) can
|
||||
// tell — a plain movie detail simply never mounts the series view.
|
||||
const canResume =
|
||||
item?.contentType === 'series' ||
|
||||
(item?.sourceType === 'stalker' && item.contentType !== 'live');
|
||||
return item
|
||||
? {
|
||||
item,
|
||||
...(item.contentType === 'series' && seriesResume
|
||||
? { seriesResume }
|
||||
: {}),
|
||||
...(canResume && seriesResume ? { seriesResume } : {}),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
SeriesResumeTarget,
|
||||
UnifiedCollectionItem,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
import { Playlist, VodDetailsItem } from '@iptvnator/shared/interfaces';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
|
||||
import { STALKER_SERIES_RESUME_TARGET } from './stalker-series-view/stalker-series-resume';
|
||||
import {
|
||||
resolveStalkerCollectionDetailMode,
|
||||
resolveStalkerCollectionItem,
|
||||
@@ -101,6 +103,14 @@ import {
|
||||
provide: VIEW_IN_PORTAL_HANDOFF,
|
||||
useExisting: forwardRef(() => StalkerCollectionDetailComponent),
|
||||
},
|
||||
{
|
||||
// The nested series view reads the one-shot resume handoff
|
||||
// through this token, like the Xtream detail injector does.
|
||||
provide: STALKER_SERIES_RESUME_TARGET,
|
||||
useFactory: (host: StalkerCollectionDetailComponent) =>
|
||||
host.seriesResume,
|
||||
deps: [forwardRef(() => StalkerCollectionDetailComponent)],
|
||||
},
|
||||
],
|
||||
styles: [
|
||||
`
|
||||
@@ -115,6 +125,7 @@ import {
|
||||
})
|
||||
export class StalkerCollectionDetailComponent implements ViewInPortalHandoff {
|
||||
readonly item = input<UnifiedCollectionItem | null>(null);
|
||||
readonly seriesResume = input<SeriesResumeTarget | null>(null);
|
||||
readonly closeRequested = output<void>();
|
||||
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
import type { SeriesResumeTarget } from '@iptvnator/portal/shared/util';
|
||||
import type {
|
||||
StalkerMappedEpisode,
|
||||
VodSeriesSeasonVm,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
findStalkerResumeLazySeason,
|
||||
resolveStalkerResumeEpisode,
|
||||
stalkerSeriesResumeKey,
|
||||
} from './stalker-series-resume';
|
||||
|
||||
function episode(
|
||||
overrides: Partial<StalkerMappedEpisode> & { id: number }
|
||||
): StalkerMappedEpisode {
|
||||
return {
|
||||
title: `Episode ${overrides.episode_num ?? 1}`,
|
||||
container_extension: 'mpg',
|
||||
custom_sid: 'vod-series',
|
||||
added: '',
|
||||
season: 1,
|
||||
episode_num: 1,
|
||||
direct_source: '',
|
||||
info: {},
|
||||
...overrides,
|
||||
} as StalkerMappedEpisode;
|
||||
}
|
||||
|
||||
function season(overrides: Partial<VodSeriesSeasonVm>): VodSeriesSeasonVm {
|
||||
return {
|
||||
id: 'season-1',
|
||||
video_id: '17572',
|
||||
name: 'Season 1',
|
||||
season_number: '1',
|
||||
episodes: [],
|
||||
isLoading: false,
|
||||
isExpanded: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const target: SeriesResumeTarget = {
|
||||
seriesXtreamId: 17572,
|
||||
contentXtreamId: 1750797722,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 5,
|
||||
};
|
||||
|
||||
describe('resolveStalkerResumeEpisode', () => {
|
||||
it('prefers the exact scoped tracking id the position was saved under', () => {
|
||||
const scoped = episode({ id: 1750797722, season: 2, episode_num: 9 });
|
||||
const byCoordinates = episode({ id: 42, season: 1, episode_num: 5 });
|
||||
|
||||
expect(
|
||||
resolveStalkerResumeEpisode({
|
||||
target,
|
||||
episodesBySeason: { '1': [byCoordinates], '2': [scoped] },
|
||||
})
|
||||
).toBe(scoped);
|
||||
});
|
||||
|
||||
it('accepts the pre-scope legacy tracking id', () => {
|
||||
const legacy = episode({
|
||||
id: 7,
|
||||
legacyTrackingId: 1750797722,
|
||||
season: 3,
|
||||
episode_num: 1,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveStalkerResumeEpisode({
|
||||
target,
|
||||
episodesBySeason: { '3': [legacy] },
|
||||
})
|
||||
).toBe(legacy);
|
||||
});
|
||||
|
||||
it('falls back to season/episode coordinates, including the provider season', () => {
|
||||
const corrected = episode({
|
||||
id: 9,
|
||||
season: 4,
|
||||
providerSeasonNumber: 1,
|
||||
episode_num: 5,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveStalkerResumeEpisode({
|
||||
target,
|
||||
episodesBySeason: {
|
||||
'4': [
|
||||
episode({ id: 8, season: 4, episode_num: 4 }),
|
||||
corrected,
|
||||
],
|
||||
},
|
||||
})
|
||||
).toBe(corrected);
|
||||
});
|
||||
|
||||
it('returns null while the episode is not on the page', () => {
|
||||
expect(
|
||||
resolveStalkerResumeEpisode({
|
||||
target,
|
||||
episodesBySeason: {
|
||||
'1': [episode({ id: 1, season: 1, episode_num: 1 })],
|
||||
},
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findStalkerResumeLazySeason', () => {
|
||||
it('names the unhydrated season the target lives in', () => {
|
||||
const seasons = [
|
||||
season({ id: 'season-1', season_number: '1' }),
|
||||
season({ id: 'season-2', season_number: '2' }),
|
||||
];
|
||||
|
||||
expect(findStalkerResumeLazySeason({ target, seasons })?.id).toBe(
|
||||
'season-1'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null once that season is loading, loaded, or answered empty', () => {
|
||||
expect(
|
||||
findStalkerResumeLazySeason({
|
||||
target,
|
||||
seasons: [season({ isLoading: true })],
|
||||
})
|
||||
).toBeNull();
|
||||
expect(
|
||||
findStalkerResumeLazySeason({
|
||||
target,
|
||||
seasons: [season({ episodesLoaded: true })],
|
||||
})
|
||||
).toBeNull();
|
||||
expect(
|
||||
findStalkerResumeLazySeason({
|
||||
target,
|
||||
seasons: [
|
||||
season({
|
||||
episodes: [{ id: 'e1', series_number: 1 } as never],
|
||||
}),
|
||||
],
|
||||
})
|
||||
).toBeNull();
|
||||
expect(
|
||||
findStalkerResumeLazySeason({
|
||||
target: { ...target, seasonNumber: 3 },
|
||||
seasons: [season({})],
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stalkerSeriesResumeKey', () => {
|
||||
it('identifies one handoff per playlist and coordinates', () => {
|
||||
expect(stalkerSeriesResumeKey('stalker-1', target)).toBe(
|
||||
'stalker-1:17572:1750797722:1:5'
|
||||
);
|
||||
expect(stalkerSeriesResumeKey('stalker-2', target)).not.toBe(
|
||||
stalkerSeriesResumeKey('stalker-1', target)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { InjectionToken, Signal, signal } from '@angular/core';
|
||||
import type { SeriesResumeTarget } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
getVodSeriesSeasonNumber,
|
||||
type StalkerMappedEpisode,
|
||||
type VodSeriesSeasonVm,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* One-shot "resume this episode" handoff for a Stalker series, the
|
||||
* counterpart of `XTREAM_SERIES_RESUME_TARGET`. Provided by the collection
|
||||
* detail host that received the target through the global-recent inline
|
||||
* detail state (dashboard hero CTA, Continue Watching ⋮ → Resume); the
|
||||
* catalog route never provides it and falls back to the null signal.
|
||||
*/
|
||||
export const STALKER_SERIES_RESUME_TARGET = new InjectionToken<
|
||||
Signal<SeriesResumeTarget | null>
|
||||
>('STALKER_SERIES_RESUME_TARGET', {
|
||||
factory: () => signal(null),
|
||||
});
|
||||
|
||||
/** Identity of one handoff, so a target is consumed at most once per series. */
|
||||
export function stalkerSeriesResumeKey(
|
||||
playlistId: string,
|
||||
target: SeriesResumeTarget
|
||||
): string {
|
||||
return [
|
||||
playlistId,
|
||||
target.seriesXtreamId,
|
||||
target.contentXtreamId,
|
||||
target.seasonNumber,
|
||||
target.episodeNumber,
|
||||
].join(':');
|
||||
}
|
||||
|
||||
/**
|
||||
* The mapped episode a resume target names, or null while it is not on the
|
||||
* page yet (lazy VOD season not hydrated) or does not exist.
|
||||
*
|
||||
* The exact tracking id wins — for lazy VOD that is the scoped id the
|
||||
* position row was saved under — then the pre-scope legacy id, then the
|
||||
* season/episode coordinates, which also cover embedded-VOD rows whose
|
||||
* tracking id derives from the command string.
|
||||
*/
|
||||
export function resolveStalkerResumeEpisode(options: {
|
||||
target: SeriesResumeTarget;
|
||||
episodesBySeason: Readonly<Record<string, readonly XtreamSerieEpisode[]>>;
|
||||
}): XtreamSerieEpisode | null {
|
||||
const { target } = options;
|
||||
const episodes: StalkerMappedEpisode[] = [];
|
||||
for (const seasonEpisodes of Object.values(options.episodesBySeason)) {
|
||||
episodes.push(...(seasonEpisodes as StalkerMappedEpisode[]));
|
||||
}
|
||||
|
||||
return (
|
||||
episodes.find(
|
||||
(episode) => Number(episode.id) === target.contentXtreamId
|
||||
) ??
|
||||
episodes.find(
|
||||
(episode) => episode.legacyTrackingId === target.contentXtreamId
|
||||
) ??
|
||||
episodes.find(
|
||||
(episode) =>
|
||||
Number(episode.episode_num) === target.episodeNumber &&
|
||||
(Number(episode.season) === target.seasonNumber ||
|
||||
episode.providerSeasonNumber === target.seasonNumber)
|
||||
) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* For a lazy Ministra VOD series: the not-yet-hydrated season the target
|
||||
* lives in, so the host can fetch it before the episode can be resolved.
|
||||
* Null once that season is loaded, loading, or unknown.
|
||||
*/
|
||||
export function findStalkerResumeLazySeason(options: {
|
||||
target: SeriesResumeTarget;
|
||||
seasons: ReadonlyArray<VodSeriesSeasonVm>;
|
||||
}): VodSeriesSeasonVm | null {
|
||||
const { target, seasons } = options;
|
||||
return (
|
||||
seasons.find(
|
||||
(season) =>
|
||||
!season.episodesLoaded &&
|
||||
!season.isLoading &&
|
||||
season.episodes.length === 0 &&
|
||||
getVodSeriesSeasonNumber(season, seasons) ===
|
||||
target.seasonNumber
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
+98
-2
@@ -17,6 +17,12 @@ import { Router } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component';
|
||||
import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
|
||||
import {
|
||||
findStalkerResumeLazySeason,
|
||||
resolveStalkerResumeEpisode,
|
||||
STALKER_SERIES_RESUME_TARGET,
|
||||
stalkerSeriesResumeKey,
|
||||
} from './stalker-series-resume';
|
||||
import {
|
||||
DetailActionsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
@@ -220,6 +226,16 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
private readonly rawSeriesPositions = signal<
|
||||
readonly PlaybackPositionData[]
|
||||
>([]);
|
||||
/**
|
||||
* `playlistId:seriesId` once the persisted positions for the shown
|
||||
* series have been READ (a failed read leaves it null): the dashboard
|
||||
* resume handoff must not start an episode from the beginning because
|
||||
* the offsets have not arrived yet.
|
||||
*/
|
||||
private readonly seriesPositionsLoadedKey = signal<string | null>(null);
|
||||
private readonly seriesResumeTarget = inject(STALKER_SERIES_RESUME_TARGET);
|
||||
private consumedSeriesResumeKey: string | null = null;
|
||||
private seriesResumeSeasonLoadKey: string | null = null;
|
||||
private readonly legacyPositionByTrackingId = signal<
|
||||
Map<number, PlaybackPositionData>
|
||||
>(new Map());
|
||||
@@ -417,6 +433,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
isSeries: item.is_series,
|
||||
});
|
||||
this.rawSeriesPositions.set([]);
|
||||
this.seriesPositionsLoadedKey.set(null);
|
||||
this.episodePlaybackPositions.set(new Map());
|
||||
this.legacyPositionByTrackingId.set(new Map());
|
||||
const context = this.activateSeriesPositionContext(
|
||||
@@ -435,6 +452,66 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
this.applyReconciledSeriesPositions();
|
||||
});
|
||||
|
||||
// Dashboard "Continue watching" handoff: once the persisted
|
||||
// positions for THIS series are in (so the episode resumes at its
|
||||
// saved offset, not from zero) and the target episode is on the
|
||||
// page, play it exactly once. A lazy Ministra season the target
|
||||
// lives in is hydrated first; the effect re-runs when its episodes
|
||||
// land. Mirrors the Xtream serial-details resume effect.
|
||||
effect(() => {
|
||||
const target = this.seriesResumeTarget();
|
||||
const item = this.displayItem();
|
||||
const playlistId = this.stalkerStore.currentPlaylist()?._id;
|
||||
const seriesXtreamId = this.toSeriesId(item?.id ?? 0);
|
||||
const episodesBySeason = this.mappedSeasons();
|
||||
const seasons = this.vodSeriesSeasons();
|
||||
// Read so the effect re-runs after reconciliation, which is
|
||||
// what `onEpisodeClicked` takes the start offset from.
|
||||
this.episodePlaybackPositions();
|
||||
if (
|
||||
!target ||
|
||||
!item ||
|
||||
!playlistId ||
|
||||
seriesXtreamId <= 0 ||
|
||||
target.seriesXtreamId !== seriesXtreamId ||
|
||||
this.seriesPositionsLoadedKey() !==
|
||||
this.seriesPositionsKey(playlistId, seriesXtreamId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resumeKey = stalkerSeriesResumeKey(playlistId, target);
|
||||
if (this.consumedSeriesResumeKey === resumeKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const episode = resolveStalkerResumeEpisode({
|
||||
target,
|
||||
episodesBySeason,
|
||||
});
|
||||
if (episode) {
|
||||
this.consumedSeriesResumeKey = resumeKey;
|
||||
// Reconciliation attaches positions by exact or legacy
|
||||
// tracking id only; an episode found by coordinates still
|
||||
// resumes at the offset the dashboard card displayed.
|
||||
const savedOffset = this.rawSeriesPositions().find(
|
||||
(position) =>
|
||||
position.contentXtreamId === target.contentXtreamId
|
||||
)?.positionSeconds;
|
||||
untracked(() => this.onEpisodeClicked(episode, savedOffset));
|
||||
return;
|
||||
}
|
||||
|
||||
const lazySeason = this.isVodSeries()
|
||||
? findStalkerResumeLazySeason({ target, seasons })
|
||||
: null;
|
||||
if (!lazySeason || this.seriesResumeSeasonLoadKey === resumeKey) {
|
||||
return;
|
||||
}
|
||||
this.seriesResumeSeasonLoadKey = resumeKey;
|
||||
untracked(() => void this.loadEpisodesForSeason(lazySeason));
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const session = this.externalPlayback.activeSession();
|
||||
const item = this.displayItem();
|
||||
@@ -857,7 +934,12 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
/**
|
||||
* Handles episode click from the container
|
||||
*/
|
||||
onEpisodeClicked(episode: XtreamSerieEpisode) {
|
||||
/**
|
||||
* `startTimeOverride` lets the dashboard resume handoff carry the saved
|
||||
* offset for an episode whose position row the page could not attach
|
||||
* (matched by coordinates only), so it resumes where the card said.
|
||||
*/
|
||||
onEpisodeClicked(episode: XtreamSerieEpisode, startTimeOverride?: number) {
|
||||
const item = this.displayItem();
|
||||
const episodeState = resolveSelectedStalkerEpisodeState({
|
||||
episodesBySeason: this.mappedSeasons(),
|
||||
@@ -876,7 +958,8 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
: item.info.name;
|
||||
const trackingId = Number(mappedEpisode.id);
|
||||
const startTime =
|
||||
this.episodePlaybackPositions().get(trackingId)?.positionSeconds;
|
||||
this.episodePlaybackPositions().get(trackingId)?.positionSeconds ??
|
||||
startTimeOverride;
|
||||
|
||||
void this.startPlayback(
|
||||
command,
|
||||
@@ -1552,11 +1635,24 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
this.rawSeriesPositions.set(positions);
|
||||
this.seriesPositionsLoadedKey.set(
|
||||
this.seriesPositionsKey(
|
||||
context.playlistId,
|
||||
context.seriesXtreamId
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
this.untrackPendingSeriesPositionLoad(context, generation);
|
||||
}
|
||||
}
|
||||
|
||||
private seriesPositionsKey(
|
||||
playlistId: string,
|
||||
seriesXtreamId: number
|
||||
): string {
|
||||
return `${playlistId}:${seriesXtreamId}`;
|
||||
}
|
||||
|
||||
private activateSeriesPositionContext(
|
||||
playlistId: string,
|
||||
seriesXtreamId: number
|
||||
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
import { EMPTY, of } from 'rxjs';
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
type SeriesResumeTarget,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
StalkerStore,
|
||||
type StalkerVodSource,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { TmdbEnrichmentService } from '@iptvnator/services';
|
||||
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerSeriesViewComponent } from './stalker-series-view.component';
|
||||
import { STALKER_SERIES_RESUME_TARGET } from './stalker-series-resume';
|
||||
|
||||
/**
|
||||
* Dashboard "Continue watching" handoff into the Stalker series view: the
|
||||
* one-shot resume target provided through `STALKER_SERIES_RESUME_TARGET`.
|
||||
*/
|
||||
describe('StalkerSeriesViewComponent dashboard resume handoff', () => {
|
||||
let fixture: ComponentFixture<StalkerSeriesViewComponent>;
|
||||
const selectedContentType = signal<'series' | 'vod'>('series');
|
||||
const selectedItem = signal<StalkerVodSource | null>(null);
|
||||
const serialSeasons = signal<unknown[]>([]);
|
||||
const vodSeasons = signal<unknown[]>([]);
|
||||
const seriesResumeTarget = signal<SeriesResumeTarget | null>(null);
|
||||
const resolveVodPlayback = jest.fn();
|
||||
const fetchVodSeriesEpisodes = jest.fn();
|
||||
const getSeriesPlaybackPositions = jest.fn();
|
||||
const openResolvedPlayback = jest.fn();
|
||||
const currentPlaylist = signal({
|
||||
_id: 'stalker-1',
|
||||
title: 'Portal',
|
||||
portalUrl: 'https://stalker.example',
|
||||
macAddress: '00:1A:79:12:34:56',
|
||||
});
|
||||
|
||||
async function stabilize(): Promise<void> {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
}
|
||||
|
||||
/** Runs the effect chain until the async position read has landed. */
|
||||
async function settle(): Promise<void> {
|
||||
await stabilize();
|
||||
await stabilize();
|
||||
await stabilize();
|
||||
}
|
||||
|
||||
function trackingIdOf(seasonKey: string, episodeNum: number): number {
|
||||
return Number(
|
||||
fixture.componentInstance
|
||||
.mappedSeasons()
|
||||
[seasonKey]?.find(
|
||||
(episode) => Number(episode.episode_num) === episodeNum
|
||||
)?.id
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
selectedContentType.set('series');
|
||||
selectedItem.set({
|
||||
id: '30001',
|
||||
cmd: '/media/file_30001.mpg',
|
||||
info: { name: 'Regular Series', movie_image: 'poster.jpg' },
|
||||
});
|
||||
serialSeasons.set([
|
||||
{
|
||||
id: 'season-1',
|
||||
name: 'Season 1',
|
||||
cmd: '/media/file_30001.mpg',
|
||||
series: [1, 2],
|
||||
},
|
||||
]);
|
||||
vodSeasons.set([]);
|
||||
seriesResumeTarget.set(null);
|
||||
resolveVodPlayback
|
||||
.mockReset()
|
||||
.mockImplementation(
|
||||
async (
|
||||
_command: string,
|
||||
title: string,
|
||||
thumbnail: string,
|
||||
_episodeNumber: number,
|
||||
episodeId: number,
|
||||
startTime?: number
|
||||
) => ({
|
||||
streamUrl: 'https://resolved.example/episode.mpg',
|
||||
title,
|
||||
thumbnail,
|
||||
startTime,
|
||||
contentInfo: {
|
||||
playlistId: 'stalker-1',
|
||||
contentXtreamId: episodeId,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: Number(selectedItem()?.id ?? 0),
|
||||
},
|
||||
})
|
||||
);
|
||||
fetchVodSeriesEpisodes.mockReset();
|
||||
getSeriesPlaybackPositions.mockReset().mockResolvedValue([]);
|
||||
openResolvedPlayback.mockReset();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [StalkerSeriesViewComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: StalkerStore,
|
||||
useValue: {
|
||||
selectedItem,
|
||||
selectedContentType,
|
||||
currentPlaylist,
|
||||
getSerialSeasonsResource: () => serialSeasons(),
|
||||
getVodSeriesSeasonsResource: () => vodSeasons(),
|
||||
isVodSeriesSeasonsLoading: signal(false),
|
||||
isSerialSeasonsLoading: signal(false),
|
||||
fetchVodSeriesEpisodes,
|
||||
resolveVodPlayback,
|
||||
fetchLinkToPlay: jest.fn(),
|
||||
clearSelectedItem: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_EXTERNAL_PLAYBACK,
|
||||
useValue: { activeSession: signal(null) },
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYBACK_POSITIONS,
|
||||
useValue: {
|
||||
getSeriesPlaybackPositions,
|
||||
savePlaybackPosition: jest.fn(),
|
||||
clearPlaybackPosition: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYER,
|
||||
useValue: {
|
||||
isEmbeddedPlayer: () => false,
|
||||
openResolvedPlayback,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: STALKER_SERIES_RESUME_TARGET,
|
||||
useValue: seriesResumeTarget,
|
||||
},
|
||||
{ provide: Router, useValue: { navigateByUrl: jest.fn() } },
|
||||
{
|
||||
provide: TmdbEnrichmentService,
|
||||
useValue: {
|
||||
isEnabled: () => false,
|
||||
getSeason: jest.fn().mockResolvedValue(null),
|
||||
getSeasonEpisodes: jest.fn(),
|
||||
},
|
||||
},
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
get: (key: string) => of(key),
|
||||
stream: (key: string) => of(key),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
onLangChange: EMPTY,
|
||||
onTranslationChange: EMPTY,
|
||||
onDefaultLangChange: EMPTY,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(StalkerSeriesViewComponent, {
|
||||
set: { template: '' },
|
||||
})
|
||||
.compileComponents();
|
||||
fixture = TestBed.createComponent(StalkerSeriesViewComponent);
|
||||
});
|
||||
|
||||
afterEach(() => fixture.destroy());
|
||||
|
||||
it('resumes the handoff episode at its saved offset once the series positions are read', async () => {
|
||||
// Position saved under the episode's own tracking id — resolved
|
||||
// lazily, since a regular-series id hashes the season command.
|
||||
getSeriesPlaybackPositions.mockImplementation(async () => [
|
||||
{
|
||||
playlistId: 'stalker-1',
|
||||
contentXtreamId: trackingIdOf('1', 2),
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 30001,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
positionSeconds: 300,
|
||||
durationSeconds: 1800,
|
||||
} satisfies PlaybackPositionData,
|
||||
]);
|
||||
await stabilize();
|
||||
seriesResumeTarget.set({
|
||||
seriesXtreamId: 30001,
|
||||
contentXtreamId: trackingIdOf('1', 2),
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
});
|
||||
|
||||
await settle();
|
||||
|
||||
expect(resolveVodPlayback).toHaveBeenCalledTimes(1);
|
||||
expect(resolveVodPlayback).toHaveBeenCalledWith(
|
||||
'/media/file_30001.mpg',
|
||||
'Regular Series',
|
||||
'poster.jpg',
|
||||
2,
|
||||
trackingIdOf('1', 2),
|
||||
300
|
||||
);
|
||||
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
|
||||
|
||||
// One-shot: further change detection never replays the handoff.
|
||||
await settle();
|
||||
expect(resolveVodPlayback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores a handoff that names another series', async () => {
|
||||
seriesResumeTarget.set({
|
||||
seriesXtreamId: 777,
|
||||
contentXtreamId: 1,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
});
|
||||
|
||||
await settle();
|
||||
|
||||
expect(resolveVodPlayback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when the positions read failed, so the episode never restarts from zero', async () => {
|
||||
getSeriesPlaybackPositions.mockRejectedValue(new Error('db down'));
|
||||
seriesResumeTarget.set({
|
||||
seriesXtreamId: 30001,
|
||||
contentXtreamId: 1,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
});
|
||||
|
||||
await settle();
|
||||
|
||||
expect(resolveVodPlayback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hydrates the lazy VOD season the handoff lives in, then resumes its episode', async () => {
|
||||
selectedContentType.set('vod');
|
||||
selectedItem.set({
|
||||
id: '50001',
|
||||
is_series: true,
|
||||
info: { name: 'VOD Flagged Series', movie_image: 'vod.jpg' },
|
||||
});
|
||||
serialSeasons.set([]);
|
||||
vodSeasons.set([
|
||||
{
|
||||
id: 'season-1',
|
||||
video_id: '50001',
|
||||
season_number: '1',
|
||||
name: 'Season 1',
|
||||
},
|
||||
]);
|
||||
fetchVodSeriesEpisodes.mockResolvedValue([
|
||||
{ id: 'episode-1', series_number: 1, name: 'Pilot' },
|
||||
{ id: 'episode-2', series_number: 2, name: 'Second' },
|
||||
]);
|
||||
// A pre-scope row: its id matches no minted tracking id, so the
|
||||
// episode is found by coordinates and the offset still comes from
|
||||
// the row the dashboard card was built from.
|
||||
getSeriesPlaybackPositions.mockResolvedValue([
|
||||
{
|
||||
playlistId: 'stalker-1',
|
||||
contentXtreamId: 123_456,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 50001,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
positionSeconds: 90,
|
||||
durationSeconds: 1500,
|
||||
} satisfies PlaybackPositionData,
|
||||
]);
|
||||
seriesResumeTarget.set({
|
||||
seriesXtreamId: 50001,
|
||||
contentXtreamId: 123_456,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
});
|
||||
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith(
|
||||
'50001',
|
||||
'season-1'
|
||||
);
|
||||
expect(resolveVodPlayback).toHaveBeenCalledTimes(1);
|
||||
expect(resolveVodPlayback).toHaveBeenCalledWith(
|
||||
'/media/file_episode-2.mpg',
|
||||
'VOD Flagged Series - Second',
|
||||
'vod.jpg',
|
||||
2,
|
||||
trackingIdOf('1', 2),
|
||||
90
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 (embedded-VOD and lazy
|
||||
// `is_series` rows route as movies); progress for those rows
|
||||
// is still per episode, keyed by this parent id.
|
||||
...(isStalkerSeriesItem(item) ? { watch_kind: 'series' } : {}),
|
||||
});
|
||||
|
||||
return acc;
|
||||
|
||||
@@ -47,6 +47,37 @@ 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. A Stalker embedded-VOD (`series[]`)
|
||||
* or lazy Ministra `is_series` row lives in the VOD catalog and must keep
|
||||
* routing there (`type: 'movie'`), yet its progress is a set of EPISODE
|
||||
* positions keyed by the parent id. 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. */
|
||||
|
||||
@@ -1066,6 +1066,103 @@ describe('DashboardDataService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves episode progress for a Stalker embedded-VOD show stored without is_series', async () => {
|
||||
// Real stored shape (RUcolor-style portal): the recent entry keeps
|
||||
// the `series[]` episode array and a numeric VOD category, but no
|
||||
// `is_series` flag. It must keep routing as a movie while its
|
||||
// progress, badge, and resume handoff come from the episode rows
|
||||
// saved under the parent id.
|
||||
playlistsSignal.set([
|
||||
...createDefaultPlaylists(),
|
||||
{
|
||||
_id: 'stalker-embedded',
|
||||
title: 'RUcolor.tv',
|
||||
count: 1,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
recentlyViewed: [
|
||||
{
|
||||
id: '17572',
|
||||
title: 'Fake (10 episodes)',
|
||||
category_id: '7',
|
||||
cmd: '/media/17572.mpg',
|
||||
series: [1, 2, 3, 4, 5, 6, 7, 8],
|
||||
added_at: '2026-09-19T16:13:50.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockImplementation(
|
||||
async (playlistId: string) =>
|
||||
playlistId === 'stalker-embedded'
|
||||
? [
|
||||
{
|
||||
playlistId,
|
||||
contentXtreamId: 1750797719,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 17572,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
positionSeconds: 2673,
|
||||
durationSeconds: 2761,
|
||||
updatedAt: '2026-09-17T10:55:35.000Z',
|
||||
},
|
||||
{
|
||||
playlistId,
|
||||
contentXtreamId: 1750797722,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 17572,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 5,
|
||||
positionSeconds: 2306,
|
||||
durationSeconds: 2920,
|
||||
updatedAt: '2026-09-18T21:45:56.000Z',
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
await service.reloadPlaybackPositions();
|
||||
|
||||
const item = service
|
||||
.globalRecentItems()
|
||||
.find((recent) => recent.playlist_id === 'stalker-embedded');
|
||||
if (!item) {
|
||||
throw new Error('expected the Stalker embedded-series recent item');
|
||||
}
|
||||
|
||||
expect(item.type).toBe('movie');
|
||||
expect(item.watch_kind).toBe('series');
|
||||
expect(service.getPlaybackPositionForItem(item)).toEqual(
|
||||
expect.objectContaining({
|
||||
contentType: 'episode',
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 5,
|
||||
positionSeconds: 2306,
|
||||
})
|
||||
);
|
||||
expect(service.getRecentItemResumeNavigation(item)).toEqual(
|
||||
expect.objectContaining({
|
||||
link: ['/workspace', 'global-recent'],
|
||||
state: {
|
||||
openCollectionDetailItem: expect.objectContaining({
|
||||
item: expect.objectContaining({
|
||||
sourceType: 'stalker',
|
||||
contentType: 'movie',
|
||||
}),
|
||||
seriesResume: {
|
||||
seriesXtreamId: 17572,
|
||||
contentXtreamId: 1750797722,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 5,
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps legacy episode-keyed recents detail-only when the position row lacks the parent series id', async () => {
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -36,11 +36,11 @@ import {
|
||||
PortalActivityType,
|
||||
PortalFavoriteItem,
|
||||
PortalRecentItem,
|
||||
resolvePortalActivityWatchKind,
|
||||
stalkerItemMatchesId,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildStalkerFavoriteItems,
|
||||
getActivityTypeLabelKey,
|
||||
mapDbFavoriteToItem,
|
||||
mapDbRecentlyAddedToItem,
|
||||
mapDbRecentToItem,
|
||||
@@ -293,7 +293,11 @@ export class DashboardDataService {
|
||||
getPlaybackPositionForItem(
|
||||
item: PortalActivityItem
|
||||
): PlaybackPositionData | null {
|
||||
if (item.type !== 'movie' && item.type !== 'series') {
|
||||
// The progress model, not the routing type: a Stalker embedded-VOD
|
||||
// or lazy `is_series` row routes as a movie but tracks episodes
|
||||
// under its parent id, and a `vod` lookup for it finds nothing.
|
||||
const watchKind = resolvePortalActivityWatchKind(item);
|
||||
if (!watchKind) {
|
||||
return null;
|
||||
}
|
||||
const xtreamId =
|
||||
@@ -304,7 +308,7 @@ export class DashboardDataService {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (item.type === 'movie') {
|
||||
if (watchKind === 'movie') {
|
||||
const key = playbackPositionMapKey(
|
||||
item.playlist_id,
|
||||
xtreamId,
|
||||
@@ -883,11 +887,6 @@ export class DashboardDataService {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.PROVIDER');
|
||||
}
|
||||
|
||||
getRecentItemTypeLabel(item: GlobalRecentItem): string {
|
||||
this.languageTick();
|
||||
return this.translateText(getActivityTypeLabelKey(item.type));
|
||||
}
|
||||
|
||||
getRecentItemLink(item: GlobalRecentItem): string[] {
|
||||
return getRecentItemLinkUtil(item);
|
||||
}
|
||||
@@ -1002,48 +1001,6 @@ export class DashboardDataService {
|
||||
}
|
||||
}
|
||||
|
||||
getFavoriteItemProviderLabel(item: DashboardFavoriteItem): string {
|
||||
return this.getActivityItemProviderLabel(item);
|
||||
}
|
||||
|
||||
getRecentlyAddedItemProviderLabel(
|
||||
item: DashboardRecentlyAddedItem
|
||||
): string {
|
||||
return this.getActivityItemProviderLabel(item);
|
||||
}
|
||||
|
||||
private getActivityItemProviderLabel(
|
||||
item: Pick<PortalActivityItem, 'source'>
|
||||
): string {
|
||||
this.languageTick();
|
||||
|
||||
if (item.source === 'stalker') {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.STALKER');
|
||||
}
|
||||
if (item.source === 'xtream') {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.XTREAM');
|
||||
}
|
||||
if (item.source === 'm3u') {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.M3U');
|
||||
}
|
||||
return this.translateText('WORKSPACE.DASHBOARD.PROVIDER');
|
||||
}
|
||||
|
||||
getFavoriteItemTypeLabel(item: DashboardFavoriteItem): string {
|
||||
return this.getActivityItemTypeLabel(item);
|
||||
}
|
||||
|
||||
getRecentlyAddedItemTypeLabel(item: DashboardRecentlyAddedItem): string {
|
||||
return this.getActivityItemTypeLabel(item);
|
||||
}
|
||||
|
||||
private getActivityItemTypeLabel(
|
||||
item: Pick<PortalActivityItem, 'type'>
|
||||
): string {
|
||||
this.languageTick();
|
||||
return this.translateText(getActivityTypeLabelKey(item.type));
|
||||
}
|
||||
|
||||
getGlobalFavoriteLink(item: DashboardFavoriteItem): string[] {
|
||||
return getGlobalFavoriteLinkUtil(item);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
extractStalkerItemTitle,
|
||||
extractStalkerItemTmdbHints,
|
||||
extractStalkerItemType,
|
||||
isStalkerSeriesItem,
|
||||
normalizeStalkerDate,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
@@ -25,12 +26,6 @@ export function normalizeActivityType(value: string): PortalActivityType {
|
||||
return value === 'live' || value === 'series' ? value : 'movie';
|
||||
}
|
||||
|
||||
export function getActivityTypeLabelKey(type: PortalActivityType): string {
|
||||
if (type === 'live') return 'WORKSPACE.DASHBOARD.TYPE_LIVE';
|
||||
if (type === 'series') return 'WORKSPACE.DASHBOARD.TYPE_SERIES';
|
||||
return 'WORKSPACE.DASHBOARD.TYPE_MOVIE';
|
||||
}
|
||||
|
||||
// ────── Xtream DB → ViewModel ──────
|
||||
|
||||
/**
|
||||
@@ -146,6 +141,11 @@ export function buildStalkerFavoriteItems(
|
||||
backdrop_url: extractStalkerItemTmdbHints(raw).backdropUrl,
|
||||
source: 'stalker' as const,
|
||||
stalker_item: item,
|
||||
// Same split as the recent-items mapper: routing type
|
||||
// vs. per-episode progress model.
|
||||
...(isStalkerSeriesItem(raw)
|
||||
? { watch_kind: 'series' as const }
|
||||
: {}),
|
||||
} as PortalFavoriteItem;
|
||||
});
|
||||
|
||||
|
||||
@@ -37,7 +37,13 @@ function episodePosition(
|
||||
|
||||
type RecentNavigationState = {
|
||||
openCollectionDetailItem?: {
|
||||
item?: { uid?: string; xtreamId?: number };
|
||||
item?: {
|
||||
uid?: string;
|
||||
xtreamId?: number;
|
||||
sourceType?: string;
|
||||
contentType?: string;
|
||||
stalkerId?: string;
|
||||
};
|
||||
seriesResume?: unknown;
|
||||
};
|
||||
};
|
||||
@@ -124,13 +130,64 @@ describe('getRecentItemResumeNavigation', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-Xtream sources and missing positions', () => {
|
||||
it('returns null for M3U sources and missing positions', () => {
|
||||
expect(
|
||||
getRecentItemResumeNavigation(
|
||||
{ ...recentSeries, source: 'stalker' },
|
||||
{ ...recentSeries, source: 'm3u' },
|
||||
episodePosition()
|
||||
)
|
||||
).toBeNull();
|
||||
expect(getRecentItemResumeNavigation(recentSeries, null)).toBeNull();
|
||||
});
|
||||
|
||||
it('resumes a Stalker embedded-VOD show that routes as a movie', () => {
|
||||
// The stored row lives in the VOD catalog (`type: 'movie'` keeps it
|
||||
// routing there) but tracks per-episode progress under its parent id.
|
||||
const stalkerShow: PortalRecentItem = {
|
||||
id: '17572',
|
||||
title: 'Fake (10 episodes)',
|
||||
type: 'movie',
|
||||
watch_kind: 'series',
|
||||
source: 'stalker',
|
||||
playlist_id: 'stalker-R',
|
||||
category_id: '7',
|
||||
xtream_id: '17572',
|
||||
viewed_at: '2026-09-19T16:13:50.000Z',
|
||||
stalker_item: { id: '17572', series: [1, 2, 3] } as never,
|
||||
};
|
||||
const navigation = getRecentItemResumeNavigation(
|
||||
stalkerShow,
|
||||
episodePosition({
|
||||
playlistId: 'stalker-R',
|
||||
seriesXtreamId: 17572,
|
||||
contentXtreamId: 1750797722,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 5,
|
||||
})
|
||||
);
|
||||
|
||||
expect(navigation?.link).toEqual(['/workspace', 'global-recent']);
|
||||
const state = navigation?.state as RecentNavigationState;
|
||||
expect(state.openCollectionDetailItem?.item).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceType: 'stalker',
|
||||
contentType: 'movie',
|
||||
stalkerId: '17572',
|
||||
})
|
||||
);
|
||||
expect(state.openCollectionDetailItem?.seriesResume).toEqual({
|
||||
seriesXtreamId: 17572,
|
||||
contentXtreamId: 1750797722,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 5,
|
||||
});
|
||||
|
||||
// Without the watch kind the same row is a plain movie: no handoff.
|
||||
expect(
|
||||
getRecentItemResumeNavigation(
|
||||
{ ...stalkerShow, watch_kind: undefined },
|
||||
episodePosition({ seriesXtreamId: 17572 })
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
PortalAddedItem,
|
||||
PortalFavoriteItem,
|
||||
PortalRecentItem,
|
||||
resolvePortalActivityWatchKind,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildStalkerDetailNavigationTarget,
|
||||
@@ -92,7 +93,7 @@ export function getRecentItemDetailNavigationState(
|
||||
|
||||
/**
|
||||
* Full navigation target carrying the one-shot resume handoff, or null when
|
||||
* the item/position cannot produce one (non-Xtream, non-series, watched or
|
||||
* 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(
|
||||
@@ -127,9 +128,12 @@ function buildRecentSeriesIdentityTarget(
|
||||
item: PortalRecentItem,
|
||||
playbackPosition?: PlaybackPositionData | null
|
||||
): SeriesResumeTarget | null {
|
||||
// Watch kind, not `type`: a Stalker embedded-VOD / lazy `is_series` 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 (
|
||||
item.type !== 'series' ||
|
||||
item.source !== 'xtream' ||
|
||||
resolvePortalActivityWatchKind(item) !== 'series' ||
|
||||
(item.source !== 'xtream' && item.source !== 'stalker') ||
|
||||
playbackPosition?.contentType !== 'episode'
|
||||
) {
|
||||
return null;
|
||||
|
||||
@@ -193,32 +193,45 @@
|
||||
<span class="rail__card-title">{{
|
||||
cardTitle(card)
|
||||
}}</span>
|
||||
<span class="rail__card-meta-row">
|
||||
@if (card.episodeBadge) {
|
||||
<span class="rail__card-episode">{{
|
||||
card.episodeBadge
|
||||
}}</span>
|
||||
}
|
||||
@if (card.subtitle) {
|
||||
<span class="rail__card-subtitle">{{
|
||||
card.subtitle
|
||||
}}</span>
|
||||
}
|
||||
@if (card.expiryBadge; as expiry) {
|
||||
<span
|
||||
class="rail__card-expiry"
|
||||
[class.rail__card-expiry--expired]="
|
||||
expiry.kind === 'expired'
|
||||
"
|
||||
[attr.data-test-id]="
|
||||
testId()
|
||||
? testId() + '-card-expiry'
|
||||
: null
|
||||
"
|
||||
>{{ expiry.label }}</span
|
||||
>
|
||||
}
|
||||
</span>
|
||||
@if (
|
||||
card.episodeBadge ||
|
||||
card.remainingLabel ||
|
||||
card.subtitle ||
|
||||
card.expiryBadge
|
||||
) {
|
||||
<span class="rail__card-meta-row">
|
||||
@if (card.episodeBadge) {
|
||||
<span class="rail__card-episode">{{
|
||||
card.episodeBadge
|
||||
}}</span>
|
||||
}
|
||||
@if (card.remainingLabel; as remaining) {
|
||||
<span class="rail__card-remaining">{{
|
||||
remaining.key
|
||||
| translate: remaining.params
|
||||
}}</span>
|
||||
}
|
||||
@if (card.subtitle) {
|
||||
<span class="rail__card-subtitle">{{
|
||||
card.subtitle
|
||||
}}</span>
|
||||
}
|
||||
@if (card.expiryBadge; as expiry) {
|
||||
<span
|
||||
class="rail__card-expiry"
|
||||
[class.rail__card-expiry--expired]="
|
||||
expiry.kind === 'expired'
|
||||
"
|
||||
[attr.data-test-id]="
|
||||
testId()
|
||||
? testId() + '-card-expiry'
|
||||
: null
|
||||
"
|
||||
>{{ expiry.label }}</span
|
||||
>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -404,6 +404,21 @@
|
||||
color: var(--app-expiry-expired-text, #b3261e);
|
||||
}
|
||||
|
||||
// "12 min left" beside the episode chip: same tone as the subtitle, but it
|
||||
// keeps its digits whole instead of truncating when the row runs short.
|
||||
.rail__card-remaining {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: var(--app-muted-color, #8891a4);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rail__card-subtitle {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
@@ -133,5 +133,34 @@ describe('DashboardRailComponent', () => {
|
||||
chips[1].classList.contains('rail__card-expiry--expired')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the episode chip and remaining time, and drops an empty meta row', async () => {
|
||||
const element = await renderCards([
|
||||
card({
|
||||
id: 'show',
|
||||
title: 'Fake',
|
||||
contentType: 'movie',
|
||||
episodeBadge: 'S1·E5',
|
||||
remainingLabel: {
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES',
|
||||
params: { minutes: 12 },
|
||||
},
|
||||
}),
|
||||
card({ id: 'bare', title: 'Bare', contentType: 'movie' }),
|
||||
]);
|
||||
|
||||
const cards = element.querySelectorAll('.rail__card');
|
||||
expect(cards).toHaveLength(2);
|
||||
expect(
|
||||
cards[0].querySelector('.rail__card-episode')?.textContent
|
||||
).toBe('S1·E5');
|
||||
expect(
|
||||
cards[0]
|
||||
.querySelector('.rail__card-remaining')
|
||||
?.textContent?.trim()
|
||||
).toBe('WORKSPACE.DASHBOARD.REMAINING_MINUTES');
|
||||
expect(cards[0].querySelector('.rail__card-subtitle')).toBeNull();
|
||||
expect(cards[1].querySelector('.rail__card-meta-row')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
|
||||
import type { DashboardRemainingLabel } from './dashboard-playback.utils';
|
||||
|
||||
export interface DashboardRailAction {
|
||||
id: string;
|
||||
@@ -67,6 +68,13 @@ export interface DashboardRailCard {
|
||||
*/
|
||||
episodeBadge?: string | null;
|
||||
|
||||
/**
|
||||
* Localised "12 min left" for Continue Watching cards with a known
|
||||
* duration. Sits beside the episode chip in the meta row; absent when
|
||||
* the position carries no duration.
|
||||
*/
|
||||
remainingLabel?: DashboardRemainingLabel | null;
|
||||
|
||||
/**
|
||||
* Subscription-expiry warning for portal source cards: a quiet amber
|
||||
* chip when the account expires soon, an error-toned one once it has.
|
||||
|
||||
+42
-36
@@ -15,6 +15,9 @@ import {
|
||||
isStalkerAccountPlaylist,
|
||||
isXtreamAccountPlaylist,
|
||||
normalizeDashboardRailsSettings,
|
||||
type PlaybackPositionData,
|
||||
playlistDisplayLabel,
|
||||
resolvePortalActivityWatchKind,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
@@ -192,18 +195,7 @@ export class WorkspaceDashboardRailsComponent {
|
||||
item.type === 'live'
|
||||
? this.getLiveEpgDetailsForCard(this.heroLiveCard())
|
||||
: null;
|
||||
const episodeBadge =
|
||||
item.type === 'series' &&
|
||||
position?.seasonNumber != null &&
|
||||
position?.episodeNumber != null
|
||||
? this.translate.instant(
|
||||
'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE',
|
||||
{
|
||||
season: position.seasonNumber,
|
||||
episode: position.episodeNumber,
|
||||
}
|
||||
)
|
||||
: null;
|
||||
const episodeBadge = this.buildEpisodeBadge(item, position);
|
||||
|
||||
return {
|
||||
...artwork,
|
||||
@@ -639,34 +631,49 @@ export class WorkspaceDashboardRailsComponent {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the source name under the hero title. Provider kind (Xtream /
|
||||
* Stalker / M3U) and content kind (movie / series) are the app's own
|
||||
* taxonomy, not a property of the title, and the badges row already
|
||||
* says "S1·E5"; a stored playlist name can be a pasted URL with
|
||||
* credentials or a MAC, so it goes through `playlistDisplayLabel`.
|
||||
*/
|
||||
private buildHeroSubtitle(item: GlobalRecentItem): string {
|
||||
const parts = [
|
||||
return playlistDisplayLabel(
|
||||
item.playlist_name,
|
||||
this.data.getRecentItemProviderLabel(item),
|
||||
this.data.getRecentItemTypeLabel(item),
|
||||
].filter((value): value is string => Boolean(value));
|
||||
return parts.join(' · ');
|
||||
this.data.getRecentItemProviderLabel(item)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "S1·E5" for an item whose progress is tracked per episode. Keyed on
|
||||
* the WATCH kind: a Stalker embedded-VOD / lazy `is_series` show routes
|
||||
* as a movie but still names the episode it is on.
|
||||
*/
|
||||
private buildEpisodeBadge(
|
||||
item: GlobalRecentItem,
|
||||
position: PlaybackPositionData | null
|
||||
): string | null {
|
||||
return resolvePortalActivityWatchKind(item) === 'series' &&
|
||||
position?.seasonNumber != null &&
|
||||
position?.episodeNumber != null
|
||||
? this.translate.instant(
|
||||
'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE',
|
||||
{
|
||||
season: position.seasonNumber,
|
||||
episode: position.episodeNumber,
|
||||
}
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
private toRecentCard(item: GlobalRecentItem): DashboardRailCard {
|
||||
const position = this.data.getPlaybackPositionForItem(item);
|
||||
const watchProgress = playbackProgressPercent(position);
|
||||
const episodeBadge =
|
||||
item.type === 'series' &&
|
||||
position?.seasonNumber != null &&
|
||||
position?.episodeNumber != null
|
||||
? this.translate.instant(
|
||||
'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE',
|
||||
{
|
||||
season: position.seasonNumber,
|
||||
episode: position.episodeNumber,
|
||||
}
|
||||
)
|
||||
: null;
|
||||
const episodeBadge = this.buildEpisodeBadge(item, position);
|
||||
return {
|
||||
id: this.recentCardId(item),
|
||||
title: item.title,
|
||||
subtitle: `${this.data.getRecentItemProviderLabel(item)} · ${this.data.getRecentItemTypeLabel(item)}`,
|
||||
imageUrl: item.poster_url,
|
||||
icon: this.typeIcon(item.type),
|
||||
contentType: item.type,
|
||||
@@ -678,6 +685,9 @@ export class WorkspaceDashboardRailsComponent {
|
||||
state: this.data.getRecentItemDetailNavigationState(item),
|
||||
watchProgress,
|
||||
episodeBadge,
|
||||
// What still separates one card from the next: where in the
|
||||
// show, and how much is left — not which provider it came from.
|
||||
remainingLabel: formatRemainingLabel(position),
|
||||
...(item.type === 'movie' || item.type === 'series'
|
||||
? {
|
||||
actions: buildDashboardContinueWatchingActions({
|
||||
@@ -702,7 +712,6 @@ export class WorkspaceDashboardRailsComponent {
|
||||
return {
|
||||
id: `fav-${item.id}-${item.playlist_id}-${item.added_at}`,
|
||||
title: item.title,
|
||||
subtitle: `${this.data.getFavoriteItemProviderLabel(item)} · ${this.data.getFavoriteItemTypeLabel(item)}`,
|
||||
imageUrl: item.poster_url,
|
||||
icon: this.typeIcon(item.type),
|
||||
contentType: item.type,
|
||||
@@ -715,14 +724,11 @@ export class WorkspaceDashboardRailsComponent {
|
||||
private toRecentlyAddedCard(
|
||||
item: DashboardRecentlyAddedItem
|
||||
): DashboardRailCard {
|
||||
const typeLabel = this.data.getRecentlyAddedItemTypeLabel(item);
|
||||
const subtitleParts = [item.playlist_name, typeLabel].filter(
|
||||
(value): value is string => Boolean(value)
|
||||
);
|
||||
return {
|
||||
id: `added-${item.id}-${item.playlist_id}-${item.added_at}`,
|
||||
title: item.title,
|
||||
subtitle: subtitleParts.join(' · '),
|
||||
// "Where was it added" is the one fact that varies per card here.
|
||||
subtitle: playlistDisplayLabel(item.playlist_name),
|
||||
imageUrl: item.poster_url,
|
||||
icon: this.typeIcon(item.type),
|
||||
contentType: item.type,
|
||||
|
||||
Reference in new issue
Block a user