diff --git a/.changes/dashboard-continue-watching-meta.md b/.changes/dashboard-continue-watching-meta.md new file mode 100644 index 000000000..4c2525bd2 --- /dev/null +++ b/.changes/dashboard-continue-watching-meta.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 2a2c2b99c..0d335504a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. The shape that forces the distinction is a Stalker embedded-VOD row: its stored entry announces episodes through a `series[]` array but carries no `is_series` flag, and `extractStalkerItemType` is deliberately blind to that array (the item must route to the VOD catalog), so it reports `type: 'movie'` while its progress is a set of episode rows. The mappers set `watch_kind: 'series'` on it and on a lazy Ministra `is_series` row (which `extractStalkerItemType` already types `series`, the flag being read) through `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): diff --git a/apps/web/src/app/global-collection-route.component.spec.ts b/apps/web/src/app/global-collection-route.component.spec.ts index fb577cbfe..920a33e4d 100644 --- a/apps/web/src/app/global-collection-route.component.spec.ts +++ b/apps/web/src/app/global-collection-route.component.spec.ts @@ -17,9 +17,22 @@ class MockXtreamCollectionDetailComponent { readonly closeRequested = output(); } +@Component({ + selector: 'app-stalker-collection-detail', + template: '', +}) +class MockStalkerCollectionDetailComponent { + readonly item = input(null); + readonly seriesResume = input(null); + readonly closeRequested = output(); +} + 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(); diff --git a/apps/web/src/app/global-collection-route.component.ts b/apps/web/src/app/global-collection-route.component.ts index 32542d89d..3a91a8dcf 100644 --- a/apps/web/src/app/global-collection-route.component.ts +++ b/apps/web/src/app/global-collection-route.component.ts @@ -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') { } diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index ab49ff539..18c17f8c2 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -74,13 +74,27 @@ 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. The two Stalker + shapes differ in the handoff state: a lazy `is_series` row carries + `contentType: 'series'` (the flag is read by `extractStalkerItemType`) and + `resolveStalkerCollectionDetailMode` sends it through the VOD detail flow + without changing that type, while an embedded-VOD row — a `series[]` + episode array and no flag — carries `contentType: 'movie'`, because the + type resolver is deliberately blind to that array so the item keeps + routing to the VOD catalog. Only the detail, reading the stored row, can + tell the second shape from a real movie, 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 +241,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 that + `resolvePortalActivityWatchKind` answers `series` for) 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 diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 81266e7e8..fe8525ce9 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -113,17 +113,29 @@ 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`. The + shape that needs the distinction is a Stalker embedded-VOD row: its + stored entry carries a `series[]` episode array but no `is_series` + flag, so `extractStalkerItemType` reports `movie` (deliberately — the + item belongs in the VOD catalog) while its progress lives in episode + rows. The mappers give both it and a lazy Ministra `is_series` row + (already typed `series`) `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 +148,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 +230,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 diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts index 8a0640f4b..426f221ff 100644 --- a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts @@ -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', () => { diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts index 4e7970c0c..fe06778fa 100644 --- a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts @@ -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 row 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,18 @@ export function getOpenCollectionDetailItemState( candidate?.['seriesResume'] ); + // An Xtream series, or any Stalker VOD detail: a Stalker `movie` may + // still be an embedded-VOD show (a `series[]` array and no flag, which + // `extractStalkerItemType` reports as a movie on purpose), 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; } diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts index 599159a6f..2034e9d63 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts @@ -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(null); + readonly seriesResume = input(null); readonly closeRequested = output(); private readonly playlistsService = inject(PlaylistsService); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.spec.ts new file mode 100644 index 000000000..46708c1be --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.spec.ts @@ -0,0 +1,218 @@ +import type { SeriesResumeTarget } from '@iptvnator/portal/shared/util'; +import type { + StalkerMappedEpisode, + VodSeriesSeasonVm, +} from '@iptvnator/portal/stalker/data-access'; +import { + findStalkerResumeLazySeason, + MAX_RESUME_SEASON_HYDRATION_ATTEMPTS, + resolveStalkerResumeEpisode, + StalkerResumeSeasonHydration, + stalkerSeriesResumeKey, +} from './stalker-series-resume'; + +function episode( + overrides: Partial & { 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 { + 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('StalkerResumeSeasonHydration', () => { + it('holds the claim while a request is in flight', () => { + const hydration = new StalkerResumeSeasonHydration(); + + expect(hydration.canRequest('a')).toBe(true); + hydration.begin('a'); + expect(hydration.canRequest('a')).toBe(false); + }); + + it('allows a bounded retry after a failed fetch, then gives up', () => { + // A failed fetch leaves the season unloaded and re-runs the effect, + // so an unbounded release would loop against a dead portal. + const hydration = new StalkerResumeSeasonHydration(); + + for ( + let attempt = 1; + attempt < MAX_RESUME_SEASON_HYDRATION_ATTEMPTS; + attempt++ + ) { + hydration.begin('a'); + hydration.settle('a', false); + expect(hydration.canRequest('a')).toBe(true); + } + + hydration.begin('a'); + hydration.settle('a', false); + expect(hydration.canRequest('a')).toBe(false); + }); + + it('stops asking once the portal answered', () => { + const hydration = new StalkerResumeSeasonHydration(); + + hydration.begin('a'); + hydration.settle('a', true); + + expect(hydration.canRequest('a')).toBe(false); + }); + + it('starts a different target fresh and ignores a late settlement', () => { + const hydration = new StalkerResumeSeasonHydration(); + + hydration.begin('a'); + hydration.begin('b'); + // 'a' settling late must not hand 'b' an extra attempt. + hydration.settle('a', false); + expect(hydration.canRequest('b')).toBe(false); + + hydration.settle('b', false); + expect(hydration.canRequest('b')).toBe(true); + expect(hydration.canRequest('a')).toBe(true); + }); +}); + +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) + ); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.ts new file mode 100644 index 000000000..a17021a17 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-resume.ts @@ -0,0 +1,162 @@ +import { InjectionToken, Signal, signal, WritableSignal } 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 +>('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>; +}): 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 + ); +} + +/** + * How many times one resume target may ask the portal for the lazy season + * it lives in. + * + * A failed fetch flips the season's `isLoading` back and leaves it + * unloaded, which re-runs the effect that asked — so releasing the claim on + * every failure would hammer a portal that is simply down, while holding it + * forever strands the handoff after a single transient error. Two attempts + * buys the recovery and then stops: the detail is open either way, and its + * "Resume episode" button hydrates the same season on demand. + */ +export const MAX_RESUME_SEASON_HYDRATION_ATTEMPTS = 2; + +/** + * The lazy-season hydration a resume target has asked for, so the owning + * effect can tell "already in flight" from "failed, may try once more" from + * "given up". Scoped to one series view; a different target starts fresh. + */ +export class StalkerResumeSeasonHydration { + /** + * A signal, not a plain field: nothing else changes when a fetch fails + * (the season's own `isLoading` has already flipped back by then), so a + * reader effect would never re-run to spend the retry. + */ + private readonly claim: WritableSignal<{ + key: string; + attempts: number; + pending: boolean; + } | null> = signal(null); + + /** May this target request its season now? */ + canRequest(key: string): boolean { + const claim = this.claim(); + if (!claim || claim.key !== key) { + return true; + } + return ( + !claim.pending && + claim.attempts < MAX_RESUME_SEASON_HYDRATION_ATTEMPTS + ); + } + + /** Claim the request before dispatching it. */ + begin(key: string): void { + const claim = this.claim(); + const attempts = claim?.key === key ? claim.attempts : 0; + this.claim.set({ key, attempts: attempts + 1, pending: true }); + } + + /** + * Record how the request ended. A late settlement for a target that is + * no longer the one being hydrated is ignored, so it cannot hand a + * retry to whatever claimed the slot after it. + */ + settle(key: string, answered: boolean): void { + const claim = this.claim(); + if (!claim || claim.key !== key) { + return; + } + this.claim.set({ + key, + attempts: answered + ? MAX_RESUME_SEASON_HYDRATION_ATTEMPTS + : claim.attempts, + pending: false, + }); + } +} + +/** + * 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 | null { + const { target, seasons } = options; + return ( + seasons.find( + (season) => + !season.episodesLoaded && + !season.isLoading && + season.episodes.length === 0 && + getVodSeriesSeasonNumber(season, seasons) === + target.seasonNumber + ) ?? null + ); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 1a4a7773f..417b8f293 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -17,6 +17,13 @@ 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, + StalkerResumeSeasonHydration, + stalkerSeriesResumeKey, +} from './stalker-series-resume'; import { DetailActionsTemplateDirective, DetailMetaTemplateDirective, @@ -220,6 +227,17 @@ 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(null); + private readonly seriesResumeTarget = inject(STALKER_SERIES_RESUME_TARGET); + private consumedSeriesResumeKey: string | null = null; + private readonly seriesResumeSeasonHydration = + new StalkerResumeSeasonHydration(); private readonly legacyPositionByTrackingId = signal< Map >(new Map()); @@ -417,6 +435,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 +454,78 @@ 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.seriesResumeSeasonHydration.canRequest(resumeKey) + ) { + return; + } + this.seriesResumeSeasonHydration.begin(resumeKey); + untracked( + () => + void this.loadEpisodesForSeason(lazySeason).then( + (answered) => + this.seriesResumeSeasonHydration.settle( + resumeKey, + answered + ) + ) + ); + }); + effect(() => { const session = this.externalPlayback.activeSession(); const item = this.displayItem(); @@ -857,7 +948,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 +972,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 +1649,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 diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.resume.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.resume.spec.ts new file mode 100644 index 000000000..8c7aa38d6 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.resume.spec.ts @@ -0,0 +1,418 @@ +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; + const selectedContentType = signal<'series' | 'vod'>('series'); + const selectedItem = signal(null); + const serialSeasons = signal([]); + const vodSeasons = signal([]); + const seriesResumeTarget = signal(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 { + fixture.detectChanges(); + await fixture.whenStable(); + } + + /** Runs the effect chain until the async position read has landed. */ + async function settle(): Promise { + 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('retries the lazy season once after a transient portal failure', async () => { + // A failed fetch leaves the season unloaded, so the handoff would + // otherwise be stranded for the lifetime of this detail host. + 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 + .mockRejectedValueOnce(new Error('portal down')) + .mockResolvedValue([ + { id: 'episode-1', series_number: 1, name: 'Pilot' }, + { id: 'episode-2', series_number: 2, name: 'Second' }, + ]); + 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(); + await settle(); + + expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(2); + expect(resolveVodPlayback).toHaveBeenCalledTimes(1); + expect(resolveVodPlayback).toHaveBeenCalledWith( + '/media/file_episode-2.mpg', + 'VOD Flagged Series - Second', + 'vod.jpg', + 2, + trackingIdOf('1', 2), + 90 + ); + }); + + it('stops asking the portal after the bounded retry also fails', 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.mockRejectedValue(new Error('portal down')); + 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(); + await settle(); + await settle(); + + // Bounded: a portal that is simply down is asked twice, not forever. + expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(2); + 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 + ); + }); +}); diff --git a/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.spec.ts b/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.spec.ts new file mode 100644 index 000000000..0528ed64b --- /dev/null +++ b/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.spec.ts @@ -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(); + }); +}); diff --git a/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts b/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts index 1ce60d2ee..2c539ca6e 100644 --- a/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts +++ b/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts @@ -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; diff --git a/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts b/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts index 79f808bb3..d66c6c907 100644 --- a/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts +++ b/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts @@ -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 +): 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. */ diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index acbd1ed89..e6a2bf911 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -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([ { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index cdd47daf2..4471d1e2b 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -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 + // 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 - ): 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 - ): string { - this.languageTick(); - return this.translateText(getActivityTypeLabelKey(item.type)); - } - getGlobalFavoriteLink(item: DashboardFavoriteItem): string[] { return getGlobalFavoriteLinkUtil(item); } diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts index 6330e83f8..be0cc61d6 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts @@ -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; }); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.spec.ts index 8e16e73d4..79942a9a2 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.spec.ts @@ -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(); + }); }); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts index 56b025258..eeecaec1f 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts @@ -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 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; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html index cfe91881b..d43ede1b6 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html @@ -193,32 +193,45 @@ {{ cardTitle(card) }} - - @if (card.episodeBadge) { - {{ - card.episodeBadge - }} - } - @if (card.subtitle) { - {{ - card.subtitle - }} - } - @if (card.expiryBadge; as expiry) { - {{ expiry.label }} - } - + @if ( + card.episodeBadge || + card.remainingLabel || + card.subtitle || + card.expiryBadge + ) { + + @if (card.episodeBadge) { + {{ + card.episodeBadge + }} + } + @if (card.remainingLabel; as remaining) { + {{ + remaining.key + | translate: remaining.params + }} + } + @if (card.subtitle) { + {{ + card.subtitle + }} + } + @if (card.expiryBadge; as expiry) { + {{ expiry.label }} + } + + } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss index b7fb55c6b..55d04266f 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss @@ -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; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts index 506825dd7..2a89999ae 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts @@ -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(); + }); }); }); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts index 60d01a07a..39f1d5d1a 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts @@ -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; @@ -73,6 +74,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. diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 118fe9ffc..49a9fc10a 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -13,6 +13,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'; @@ -185,18 +188,7 @@ export class WorkspaceDashboardRailsComponent { item.type === 'live' ? this.liveEpg.detailsFor(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, @@ -593,34 +585,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, @@ -633,6 +640,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({ @@ -657,7 +667,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, @@ -671,14 +680,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,