From 2dfdd65f533dc5c243a39482ab7005ac3844f75a Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:52:53 +0200 Subject: [PATCH] refactor(details): give the detail-page files real max-lines headroom (#1813) * refactor(xtream): split the series details page into focused services serial-details.component.ts sat at the 400-line max-lines limit and its playback service and spec were close behind. Move cohesive concerns out without changing behavior: - route params, the provider-only flag and the (re)load of the addressed series go to SerialDetailsRouteService; the component still registers the effect, so effect order is unchanged - season descriptions, posters and the TMDB season enrichment go to SerialDetailsSeasonsService - actor, Similar and Discover navigation go to injectXtreamDetailNavigation, shared with the movie page - the watched toggles and the episode playback payload leave SerialDetailsPlaybackService for SerialDetailsWatchToggles and buildSerialEpisodePlayback - the spec's TestBed moves to a harness and the watched-toggle cases to serial-details.season-watch.spec.ts Counted lines: component 400 -> 318, playback service 388 -> 342, component spec 1196 -> 674. Co-Authored-By: Claude Fable 5.1 * refactor(xtream): move VOD route selection and position state out of the page vod-details-route.component.ts grew from 618 to 741 counted lines and vod-details-playback.service.ts reached 395. Extract without changing behavior: - the route-derived read model (selected movie, category, catalog item, fallback view, multi-source identity, session and content keys) goes to VodDetailsSelectionService; the component keeps the same member names - trailer state and the Similar-rail click move into the hero presenter, the cancel-download prompt and the progress-ring geometry into the downloads service, navigation into injectXtreamDetailNavigation - stored positions (last seen, route row, guarded load) become VodDetailsPositionState and the external-launch bookkeeping becomes VodExternalLaunchClaim - drop the unused MatTooltip import (NG8113) and eight unused imports Counted lines: route component 741 -> 492, playback service 395 -> 344. Co-Authored-By: Claude Fable 5.1 * refactor(stalker): extract series-view positions and watched toggles stalker-series-view.component.ts grew from 1601 to 1813 counted lines and its spec reached 1198 of 1200. Move code out verbatim: - saved positions, their reconcile and the persist/clear writes go to the component-provided StalkerSeriesPositionsService; the ordering of reads and writes goes to StalkerSeriesPositionQueue - episode, season and series watched toggles go to StalkerSeriesWatchToggleService, the batch core to runStalkerWatchToggleBatch - the lazy VOD season loads go to StalkerVodSeasonEpisodeLoader Every effect stays registered in the component constructor in its original order; template bindings and public member names are unchanged. The spec setup moves to a shared harness and the spillover-prefetch and TMDB season cases to their own specs; the 184 cases of the directory are unchanged. Counted lines: component 1813 -> 1136, component spec 1198 -> 670. Co-Authored-By: Claude Fable 5.1 * refactor(stalker): extract catalog-detail position and search paging - stalker-catalog-detail.component.ts (397): the stored VOD position and its runtime updates become StalkerCatalogVodPosition, the Play/Resume start becomes startStalkerCatalogVodPlayback - stalker-search.component.ts (776, baselined): the paging resource, the accumulated results and the parental-lock bookkeeping become StalkerSearchPagingController, with pure helpers in stalker-search-results.util.ts. The spec reaches the moved members through component.paging; its cases and assertions are unchanged. Counted lines: catalog detail 397 -> 317, search 776 -> 482. Co-Authored-By: Claude Fable 5.1 * refactor(ui): give season-container and vod-details real line headroom - season-container.component.ts (396): season auto-selection and the seasonSelected emission move to createSeasonAutoSelectState, called at the same place in the constructor so effect order is unchanged; the episode subline becomes buildEpisodeSubline - vod-details.component.ts (393): the cross-portal Similar loader, the provider-only download state and the actor/Similar route helpers move to sibling modules Inputs, outputs, selectors and public members are unchanged. Counted lines: season container 396 -> 341, vod details 393 -> 337. Co-Authored-By: Claude Fable 5.1 * test(web-e2e): move Stalker portal helpers into fixtures stalker.e2e.ts was at 1196 of 1200 counted lines. Move the mock endpoints, scenario MACs and page helpers to stalker-portal.fixture.ts and the embedded-series steps three tests repeated to stalker-embedded-series.fixture.ts. Every test stays in stalker.e2e.ts in the same order, with the same serial mode and OWNED_MACS reset. Counted lines: 1196 -> 981. Co-Authored-By: Claude Fable 5.1 * docs: follow the detail-page extractions Name the Stalker watched-toggle and position services that now own the batch and reconcile code, point AUTH_REJECTED_MAC and the scenario MACs at stalker-portal.fixture.ts, and drop two stale statements about components sitting at the max-lines cap. Co-Authored-By: Claude Fable 5.1 * docs(xtream): note why the seasons service is injected last Its TMDB enrichment effect keeps the position it had as the first effect of the component constructor only while it is created after the other services' effects. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: 4gray Co-authored-by: Claude Fable 5.1 --- .../src/stalker-embedded-series.fixture.ts | 104 ++ apps/web-e2e/src/stalker-portal.fixture.ts | 238 +++++ apps/web-e2e/src/stalker.e2e.ts | 366 +------ docs/architecture/embedded-inline-playback.md | 13 +- docs/architecture/stalker-mock-server.md | 3 +- docs/architecture/stalker-portal.md | 3 +- docs/architecture/tmdb-roadmap.md | 2 +- .../stalker-catalog-detail.component.ts | 161 +-- .../stalker-catalog-vod-playback.ts | 84 ++ .../stalker-catalog-vod-position.ts | 110 +++ .../stalker-search-paging.controller.ts | 391 ++++++++ .../stalker-search-results.util.ts | 138 +++ .../stalker-search.component.spec.ts | 101 +- .../stalker-search.component.ts | 450 +-------- .../stalker-series-view/stalker-series-id.ts | 12 + .../stalker-series-position-queue.ts | 187 ++++ .../stalker-series-positions.service.ts | 331 +++++++ .../stalker-series-view.component.spec.ts | 648 +----------- .../stalker-series-view.component.ts | 934 ++---------------- ...ker-series-view.spillover-prefetch.spec.ts | 281 ++++++ .../stalker-series-view.test-harness.ts | 273 +++++ .../stalker-series-view.tmdb-season.spec.ts | 192 ++++ .../stalker-series-watch-batch.ts | 113 +++ .../stalker-series-watch-toggle.service.ts | 323 ++++++ .../stalker-vod-season-episode-loader.ts | 125 +++ .../serial-details-playback.service.ts | 97 +- .../serial-details-route.service.ts | 75 ++ .../serial-details-seasons.service.ts | 75 ++ .../serial-details-watch-toggles.ts | 104 ++ .../serial-details.component.spec.ts | 608 +----------- .../serial-details.component.ts | 163 +-- .../serial-details/serial-details.harness.ts | 314 ++++++ .../serial-details.season-watch.spec.ts | 267 +++++ .../serial-details.test-stubs.ts | 70 ++ .../serial-details/serial-episode-playback.ts | 58 ++ .../vod-details-downloads.service.ts | 38 + .../vod-details-external-launch.ts | 48 +- .../vod-details/vod-details-hero.presenter.ts | 62 +- .../vod-details-playback.service.ts | 162 +-- .../vod-details/vod-details-position-state.ts | 121 +++ .../vod-details-route.component.ts | 358 +------ .../vod-details-selection.service.ts | 213 ++++ .../src/lib/xtream-detail-navigation.spec.ts | 114 +++ .../src/lib/xtream-detail-navigation.ts | 73 ++ .../season-container/episode-subline.util.ts | 37 + .../season-auto-select.state.ts | 136 +++ .../season-container.component.ts | 140 +-- .../vod-details-navigation.util.ts | 43 + .../lib/vod-details/vod-details.component.ts | 111 +-- .../vod-details/vod-download-state.util.ts | 55 ++ .../vod-similar-in-portals.state.ts | 51 + 51 files changed, 5326 insertions(+), 3850 deletions(-) create mode 100644 apps/web-e2e/src/stalker-embedded-series.fixture.ts create mode 100644 apps/web-e2e/src/stalker-portal.fixture.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-vod-playback.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-vod-position.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-search/stalker-search-paging.controller.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-search/stalker-search-results.util.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-id.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-queue.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-positions.service.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.spillover-prefetch.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.test-harness.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-season.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-watch-batch.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-watch-toggle.service.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-vod-season-episode-loader.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details-route.service.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details-seasons.service.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details-watch-toggles.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details.harness.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details.season-watch.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details.test-stubs.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-episode-playback.ts create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-details-position-state.ts create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-details-selection.service.ts create mode 100644 libs/portal/xtream/feature/src/lib/xtream-detail-navigation.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/xtream-detail-navigation.ts create mode 100644 libs/ui/components/src/lib/season-container/episode-subline.util.ts create mode 100644 libs/ui/components/src/lib/season-container/season-auto-select.state.ts create mode 100644 libs/ui/playback/src/lib/vod-details/vod-details-navigation.util.ts create mode 100644 libs/ui/playback/src/lib/vod-details/vod-similar-in-portals.state.ts diff --git a/apps/web-e2e/src/stalker-embedded-series.fixture.ts b/apps/web-e2e/src/stalker-embedded-series.fixture.ts new file mode 100644 index 000000000..67096aef6 --- /dev/null +++ b/apps/web-e2e/src/stalker-embedded-series.fixture.ts @@ -0,0 +1,104 @@ +import type { APIRequestContext, Locator, Page } from '@playwright/test'; +import { expect } from './fixtures'; +import { + BACKEND_PROXY, + EMBEDDED_SERIES_MAC, + MOCK_SERVER, +} from './stalker-portal.fixture'; + +/** A VOD row of the embedded-series scenario carrying a `series[]` array. */ +interface EmbeddedSeriesItem { + name: string; + series: unknown[]; +} + +/** + * Finds a VOD item carrying an embedded series[] array in the mock catalog + * and reports how many episodes it currently has. + */ +export async function findEmbeddedSeriesItem( + request: APIRequestContext +): Promise<{ embeddedItem: EmbeddedSeriesItem; episodeCount: number }> { + const listResponse = await request.get( + `${MOCK_SERVER}/stalker?action=get_ordered_list&type=vod&category=2001&p=1&macAddress=${EMBEDDED_SERIES_MAC}&JsHttpRequest=1-xml` + ); + const listBody = await listResponse.json(); + const embeddedItem = listBody.payload.js.data.find( + (item: { series?: unknown[] }) => + Array.isArray(item.series) && item.series.length > 0 + ); + expect(embeddedItem).toBeDefined(); + const episodeCount: number = embeddedItem.series.length; + + return { embeddedItem, episodeCount }; +} + +/** + * Opens the embedded-series item from its category (the first row is "All") + * and waits until the series detail lists its last episode. Returns the + * item's card in the category grid. + */ +export async function openEmbeddedSeriesItem( + page: Page, + itemName: string, + episodeCount: number +): Promise { + const categories = page.locator('.category-item'); + await expect(categories.first()).toBeVisible({ timeout: 10_000 }); + await categories.nth(1).click(); + const card = page.getByText(itemName).first(); + await expect(card).toBeVisible({ timeout: 10_000 }); + await card.click(); + + await expect( + page.getByRole('heading', { + name: `${episodeCount}. Episode ${episodeCount}`, + exact: true, + }) + ).toBeVisible({ timeout: 10_000 }); + + return card; +} + +/** + * From now on the portal has "released" one more episode: extend series[] in + * every search response (the background snapshot refresh re-fetches the item + * via a title search). + */ +export async function releaseExtraEpisodeInSearchResponses( + page: Page +): Promise { + await page.route('**/localhost:3000/stalker**', async (route) => { + const originalUrl = new URL(route.request().url()); + if (!originalUrl.searchParams.get('search')) { + await route.fallback(); + return; + } + + const mockUrl = new URL(BACKEND_PROXY); + const targetId = originalUrl.searchParams.get('targetId'); + const providerUrl = targetId + ? Buffer.from(targetId, 'base64url').toString() + : originalUrl.searchParams.get('url'); + if (providerUrl) { + mockUrl.searchParams.set('url', providerUrl); + } + originalUrl.searchParams.forEach((value, key) => { + if (key === 'targetId') { + return; + } + mockUrl.searchParams.set(key, value); + }); + + const response = await route.fetch({ url: mockUrl.toString() }); + const body = await response.json(); + const rows: { series?: string[] }[] = + body?.payload?.js?.data ?? body?.js?.data ?? []; + for (const row of rows) { + if (Array.isArray(row.series) && row.series.length > 0) { + row.series = [...row.series, String(row.series.length + 1)]; + } + } + await route.fulfill({ response, body: JSON.stringify(body) }); + }); +} diff --git a/apps/web-e2e/src/stalker-portal.fixture.ts b/apps/web-e2e/src/stalker-portal.fixture.ts new file mode 100644 index 000000000..08d0f0985 --- /dev/null +++ b/apps/web-e2e/src/stalker-portal.fixture.ts @@ -0,0 +1,238 @@ +import type { Page } from '@playwright/test'; +import { setInputValue } from './e2e-helpers'; +import { expect } from './fixtures'; +import { + getRegisteredProviderUrl, + interceptProviderTargetRegistration, +} from './provider-target-route'; + +/** + * Mock-portal endpoints, scenario MACs and page helpers of `stalker.e2e.ts`. + * + * The scenario MACs declared here belong to that spec alone: it lists them in + * its `OWNED_MACS` and resets them before every test, so a sibling spec that + * reused one would have its mock state cleared mid-run. See the isolation + * notes at the top of `stalker.e2e.ts`. + */ + +const MOCK_PORT = process.env['MOCK_PORT'] ?? '3210'; +export const MOCK_SERVER = `http://localhost:${MOCK_PORT}`; +const PORTAL_URL = `${MOCK_SERVER}/portal.php`; +/** + * Canonical Ministra path. `PORTAL_URL` above is classified by the app as a + * "simple" portal (no handshake, no token, no watchdog); this shape is the + * authenticated branch, which the mock guards like the real middleware. + */ +export const FULL_PORTAL_URL = `${MOCK_SERVER}/stalker_portal/server/load.php`; +export const BACKEND_PROXY = `${MOCK_SERVER}/stalker`; + +/** Default scenario MAC — balanced catalog, 8 categories, 40 items */ +export const DEFAULT_MAC = '00:1A:79:00:00:01'; + +/** Minimal scenario MAC — 2 categories, 5 items (edge case testing) */ +export const MINIMAL_MAC = '00:1A:79:00:00:03'; + +/** Embedded-series MAC — 50% of VOD items carry an embedded series[] array */ +export const EMBEDDED_SERIES_MAC = '00:1A:79:00:00:05'; + +/** Legacy pagination MAC — portal without get_all_channels support */ +export const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06'; + +/** + * Static-cmd MAC — ITV rows carrying a directly playable `cmd` with + * `use_http_tmp_link` and `use_load_balancing` both `'0'`, i.e. a portal that + * expects no `create_link` call at all. + */ +export const STATIC_CMD_MAC = '00:1A:79:00:00:0A'; + +/** + * The full-portal authentication tests assert state transitions within one + * portal session, so a reset from a concurrent browser project or repeat + * worker would invalidate the assertion itself. Giving every concurrent + * worker slot its own MAC range preserves browser parallelism and also keeps + * `--repeat-each` runs isolated. + */ +export interface StatefulAuthMacs { + authenticatedFlow: string; + loginRequired: string; + tokenReuse: string; + deviceConflict: string; + reauthentication: string; +} + +export function getStatefulAuthMacs({ + parallelIndex, +}: { + parallelIndex: number; +}): StatefulAuthMacs { + if ( + !Number.isSafeInteger(parallelIndex) || + parallelIndex < 0 || + parallelIndex > 255 + ) { + throw new Error( + `Unsupported Playwright parallel index: ${parallelIndex}` + ); + } + + const workerOctet = parallelIndex + .toString(16) + .padStart(2, '0') + .toUpperCase(); + const workerPrefix = `00:1A:79:AE:${workerOctet}`; + + return { + authenticatedFlow: `${workerPrefix}:01`, + loginRequired: `${workerPrefix}:02`, + tokenReuse: `${workerPrefix}:03`, + deviceConflict: `${workerPrefix}:04`, + reauthentication: `${workerPrefix}:05`, + }; +} + +/** + * Deliberately NOT an Infomir MAC: the strict endpoint rejects get_profile for + * it, so no token is ever adopted and content requests fail permanently. + */ +export const AUTH_REJECTED_MAC = 'AA:BB:CC:DD:EE:01'; + +/** + * Intercept calls to the Angular dev backend (/stalker proxy) and redirect + * them to the mock server. This avoids needing a real backend or changing + * any app environment configuration. + */ +export async function interceptStalkerRequests(page: Page): Promise { + const providerTargets = await interceptProviderTargetRegistration(page); + + await page.route('**/localhost:3000/stalker**', async (route) => { + const originalUrl = new URL(route.request().url()); + const mockUrl = new URL(BACKEND_PROXY); + const providerUrl = getRegisteredProviderUrl( + originalUrl, + providerTargets + ); + + if (providerUrl) { + mockUrl.searchParams.set('url', providerUrl); + } + + originalUrl.searchParams.forEach((value, key) => { + if (key === 'targetId') { + return; + } + + mockUrl.searchParams.set(key, value); + }); + await route.continue({ url: mockUrl.toString() }); + }); +} + +/** + * Add a Stalker portal via the UI: + * 1. Click the "add playlist" button to open the unified dialog + * 2. Select "Stalker" toggle + * 3. Fill in the form and submit + */ +export async function addStalkerPortal( + page: Page, + options: { name?: string; mac?: string } = {} +): Promise { + const { name = 'Mock Stalker Portal', mac = DEFAULT_MAC } = options; + + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + // v0.22 redesign: tabs were replaced with a flat 5-card radio picker. + await dialog.getByRole('radio', { name: /Stalker portal/i }).click(); + + await setInputValue(dialog.locator('input#title'), name); + await setInputValue(dialog.locator('input#portalUrl'), PORTAL_URL); + await setInputValue(dialog.locator('input#macAddress'), mac); + + const addButton = dialog.getByRole('button', { + name: 'Add playlist', + exact: true, + }); + await expect(addButton).toBeEnabled({ timeout: 10_000 }); + await addButton.click(); + await expect(dialog).toBeHidden(); + await page.waitForURL(/stalker.*vod/); +} + +/** + * Add a Stalker portal through the canonical Ministra URL, which the app + * imports as a FULL portal: handshake, Bearer token and watchdog. + */ +export async function addFullStalkerPortal( + page: Page, + options: { + name?: string; + mac: string; + expectContent?: boolean; + username?: string; + password?: string; + } +): Promise { + const { + name = 'Full Stalker Portal', + mac, + expectContent = true, + username, + password, + } = options; + + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('radio', { name: /Stalker portal/i }).click(); + + await setInputValue(dialog.locator('input#title'), name); + await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL); + await setInputValue(dialog.locator('input#macAddress'), mac); + if (username !== undefined) { + await setInputValue(dialog.locator('input#username'), username); + } + if (password !== undefined) { + await setInputValue(dialog.locator('input#password'), password); + } + + const addButton = dialog.getByRole('button', { + name: 'Add playlist', + exact: true, + }); + await expect(addButton).toBeEnabled({ timeout: 10_000 }); + await addButton.click(); + await expect(dialog).toBeHidden(); + + if (expectContent) { + await page.waitForURL(/stalker.*vod/, { timeout: 30_000 }); + } +} + +export const CONTENT_ACTIONS = [ + 'get_categories', + 'get_genres', + 'get_ordered_list', + 'get_all_channels', +]; + +/** Every portal request in order, with the token it carried. */ +export function recordPortalRequests( + page: Page +): Array<{ action: string; token: string | null }> { + const requests: Array<{ action: string; token: string | null }> = []; + + page.on('request', (request) => { + const url = new URL(request.url()); + if (!url.pathname.endsWith('/stalker')) { + return; + } + const action = url.searchParams.get('action'); + if (!action) { + return; + } + requests.push({ action, token: url.searchParams.get('token') }); + }); + + return requests; +} diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 99bd00708..051acc8af 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -1,4 +1,4 @@ -import { type APIRequestContext, type Page } from '@playwright/test'; +import { type APIRequestContext } from '@playwright/test'; import { closeSeriesMenu, expectSeriesSurfacesInBothThemes, @@ -10,14 +10,33 @@ import { verifyStalkerPlaybackCategoryReturn, verifyUncachedStalkerSearch, } from './stalker-category-search.fixture'; +import { + findEmbeddedSeriesItem, + openEmbeddedSeriesItem, + releaseExtraEpisodeInSearchResponses, +} from './stalker-embedded-series.fixture'; import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture'; import { verifyStalkerOpenInPlaylist } from './stalker-open-in-playlist.fixture'; import { playFirstItvChannel } from './stalker-itv-playback.fixture'; -import { expect, test } from './fixtures'; import { - getRegisteredProviderUrl, - interceptProviderTargetRegistration, -} from './provider-target-route'; + AUTH_REJECTED_MAC, + BACKEND_PROXY, + CONTENT_ACTIONS, + DEFAULT_MAC, + EMBEDDED_SERIES_MAC, + FULL_PORTAL_URL, + LEGACY_PAGINATION_MAC, + MINIMAL_MAC, + MOCK_SERVER, + STATIC_CMD_MAC, + type StatefulAuthMacs, + addFullStalkerPortal, + addStalkerPortal, + getStatefulAuthMacs, + interceptStalkerRequests, + recordPortalRequests, +} from './stalker-portal.fixture'; +import { expect, test } from './fixtures'; /** * Stalker Portal E2E Tests @@ -58,121 +77,10 @@ import { test.describe.configure({ mode: 'serial' }); -const MOCK_PORT = process.env['MOCK_PORT'] ?? '3210'; -const MOCK_SERVER = `http://localhost:${MOCK_PORT}`; -const PORTAL_URL = `${MOCK_SERVER}/portal.php`; -/** - * Canonical Ministra path. `PORTAL_URL` above is classified by the app as a - * "simple" portal (no handshake, no token, no watchdog); this shape is the - * authenticated branch, which the mock guards like the real middleware. - */ -const FULL_PORTAL_URL = `${MOCK_SERVER}/stalker_portal/server/load.php`; -const BACKEND_PROXY = `${MOCK_SERVER}/stalker`; - -/** Default scenario MAC — balanced catalog, 8 categories, 40 items */ -const DEFAULT_MAC = '00:1A:79:00:00:01'; - -/** Minimal scenario MAC — 2 categories, 5 items (edge case testing) */ -const MINIMAL_MAC = '00:1A:79:00:00:03'; - -/** Embedded-series MAC — 50% of VOD items carry an embedded series[] array */ -const EMBEDDED_SERIES_MAC = '00:1A:79:00:00:05'; - -/** Legacy pagination MAC — portal without get_all_channels support */ -const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06'; - -/** - * Static-cmd MAC — ITV rows carrying a directly playable `cmd` with - * `use_http_tmp_link` and `use_load_balancing` both `'0'`, i.e. a portal that - * expects no `create_link` call at all. - */ -const STATIC_CMD_MAC = '00:1A:79:00:00:0A'; - -/** - * These tests assert state transitions within one portal session, so a reset - * from a concurrent browser project or repeat worker would invalidate the - * assertion itself. Giving every concurrent worker slot its own MAC range - * preserves browser parallelism and also keeps `--repeat-each` runs isolated. - */ -interface StatefulAuthMacs { - authenticatedFlow: string; - loginRequired: string; - tokenReuse: string; - deviceConflict: string; - reauthentication: string; -} - -function getStatefulAuthMacs({ - parallelIndex, -}: { - parallelIndex: number; -}): StatefulAuthMacs { - if ( - !Number.isSafeInteger(parallelIndex) || - parallelIndex < 0 || - parallelIndex > 255 - ) { - throw new Error( - `Unsupported Playwright parallel index: ${parallelIndex}` - ); - } - - const workerOctet = parallelIndex - .toString(16) - .padStart(2, '0') - .toUpperCase(); - const workerPrefix = `00:1A:79:AE:${workerOctet}`; - - return { - authenticatedFlow: `${workerPrefix}:01`, - loginRequired: `${workerPrefix}:02`, - tokenReuse: `${workerPrefix}:03`, - deviceConflict: `${workerPrefix}:04`, - reauthentication: `${workerPrefix}:05`, - }; -} - -/** - * Deliberately NOT an Infomir MAC: the strict endpoint rejects get_profile for - * it, so no token is ever adopted and content requests fail permanently. - */ -const AUTH_REJECTED_MAC = 'AA:BB:CC:DD:EE:01'; - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -/** - * Intercept calls to the Angular dev backend (/stalker proxy) and redirect - * them to the mock server. This avoids needing a real backend or changing - * any app environment configuration. - */ -async function interceptStalkerRequests(page: Page): Promise { - const providerTargets = await interceptProviderTargetRegistration(page); - - await page.route('**/localhost:3000/stalker**', async (route) => { - const originalUrl = new URL(route.request().url()); - const mockUrl = new URL(BACKEND_PROXY); - const providerUrl = getRegisteredProviderUrl( - originalUrl, - providerTargets - ); - - if (providerUrl) { - mockUrl.searchParams.set('url', providerUrl); - } - - originalUrl.searchParams.forEach((value, key) => { - if (key === 'targetId') { - return; - } - - mockUrl.searchParams.set(key, value); - }); - await route.continue({ url: mockUrl.toString() }); - }); -} - /** Every MAC this file owns; all are cleared in one batched reset request. */ const OWNED_MACS = [ DEFAULT_MAC, @@ -223,116 +131,6 @@ async function resetMockServer( throw lastError; } -/** - * Add a Stalker portal via the UI: - * 1. Click the "add playlist" button to open the unified dialog - * 2. Select "Stalker" toggle - * 3. Fill in the form and submit - */ -async function addStalkerPortal( - page: Page, - options: { name?: string; mac?: string } = {} -): Promise { - const { name = 'Mock Stalker Portal', mac = DEFAULT_MAC } = options; - - await page.getByRole('button', { name: 'Add playlist' }).click(); - const dialog = page.locator('mat-dialog-container'); - await expect(dialog).toBeVisible(); - // v0.22 redesign: tabs were replaced with a flat 5-card radio picker. - await dialog.getByRole('radio', { name: /Stalker portal/i }).click(); - - await setInputValue(dialog.locator('input#title'), name); - await setInputValue(dialog.locator('input#portalUrl'), PORTAL_URL); - await setInputValue(dialog.locator('input#macAddress'), mac); - - const addButton = dialog.getByRole('button', { - name: 'Add playlist', - exact: true, - }); - await expect(addButton).toBeEnabled({ timeout: 10_000 }); - await addButton.click(); - await expect(dialog).toBeHidden(); - await page.waitForURL(/stalker.*vod/); -} - -/** - * Add a Stalker portal through the canonical Ministra URL, which the app - * imports as a FULL portal: handshake, Bearer token and watchdog. - */ -async function addFullStalkerPortal( - page: Page, - options: { - name?: string; - mac: string; - expectContent?: boolean; - username?: string; - password?: string; - } -): Promise { - const { - name = 'Full Stalker Portal', - mac, - expectContent = true, - username, - password, - } = options; - - await page.getByRole('button', { name: 'Add playlist' }).click(); - const dialog = page.locator('mat-dialog-container'); - await expect(dialog).toBeVisible(); - await dialog.getByRole('radio', { name: /Stalker portal/i }).click(); - - await setInputValue(dialog.locator('input#title'), name); - await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL); - await setInputValue(dialog.locator('input#macAddress'), mac); - if (username !== undefined) { - await setInputValue(dialog.locator('input#username'), username); - } - if (password !== undefined) { - await setInputValue(dialog.locator('input#password'), password); - } - - const addButton = dialog.getByRole('button', { - name: 'Add playlist', - exact: true, - }); - await expect(addButton).toBeEnabled({ timeout: 10_000 }); - await addButton.click(); - await expect(dialog).toBeHidden(); - - if (expectContent) { - await page.waitForURL(/stalker.*vod/, { timeout: 30_000 }); - } -} - -const CONTENT_ACTIONS = [ - 'get_categories', - 'get_genres', - 'get_ordered_list', - 'get_all_channels', -]; - -/** Every portal request in order, with the token it carried. */ -function recordPortalRequests( - page: Page -): Array<{ action: string; token: string | null }> { - const requests: Array<{ action: string; token: string | null }> = []; - - page.on('request', (request) => { - const url = new URL(request.url()); - if (!url.pathname.endsWith('/stalker')) { - return; - } - const action = url.searchParams.get('action'); - if (!action) { - return; - } - requests.push({ action, token: url.searchParams.get('token') }); - }); - - return requests; -} - // --------------------------------------------------------------------------- // Test setup // --------------------------------------------------------------------------- @@ -967,16 +765,8 @@ test('@stalker favorites — embedded-series favorite refreshes newly released e request, }) => { // Find an embedded-series VOD item in the mock catalog first - const listResponse = await request.get( - `${MOCK_SERVER}/stalker?action=get_ordered_list&type=vod&category=2001&p=1&macAddress=${EMBEDDED_SERIES_MAC}&JsHttpRequest=1-xml` - ); - const listBody = await listResponse.json(); - const embeddedItem = listBody.payload.js.data.find( - (item: { series?: unknown[] }) => - Array.isArray(item.series) && item.series.length > 0 - ); - expect(embeddedItem).toBeDefined(); - const episodeCount: number = embeddedItem.series.length; + const { embeddedItem, episodeCount } = + await findEmbeddedSeriesItem(request); await addStalkerPortal(page, { name: 'Embedded Series Portal', @@ -985,19 +775,7 @@ test('@stalker favorites — embedded-series favorite refreshes newly released e // Open the embedded-series item from its category and favorite it — // this persists a snapshot with the current episode list - const categories = page.locator('.category-item'); - await expect(categories.first()).toBeVisible({ timeout: 10_000 }); - await categories.nth(1).click(); - const card = page.getByText(embeddedItem.name).first(); - await expect(card).toBeVisible({ timeout: 10_000 }); - await card.click(); - - await expect( - page.getByRole('heading', { - name: `${episodeCount}. Episode ${episodeCount}`, - exact: true, - }) - ).toBeVisible({ timeout: 10_000 }); + await openEmbeddedSeriesItem(page, embeddedItem.name, episodeCount); await page.getByRole('button', { name: 'Add to favorites' }).click(); // Wait for the async favorite persistence before navigating away await expect( @@ -1007,39 +785,7 @@ test('@stalker favorites — embedded-series favorite refreshes newly released e // From now on the portal has "released" one more episode: extend // series[] in every search response (the background snapshot refresh // re-fetches the item via a title search) - await page.route('**/localhost:3000/stalker**', async (route) => { - const originalUrl = new URL(route.request().url()); - if (!originalUrl.searchParams.get('search')) { - await route.fallback(); - return; - } - - const mockUrl = new URL(BACKEND_PROXY); - const targetId = originalUrl.searchParams.get('targetId'); - const providerUrl = targetId - ? Buffer.from(targetId, 'base64url').toString() - : originalUrl.searchParams.get('url'); - if (providerUrl) { - mockUrl.searchParams.set('url', providerUrl); - } - originalUrl.searchParams.forEach((value, key) => { - if (key === 'targetId') { - return; - } - mockUrl.searchParams.set(key, value); - }); - - const response = await route.fetch({ url: mockUrl.toString() }); - const body = await response.json(); - const rows: { series?: string[] }[] = - body?.payload?.js?.data ?? body?.js?.data ?? []; - for (const row of rows) { - if (Array.isArray(row.series) && row.series.length > 0) { - row.series = [...row.series, String(row.series.length + 1)]; - } - } - await route.fulfill({ response, body: JSON.stringify(body) }); - }); + await releaseExtraEpisodeInSearchResponses(page); // Open the item from the Favorites view: the stored snapshot renders // first, then the background refresh patches in the new episode @@ -1083,35 +829,15 @@ test('@stalker season watched toggle — embedded series marks and clears every // Reuse the modeled embedded-series flow: find a VOD item carrying an // embedded series[] array, open it from its category, and land on the // series detail with its episode list. - const listResponse = await request.get( - `${MOCK_SERVER}/stalker?action=get_ordered_list&type=vod&category=2001&p=1&macAddress=${EMBEDDED_SERIES_MAC}&JsHttpRequest=1-xml` - ); - const listBody = await listResponse.json(); - const embeddedItem = listBody.payload.js.data.find( - (item: { series?: unknown[] }) => - Array.isArray(item.series) && item.series.length > 0 - ); - expect(embeddedItem).toBeDefined(); - const episodeCount: number = embeddedItem.series.length; + const { embeddedItem, episodeCount } = + await findEmbeddedSeriesItem(request); await addStalkerPortal(page, { name: 'Embedded Series Watch Portal', mac: EMBEDDED_SERIES_MAC, }); - const categories = page.locator('.category-item'); - await expect(categories.first()).toBeVisible({ timeout: 10_000 }); - await categories.nth(1).click(); - const card = page.getByText(embeddedItem.name).first(); - await expect(card).toBeVisible({ timeout: 10_000 }); - await card.click(); - - await expect( - page.getByRole('heading', { - name: `${episodeCount}. Episode ${episodeCount}`, - exact: true, - }) - ).toBeVisible({ timeout: 10_000 }); + await openEmbeddedSeriesItem(page, embeddedItem.name, episodeCount); await expectSeriesSurfacesInBothThemes(page, testInfo); @@ -1153,35 +879,19 @@ test('@stalker series watched toggle — embedded series marks and clears from t }) => { // Same modeled embedded-series flow as the season test above, driven // through the series-level ⋮ menu instead of the season button. - const listResponse = await request.get( - `${MOCK_SERVER}/stalker?action=get_ordered_list&type=vod&category=2001&p=1&macAddress=${EMBEDDED_SERIES_MAC}&JsHttpRequest=1-xml` - ); - const listBody = await listResponse.json(); - const embeddedItem = listBody.payload.js.data.find( - (item: { series?: unknown[] }) => - Array.isArray(item.series) && item.series.length > 0 - ); - expect(embeddedItem).toBeDefined(); - const episodeCount: number = embeddedItem.series.length; + const { embeddedItem, episodeCount } = + await findEmbeddedSeriesItem(request); await addStalkerPortal(page, { name: 'Embedded Series Watch Menu Portal', mac: EMBEDDED_SERIES_MAC, }); - const categories = page.locator('.category-item'); - await expect(categories.first()).toBeVisible({ timeout: 10_000 }); - await categories.nth(1).click(); - const card = page.getByText(embeddedItem.name).first(); - await expect(card).toBeVisible({ timeout: 10_000 }); - await card.click(); - - await expect( - page.getByRole('heading', { - name: `${episodeCount}. Episode ${episodeCount}`, - exact: true, - }) - ).toBeVisible({ timeout: 10_000 }); + const card = await openEmbeddedSeriesItem( + page, + embeddedItem.name, + episodeCount + ); // The series row sits in the hero's "…" menu (data-test-id with a dash — // getByTestId only matches data-testid in this suite; the row renders diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 38751413c..d03a8e67e 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -383,15 +383,17 @@ meanwhile. The host reports busy-state back through the A series-level counterpart lives in a `⋮` menu at the end of the same header row (`SeasonWatchPresenter` in `libs/ui/components` owns the state -math for both scopes; the container component sits at the max-lines cap). +math for both scopes, which keeps the container component under the +max-lines cap). `buildSeriesWatchToggleRequest` flattens every LOADED season with the same mark/unmark semantics, and the direction is always the one the label advertised (`markWatched: !seriesFullyWatched()`), never re-inferred from data at persist time. Hosts route the request through the same machinery as the season toggle — Xtream via the scope-parameterized -`SerialDetailsSeasonWatchService.handle(..., scope)`, Stalker via the -extracted `runWatchToggleBatch` core — sharing the busy flag, the -ownership guards, and the catalog-badge refresh. Stalker lazy-VOD is the +`SerialDetailsSeasonWatchService.handle(..., scope)`, Stalker via +`StalkerSeriesWatchToggleService` and its `runStalkerWatchToggleBatch` +core — sharing the busy flag, the ownership guards, and the catalog-badge +refresh. Stalker lazy-VOD is the special case: unopened seasons have empty episode lists, so the container reports them through the `hasUnloadedSeasons` input (blocks the "fully watched" verdict and switches the label to its countless variant), @@ -412,7 +414,8 @@ series-toggle hydration join one in-flight request instead of duplicating it (a second request's failure could abort a toggle whose original request succeeded). The host synchronously re-runs the position reconcile -(`applyReconciledSeriesPositions` — the effect-fed maps only update on +(`StalkerSeriesPositionsService.applyReconciledSeriesPositions` — the +effect-fed maps only update on the next change-detection tick, and enqueuing against stale maps would miss the hydrated episodes' legacy rows), rebuilds the request from the now-complete seasons keeping the captured direction, and reports an diff --git a/docs/architecture/stalker-mock-server.md b/docs/architecture/stalker-mock-server.md index 93cd022ef..bd17dd53b 100644 --- a/docs/architecture/stalker-mock-server.md +++ b/docs/architecture/stalker-mock-server.md @@ -408,7 +408,8 @@ file running in parallel workers, so isolation is per-MAC rather than global: else is talking to the server — a spec that used it would wipe a sibling spec's session mid-test. - `apps/web-e2e/src/stalker.e2e.ts` declares its shared scenario MACs in - `OWNED_MACS` and clears them in one batched request. The sibling specs that + `OWNED_MACS` (the constants live in `stalker-portal.fixture.ts`) and clears + them in one batched request. The sibling specs that reach this server (`self-hosted.e2e.ts`, the `sources-pwa` helpers) own a disjoint `00:1A:79:5F:*` range, so neither file can clear the other's state. - Within each browser project, tests deliberately share content-scenario MACs diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index d39c1f61b..ccc755405 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -484,7 +484,8 @@ the format at all, so a large share of working installations use a non-Infomir MAC. Refusing one would stop those users adding or editing a portal that works for them. The mock encodes the same split (`enforceMacFormat` is set only on the strict endpoint; `/portal.php` ignores it), and `AUTH_REJECTED_MAC` in -`stalker.e2e.ts` depends on it — a non-Infomir MAC that must reach the strict +`stalker-portal.fixture.ts` (used by `stalker.e2e.ts`) depends on it — a +non-Infomir MAC that must reach the strict endpoint and be refused _there_, not in the form. In the edit dialog **both** passes — blur and submit — normalize only a MAC the diff --git a/docs/architecture/tmdb-roadmap.md b/docs/architecture/tmdb-roadmap.md index 67adfa664..64b429ad1 100644 --- a/docs/architecture/tmdb-roadmap.md +++ b/docs/architecture/tmdb-roadmap.md @@ -257,7 +257,7 @@ Zero i18n keys. Zero UI change. - populated in all three of `mergeVodInfoWithTmdb` (`:168`), `mergeSerieInfoWithTmdb` (`:215`), `mergeStalkerInfoWithTmdb` (`:257`) - through `NormalizedVodMeta` (`libs/shared/interfaces/src/lib/vod-details-item.interface.ts`) + **both** normalizers in `vod-details-adapters.ts` — the single convergence point where Xtream and Stalker meet -**Component.** New standalone `app-tmdb-extras-shelf` in `libs/ui/shared-portals`. It must **not** go inline: `libs/ui/playback/src/lib/vod-details/vod-details.component.ts` is **388 lines and is NOT in `tools/eslint/max-lines-baseline.mjs`** — roughly 12 lines of headroom against the hard 400 lint cap. +**Component.** New standalone `app-tmdb-extras-shelf` in `libs/ui/shared-portals`. It must **not** go inline: `libs/ui/playback/src/lib/vod-details/vod-details.component.ts` is **NOT in `tools/eslint/max-lines-baseline.mjs`** and stays under the hard 400 lint cap only because its state lives in sibling helpers (about 340 counted lines today). **Render** into the `detail-extras` projection slot at **four** sites (`vod-details.component.html:225`, `serial-details.component.html:201`, `vod-details-route.component.html:247`, `stalker-series-view.component.html:202`) — note Xtream `serial-details` has no trailer block today, so it either gains one or the shelf lands inconsistently. Reuse the existing nocookie iframe + `| safe` pipe so the Electron Referer shim keeps working; clicking swaps the embed `src` rather than opening a new player. Cards use `https://img.youtube.com/vi/{key}/hqdefault.jpg` (CSP verified: `img-src` covers it, `frame-src https://www.youtube-nocookie.com` covers the embed). `@if (extras().length > 1)` … `@else` the existing single-trailer markup **verbatim**. diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index 23ed55ada..488abdeeb 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -38,7 +38,6 @@ import { PlaylistsService, } from '@iptvnator/services'; import { - PlaybackPositionData, ResolvedPortalPlayback, StalkerVodDetails, VodDetailsItem, @@ -47,6 +46,8 @@ import { import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service'; import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component'; +import { startStalkerCatalogVodPlayback } from './stalker-catalog-vod-playback'; +import { StalkerCatalogVodPosition } from './stalker-catalog-vod-position'; import { startStalkerVodDownload } from './stalker-vod-download'; import { createStalkerVodWatchedToggle } from '../stalker-vod-watched-toggle'; import { @@ -107,13 +108,16 @@ export class StalkerCatalogDetailComponent implements OnDestroy { readonly playbackOwnerKey = computed(() => JSON.stringify([this.playbackSessionKey(), this.contentType()]) ); - private readonly selectedVodPosition = signal( - null - ); - private unsubscribePositionUpdates: (() => void) | null = null; - private positionLoadGeneration = 0; + private readonly vodPosition = new StalkerCatalogVodPosition({ + playbackPositions: this.playbackPositions, + playbackPositionBridge: this.playbackPositionBridge, + playlistId: () => this.catalog.playlist()?.id, + selectedItem: this.selectedItem, + contentType: this.contentType, + isSeriesDetail: () => this.isSeriesDetail(), + }); /** The stored row is in hand (not the placeholder shown while reading). */ - readonly positionLoaded = signal(false); + readonly positionLoaded = this.vodPosition.loaded; /** * The start still waiting on the portal between the click and playback, * keyed by its owner: a stale resolution for the previous movie must not @@ -146,13 +150,13 @@ export class StalkerCatalogDetailComponent implements OnDestroy { }); readonly selectedVodPlaybackDuration = computed( - () => this.selectedVodPosition()?.durationSeconds ?? null + () => this.vodPosition.position()?.durationSeconds ?? null ); readonly sourceLabel = computed( () => this.catalog.playlist()?.title ?? null ); readonly selectedVodPlaybackPosition = computed( - () => this.selectedVodPosition()?.positionSeconds ?? null + () => this.vodPosition.position()?.positionSeconds ?? null ); /** Manual watched toggle; the child gates it on live playback itself. */ @@ -165,7 +169,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy { : null; }, playbackPositions: this.playbackPositions, - position: this.selectedVodPosition, + position: this.vodPosition.position, playingNow: computed( () => this.inlinePlayback() !== null || this.playbackStartPending() ), @@ -173,8 +177,8 @@ export class StalkerCatalogDetailComponent implements OnDestroy { applyPosition: (position) => { // A read still in flight started from the pre-write row; letting // it land would revert the toggle it never saw. - this.positionLoadGeneration++; - this.selectedVodPosition.set(position); + this.vodPosition.discardPendingLoad(); + this.vodPosition.position.set(position); }, snackBar: this.snackBar, translateService: this.translateService, @@ -196,22 +200,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy { ); constructor() { - effect(() => { - const item = this.selectedItem(); - const playlistId = this.catalog.playlist()?.id; - - if ( - !item || - !playlistId || - this.contentType() !== 'vod' || - this.isSeriesDetail() - ) { - this.selectedVodPosition.set(null); - return; - } - - void this.loadSelectedVodPosition(playlistId, Number(item.id)); - }); + this.vodPosition.connect(); effect(() => { const ownerKey = this.playbackOwnerKey(); @@ -222,22 +211,6 @@ export class StalkerCatalogDetailComponent implements OnDestroy { this.currentPlaybackOwnerKey = ownerKey; this.closeInlinePlayer(); }); - - this.unsubscribePositionUpdates = - this.playbackPositionBridge.onPlaybackPositionUpdate( - (data: PlaybackPositionData) => { - const currentItem = this.selectedItem(); - if ( - data.contentType !== 'vod' || - data.playlistId !== this.catalog.playlist()?.id || - data.contentXtreamId !== Number(currentItem?.id) - ) { - return; - } - - this.selectedVodPosition.set(data); - } - ) ?? null; } onVodPlay(item: VodDetailsItem, positionSeconds?: number): void { @@ -281,8 +254,8 @@ export class StalkerCatalogDetailComponent implements OnDestroy { this.contentType() === 'vod' && !this.isSeriesDetail() ? Number(this.selectedItem()?.id) || null : null, - selectedVodPosition: this.selectedVodPosition, - discardPendingPositionLoad: () => ++this.positionLoadGeneration, + selectedVodPosition: this.vodPosition.position, + discardPendingPositionLoad: () => this.vodPosition.discardPendingLoad(), beforeExternalLaunch: () => this.closeInlinePlayer(), beginPendingStart: () => beginTrackedExternalLaunch(this), afterProgressReset: (playlistId) => @@ -338,7 +311,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy { playlistId, position ), - onSaved: (position) => this.selectedVodPosition.set(position), + onSaved: (position) => this.vodPosition.position.set(position), }); handleInlineTimeUpdate(event: { @@ -356,36 +329,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy { ngOnDestroy(): void { this.closeInlinePlayer(); - this.unsubscribePositionUpdates?.(); - } - - private async loadSelectedVodPosition( - playlistId: string, - vodId: number - ): Promise { - const generation = ++this.positionLoadGeneration; - this.positionLoaded.set(false); - if (Number.isNaN(vodId)) { - this.selectedVodPosition.set(null); - return; - } - - const position = await this.playbackPositions.getPlaybackPosition( - playlistId, - vodId, - 'vod' - ); - // Only the newest read for the item still on screen may land: an - // older one would revert a watched toggle or a later selection. - if ( - generation !== this.positionLoadGeneration || - this.catalog.playlist()?.id !== playlistId || - Number(this.selectedItem()?.id) !== vodId - ) { - return; - } - this.selectedVodPosition.set(position ?? null); - this.positionLoaded.set(true); + this.vodPosition.disconnect(); } private async startStalkerVodPlayback( @@ -394,54 +338,19 @@ export class StalkerCatalogDetailComponent implements OnDestroy { thumbnail?: string, startTime?: number ): Promise { - const requestId = ++this.playbackRequestId; - const sessionKey = this.playbackSessionKey(); - const ownerKey = this.playbackOwnerKey(); - const usesEmbeddedPlayer = this.portalPlayer.isEmbeddedPlayer(); - if (usesEmbeddedPlayer && !sessionKey) return; - - const startId = this.pendingStart.begin(ownerKey); - try { - const playback = await this.catalog.resolveVodPlayback( - cmd, - title, - thumbnail, - startTime - ); - if ( - requestId !== this.playbackRequestId || - this.playbackOwnerKey() !== ownerKey - ) { - return; - } - - this.positionWriter.reset(); - if (usesEmbeddedPlayer) { - this.inlinePlayback.set(playback); - return; - } - - this.closeInlinePlayer(); - void this.portalPlayer.openResolvedPlayback(playback, true); - } catch (error) { - if ( - requestId !== this.playbackRequestId || - this.playbackOwnerKey() !== ownerKey - ) { - return; - } - this.logger.error('Failed to start inline VOD playback', error); - const errorMessage = - error instanceof Error && error.message === 'nothing_to_play' - ? this.translateService.instant( - 'PORTALS.CONTENT_NOT_AVAILABLE' - ) - : this.translateService.instant('PORTALS.PLAYBACK_ERROR'); - this.snackBar.open(errorMessage, undefined, { - duration: 3000, - }); - } finally { - this.pendingStart.settle(startId); - } + await startStalkerCatalogVodPlayback(this, { + resolvePlayback: () => + this.catalog.resolveVodPlayback( + cmd, + title, + thumbnail, + startTime + ), + portalPlayer: this.portalPlayer, + resetPositionWriter: () => this.positionWriter.reset(), + logger: this.logger, + translate: this.translateService, + snackBar: this.snackBar, + }); } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-vod-playback.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-vod-playback.ts new file mode 100644 index 000000000..b26f4f11c --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-vod-playback.ts @@ -0,0 +1,84 @@ +import type { WritableSignal } from '@angular/core'; +import type { MatSnackBar } from '@angular/material/snack-bar'; +import type { TranslateService } from '@ngx-translate/core'; +import type { Logger, PortalPlayer } from '@iptvnator/portal/shared/util'; +import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; + +/** The detail page as the owner of its inline player: it tracks its own request ids. */ +interface StalkerCatalogVodPlaybackHost { + playbackRequestId: number; + readonly pendingStart: { + begin(owner: string): number; + settle(startId: number): void; + }; + playbackOwnerKey(): string; + playbackSessionKey(): string; + readonly inlinePlayback: WritableSignal; + closeInlinePlayer(): void; +} + +interface StalkerCatalogVodPlaybackDeps { + readonly resolvePlayback: () => Promise; + readonly portalPlayer: Pick< + PortalPlayer, + 'isEmbeddedPlayer' | 'openResolvedPlayback' + >; + /** Clears the inline position writer for the playback about to mount. */ + readonly resetPositionWriter: () => void; + readonly logger: Pick; + readonly translate: Pick; + readonly snackBar: Pick; +} + +/** + * Play/Resume of the movie on the routed catalog detail: resolves the + * stream and hands it to the inline or the configured player, unless the + * page moved on or a newer start took over while the portal answered. + */ +export async function startStalkerCatalogVodPlayback( + host: StalkerCatalogVodPlaybackHost, + deps: StalkerCatalogVodPlaybackDeps +): Promise { + const requestId = ++host.playbackRequestId; + const sessionKey = host.playbackSessionKey(); + const ownerKey = host.playbackOwnerKey(); + const usesEmbeddedPlayer = deps.portalPlayer.isEmbeddedPlayer(); + if (usesEmbeddedPlayer && !sessionKey) return; + + const startId = host.pendingStart.begin(ownerKey); + try { + const playback = await deps.resolvePlayback(); + if ( + requestId !== host.playbackRequestId || + host.playbackOwnerKey() !== ownerKey + ) { + return; + } + + deps.resetPositionWriter(); + if (usesEmbeddedPlayer) { + host.inlinePlayback.set(playback); + return; + } + + host.closeInlinePlayer(); + void deps.portalPlayer.openResolvedPlayback(playback, true); + } catch (error) { + if ( + requestId !== host.playbackRequestId || + host.playbackOwnerKey() !== ownerKey + ) { + return; + } + deps.logger.error('Failed to start inline VOD playback', error); + const errorMessage = + error instanceof Error && error.message === 'nothing_to_play' + ? deps.translate.instant('PORTALS.CONTENT_NOT_AVAILABLE') + : deps.translate.instant('PORTALS.PLAYBACK_ERROR'); + deps.snackBar.open(errorMessage, undefined, { + duration: 3000, + }); + } finally { + host.pendingStart.settle(startId); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-vod-position.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-vod-position.ts new file mode 100644 index 000000000..f60b4176b --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-vod-position.ts @@ -0,0 +1,110 @@ +import { type Signal, effect, signal } from '@angular/core'; +import type { PortalPlaybackPositions } from '@iptvnator/portal/shared/util'; +import type { StalkerSelectedVodItem } from '@iptvnator/portal/stalker/data-access'; +import type { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; +import type { PlaybackPositionData } from '@iptvnator/shared/interfaces'; + +interface StalkerCatalogVodPositionConfig { + readonly playbackPositions: Pick< + PortalPlaybackPositions, + 'getPlaybackPosition' + >; + readonly playbackPositionBridge: Pick< + PlaybackPositionRuntimeBridgeService, + 'onPlaybackPositionUpdate' + >; + readonly playlistId: () => string | undefined; + readonly selectedItem: Signal; + readonly contentType: () => string; + readonly isSeriesDetail: () => boolean; +} + +/** + * The stored playback position of the movie the routed catalog detail + * shows: read whenever the selection changes and kept current from the + * playback runtime while that movie stays on screen. + */ +export class StalkerCatalogVodPosition { + readonly position = signal(null); + /** The stored row is in hand (not the placeholder shown while reading). */ + readonly loaded = signal(false); + private unsubscribePositionUpdates: (() => void) | null = null; + private loadGeneration = 0; + + constructor(private readonly config: StalkerCatalogVodPositionConfig) {} + + /** + * Starts following the selection and the playback runtime. Registers an + * effect, so the host calls it from its constructor. + */ + connect(): void { + effect(() => { + const item = this.config.selectedItem(); + const playlistId = this.config.playlistId(); + + if ( + !item || + !playlistId || + this.config.contentType() !== 'vod' || + this.config.isSeriesDetail() + ) { + this.position.set(null); + return; + } + + void this.load(playlistId, Number(item.id)); + }); + + this.unsubscribePositionUpdates = + this.config.playbackPositionBridge.onPlaybackPositionUpdate( + (data: PlaybackPositionData) => { + const currentItem = this.config.selectedItem(); + if ( + data.contentType !== 'vod' || + data.playlistId !== this.config.playlistId() || + data.contentXtreamId !== Number(currentItem?.id) + ) { + return; + } + + this.position.set(data); + } + ) ?? null; + } + + disconnect(): void { + this.unsubscribePositionUpdates?.(); + } + + /** Retires a stored-position read still in flight (a row was written since). */ + discardPendingLoad(): void { + this.loadGeneration++; + } + + private async load(playlistId: string, vodId: number): Promise { + const generation = ++this.loadGeneration; + this.loaded.set(false); + if (Number.isNaN(vodId)) { + this.position.set(null); + return; + } + + const position = + await this.config.playbackPositions.getPlaybackPosition( + playlistId, + vodId, + 'vod' + ); + // Only the newest read for the item still on screen may land: an + // older one would revert a watched toggle or a later selection. + if ( + generation !== this.loadGeneration || + this.config.playlistId() !== playlistId || + Number(this.config.selectedItem()?.id) !== vodId + ) { + return; + } + this.position.set(position ?? null); + this.loaded.set(true); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search-paging.controller.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search-paging.controller.ts new file mode 100644 index 000000000..f261c0598 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search-paging.controller.ts @@ -0,0 +1,391 @@ +import { + type Signal, + computed, + linkedSignal, + resource, + signal, +} from '@angular/core'; +import type { Logger } from '@iptvnator/portal/shared/util'; +import { + executeStalkerRequest, + type StalkerPortalRepairService, + type StalkerSessionService, + type StalkerVodSource, + withoutWithheldStalkerItems, +} from '@iptvnator/portal/stalker/data-access'; +import { + type DataService, + type ParentalLockService, + resetHostConnectivityGuard, +} from '@iptvnator/services'; +import { + type PlaylistMeta, + StalkerPortalActions, +} from '@iptvnator/shared/interfaces'; +import { isStalkerSearchRequestCurrent } from './stalker-search-request.util'; +import { + buildStalkerSearchRequestParams, + dedupeSearchResults, + recordNewWithheldRows, + resolveSearchWithheldCategoryIds, + type StalkerSearchContentType, + type StalkerSearchResponse, + withAbsoluteScreenshotUri, +} from './stalker-search-results.util'; + +interface StalkerSearchPagingControllerConfig { + searchTerm: Signal; + selectedFilterType: Signal; + /** The portal being searched; null while none is resolved. */ + currentPlaylist: Signal; + dataService: DataService; + parentalLock: ParentalLockService; + stalkerSession: StalkerSessionService; + portalRepair: StalkerPortalRepairService; + logger: Logger; + /** Closes the host's open detail when its genre became withheld. */ + closeWithheldDetail: (withheldCategoryIds: ReadonlySet) => void; +} + +/** + * Owns the result paging of the Stalker search page: the portal page being + * requested, the pages accumulated into one list and the parental-lock + * bookkeeping that decides whether paging continues. + */ +export class StalkerSearchPagingController { + /** Lock version the accumulated results were built under. */ + private searchResultsLockVersion: number | null = null; + /** + * Withheld row ids seen for the current search identity. A page adding + * only new withheld ids is still progress and is skipped automatically; + * a page adding nothing new is the end of the results. + */ + private searchWithheldKey = ''; + private readonly searchWithheldIds = new Set(); + + constructor(private readonly config: StalkerSearchPagingControllerConfig) {} + + /** + * Portal page for the current term+filter+portal; resets when any of + * them changes. The playlist belongs to the identity: Angular reuses the + * search route across `/stalker/A/search` -> `/stalker/B/search`, and a + * surviving page number would append portal B's later page onto portal + * A's accumulated results while skipping B's first page. + */ + readonly searchPage = linkedSignal({ + source: () => ({ + term: this.config.searchTerm(), + type: this.config.selectedFilterType(), + playlistId: this.config.currentPlaylist()?._id ?? null, + }), + computation: () => 1, + }); + /** Pages accumulated into one continuous, deduplicated result list. */ + private readonly accumulatedSearchResults = signal([]); + readonly searchResults = this.accumulatedSearchResults.asReadonly(); + readonly searchHasMore = signal(false); + /** + * A failed append page. The next near-end RETRIES that page instead of + * advancing — incrementing past it would silently omit its results. + */ + readonly searchAppendError = signal(false); + + readonly searchResultsResource = resource({ + params: () => ({ + contentType: this.config.selectedFilterType(), + search: this.config.searchTerm(), + page: this.searchPage(), + playlistId: this.config.currentPlaylist()?._id ?? null, + action: StalkerPortalActions.GetOrderedList, + // Lock/unlock re-fires the search: withheld rows are dropped at + // page time, so the results must be rebuilt when they change. + parentalLockVersion: this.config.parentalLock.version(), + }), + loader: async ({ params }) => { + if (params.search.length < 3) { + this.resetSearchAccumulator(); + return []; + } + const playlist = this.config.currentPlaylist(); + if (!playlist) { + // A reused route can land on a deleted/unresolved portal — + // the previous portal's cards must not keep rendering. + this.resetSearchAccumulator(); + return []; + } + const { portalUrl, macAddress } = playlist; + if (!portalUrl || !macAddress) { + this.resetSearchAccumulator(); + return []; + } + const contentType = params.contentType; + const withheldCategoryIds = resolveSearchWithheldCategoryIds( + this.config.parentalLock, + playlist._id, + contentType + ); + const lockVersionChanged = + this.searchResultsLockVersion !== null && + this.searchResultsLockVersion !== params.parentalLockVersion; + this.searchResultsLockVersion = params.parentalLockVersion; + if (lockVersionChanged) { + this.applyRelockToResults(withheldCategoryIds, contentType); + } + if (lockVersionChanged && params.page > 1) { + // A lock flip past page 1: rebuild from page 1 rather than + // appending to pages accumulated under the old lock state. + this.searchPage.set(1); + return this.accumulatedSearchResults(); + } + const withheldKey = JSON.stringify([ + params.playlistId, + contentType, + params.search, + params.parentalLockVersion, + ]); + if (params.page === 1 || this.searchWithheldKey !== withheldKey) { + this.searchWithheldKey = withheldKey; + this.searchWithheldIds.clear(); + } + + const requestParams = buildStalkerSearchRequestParams( + contentType, + params.search, + params.page + ); + + // A stale response (term/filter/page/portal — or the parental + // lock — moved on while this page was in flight) must not clobber + // the accumulated list: the request is not aborted, and a + // pre-relock response was filtered with the pre-relock set. + const isCurrent = (): boolean => + isStalkerSearchRequestCurrent(params, { + search: this.config.searchTerm(), + contentType: this.config.selectedFilterType(), + page: this.searchPage(), + playlistId: this.config.currentPlaylist()?._id ?? null, + parentalLockVersion: this.config.parentalLock.version(), + }); + + try { + // executeStalkerRequest owns the portal-mode decision (shared + // predicate with URL fallback for legacy rows) and the lazy + // portal repair, so search cannot drift from the catalog + // paths. + const response = + await executeStalkerRequest( + { + dataService: this.config.dataService, + stalkerSession: this.config.stalkerSession, + portalRepair: this.config.portalRepair, + }, + playlist, + requestParams + ); + const rawItems = (response.js?.data || []).map( + (item: StalkerVodSource) => + withAbsoluteScreenshotUri(item, portalUrl) + ); + const items = withoutWithheldStalkerItems( + rawItems, + contentType, + withheldCategoryIds + ); + // Before the withheld-id bookkeeping: a stale page must not + // pre-record ids into a set a newer relock request cleared, + // or that request's page counts no new withheld rows and + // stops paging short of later visible matches. + if (!isCurrent()) { + return items; + } + const newWithheldCount = recordNewWithheldRows( + rawItems, + items, + this.searchWithheldIds + ); + + const merged = this.applySearchPageSuccess( + params.page, + items, + response.js?.total_items, + // A page made only of withheld rows still is a page the + // portal served; judge progress on what it sent. + rawItems.length > 0 && + (items.length > 0 || newWithheldCount > 0) + ); + this.advancePastWithheldPage( + params.page, + items.length, + newWithheldCount, + isCurrent + ); + return merged; + } catch (error) { + this.config.logger.warn('Stalker search page failed', { + page: params.page, + error, + }); + if (!isCurrent()) { + return this.accumulatedSearchResults(); + } + + return this.applySearchPageFailure(params.page); + } + }, + }); + + /** + * Empties the accumulator and every paging flag — used whenever there is + * no searchable portal (short term, missing playlist, malformed row). + */ + resetSearchAccumulator(): void { + this.accumulatedSearchResults.set([]); + this.searchHasMore.set(false); + this.searchAppendError.set(false); + } + + /** Merges a successful portal page into the accumulated result list. */ + applySearchPageSuccess( + page: number, + items: StalkerVodSource[], + totalItems: number | undefined, + pageHadRows: boolean = items.length > 0 + ): StalkerVodSource[] { + const previous = page === 1 ? [] : this.accumulatedSearchResults(); + const merged = + page === 1 ? items : dedupeSearchResults([...previous, ...items]); + // Paging continues only while pages make progress — with OR without + // a reported total. Dedup after mid-list portal mutations can leave + // the unique list permanently shorter than total_items, and a + // repeated page dedupes to no growth; either way a no-progress + // append is the practical end of the results. A page whose rows were + // all withheld by the parental lock counts as progress too. + const withheldRows = pageHadRows && items.length === 0; + const madeProgress = + page === 1 || merged.length > previous.length || withheldRows; + this.searchHasMore.set( + madeProgress && + (typeof totalItems === 'number' && totalItems >= 0 + ? merged.length < totalItems + : pageHadRows) + ); + this.searchAppendError.set(false); + this.accumulatedSearchResults.set(merged); + return merged; + } + + /** + * A failed FRESH search (page 1) must not keep rendering the previous + * query's cards; a failed append keeps the accumulated pages and flags + * the error so the next near-end retries this page instead of advancing. + */ + applySearchPageFailure(page: number): StalkerVodSource[] { + if (page === 1) { + this.accumulatedSearchResults.set([]); + this.searchHasMore.set(false); + this.searchAppendError.set(false); + return []; + } + + this.searchAppendError.set(true); + return this.accumulatedSearchResults(); + } + + /** + * Result-set identity for the layout's near-end latch and auto-fill + * budget — term, filter, and portal, mirroring the paging identity. + */ + readonly searchScrollResetKey = computed(() => + [ + this.config.searchTerm(), + this.config.selectedFilterType(), + this.config.currentPlaylist()?._id ?? '', + ].join('|') + ); + + readonly isInitialSearchLoading = computed( + () => this.searchResultsResource.isLoading() && this.searchPage() === 1 + ); + readonly isAppendingSearchResults = computed( + () => this.searchResultsResource.isLoading() && this.searchPage() > 1 + ); + + loadMoreSearchResults(): void { + if (this.searchResultsResource.isLoading() || !this.searchHasMore()) { + return; + } + + if (this.searchAppendError()) { + // Retry the SAME page — advancing would permanently omit it. + void this.retrySearchPage(); + return; + } + + this.searchPage.update((page) => page + 1); + } + + /** + * Two failed search pages are exactly what opens the main process' + * connectivity guard, so the reset has to precede the reload — otherwise + * this retry fast-fails without contacting a portal that may have + * recovered, and keeps repeating the same error until the window expires. + */ + private async retrySearchPage(): Promise { + // Clear the flag synchronously: awaiting first would leave this branch + // re-enterable, and the next `nearEnd` event would fire a second retry. + this.searchAppendError.set(false); + await resetHostConnectivityGuard( + this.config.dataService, + this.config.currentPlaylist()?.portalUrl + ); + this.searchResultsResource.reload(); + } + + /** + * The infinite scroll gives up after a few loads that add no height, so + * a run of pages made only of parental-locked rows must advance by + * itself until a visible row (or the real end) is reached. Only a page + * that added withheld ids not seen before counts — a stalled portal + * repeating the same locked rows must still end the loop. + */ + advancePastWithheldPage( + page: number, + visibleCount: number, + newWithheldCount: number, + isCurrent: () => boolean + ): void { + if ( + visibleCount > 0 || + newWithheldCount === 0 || + !this.searchHasMore() + ) { + return; + } + queueMicrotask(() => { + if (isCurrent()) { + this.searchPage.set(page + 1); + } + }); + } + + /** + * A lock change reached the results on screen, which were read under + * the old lock state: close an open detail of a now-withheld genre (the + * list hiding it is not enough) and drop the withheld rows NOW, before + * the replacement page is awaited — on page 1 too, or they stay + * clickable while (or, if it hangs, after) that request is pending. + */ + applyRelockToResults( + withheldCategoryIds: ReadonlySet, + contentType: Parameters[1] + ): void { + this.config.closeWithheldDetail(withheldCategoryIds); + this.accumulatedSearchResults.set( + withoutWithheldStalkerItems( + this.accumulatedSearchResults(), + contentType, + withheldCategoryIds + ) + ); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search-results.util.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search-results.util.ts new file mode 100644 index 000000000..d49a76884 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search-results.util.ts @@ -0,0 +1,138 @@ +import { + StalkerContentTypes, + type StalkerVodSource, + stalkerWithheldRowKey, +} from '@iptvnator/portal/stalker/data-access'; +import type { ParentalLockService } from '@iptvnator/services'; +import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces'; + +export type StalkerSearchContentType = 'vod' | 'series'; + +export interface StalkerSearchResponse { + js?: { + data?: StalkerVodSource[]; + total_items?: number; + }; + message?: string; + status?: number; +} + +/** Portals can shift items between pages mid-append — drop duplicate ids. */ +export function dedupeSearchResults( + items: StalkerVodSource[] +): StalkerVodSource[] { + const seenIds = new Set(); + return items.filter((item) => { + const id = + item.id === undefined || item.id === null ? null : String(item.id); + if (id === null) { + return true; + } + if (seenIds.has(id)) { + return false; + } + seenIds.add(id); + return true; + }); +} + +/** + * The dedicated search route has no category guard, so it filters the + * portal's rows itself: a locked genre's title must not reach the grid, its + * detail or playback through search. + */ +export function resolveSearchWithheldCategoryIds( + parentalLock: ParentalLockService, + playlistId: string, + contentType: StalkerSearchContentType +): ReadonlySet { + return !parentalLock.active() + ? new Set() + : parentalLock.withholdsEverything?.() + ? ALL_CATEGORIES_WITHHELD + : new Set(parentalLock.lockedStalkerIds(playlistId, contentType)); +} + +/** + * Mirror the catalog request shape: many Ministra portals return an empty + * list for get_ordered_list without the category/genre/sortby params the STB + * client always sends. `max_page_items` is a HINT — plenty of portals ignore + * it and return their own page size, which is why paging cannot rely on it + * (progress and `total_items` decide hasMore instead). + */ +export function buildStalkerSearchRequestParams( + contentType: StalkerSearchContentType, + search: string, + page: number +): Record { + return { + action: StalkerContentTypes[contentType].getContentAction, + type: contentType, + sortby: 'added', + search, + p: page, + max_page_items: 100, + category: '*', + ...(contentType === 'vod' ? { genre: '0' } : {}), + }; +} + +/** + * Records the rows of `rawItems` the parental lock withheld (those missing + * from `keptItems`) into `seenWithheldIds` and returns how many of them were + * not seen before. + */ +export function recordNewWithheldRows( + rawItems: StalkerVodSource[], + keptItems: StalkerVodSource[], + seenWithheldIds: Set +): number { + let newWithheldCount = 0; + if (keptItems.length < rawItems.length) { + const kept = new Set(keptItems); + for (const item of rawItems) { + const id = stalkerWithheldRowKey(item); + if (!kept.has(item) && !seenWithheldIds.has(id)) { + seenWithheldIds.add(id); + newWithheldCount += 1; + } + } + } + return newWithheldCount; +} + +/** Resolves a result row's portal-relative poster against the portal origin. */ +export function withAbsoluteScreenshotUri( + item: StalkerVodSource, + portalUrl: string +): StalkerVodSource { + const processed = { ...item }; + + if (processed.screenshot_uri) { + processed.screenshot_uri = makeAbsoluteUrl( + portalUrl, + processed.screenshot_uri + ); + } + + return processed; +} + +function makeAbsoluteUrl(baseUrl: string, relativePath: string): string { + if (!relativePath) return ''; + if ( + relativePath.startsWith('http://') || + relativePath.startsWith('https://') + ) { + return relativePath; + } + try { + const url = new URL(baseUrl); + const path = relativePath.startsWith('/') + ? relativePath + : `/${relativePath}`; + return `${url.origin}${path}`; + } catch { + return relativePath; + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.spec.ts index 9bc96671b..f1e5bdec6 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.spec.ts @@ -429,12 +429,12 @@ describe('StalkerSearchComponent result paging', () => { ...searchItems('page1', 3), { id: 'shared', name: 'Shared item' }, ]; - component.applySearchPageSuccess(1, pageOne, 7); + component.paging.applySearchPageSuccess(1, pageOne, 7); expect(component.searchResults()).toHaveLength(4); expect(component.searchHasMore()).toBe(true); // The portal shifted `shared` between pages — it must not duplicate. - component.applySearchPageSuccess( + component.paging.applySearchPageSuccess( 2, [...searchItems('page2', 2), { id: 'shared', name: 'Shared item' }], 7 @@ -442,31 +442,46 @@ describe('StalkerSearchComponent result paging', () => { expect(component.searchResults()).toHaveLength(6); expect(component.searchHasMore()).toBe(true); - component.applySearchPageSuccess(3, searchItems('page3', 1), 7); + component.paging.applySearchPageSuccess(3, searchItems('page3', 1), 7); expect(component.searchResults()).toHaveLength(7); expect(component.searchHasMore()).toBe(false); }); it('advances past a page made only of parental-locked rows, but not past a repeated one', async () => { - component.applySearchPageSuccess(1, searchItems('page1', 3), 10); - const pageBefore = component.searchPage(); + component.paging.applySearchPageSuccess(1, searchItems('page1', 3), 10); + const pageBefore = component.paging.searchPage(); // Locked rows the list had not seen: schedule the next page. - component.advancePastWithheldPage(pageBefore, 0, 2, () => true); + component.paging.advancePastWithheldPage(pageBefore, 0, 2, () => true); await flushMicrotasks(); - expect(component.searchPage()).toBe(pageBefore + 1); + expect(component.paging.searchPage()).toBe(pageBefore + 1); // A page with visible rows, or one adding no new withheld ids, or a // request that is no longer current: stay put. - component.advancePastWithheldPage(pageBefore + 1, 1, 2, () => true); - component.advancePastWithheldPage(pageBefore + 1, 0, 0, () => true); - component.advancePastWithheldPage(pageBefore + 1, 0, 2, () => false); + component.paging.advancePastWithheldPage( + pageBefore + 1, + 1, + 2, + () => true + ); + component.paging.advancePastWithheldPage( + pageBefore + 1, + 0, + 0, + () => true + ); + component.paging.advancePastWithheldPage( + pageBefore + 1, + 0, + 2, + () => false + ); await flushMicrotasks(); - expect(component.searchPage()).toBe(pageBefore + 1); + expect(component.paging.searchPage()).toBe(pageBefore + 1); }); it('drops withheld rows on screen at relock time, page 1 included', () => { - component.applySearchPageSuccess( + component.paging.applySearchPageSuccess( 1, [ { id: 'news-1', name: 'News', category_id: '5' }, @@ -476,7 +491,7 @@ describe('StalkerSearchComponent result paging', () => { ); expect(component.searchResults()).toHaveLength(2); - component.applyRelockToResults(new Set(['9']), 'vod'); + component.paging.applyRelockToResults(new Set(['9']), 'vod'); expect(component.searchResults().map((item) => item.id)).toEqual([ 'news-1', @@ -509,46 +524,54 @@ describe('StalkerSearchComponent result paging', () => { }); it('keeps paging past a page whose rows were all withheld by the parental lock', () => { - component.applySearchPageSuccess(1, searchItems('page1', 3), 10); + component.paging.applySearchPageSuccess(1, searchItems('page1', 3), 10); expect(component.searchHasMore()).toBe(true); // The portal sent rows, every one of them locked: no visible growth, // but not the end of the results either. - component.applySearchPageSuccess(2, [], 10, true); + component.paging.applySearchPageSuccess(2, [], 10, true); expect(component.searchResults()).toHaveLength(3); expect(component.searchHasMore()).toBe(true); // An actually empty page still ends it. - component.applySearchPageSuccess(3, [], 10, false); + component.paging.applySearchPageSuccess(3, [], 10, false); expect(component.searchHasMore()).toBe(false); }); it('stops paging when a total-backed append makes no progress', () => { - component.applySearchPageSuccess(1, searchItems('page1', 3), 10); + component.paging.applySearchPageSuccess(1, searchItems('page1', 3), 10); expect(component.searchHasMore()).toBe(true); // The portal repeats page 1 under a larger claimed total — dedupe // yields no growth, which must still end the paging loop. - component.applySearchPageSuccess(2, searchItems('page1', 3), 10); + component.paging.applySearchPageSuccess(2, searchItems('page1', 3), 10); expect(component.searchResults()).toHaveLength(3); expect(component.searchHasMore()).toBe(false); }); it('stops paging without a total once pages stop making progress', () => { - component.applySearchPageSuccess(1, searchItems('page1', 3), undefined); + component.paging.applySearchPageSuccess( + 1, + searchItems('page1', 3), + undefined + ); expect(component.searchHasMore()).toBe(true); // The portal ignores paging and repeats the same page — dedupe // yields no growth, which must terminate the loop. - component.applySearchPageSuccess(2, searchItems('page1', 3), undefined); + component.paging.applySearchPageSuccess( + 2, + searchItems('page1', 3), + undefined + ); expect(component.searchHasMore()).toBe(false); }); it('keeps accumulated pages on a failed append and retries the SAME page', async () => { - component.applySearchPageSuccess(1, searchItems('page1', 3), 6); + component.paging.applySearchPageSuccess(1, searchItems('page1', 3), 6); expect(component.searchHasMore()).toBe(true); - component.applySearchPageFailure(2); + component.paging.applySearchPageFailure(2); // The failed append kept page 1 on screen and flagged the error. expect(component.searchResults()).toHaveLength(3); expect(component.searchAppendError()).toBe(true); @@ -557,16 +580,16 @@ describe('StalkerSearchComponent result paging', () => { // The real resource never settles in this template-less harness — // substitute a deterministic stand-in for the guard checks. const reload = jest.fn(() => true); - Object.defineProperty(component, 'searchResultsResource', { + Object.defineProperty(component.paging, 'searchResultsResource', { configurable: true, value: { isLoading: () => false, reload }, }); // The next near-end must RETRY page 2 (page stays put, the error is // consumed) instead of advancing to page 3 and skipping results. - const pageBefore = component.searchPage(); + const pageBefore = component.paging.searchPage(); component.loadMoreSearchResults(); - expect(component.searchPage()).toBe(pageBefore); + expect(component.paging.searchPage()).toBe(pageBefore); // Cleared synchronously, so a second near-end cannot re-enter the retry // while the connectivity-guard reset is still in flight. expect(component.searchAppendError()).toBe(false); @@ -583,15 +606,15 @@ describe('StalkerSearchComponent result paging', () => { // With the error cleared, the following near-end advances normally. component.loadMoreSearchResults(); - expect(component.searchPage()).toBe(pageBefore + 1); + expect(component.paging.searchPage()).toBe(pageBefore + 1); expect(reload).toHaveBeenCalledTimes(1); }); it("clears the previous query's results when a fresh search fails", () => { - component.applySearchPageSuccess(1, searchItems('matrix', 3), 3); + component.paging.applySearchPageSuccess(1, searchItems('matrix', 3), 3); expect(component.searchResults()).toHaveLength(3); - component.applySearchPageFailure(1); + component.paging.applySearchPageFailure(1); expect(component.searchResults()).toHaveLength(0); expect(component.searchHasMore()).toBe(false); @@ -644,11 +667,15 @@ describe('StalkerSearchComponent result paging', () => { // The loader calls this on every no-portal early return (deleted or // malformed playlist on a reused route, short term) so the previous // portal's cards cannot keep rendering under the new context. - component.applySearchPageSuccess(1, searchItems('portalA', 3), 6); - component.applySearchPageFailure(2); + component.paging.applySearchPageSuccess( + 1, + searchItems('portalA', 3), + 6 + ); + component.paging.applySearchPageFailure(2); expect(component.searchResults()).toHaveLength(3); - component.resetSearchAccumulator(); + component.paging.resetSearchAccumulator(); expect(component.searchResults()).toHaveLength(0); expect(component.searchHasMore()).toBe(false); @@ -659,13 +686,17 @@ describe('StalkerSearchComponent result paging', () => { // Regression: /stalker/A/search -> /stalker/B/search reuses the // component; a surviving page number would append portal B's later // page onto portal A's results and skip B's first page. - Object.defineProperty(component, 'searchResultsResource', { + Object.defineProperty(component.paging, 'searchResultsResource', { configurable: true, value: { isLoading: () => false, reload: jest.fn(() => true) }, }); - component.applySearchPageSuccess(1, searchItems('portalA', 3), 6); + component.paging.applySearchPageSuccess( + 1, + searchItems('portalA', 3), + 6 + ); component.loadMoreSearchResults(); - expect(component.searchPage()).toBe(2); + expect(component.paging.searchPage()).toBe(2); activePlaylist.set({ _id: 'playlist|two', @@ -674,7 +705,7 @@ describe('StalkerSearchComponent result paging', () => { macAddress: '00:1A:79:00:00:02', }); - expect(component.searchPage()).toBe(1); + expect(component.paging.searchPage()).toBe(1); expect(component.searchScrollResetKey()).toContain('playlist|two'); }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index 15fc9eaea..0c2a71fd6 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -4,8 +4,6 @@ import { computed, effect, inject, - linkedSignal, - resource, signal, untracked, viewChild, @@ -18,30 +16,24 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { - executeStalkerRequest, StalkerPortalRepairService, StalkerSessionService, - stalkerWithheldRowKey, - withoutWithheldStalkerItems, } from '@iptvnator/portal/stalker/data-access'; import { DataService, ParentalLockService, PlaylistsService, - resetHostConnectivityGuard, } from '@iptvnator/services'; import { ALL_CATEGORIES_WITHHELD, PlaybackPositionData, ResolvedPortalPlayback, - StalkerPortalActions, VodDetailsItem, } from '@iptvnator/shared/interfaces'; import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback'; import { ContentCardComponent } from '@iptvnator/portal/shared/ui'; import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui'; import { StalkerInlineDetailComponent } from '../stalker-inline-detail/stalker-inline-detail.component'; -import { StalkerContentTypes } from '@iptvnator/portal/stalker/data-access'; import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { @@ -71,7 +63,8 @@ import { import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller'; import { createPlaybackSessionKey } from '@iptvnator/playback/util'; import { createStalkerVodDetailActions } from '../stalker-vod-detail-actions'; -import { isStalkerSearchRequestCurrent } from './stalker-search-request.util'; +import { StalkerSearchPagingController } from './stalker-search-paging.controller'; +import type { StalkerSearchContentType } from './stalker-search-results.util'; interface StalkerFilter { key: StalkerSearchContentType; @@ -79,34 +72,6 @@ interface StalkerFilter { translationKey: string; } -type StalkerSearchContentType = 'vod' | 'series'; - -interface StalkerSearchResponse { - js?: { - data?: StalkerVodSource[]; - total_items?: number; - }; - message?: string; - status?: number; -} - -/** Portals can shift items between pages mid-append — drop duplicate ids. */ -function dedupeSearchResults(items: StalkerVodSource[]): StalkerVodSource[] { - const seenIds = new Set(); - return items.filter((item) => { - const id = - item.id === undefined || item.id === null ? null : String(item.id); - if (id === null) { - return true; - } - if (seenIds.has(id)) { - return false; - } - seenIds.add(id); - return true; - }); -} - @Component({ selector: 'app-stalker-search', imports: [ @@ -127,15 +92,6 @@ export class StalkerSearchComponent { private readonly location = inject(Location); private readonly dataService = inject(DataService); private readonly parentalLock = inject(ParentalLockService); - /** Lock version the accumulated results were built under. */ - private searchResultsLockVersion: number | null = null; - /** - * Withheld row ids seen for the current search identity. A page adding - * only new withheld ids is still progress and is skipped automatically; - * a page adding nothing new is the end of the results. - */ - private searchWithheldKey = ''; - private readonly searchWithheldIds = new Set(); private readonly playlistContext = inject(PlaylistContextFacade); private readonly playlistService = inject(PlaylistsService); readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); @@ -235,309 +191,28 @@ export class StalkerSearchComponent { () => this.favoritesRefresh.refreshVersion() ); - /** - * Portal page for the current term+filter+portal; resets when any of - * them changes. The playlist belongs to the identity: Angular reuses the - * search route across `/stalker/A/search` -> `/stalker/B/search`, and a - * surviving page number would append portal B's later page onto portal - * A's accumulated results while skipping B's first page. - */ - readonly searchPage = linkedSignal({ - source: () => ({ - term: this.searchTerm(), - type: this.selectedFilterType(), - playlistId: this.currentPlaylist()?._id ?? null, - }), - computation: () => 1, + /** Result paging: the portal page, the accumulated list and its flags. */ + readonly paging = new StalkerSearchPagingController({ + searchTerm: this.searchTerm, + selectedFilterType: this.selectedFilterType, + currentPlaylist: this.currentPlaylist, + dataService: this.dataService, + parentalLock: this.parentalLock, + stalkerSession: this.stalkerSession, + portalRepair: this.portalRepair, + logger: this.logger, + closeWithheldDetail: (withheldCategoryIds) => + this.closeWithheldDetail(withheldCategoryIds), }); - /** Pages accumulated into one continuous, deduplicated result list. */ - private readonly accumulatedSearchResults = signal([]); - readonly searchResults = this.accumulatedSearchResults.asReadonly(); - readonly searchHasMore = signal(false); - /** - * A failed append page. The next near-end RETRIES that page instead of - * advancing — incrementing past it would silently omit its results. - */ - readonly searchAppendError = signal(false); - - readonly searchResultsResource = resource({ - params: () => ({ - contentType: this.selectedFilterType(), - search: this.searchTerm(), - page: this.searchPage(), - playlistId: this.currentPlaylist()?._id ?? null, - action: StalkerPortalActions.GetOrderedList, - // Lock/unlock re-fires the search: withheld rows are dropped at - // page time, so the results must be rebuilt when they change. - parentalLockVersion: this.parentalLock.version(), - }), - loader: async ({ params }) => { - if (params.search.length < 3) { - this.resetSearchAccumulator(); - return []; - } - const playlist = this.currentPlaylist(); - if (!playlist) { - // A reused route can land on a deleted/unresolved portal — - // the previous portal's cards must not keep rendering. - this.resetSearchAccumulator(); - return []; - } - const { portalUrl, macAddress } = playlist; - if (!portalUrl || !macAddress) { - this.resetSearchAccumulator(); - return []; - } - const contentType = params.contentType; - // The dedicated search route has no category guard, so it filters - // the portal's rows itself: a locked genre's title must not reach - // the grid, its detail or playback through search. - const withheldCategoryIds: ReadonlySet = - !this.parentalLock.active() - ? new Set() - : this.parentalLock.withholdsEverything?.() - ? ALL_CATEGORIES_WITHHELD - : new Set( - this.parentalLock.lockedStalkerIds( - playlist._id, - contentType - ) - ); - const lockVersionChanged = - this.searchResultsLockVersion !== null && - this.searchResultsLockVersion !== params.parentalLockVersion; - this.searchResultsLockVersion = params.parentalLockVersion; - if (lockVersionChanged) { - this.applyRelockToResults(withheldCategoryIds, contentType); - } - if (lockVersionChanged && params.page > 1) { - // A lock flip past page 1: rebuild from page 1 rather than - // appending to pages accumulated under the old lock state. - this.searchPage.set(1); - return this.accumulatedSearchResults(); - } - const withheldKey = JSON.stringify([ - params.playlistId, - contentType, - params.search, - params.parentalLockVersion, - ]); - if (params.page === 1 || this.searchWithheldKey !== withheldKey) { - this.searchWithheldKey = withheldKey; - this.searchWithheldIds.clear(); - } - - // Mirror the catalog request shape: many Ministra portals - // return an empty list for get_ordered_list without the - // category/genre/sortby params the STB client always sends. - // `max_page_items` is a HINT — plenty of portals ignore it and - // return their own page size, which is why paging cannot rely - // on it (progress and `total_items` decide hasMore instead). - const requestParams: Record = { - action: StalkerContentTypes[contentType].getContentAction, - type: contentType, - sortby: 'added', - search: params.search, - p: params.page, - max_page_items: 100, - category: '*', - ...(contentType === 'vod' ? { genre: '0' } : {}), - }; - - // A stale response (term/filter/page/portal — or the parental - // lock — moved on while this page was in flight) must not clobber - // the accumulated list: the request is not aborted, and a - // pre-relock response was filtered with the pre-relock set. - const isCurrent = (): boolean => - isStalkerSearchRequestCurrent(params, { - search: this.searchTerm(), - contentType: this.selectedFilterType(), - page: this.searchPage(), - playlistId: this.currentPlaylist()?._id ?? null, - parentalLockVersion: this.parentalLock.version(), - }); - - try { - // executeStalkerRequest owns the portal-mode decision (shared - // predicate with URL fallback for legacy rows) and the lazy - // portal repair, so search cannot drift from the catalog - // paths. - const response = - await executeStalkerRequest( - { - dataService: this.dataService, - stalkerSession: this.stalkerSession, - portalRepair: this.portalRepair, - }, - playlist, - requestParams - ); - const rawItems = (response.js?.data || []).map( - (item: StalkerVodSource) => - this.processItemUrls(item, portalUrl) - ); - const items = withoutWithheldStalkerItems( - rawItems, - contentType, - withheldCategoryIds - ); - // Before the withheld-id bookkeeping: a stale page must not - // pre-record ids into a set a newer relock request cleared, - // or that request's page counts no new withheld rows and - // stops paging short of later visible matches. - if (!isCurrent()) { - return items; - } - let newWithheldCount = 0; - if (items.length < rawItems.length) { - const kept = new Set(items); - for (const item of rawItems) { - const id = stalkerWithheldRowKey(item); - if ( - !kept.has(item) && - !this.searchWithheldIds.has(id) - ) { - this.searchWithheldIds.add(id); - newWithheldCount += 1; - } - } - } - - const merged = this.applySearchPageSuccess( - params.page, - items, - response.js?.total_items, - // A page made only of withheld rows still is a page the - // portal served; judge progress on what it sent. - rawItems.length > 0 && - (items.length > 0 || newWithheldCount > 0) - ); - this.advancePastWithheldPage( - params.page, - items.length, - newWithheldCount, - isCurrent - ); - return merged; - } catch (error) { - this.logger.warn('Stalker search page failed', { - page: params.page, - error, - }); - if (!isCurrent()) { - return this.accumulatedSearchResults(); - } - - return this.applySearchPageFailure(params.page); - } - }, - }); - - /** - * Empties the accumulator and every paging flag — used whenever there is - * no searchable portal (short term, missing playlist, malformed row). - */ - resetSearchAccumulator(): void { - this.accumulatedSearchResults.set([]); - this.searchHasMore.set(false); - this.searchAppendError.set(false); - } - - /** Merges a successful portal page into the accumulated result list. */ - applySearchPageSuccess( - page: number, - items: StalkerVodSource[], - totalItems: number | undefined, - pageHadRows: boolean = items.length > 0 - ): StalkerVodSource[] { - const previous = page === 1 ? [] : this.accumulatedSearchResults(); - const merged = - page === 1 ? items : dedupeSearchResults([...previous, ...items]); - // Paging continues only while pages make progress — with OR without - // a reported total. Dedup after mid-list portal mutations can leave - // the unique list permanently shorter than total_items, and a - // repeated page dedupes to no growth; either way a no-progress - // append is the practical end of the results. A page whose rows were - // all withheld by the parental lock counts as progress too. - const withheldRows = pageHadRows && items.length === 0; - const madeProgress = - page === 1 || merged.length > previous.length || withheldRows; - this.searchHasMore.set( - madeProgress && - (typeof totalItems === 'number' && totalItems >= 0 - ? merged.length < totalItems - : pageHadRows) - ); - this.searchAppendError.set(false); - this.accumulatedSearchResults.set(merged); - return merged; - } - - /** - * A failed FRESH search (page 1) must not keep rendering the previous - * query's cards; a failed append keeps the accumulated pages and flags - * the error so the next near-end retries this page instead of advancing. - */ - applySearchPageFailure(page: number): StalkerVodSource[] { - if (page === 1) { - this.accumulatedSearchResults.set([]); - this.searchHasMore.set(false); - this.searchAppendError.set(false); - return []; - } - - this.searchAppendError.set(true); - return this.accumulatedSearchResults(); - } - - /** - * Result-set identity for the layout's near-end latch and auto-fill - * budget — term, filter, and portal, mirroring the paging identity. - */ - readonly searchScrollResetKey = computed(() => - [ - this.searchTerm(), - this.selectedFilterType(), - this.currentPlaylist()?._id ?? '', - ].join('|') - ); - - readonly isInitialSearchLoading = computed( - () => this.searchResultsResource.isLoading() && this.searchPage() === 1 - ); - readonly isAppendingSearchResults = computed( - () => this.searchResultsResource.isLoading() && this.searchPage() > 1 - ); + readonly searchResults = this.paging.searchResults; + readonly searchHasMore = this.paging.searchHasMore; + readonly searchAppendError = this.paging.searchAppendError; + readonly searchScrollResetKey = this.paging.searchScrollResetKey; + readonly isInitialSearchLoading = this.paging.isInitialSearchLoading; + readonly isAppendingSearchResults = this.paging.isAppendingSearchResults; loadMoreSearchResults(): void { - if (this.searchResultsResource.isLoading() || !this.searchHasMore()) { - return; - } - - if (this.searchAppendError()) { - // Retry the SAME page — advancing would permanently omit it. - void this.retrySearchPage(); - return; - } - - this.searchPage.update((page) => page + 1); - } - - /** - * Two failed search pages are exactly what opens the main process' - * connectivity guard, so the reset has to precede the reload — otherwise - * this retry fast-fails without contacting a portal that may have - * recovered, and keeps repeating the same error until the window expires. - */ - private async retrySearchPage(): Promise { - // Clear the flag synchronously: awaiting first would leave this branch - // re-enterable, and the next `nearEnd` event would fire a second retry. - this.searchAppendError.set(false); - await resetHostConnectivityGuard( - this.dataService, - this.currentPlaylist()?.portalUrl - ); - this.searchResultsResource.reload(); + this.paging.loadMoreSearchResults(); } readonly isSelectedVodFavorite = signal(false); @@ -713,59 +388,11 @@ export class StalkerSearchComponent { }); } - /** - * The infinite scroll gives up after a few loads that add no height, so - * a run of pages made only of parental-locked rows must advance by - * itself until a visible row (or the real end) is reached. Only a page - * that added withheld ids not seen before counts — a stalled portal - * repeating the same locked rows must still end the loop. - */ - advancePastWithheldPage( - page: number, - visibleCount: number, - newWithheldCount: number, - isCurrent: () => boolean - ): void { - if ( - visibleCount > 0 || - newWithheldCount === 0 || - !this.searchHasMore() - ) { - return; - } - queueMicrotask(() => { - if (isCurrent()) { - this.searchPage.set(page + 1); - } - }); - } - /** * Closes the open detail when its genre is withheld by the parental * lock (Lock now, idle relock): the title, its playback actions and the * store's selected item must not outlive the list row. */ - /** - * A lock change reached the results on screen, which were read under - * the old lock state: close an open detail of a now-withheld genre (the - * list hiding it is not enough) and drop the withheld rows NOW, before - * the replacement page is awaited — on page 1 too, or they stay - * clickable while (or, if it hangs, after) that request is pending. - */ - applyRelockToResults( - withheldCategoryIds: ReadonlySet, - contentType: Parameters[1] - ): void { - this.closeWithheldDetail(withheldCategoryIds); - this.accumulatedSearchResults.set( - withoutWithheldStalkerItems( - this.accumulatedSearchResults(), - contentType, - withheldCategoryIds - ) - ); - } - closeWithheldDetail(withheldCategoryIds: ReadonlySet): void { const details = this.itemDetails(); if (!details) { @@ -871,41 +498,6 @@ export class StalkerSearchComponent { ); } - private processItemUrls( - item: StalkerVodSource, - portalUrl: string - ): StalkerVodSource { - const processed = { ...item }; - - if (processed.screenshot_uri) { - processed.screenshot_uri = this.makeAbsoluteUrl( - portalUrl, - processed.screenshot_uri - ); - } - - return processed; - } - - private makeAbsoluteUrl(baseUrl: string, relativePath: string): string { - if (!relativePath) return ''; - if ( - relativePath.startsWith('http://') || - relativePath.startsWith('https://') - ) { - return relativePath; - } - try { - const url = new URL(baseUrl); - const path = relativePath.startsWith('/') - ? relativePath - : `/${relativePath}`; - return `${url.origin}${path}`; - } catch { - return relativePath; - } - } - readonly vodDetailActions = createStalkerVodDetailActions({ resolvePlayback: (cmd, title, thumbnail, startTime) => this.stalkerStore.resolveVodPlayback( diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-id.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-id.ts new file mode 100644 index 000000000..877482424 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-id.ts @@ -0,0 +1,12 @@ +/** + * The numeric series id of a Stalker item id: the part before the first `:` + * when the id carries a suffix. An empty or non-numeric id resolves to 0, + * which callers treat as "no series". + */ +export function toStalkerSeriesId(id: string | number): number { + const raw = String(id ?? '').trim(); + if (!raw) return 0; + const primary = raw.includes(':') ? raw.split(':')[0] : raw; + const parsed = Number(primary); + return Number.isFinite(parsed) ? parsed : 0; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-queue.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-queue.ts new file mode 100644 index 000000000..2a30fc75b --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-position-queue.ts @@ -0,0 +1,187 @@ +import type { PlaybackPositionData } from '@iptvnator/shared/interfaces'; + +/** The playlist and series one generation of position reads and writes belongs to. */ +export interface StalkerSeriesPositionContext { + readonly generation: number; + readonly playlistId: string; + readonly seriesXtreamId: number; + readonly mutationKey: string; +} + +/** What the queue needs from the owner of the position rows. */ +export interface StalkerSeriesPositionQueueHost { + /** Whether the page still shows the playlist and series of `context`. */ + readonly isShown: (context: StalkerSeriesPositionContext) => boolean; + /** Reads the persisted position rows of `context`. */ + readonly read: ( + context: StalkerSeriesPositionContext + ) => Promise; + /** Takes over rows read for a context that is still the active one. */ + readonly publish: ( + context: StalkerSeriesPositionContext, + positions: PlaybackPositionData[] + ) => void; +} + +/** + * Orders the position reads and writes of the Stalker series view. Writes of + * one playlist/series run one after another; a read waits for the writes + * queued before it, and its answer is dropped once a newer read or write + * started or the page moved on to another series. A write that overtakes a + * read still in flight re-reads the rows after the queue drained. + */ +export class StalkerSeriesPositionQueue { + private activeContext: StalkerSeriesPositionContext | null = null; + private contextGeneration = 0; + private readonly mutationQueues = new Map>(); + private readonly pendingLoads = new Map< + StalkerSeriesPositionContext, + Set + >(); + private readonly reloadKeys = new Set(); + private loadGeneration = 0; + + constructor(private readonly host: StalkerSeriesPositionQueueHost) {} + + /** Makes `playlistId`/`seriesXtreamId` the series reads and writes apply to. */ + activate( + playlistId: string, + seriesXtreamId: number + ): StalkerSeriesPositionContext { + const context: StalkerSeriesPositionContext = { + generation: ++this.contextGeneration, + playlistId, + seriesXtreamId, + mutationKey: JSON.stringify([playlistId, seriesXtreamId]), + }; + this.activeContext = context; + return context; + } + + /** No series is shown: retires the active context and every read in flight. */ + deactivate(): void { + this.activeContext = null; + this.contextGeneration++; + this.loadGeneration++; + } + + isActive(context: StalkerSeriesPositionContext): boolean { + const activeContext = this.activeContext; + return ( + activeContext === context && + activeContext.generation === context.generation && + this.host.isShown(context) + ); + } + + /** The active context when it belongs to `playlistId` (and `seriesXtreamId`, if given). */ + mutationContext( + playlistId: string, + seriesXtreamId?: number | null + ): StalkerSeriesPositionContext | null { + const context = this.activeContext; + if ( + !context || + context.playlistId !== playlistId || + (seriesXtreamId != null && + context.seriesXtreamId !== seriesXtreamId) + ) { + return null; + } + return context; + } + + async load(context: StalkerSeriesPositionContext): Promise { + const generation = ++this.loadGeneration; + this.trackPendingLoad(context, generation); + try { + await this.waitForMutations(context.mutationKey); + + if (generation !== this.loadGeneration || !this.isActive(context)) { + return; + } + + const positions = await this.host.read(context); + + if (generation !== this.loadGeneration || !this.isActive(context)) { + return; + } + + this.host.publish(context, positions); + } finally { + this.untrackPendingLoad(context, generation); + } + } + + enqueue( + context: StalkerSeriesPositionContext, + operation: () => Promise + ): Promise { + if (this.hasCurrentPendingLoad(context)) { + this.reloadKeys.add(context.mutationKey); + } + this.loadGeneration++; + const previous = this.waitForMutations(context.mutationKey); + const result = previous.then(operation); + const barrier = result.then( + () => undefined, + () => undefined + ); + this.mutationQueues.set(context.mutationKey, barrier); + void barrier.then(() => { + if (this.mutationQueues.get(context.mutationKey) === barrier) { + this.mutationQueues.delete(context.mutationKey); + this.reloadAfterMutations(context.mutationKey); + } + }); + return result; + } + + private waitForMutations(mutationKey: string): Promise { + return this.mutationQueues.get(mutationKey) ?? Promise.resolve(); + } + + private trackPendingLoad( + context: StalkerSeriesPositionContext, + generation: number + ): void { + const generations = this.pendingLoads.get(context) ?? new Set(); + generations.add(generation); + this.pendingLoads.set(context, generations); + } + + private untrackPendingLoad( + context: StalkerSeriesPositionContext, + generation: number + ): void { + const generations = this.pendingLoads.get(context); + generations?.delete(generation); + if (generations?.size === 0) { + this.pendingLoads.delete(context); + } + } + + private hasCurrentPendingLoad( + context: StalkerSeriesPositionContext + ): boolean { + return Boolean( + this.pendingLoads.get(context)?.has(this.loadGeneration) + ); + } + + private reloadAfterMutations(mutationKey: string): void { + if (!this.reloadKeys.delete(mutationKey)) { + return; + } + const context = this.activeContext; + if ( + !context || + context.mutationKey !== mutationKey || + !this.isActive(context) || + this.hasCurrentPendingLoad(context) + ) { + return; + } + void this.load(context); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-positions.service.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-positions.service.ts new file mode 100644 index 000000000..ee3004f93 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-positions.service.ts @@ -0,0 +1,331 @@ +import { Injectable, Signal, inject, signal, untracked } from '@angular/core'; +import { + PORTAL_PLAYBACK_POSITIONS, + createLogger, +} from '@iptvnator/portal/shared/util'; +import { + StalkerStore, + type StalkerSelectedVodItem, +} from '@iptvnator/portal/stalker/data-access'; +import type { + PlaybackPositionData, + XtreamSerieEpisode, +} from '@iptvnator/shared/interfaces'; +import { toStalkerSeriesId } from './stalker-series-id'; +import { + clearStalkerSeriesPosition, + reconcileStalkerSeriesPositions, + saveStalkerSeriesPosition, + StalkerSeriesPositionPartialSaveError, +} from './stalker-series-position-compatibility'; +import { StalkerSeriesPositionQueue } from './stalker-series-position-queue'; + +interface StalkerSeriesPositionsBindings { + readonly displayItem: Signal; + readonly mappedSeasons: Signal>; +} + +/** + * Saved episode positions of the series the Stalker series view shows: the + * rows read from the repository, their mapping onto the episodes on the page + * (scoped rows plus compatible legacy promotions) and the writes that keep + * both in step. Component-scoped (provide it in the component's `providers`). + */ +@Injectable() +export class StalkerSeriesPositionsService { + private readonly stalkerStore = inject(StalkerStore); + private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); + private readonly migrationPlaybackPositions = { + savePlaybackPosition: ( + playlistId: string, + data: PlaybackPositionData + ) => + this.playbackPositions.savePlaybackPositionOrThrow( + playlistId, + data + ), + clearPlaybackPosition: ( + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ) => + this.playbackPositions.clearPlaybackPositionOrThrow( + playlistId, + contentXtreamId, + contentType + ), + }; + private readonly logger = createLogger('StalkerSeriesView'); + private readonly bindings = signal( + null + ); + readonly episodePlaybackPositions = signal< + Map + >(new Map()); + readonly rawSeriesPositions = signal([]); + /** + * `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. + */ + readonly seriesPositionsLoadedKey = signal(null); + private readonly legacyPositionByTrackingId = signal< + Map + >(new Map()); + private readonly queue = new StalkerSeriesPositionQueue({ + isShown: (context) => + this.stalkerStore.currentPlaylist()?._id === context.playlistId && + this.shownSeriesId() === context.seriesXtreamId, + read: (context) => + this.playbackPositions.getSeriesPlaybackPositions( + context.playlistId, + context.seriesXtreamId + ), + publish: (context, positions) => { + this.rawSeriesPositions.set(positions); + this.seriesPositionsLoadedKey.set( + this.seriesPositionsKey( + context.playlistId, + context.seriesXtreamId + ) + ); + }, + }); + + bind(bindings: StalkerSeriesPositionsBindings): void { + this.bindings.set(bindings); + } + + /** + * Starts over for the series on screen: drops what the previous series + * left behind and reads the persisted rows. Reads the shown item and the + * playlist, so an `effect` calling it re-runs when either changes. + */ + loadShownSeries(): void { + const item = this.displayItem(); + const playlist = this.stalkerStore.currentPlaylist(); + const normalizedSeriesId = toStalkerSeriesId(item?.id ?? 0); + if (item && playlist?._id && normalizedSeriesId > 0) { + this.logger.debug('Loading positions for series', { + id: item.id, + seriesId: normalizedSeriesId, + isSeries: item.is_series, + }); + this.rawSeriesPositions.set([]); + this.seriesPositionsLoadedKey.set(null); + this.episodePlaybackPositions.set(new Map()); + this.legacyPositionByTrackingId.set(new Map()); + const context = this.queue.activate( + playlist._id, + normalizedSeriesId + ); + void this.queue.load(context); + } else { + this.queue.deactivate(); + } + } + + /** + * Maps the raw series position rows onto the currently mapped episodes + * (scoped rows plus compatible legacy promotions). Runs reactively from + * the component's constructor effect, and synchronously from the + * series-level watch toggle right after it hydrates lazy seasons — the + * effect only re-runs on the next change-detection tick, and enqueuing + * the batch against the stale maps would miss the hydrated episodes' + * legacy rows (an unwatch would leave rows behind that a later reconcile + * resurrects as watched). + */ + applyReconciledSeriesPositions(): void { + const item = this.displayItem(); + const playlistId = this.stalkerStore.currentPlaylist()?._id; + const seriesXtreamId = toStalkerSeriesId(item?.id ?? 0); + const rawSeriesPositions = this.rawSeriesPositions(); + const episodesBySeason = this.bindings()?.mappedSeasons() ?? {}; + + if (!item || !playlistId || seriesXtreamId <= 0) { + if (rawSeriesPositions.length > 0) { + this.rawSeriesPositions.set([]); + } + if (this.episodePlaybackPositions().size > 0) { + this.episodePlaybackPositions.set(new Map()); + } + if (this.legacyPositionByTrackingId().size > 0) { + this.legacyPositionByTrackingId.set(new Map()); + } + return; + } + + const reconciled = reconcileStalkerSeriesPositions({ + seriesXtreamId, + episodesBySeason, + seriesPositions: rawSeriesPositions, + }); + if ( + rawSeriesPositions.length === 0 && + reconciled.positionsByTrackingId.size === 0 && + untracked(() => this.episodePlaybackPositions().size) > 0 + ) { + return; + } + this.episodePlaybackPositions.set(reconciled.positionsByTrackingId); + this.legacyPositionByTrackingId.set( + reconciled.legacyPositionByTrackingId + ); + } + + seriesPositionsKey(playlistId: string, seriesXtreamId: number): string { + return `${playlistId}:${seriesXtreamId}`; + } + + persistSeriesPosition( + playlistId: string, + position: PlaybackPositionData + ): Promise { + const context = this.queue.mutationContext( + playlistId, + position.seriesXtreamId + ); + if (!context) { + return Promise.resolve(); + } + const legacyPosition = this.legacyPositionByTrackingId().get( + position.contentXtreamId + ); + return this.queue.enqueue(context, async () => { + let clearedLegacy: boolean; + try { + clearedLegacy = await saveStalkerSeriesPosition({ + repository: this.migrationPlaybackPositions, + playlistId, + position, + legacyPosition, + }); + } catch (error) { + if ( + error instanceof StalkerSeriesPositionPartialSaveError && + this.queue.isActive(context) + ) { + this.publishSavedSeriesPosition( + position, + legacyPosition, + false + ); + } + throw error; + } + if (!this.queue.isActive(context)) { + return; + } + this.publishSavedSeriesPosition( + position, + legacyPosition, + clearedLegacy + ); + }); + } + + clearSeriesPosition( + playlistId: string, + contentXtreamId: number + ): Promise { + const context = this.queue.mutationContext(playlistId); + if (!context) { + return Promise.resolve(); + } + const position = this.episodePlaybackPositions().get( + contentXtreamId + ) ?? { + contentXtreamId, + contentType: 'episode', + positionSeconds: 0, + playlistId, + seriesXtreamId: context.seriesXtreamId, + }; + const legacyPosition = + this.legacyPositionByTrackingId().get(contentXtreamId); + return this.queue.enqueue(context, async () => { + const clearedLegacy = await clearStalkerSeriesPosition({ + repository: this.migrationPlaybackPositions, + playlistId, + position, + legacyPosition, + }); + if (!this.queue.isActive(context)) { + return; + } + this.publishClearedSeriesPosition( + contentXtreamId, + legacyPosition, + clearedLegacy + ); + }); + } + + private displayItem(): StalkerSelectedVodItem | null { + return this.bindings()?.displayItem() ?? null; + } + + private shownSeriesId(): number { + return toStalkerSeriesId(this.displayItem()?.id ?? 0); + } + + private publishSavedSeriesPosition( + position: PlaybackPositionData, + legacyPosition: PlaybackPositionData | undefined, + clearedLegacy: boolean + ): void { + const removedTrackingIds = new Set([position.contentXtreamId]); + if (clearedLegacy && legacyPosition) { + removedTrackingIds.add(legacyPosition.contentXtreamId); + const legacyPositions = new Map(this.legacyPositionByTrackingId()); + legacyPositions.delete(position.contentXtreamId); + this.legacyPositionByTrackingId.set(legacyPositions); + } + + this.rawSeriesPositions.set([ + ...this.rawSeriesPositions().filter( + (candidate) => + !removedTrackingIds.has(candidate.contentXtreamId) + ), + position, + ]); + this.updateEpisodePlaybackPosition(position); + } + + private publishClearedSeriesPosition( + contentXtreamId: number, + legacyPosition: PlaybackPositionData | undefined, + clearedLegacy: boolean + ): void { + const removedTrackingIds = new Set([contentXtreamId]); + if (clearedLegacy && legacyPosition) { + removedTrackingIds.add(legacyPosition.contentXtreamId); + const legacyPositions = new Map(this.legacyPositionByTrackingId()); + legacyPositions.delete(contentXtreamId); + this.legacyPositionByTrackingId.set(legacyPositions); + } + + this.rawSeriesPositions.set( + this.rawSeriesPositions().filter( + (candidate) => + !removedTrackingIds.has(candidate.contentXtreamId) + ) + ); + this.removeEpisodePlaybackPosition(contentXtreamId); + } + + private updateEpisodePlaybackPosition( + position: PlaybackPositionData + ): void { + const updated = new Map(this.episodePlaybackPositions()); + updated.set(position.contentXtreamId, position); + this.episodePlaybackPositions.set(updated); + } + + private removeEpisodePlaybackPosition(contentXtreamId: number): void { + const updated = new Map(this.episodePlaybackPositions()); + updated.delete(contentXtreamId); + this.episodePlaybackPositions.set(updated); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts index d6937ca07..e27488ffc 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts @@ -1,54 +1,30 @@ -import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { Router } from '@angular/router'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { MockPipe } from 'ng-mocks'; -import { SeasonContainerComponent } from '@iptvnator/ui/components'; -import { - PORTAL_EXTERNAL_PLAYBACK, - PORTAL_PLAYBACK_POSITIONS, - PORTAL_PLAYER, -} from '@iptvnator/portal/shared/util'; -import { - StalkerStore, - StalkerVodSource, -} from '@iptvnator/portal/stalker/data-access'; import { PlaybackPositionData } from '@iptvnator/shared/interfaces'; -import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback'; -import { TmdbEnrichmentService } from '@iptvnator/services'; -import { EMPTY, of } from 'rxjs'; -import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component'; import { StalkerSeriesViewComponent } from './stalker-series-view.component'; +import { createStalkerSeriesViewHarness } from './stalker-series-view.test-harness'; import { - StubFavoritesButtonComponent, StubPortalInlinePlayerComponent, StubSeasonContainerComponent, } from './stalker-series-view.test-helpers'; describe('StalkerSeriesViewComponent', () => { let fixture: ComponentFixture; - const selectedContentType = signal<'series' | 'vod'>('series'); - const selectedItem = signal(null); - const serialSeasonsResource = signal([]); - const vodSeriesSeasonsResource = signal([]); - const isSerialSeasonsLoading = signal(false); - const fetchVodSeriesEpisodes = jest.fn(); - const resolveVodPlayback = jest.fn(); - const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]); - const openResolvedPlayback = jest.fn(); - const openExternalPlayback = jest.fn(); - const isEmbeddedPlayer = jest.fn(); - const tmdbGetSeason = jest.fn(); - const fetchLinkToPlay = jest.fn(); - const currentPlaylist = signal({ - _id: 'stalker-1', - title: 'Living Room Portal', - portalUrl: 'https://stalker.example.test', - macAddress: '00:1A:79:12:34:56', - }); + const { + selectedContentType, + selectedItem, + serialSeasonsResource, + vodSeriesSeasonsResource, + fetchVodSeriesEpisodes, + resolveVodPlayback, + openResolvedPlayback, + openExternalPlayback, + isEmbeddedPlayer, + fetchLinkToPlay, + currentPlaylist, + createFixture, + } = createStalkerSeriesViewHarness(() => jest.fn()); async function stabilize(): Promise { fixture.detectChanges(); @@ -63,184 +39,7 @@ describe('StalkerSeriesViewComponent', () => { } beforeEach(async () => { - selectedContentType.set('series'); - selectedItem.set({ - id: '30001', - cmd: '/media/file_30001.mpg', - info: { - name: 'Regular Series', - description: 'Series description', - movie_image: 'poster.jpg', - }, - }); - serialSeasonsResource.set([ - { - id: 'season-1', - name: 'Season 1', - cmd: '/media/file_30001.mpg', - series: [1, 2], - }, - ]); - vodSeriesSeasonsResource.set([]); - isSerialSeasonsLoading.set(false); - fetchVodSeriesEpisodes.mockReset(); - resolveVodPlayback.mockReset(); - resolveVodPlayback.mockImplementation( - async ( - _cmd?: string, - title?: string, - thumbnail?: string, - _episodeNum?: number, - episodeId?: number, - startTime?: number - ) => ({ - streamUrl: 'http://stalker.example/episode.mpg', - title: title ?? 'Regular Series', - thumbnail: thumbnail ?? 'poster.jpg', - startTime, - contentInfo: { - playlistId: 'stalker-1', - contentXtreamId: - episodeId ?? Number(selectedItem()?.id ?? 0), - contentType: episodeId ? 'episode' : 'vod', - seriesXtreamId: episodeId - ? Number(selectedItem()?.id ?? 0) - : undefined, - }, - }) - ); - getSeriesPlaybackPositions.mockClear(); - getSeriesPlaybackPositions.mockResolvedValue([]); - openResolvedPlayback.mockClear(); - openExternalPlayback.mockReset(); - isEmbeddedPlayer.mockReset(); - isEmbeddedPlayer.mockReturnValue(false); - tmdbGetSeason.mockReset(); - tmdbGetSeason.mockResolvedValue({ - overview: 'Season overview from TMDB', - episodes: [], - }); - fetchLinkToPlay - .mockReset() - .mockResolvedValue('https://cdn.example.test/episode.mpg'); - currentPlaylist.set({ - _id: 'stalker-1', - title: 'Living Room Portal', - portalUrl: 'https://stalker.example.test', - macAddress: '00:1A:79:12:34:56', - }); - - await TestBed.configureTestingModule({ - imports: [StalkerSeriesViewComponent], - providers: [ - { - provide: StalkerStore, - useValue: { - selectedItem, - selectedContentType, - currentPlaylist, - getSerialSeasonsResource: () => serialSeasonsResource(), - getVodSeriesSeasonsResource: () => - vodSeriesSeasonsResource(), - isVodSeriesSeasonsLoading: signal(false), - isSerialSeasonsLoading, - fetchVodSeriesEpisodes, - resolveVodPlayback, - fetchLinkToPlay, - }, - }, - { - 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, - openResolvedPlayback, - openExternalPlayback, - }, - }, - { - provide: Router, - useValue: { - navigateByUrl: jest.fn(), - }, - }, - { - provide: TmdbEnrichmentService, - useValue: { - isEnabled: () => true, - getSeason: tmdbGetSeason, - }, - }, - { - 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', - // EMPTY (not of(null)): the real TranslatePipe inside - // the detail shell reads `event.lang` from emissions. - onLangChange: EMPTY, - onTranslationChange: EMPTY, - onDefaultLangChange: EMPTY, - }, - }, - ], - }) - .overrideComponent(StalkerSeriesViewComponent, { - remove: { - imports: [ - FavoritesButtonComponent, - PortalInlinePlayerComponent, - SeasonContainerComponent, - TranslatePipe, - ], - }, - add: { - imports: [ - StubFavoritesButtonComponent, - StubPortalInlinePlayerComponent, - StubSeasonContainerComponent, - MockPipe( - TranslatePipe, - ( - value: string | null | undefined, - params?: Record - ) => { - if (value === 'XTREAM.PLAY_EPISODE') { - return `Play episode ${ - params?.['episode'] ?? '{{episode}}' - }`; - } - return value ?? ''; - } - ), - ], - }, - }) - .compileComponents(); - - fixture = TestBed.createComponent(StalkerSeriesViewComponent); + fixture = await createFixture(); }); afterEach(() => { @@ -783,10 +582,12 @@ describe('StalkerSeriesViewComponent', () => { }), }) ); - // Prettier expands these compact fixtures enough to breach the spec's - // hard 1,200-line lint limit. - // prettier-ignore - expect(inlinePlayer.episodeMetadata()).toEqual({ label: 'S01E01', title: 'Pilot', seasonNumber: 1, episodeNumber: 1 }); + expect(inlinePlayer.episodeMetadata()).toEqual({ + label: 'S01E01', + title: 'Pilot', + seasonNumber: 1, + episodeNumber: 1, + }); expect(inlinePlayer.seriesNavigation()).toEqual({ canPrevious: false, canNext: true, @@ -834,251 +635,6 @@ describe('StalkerSeriesViewComponent', () => { ); }); - it('prefetches the next unopened season so the Up Next rail can spill over', async () => { - selectedContentType.set('vod'); - selectedItem.set({ - id: '50001', - is_series: true, - info: { - name: 'VOD Flagged Series', - description: 'Lazy seasons', - movie_image: 'vod-series.jpg', - }, - }); - serialSeasonsResource.set([]); - vodSeriesSeasonsResource.set([ - { - id: 'season-1', - video_id: '50001', - season_number: '1', - name: 'Season 1', - }, - { - id: 'season-2', - video_id: '50001', - season_number: '2', - name: 'Season 2', - }, - ]); - isEmbeddedPlayer.mockReturnValue(true); - - await stabilize(); - - // Season 1 loaded, season 2 still empty — the state a user is in - // right after opening the series and starting the first episode. - fixture.componentInstance.vodSeriesSeasons.set([ - { - id: 'season-1', - video_id: '50001', - season_number: '1', - name: 'Season 1', - episodes: [ - { id: 'episode-1', series_number: 1, name: 'Pilot' }, - ], - isLoading: false, - isExpanded: false, - }, - { - id: 'season-2', - video_id: '50001', - season_number: '2', - name: 'Season 2', - episodes: [], - isLoading: false, - isExpanded: false, - }, - ]); - fetchVodSeriesEpisodes.mockClear(); - fetchVodSeriesEpisodes.mockResolvedValue([ - { id: 'episode-3', series_number: 1, name: 'Next Season' }, - ]); - - const firstEpisode = fixture.componentInstance.mappedSeasons()['1'][0]; - fixture.componentInstance.onEpisodeClicked(firstEpisode); - await fixture.whenStable(); - await stabilize(); - fixture.detectChanges(); - - expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith( - '50001', - 'season-2' - ); - - const inlinePlayer = fixture.debugElement.query( - By.directive(StubPortalInlinePlayerComponent) - ).componentInstance as StubPortalInlinePlayerComponent; - const railItems = inlinePlayer.upNextEpisodes() as Array<{ - label: string; - }>; - expect(railItems.map((item) => item.label)).toEqual([ - 'S01E01', - 'S02E01', - ]); - }); - - it('does not re-request a spillover season that came back empty', async () => { - selectedContentType.set('vod'); - selectedItem.set({ - id: '50001', - is_series: true, - info: { - name: 'VOD Flagged Series', - description: 'Lazy seasons', - movie_image: 'vod-series.jpg', - }, - }); - serialSeasonsResource.set([]); - vodSeriesSeasonsResource.set([ - { - id: 'season-1', - video_id: '50001', - season_number: '1', - name: 'Season 1', - }, - { - id: 'season-2', - video_id: '50001', - season_number: '2', - name: 'Season 2', - }, - ]); - isEmbeddedPlayer.mockReturnValue(true); - - await stabilize(); - - fixture.componentInstance.vodSeriesSeasons.set([ - { - id: 'season-1', - video_id: '50001', - season_number: '1', - name: 'Season 1', - episodes: [ - { id: 'episode-1', series_number: 1, name: 'Pilot' }, - { id: 'episode-2', series_number: 2, name: 'Second' }, - ], - isLoading: false, - isExpanded: false, - }, - { - id: 'season-2', - video_id: '50001', - season_number: '2', - name: 'Season 2', - episodes: [], - isLoading: false, - isExpanded: false, - }, - ]); - fetchVodSeriesEpisodes.mockClear(); - // Failed or genuinely empty season: isLoading returns to false while - // episodes stays empty — the retry trap. - fetchVodSeriesEpisodes.mockResolvedValue([]); - - const seasonOne = fixture.componentInstance.mappedSeasons()['1']; - fixture.componentInstance.onEpisodeClicked(seasonOne[0]); - await fixture.whenStable(); - await stabilize(); - fixture.detectChanges(); - - expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1); - - // Further playback activity in the same season must not retrigger it. - fixture.componentInstance.onEpisodeClicked(seasonOne[1]); - await fixture.whenStable(); - await stabilize(); - fixture.detectChanges(); - - expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1); - }); - - it('retries a failed spillover prefetch on the next episode', async () => { - selectedContentType.set('vod'); - selectedItem.set({ - id: '50001', - is_series: true, - info: { - name: 'VOD Flagged Series', - description: 'Lazy seasons', - movie_image: 'vod-series.jpg', - }, - }); - serialSeasonsResource.set([]); - vodSeriesSeasonsResource.set([ - { - id: 'season-1', - video_id: '50001', - season_number: '1', - name: 'Season 1', - }, - { - id: 'season-2', - video_id: '50001', - season_number: '2', - name: 'Season 2', - }, - ]); - isEmbeddedPlayer.mockReturnValue(true); - - await stabilize(); - - fixture.componentInstance.vodSeriesSeasons.set([ - { - id: 'season-1', - video_id: '50001', - season_number: '1', - name: 'Season 1', - episodes: [ - { id: 'episode-1', series_number: 1, name: 'Pilot' }, - { id: 'episode-2', series_number: 2, name: 'Second' }, - ], - isLoading: false, - isExpanded: false, - }, - { - id: 'season-2', - video_id: '50001', - season_number: '2', - name: 'Season 2', - episodes: [], - isLoading: false, - isExpanded: false, - }, - ]); - fetchVodSeriesEpisodes.mockClear(); - fetchVodSeriesEpisodes.mockRejectedValueOnce(new Error('network')); - - const seasonOne = fixture.componentInstance.mappedSeasons()['1']; - fixture.componentInstance.onEpisodeClicked(seasonOne[0]); - await fixture.whenStable(); - await stabilize(); - fixture.detectChanges(); - - // The failure must not loop while the same episode keeps playing. - expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1); - - // Moving to the next episode gives the transient failure a new chance. - fetchVodSeriesEpisodes.mockResolvedValue([ - { id: 'episode-3', series_number: 1, name: 'Next Season' }, - ]); - fixture.componentInstance.onEpisodeClicked(seasonOne[1]); - await fixture.whenStable(); - await stabilize(); - fixture.detectChanges(); - - expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(2); - - const inlinePlayer = fixture.debugElement.query( - By.directive(StubPortalInlinePlayerComponent) - ).componentInstance as StubPortalInlinePlayerComponent; - const railItems = inlinePlayer.upNextEpisodes() as Array<{ - label: string; - }>; - expect(railItems.map((item) => item.label)).toEqual([ - 'S01E02', - 'S02E01', - ]); - }); - it('loads the next unloaded VOD-series season after the loaded season is watched', async () => { selectedContentType.set('vod'); selectedItem.set({ @@ -1180,162 +736,4 @@ describe('StalkerSeriesViewComponent', () => { undefined ); }); - - it('fetches the TMDB season once the show-level match arrives after auto-select', async () => { - await stabilize(); - fixture.detectChanges(); - - // Season tabs auto-select immediately — usually before the async - // show-level enrichment has written tmdb_id. - const seasonContainer = fixture.debugElement.query( - By.directive(StubSeasonContainerComponent) - ).componentInstance as StubSeasonContainerComponent; - seasonContainer.seasonSelected.emit('1'); - await stabilize(); - expect(tmdbGetSeason).not.toHaveBeenCalled(); - - // The TMDB match lands afterwards — the fetch must run now. - selectedItem.set({ - id: '30001', - cmd: '/media/file_30001.mpg', - info: { - name: 'Regular Series', - description: 'Series description', - movie_image: 'poster.jpg', - tmdb_id: 777, - }, - } as never); - await stabilize(); - - expect(tmdbGetSeason).toHaveBeenCalledWith(777, 1); - }); - - it('waits for the season map before fetching so a per-season slice gets the title-marked season', async () => { - serialSeasonsResource.set([]); - selectedItem.set({ - id: '30001', - cmd: '/media/file_30001.mpg', - info: { - name: 'Regular Series (2 season)', - description: 'Series description', - movie_image: 'poster.jpg', - tmdb_id: 777, - }, - } as never); - await stabilize(); - - fixture.componentInstance.onSeasonSelected('2'); - await stabilize(); - - // Season resource still loading — fetching now would pass a zero - // season count, suppress the title-marker override and cache the - // wrong season forever (fetchSeason is idempotent). - expect(tmdbGetSeason).not.toHaveBeenCalled(); - - serialSeasonsResource.set([ - { - id: 'season-1', - name: 'Season 1', - cmd: '/media/file_30001.mpg', - series: [1, 2], - }, - ]); - await stabilize(); - - // Single-season slice whose provider season is renumbered to 1: - // the title marker names the real TMDB season. - expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2); - }); - - it('gates the fetch on the reloading season resource during detail-to-detail navigation', async () => { - await stabilize(); - - fixture.componentInstance.onSeasonSelected('1'); - await stabilize(); - // No show-level TMDB match yet — nothing fetched for the first item - expect(tmdbGetSeason).not.toHaveBeenCalled(); - - // Detail-to-detail navigation reuses the component; the new item's - // TMDB match can arrive while the season resource reloads and the - // map still shows the previous series' seasons. - isSerialSeasonsLoading.set(true); - selectedItem.set({ - id: '30002', - cmd: '/media/file_30002.mpg', - info: { - name: 'Other Series (2 season)', - description: 'Other description', - movie_image: 'poster2.jpg', - tmdb_id: 888, - }, - } as never); - await stabilize(); - - // The new tmdb_id must NOT pair with the previous series' season - // context while the resource reloads. - expect(tmdbGetSeason).not.toHaveBeenCalled(); - - // Once the new item's own seasons land, the fetch runs without a - // stale-season fetch. The container selects the corrected key once - // the new season map is rendered. - serialSeasonsResource.set([ - { - id: 'season-1', - name: 'Season 1', - cmd: '/media/file_30002.mpg', - series: [1, 2], - }, - ]); - isSerialSeasonsLoading.set(false); - fixture.componentInstance.onSeasonSelected('2'); - await stabilize(); - expect(tmdbGetSeason).toHaveBeenCalledWith(888, 2); - }); - - it('enriches after equal-id navigation once the season resource settles', async () => { - await stabilize(); - fixture.componentInstance.onSeasonSelected('1'); - await stabilize(); - - // Distinct items can reuse a provider id; the loading gate (not an - // id comparison) keeps the stale map from being used. - isSerialSeasonsLoading.set(true); - selectedItem.set({ - id: '30001', - cmd: '/media/file_30001.mpg', - info: { - name: 'Different Series (3 season)', - description: 'Different description', - movie_image: 'poster3.jpg', - tmdb_id: 999, - }, - } as never); - await stabilize(); - expect(tmdbGetSeason).not.toHaveBeenCalled(); - - isSerialSeasonsLoading.set(false); - fixture.componentInstance.onSeasonSelected('3'); - await stabilize(); - expect(tmdbGetSeason).toHaveBeenCalledWith(999, 3); - }); - - it('reads the season marker from o_name when name is generic', async () => { - selectedItem.set({ - id: '30001', - cmd: '/media/file_30001.mpg', - info: { - name: 'Regular Series', - o_name: 'Regular Series (2 season)', - description: 'Series description', - movie_image: 'poster.jpg', - tmdb_id: 777, - }, - } as never); - await stabilize(); - - fixture.componentInstance.onSeasonSelected('2'); - await stabilize(); - - expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2); - }); }); 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 3c250b61b..a4fd531f4 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 @@ -16,7 +16,6 @@ import { MatSnackBar } from '@angular/material/snack-bar'; 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, @@ -40,7 +39,6 @@ import { SimilarRailComponent, ViewInPortalActionComponent, VodMoreMenuComponent, - buildSeriesWatchToggleRequest, scrollToCastCrewRow, } from '@iptvnator/ui/components'; import { @@ -53,14 +51,11 @@ import { XtreamSerieEpisode, pickSeasonMarkedTitle, seriesStatusLabelKey, - youtubeEmbedUrl, } from '@iptvnator/shared/interfaces'; import { isLiveExternalPlayerSession, replaceOwnedExternalSession, - isPortalPlaybackWatched, PORTAL_EXTERNAL_PLAYBACK, - PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, createLogger, consumeStalkerReturnMarker, @@ -108,12 +103,10 @@ import { getStalkerSeriesQuickStartButton, type StalkerQuickStartButton, } from './stalker-series-quick-start'; -import { - clearStalkerSeriesPosition, - reconcileStalkerSeriesPositions, - saveStalkerSeriesPosition, - StalkerSeriesPositionPartialSaveError, -} from './stalker-series-position-compatibility'; +import { toStalkerSeriesId } from './stalker-series-id'; +import { StalkerSeriesPositionsService } from './stalker-series-positions.service'; +import { StalkerSeriesWatchToggleService } from './stalker-series-watch-toggle.service'; +import { StalkerVodSeasonEpisodeLoader } from './stalker-vod-season-episode-loader'; import { createStalkerSeriesDownloadAdapter, STALKER_SERIES_DOWNLOAD_MODES, @@ -128,40 +121,6 @@ import { type StalkerEpisodePlaybackStructuralIdentity, } from './stalker-episode-playback-session-key'; -interface SeriesPositionContext { - readonly generation: number; - readonly playlistId: string; - readonly seriesXtreamId: number; - readonly mutationKey: string; -} - -interface StalkerWatchToggleFeedback { - readonly marked: string; - readonly unmarked: string; - readonly partialMarked: string; - readonly partialUnmarked: string; - readonly failed: string; -} - -// The marked and partial keys are scope-generic on purpose ("{{count}} -// episodes marked as watched", "{{count}} marked · {{failed}} failed"); -// only unmark-success and failure name their scope. -const SEASON_WATCH_FEEDBACK: StalkerWatchToggleFeedback = { - marked: 'XTREAM.SEASON_MARKED_WATCHED', - unmarked: 'XTREAM.SEASON_MARKED_UNWATCHED', - partialMarked: 'XTREAM.SEASON_MARKED_WATCHED_PARTIAL', - partialUnmarked: 'XTREAM.SEASON_MARKED_UNWATCHED_PARTIAL', - failed: 'XTREAM.SEASON_WATCH_UPDATE_FAILED', -}; - -const SERIES_WATCH_FEEDBACK: StalkerWatchToggleFeedback = { - marked: 'XTREAM.SEASON_MARKED_WATCHED', - unmarked: 'XTREAM.SERIES_MARKED_UNWATCHED', - partialMarked: 'XTREAM.SEASON_MARKED_WATCHED_PARTIAL', - partialUnmarked: 'XTREAM.SEASON_MARKED_UNWATCHED_PARTIAL', - failed: 'XTREAM.SERIES_WATCH_UPDATE_FAILED', -}; - interface StalkerSeriesPlaybackRequestContext { readonly generation: number; readonly usesEmbeddedPlayer: boolean; @@ -201,6 +160,8 @@ interface StalkerSeriesPlaybackRequestContext { StalkerSeriesTmdbSeasonsService, StalkerSeriesHeroPresenter, StalkerSeriesMenuService, + StalkerSeriesPositionsService, + StalkerSeriesWatchToggleService, ], }) export class StalkerSeriesViewComponent implements OnDestroy { @@ -209,27 +170,8 @@ export class StalkerSeriesViewComponent implements OnDestroy { readonly menu = inject(StalkerSeriesMenuService); private readonly seasonContainerRef = viewChild('seasonContainer'); - private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); - private readonly migrationPlaybackPositions = { - savePlaybackPosition: ( - playlistId: string, - data: PlaybackPositionData - ) => - this.playbackPositions.savePlaybackPositionOrThrow( - playlistId, - data - ), - clearPlaybackPosition: ( - playlistId: string, - contentXtreamId: number, - contentType: 'vod' | 'episode' - ) => - this.playbackPositions.clearPlaybackPositionOrThrow( - playlistId, - contentXtreamId, - contentType - ), - }; + private readonly positions = inject(StalkerSeriesPositionsService); + private readonly watchToggle = inject(StalkerSeriesWatchToggleService); private readonly portalPlayer = inject(PORTAL_PLAYER); private readonly router = inject(Router); private readonly location = inject(Location); @@ -238,46 +180,15 @@ export class StalkerSeriesViewComponent implements OnDestroy { PlaybackPositionRuntimeBridgeService ); private readonly snackBar = inject(MatSnackBar); - // Optional: absent in collection-detail mounts outside the catalog. - private readonly catalogFacade = inject(StalkerCatalogFacadeService, { - optional: true, - }); private readonly translateService = inject(TranslateService); readonly backClicked = output(); private readonly logger = createLogger('StalkerSeriesView'); readonly inlinePlayback = signal(null); - readonly episodePlaybackPositions = signal< - Map - >(new Map()); - 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); + readonly episodePlaybackPositions = this.positions.episodePlaybackPositions; 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()); - private activeSeriesPositionContext: SeriesPositionContext | null = null; - private seriesPositionContextGeneration = 0; - private readonly seriesPositionMutationQueues = new Map< - string, - Promise - >(); - private readonly pendingSeriesPositionLoads = new Map< - SeriesPositionContext, - Set - >(); - private readonly seriesPositionReloadKeys = new Set(); - private seriesPositionsLoadGeneration = 0; private seriesPlaybackRequestGeneration = 0; private currentSeriesPlaybackOwnerKey = ''; private readonly inlinePlaybackEpisodeIdentity = @@ -294,15 +205,6 @@ export class StalkerSeriesViewComponent implements OnDestroy { private readonly pendingStartSeriesIds = signal([]); /** Episode choices made while a forced MPV/VLC launch is mid-flight. */ private readonly launchQueue = new StalkerSeriesLaunchQueue(); - /** - * The episode chosen while a watched/reset batch still rewrote the rows - * a start resumes from; the last choice plays once the batch settled, - * and only on the series it was made for. - */ - private choiceHeldForBatch: { - readonly seriesKey: string; - readonly play: () => void; - } | null = null; /** `playlist:series` of the series on screen; provider ids collide across playlists. */ readonly currentSeriesKey = computed( () => @@ -312,7 +214,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { readonly startPending = computed(() => this.pendingStartSeriesIds().includes(this.currentSeriesKey()) ); - readonly seasonWatchBatchRunning = signal(false); + readonly seasonWatchBatchRunning = this.watchToggle.seasonWatchBatchRunning; /** * Optional input for VOD items with embedded series array (vclub mode) @@ -437,6 +339,20 @@ export class StalkerSeriesViewComponent implements OnDestroy { resetProgress: () => this.resetProgress(), openExternal: (player) => this.openQuickStartExternally(player), }); + this.positions.bind({ + displayItem: this.displayItem, + mappedSeasons: this.mappedSeasons, + }); + this.watchToggle.bind({ + displayItem: this.displayItem, + currentSeriesKey: this.currentSeriesKey, + isVodSeries: this.isVodSeries, + vodSeriesSeasons: this.vodSeriesSeasons, + mappedSeasons: this.mappedSeasons, + excludedEpisodeIds: () => this.seriesWatchExcludedIds(), + loadEpisodesForSeason: (season) => + this.loadEpisodesForSeason(season), + }); effect(() => { const ownerKey = this.seriesPlaybackOwnerKey(); untracked(() => this.syncSeriesPlaybackOwner(ownerKey)); @@ -506,33 +422,11 @@ export class StalkerSeriesViewComponent implements OnDestroy { // Effect to load playback positions for Stalker series effect(() => { - const item = this.displayItem(); - const playlist = this.stalkerStore.currentPlaylist(); - const normalizedSeriesId = this.toSeriesId(item?.id ?? 0); - if (item && playlist?._id && normalizedSeriesId > 0) { - this.logger.debug('Loading positions for series', { - id: item.id, - seriesId: normalizedSeriesId, - 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( - playlist._id, - normalizedSeriesId - ); - void this.loadSeriesPositions(context); - } else { - this.activeSeriesPositionContext = null; - this.seriesPositionContextGeneration++; - this.seriesPositionsLoadGeneration++; - } + this.positions.loadShownSeries(); }); effect(() => { - this.applyReconciledSeriesPositions(); + this.positions.applyReconciledSeriesPositions(); }); // Dashboard "Continue watching" handoff: once the persisted @@ -557,8 +451,11 @@ export class StalkerSeriesViewComponent implements OnDestroy { !playlistId || seriesXtreamId <= 0 || target.seriesXtreamId !== seriesXtreamId || - this.seriesPositionsLoadedKey() !== - this.seriesPositionsKey(playlistId, seriesXtreamId) + this.positions.seriesPositionsLoadedKey() !== + this.positions.seriesPositionsKey( + playlistId, + seriesXtreamId + ) ) { return; } @@ -577,10 +474,12 @@ export class StalkerSeriesViewComponent implements OnDestroy { // 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; + const savedOffset = this.positions + .rawSeriesPositions() + .find( + (position) => + position.contentXtreamId === target.contentXtreamId + )?.positionSeconds; untracked(() => this.onEpisodeClicked(episode, savedOffset)); return; } @@ -661,14 +560,14 @@ export class StalkerSeriesViewComponent implements OnDestroy { // The facade/runtime already saved this row. Repeat the // idempotent upsert because only this view owns the // scoped-to-legacy cleanup mapping. - void this.persistSeriesPosition(playlistId, data).catch( - (error: unknown) => { + void this.positions + .persistSeriesPosition(playlistId, data) + .catch((error: unknown) => { this.logger.error( 'Failed to persist runtime series position', error ); - } - ); + }); } ) ?? null; } @@ -915,106 +814,17 @@ export class StalkerSeriesViewComponent implements OnDestroy { } } - private readonly vodSeasonEpisodeLoads = new Map< - string, - { season: VodSeriesSeasonVm; promise: Promise } - >(); + private readonly vodSeasonLoader = new StalkerVodSeasonEpisodeLoader({ + seasons: this.vodSeriesSeasons, + ownerKey: () => this.seriesPlaybackOwnerKey(), + fetchEpisodes: (videoId, seasonId) => + this.stalkerStore.fetchVodSeriesEpisodes(videoId, seasonId), + logError: (message, error) => this.logger.error(message, error), + }); - /** - * Loads episodes for a specific VOD season. - * - * Single-flight per season: a tab click, the spillover prefetch, the - * quick-start recursion, and the series-toggle hydration can all ask for - * the same season — a second concurrent request would duplicate portal - * traffic, and its failure could abort a series toggle whose original - * request succeeded. - */ /** Resolves true when the portal answered, false when the request failed. */ loadEpisodesForSeason(season: VodSeriesSeasonVm): Promise { - const key = JSON.stringify([ - this.seriesPlaybackOwnerKey(), - season.video_id, - season.id, - getVodSeriesSeasonKey(season), - ]); - const inFlight = this.vodSeasonEpisodeLoads.get(key); - if (inFlight && this.vodSeriesSeasons().includes(inFlight.season)) { - return inFlight.promise; - } - const load = this.fetchEpisodesForSeason(season).finally(() => { - if (this.vodSeasonEpisodeLoads.get(key)?.promise === load) { - this.vodSeasonEpisodeLoads.delete(key); - } - }); - const loadingSeason = this.vodSeriesSeasons().find( - (candidate) => - candidate.id === season.id && - candidate.video_id === season.video_id - ); - if (loadingSeason) { - this.vodSeasonEpisodeLoads.set(key, { - season: loadingSeason, - promise: load, - }); - } - return load; - } - - private async fetchEpisodesForSeason( - season: VodSeriesSeasonVm - ): Promise { - // Set loading state in local signal - const seasons = this.vodSeriesSeasons(); - const index = seasons.findIndex( - (s) => - s.id === season.id && - s.video_id === season.video_id && - getVodSeriesSeasonKey(s) === getVodSeriesSeasonKey(season) - ); - if (index === -1) return false; - - const updatedSeasons = [...seasons]; - const loadingSeason = { ...updatedSeasons[index], isLoading: true }; - updatedSeasons[index] = loadingSeason; - this.vodSeriesSeasons.set(updatedSeasons); - - try { - const episodes = await this.stalkerStore.fetchVodSeriesEpisodes( - season.video_id, - season.id - ); - - // Update with loaded episodes - const newSeasons = [...this.vodSeriesSeasons()]; - // Only the exact loading VM owns this response. A navigation or - // refresh can reuse provider ids while replacing the season list. - const newIndex = newSeasons.indexOf(loadingSeason); - if (newIndex !== -1) { - newSeasons[newIndex] = { - ...newSeasons[newIndex], - episodes: episodes, - // Even an EMPTY answer marks the season loaded: the - // portal spoke, so it must stop counting as "unloaded" - // (label/verdict gating and series-toggle hydration). - episodesLoaded: true, - isLoading: false, - }; - this.vodSeriesSeasons.set(newSeasons); - } - return newIndex !== -1; - } catch (error) { - this.logger.error('Failed to load episodes', error); - const newSeasons = [...this.vodSeriesSeasons()]; - const newIndex = newSeasons.indexOf(loadingSeason); - if (newIndex !== -1) { - newSeasons[newIndex] = { - ...newSeasons[newIndex], - isLoading: false, - }; - this.vodSeriesSeasons.set(newSeasons); - } - return false; - } + return this.vodSeasonLoader.load(season); } /** @@ -1045,15 +855,9 @@ export class StalkerSeriesViewComponent implements OnDestroy { if (this.seasonWatchBatchRunning()) { // The batch rewrites the very rows a start resumes from: the // choice waits for it, like the Reset and watched rows do. - this.choiceHeldForBatch = { - seriesKey: this.currentSeriesKey(), - play: () => - this.onEpisodeClicked( - episode, - startTimeOverride, - forcePlayer - ), - }; + this.watchToggle.holdChoice(() => + this.onEpisodeClicked(episode, startTimeOverride, forcePlayer) + ); return; } const seriesKey = this.currentSeriesKey(); @@ -1068,20 +872,6 @@ export class StalkerSeriesViewComponent implements OnDestroy { this.startEpisode(episode, startTimeOverride, forcePlayer); } - /** - * The batch settled: the choice held meanwhile goes through the usual - * gates, unless the viewer switched series since. Episode identities - * overlap across series, so it must never resolve against another one. - */ - private endWatchBatch(): void { - this.seasonWatchBatchRunning.set(false); - const held = this.choiceHeldForBatch; - this.choiceHeldForBatch = null; - if (held && held.seriesKey === this.currentSeriesKey()) { - held.play(); - } - } - private startEpisode( episode: XtreamSerieEpisode, startTimeOverride?: number, @@ -1208,11 +998,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { } toSeriesId(id: string | number): number { - const raw = String(id ?? '').trim(); - if (!raw) return 0; - const primary = raw.includes(':') ? raw.split(':')[0] : raw; - const parsed = Number(primary); - return Number.isFinite(parsed) ? parsed : 0; + return toStalkerSeriesId(id); } closeInlinePlayer(): void { @@ -1239,15 +1025,14 @@ export class StalkerSeriesViewComponent implements OnDestroy { positionSeconds: Math.floor(event.currentTime), durationSeconds: Math.floor(event.duration), }; - void this.persistSeriesPosition( - playback.contentInfo.playlistId, - position - ).catch((error: unknown) => { - this.logger.error( - 'Failed to persist inline series position', - error - ); - }); + void this.positions + .persistSeriesPosition(playback.contentInfo.playlistId, position) + .catch((error: unknown) => { + this.logger.error( + 'Failed to persist inline series position', + error + ); + }); } showCopyNotification(): void { @@ -1517,27 +1302,10 @@ export class StalkerSeriesViewComponent implements OnDestroy { this.closeInlinePlayer(); } - async handlePlaybackToggleRequested( + handlePlaybackToggleRequested( request: SeasonContainerPlaybackToggleRequest ): Promise { - const playlistId = this.stalkerStore.currentPlaylist()?._id; - if (!playlistId) { - return; - } - - if (request.nextPosition) { - await this.persistSeriesPosition(playlistId, request.nextPosition); - } else { - await this.clearSeriesPosition(playlistId, request.contentXtreamId); - } - // Keep the catalog grid's progress badge in sync (ownership-checked - // inside the facade; no-op outside the catalog context). A failed - // refresh keeps the cache populated-but-stale. - await this.catalogFacade - ?.refreshPositions(playlistId) - .catch((error: unknown) => - this.logger.warn('Catalog position refresh failed', error) - ); + return this.watchToggle.handlePlaybackToggleRequested(request); } handlePlaybackToggleRequestedFromUi( @@ -1553,36 +1321,10 @@ export class StalkerSeriesViewComponent implements OnDestroy { ); } - async handleSeasonPlaybackToggleRequested( + handleSeasonPlaybackToggleRequested( request: SeasonContainerSeasonPlaybackToggleRequest ): Promise { - const playlistId = this.stalkerStore.currentPlaylist()?._id; - if ( - !playlistId || - request.requests.length === 0 || - this.seasonWatchBatchRunning() - ) { - return; - } - // The mutation context already keeps a stale batch out of the next - // series' state; the snackbars need the same ownership so feedback - // for the old season is not presented on a newly opened page. - const seriesXtreamId = this.toSeriesId(this.displayItem()?.id ?? 0); - const stillCurrent = () => - this.stalkerStore.currentPlaylist()?._id === playlistId && - this.toSeriesId(this.displayItem()?.id ?? 0) === seriesXtreamId; - - this.seasonWatchBatchRunning.set(true); - try { - await this.runWatchToggleBatch( - request, - playlistId, - stillCurrent, - SEASON_WATCH_FEEDBACK - ); - } finally { - this.endWatchBatch(); - } + return this.watchToggle.handleSeasonPlaybackToggleRequested(request); } handleSeasonPlaybackToggleRequestedFromUi( @@ -1624,89 +1366,10 @@ export class StalkerSeriesViewComponent implements OnDestroy { : {} ); - async handleSeriesPlaybackToggleRequested( + handleSeriesPlaybackToggleRequested( request: SeasonContainerSeriesPlaybackToggleRequest ): Promise { - const playlistId = this.stalkerStore.currentPlaylist()?._id; - if (!playlistId || this.seasonWatchBatchRunning()) { - return; - } - const pendingSeasons = this.isVodSeries() - ? this.vodSeriesSeasons().filter((season) => - this.isSeasonHydrationPending(season) - ) - : []; - // An empty request is only meaningful when unloaded seasons remain: - // every loaded episode is watched, so the container could not build - // a target list, but hydration below may still surface unwatched - // episodes to mark. - if (request.requests.length === 0 && pendingSeasons.length === 0) { - return; - } - - const seriesXtreamId = this.toSeriesId(this.displayItem()?.id ?? 0); - const stillCurrent = () => - this.stalkerStore.currentPlaylist()?._id === playlistId && - this.toSeriesId(this.displayItem()?.id ?? 0) === seriesXtreamId; - - this.seasonWatchBatchRunning.set(true); - try { - let effective: SeasonContainerSeriesPlaybackToggleRequest | null = - request; - if (pendingSeasons.length > 0) { - const hydrated = await this.hydrateSeasonsForSeriesToggle( - pendingSeasons, - stillCurrent - ); - if (hydrated !== 'complete') { - if (hydrated === 'failed' && stillCurrent()) { - this.notifySeasonWatchToggle( - SERIES_WATCH_FEEDBACK.failed - ); - } - return; - } - // The reconcile effect only flushes on the next change- - // detection tick; rebuild the maps synchronously so the - // batch below sees the hydrated episodes' scoped and legacy - // rows (see applyReconciledSeriesPositions). - this.applyReconciledSeriesPositions(); - effective = buildSeriesWatchToggleRequest({ - seasons: this.mappedSeasons(), - seriesId: seriesXtreamId, - playlistId, - isEpisodeWatched: (episode) => - isPortalPlaybackWatched( - this.episodePlaybackPositions().get( - Number(episode.id) - ) - ), - excludedEpisodeIds: this.seriesWatchExcludedIds(), - // Keep the direction the user clicked; re-inference over - // the now-complete data could flip a "mark" into an - // unwatch when everything turned out watched. - markWatched: request.markWatched, - }); - if (!effective) { - if (stillCurrent()) { - this.notifySeasonWatchToggle( - SERIES_WATCH_FEEDBACK.marked, - { count: 0 } - ); - } - return; - } - } - - await this.runWatchToggleBatch( - effective, - playlistId, - stillCurrent, - SERIES_WATCH_FEEDBACK - ); - } finally { - this.endWatchBatch(); - } + return this.watchToggle.handleSeriesPlaybackToggleRequested(request); } handleSeriesPlaybackToggleRequestedFromUi( @@ -1722,34 +1385,6 @@ export class StalkerSeriesViewComponent implements OnDestroy { ); } - /** - * Sequential on purpose: loadEpisodesForSeason snapshots the season VM - * array before its writes, so concurrent calls clobber each other's - * loading flags; and one request at a time keeps the portal load bounded. - */ - private async hydrateSeasonsForSeriesToggle( - pendingSeasons: readonly VodSeriesSeasonVm[], - stillCurrent: () => boolean - ): Promise<'complete' | 'failed' | 'superseded'> { - for (const season of pendingSeasons) { - // A tab click may have loaded this season meanwhile. - const current = this.vodSeriesSeasons().find( - (candidate) => candidate.id === season.id - ); - if (!current || !this.isSeasonHydrationPending(current)) { - continue; - } - const answered = await this.loadEpisodesForSeason(current); - if (!stillCurrent()) { - return 'superseded'; - } - if (!answered) { - return 'failed'; - } - } - return 'complete'; - } - /** * Mirrors the exclusions the template binds into the season container * (playingEpisodeId / activeEpisodeId / openingEpisodeId): the episode @@ -1765,427 +1400,6 @@ export class StalkerSeriesViewComponent implements OnDestroy { return new Set(ids); } - private async runWatchToggleBatch( - request: SeasonContainerSeriesPlaybackToggleRequest, - playlistId: string, - stillCurrent: () => boolean, - feedback: StalkerWatchToggleFeedback - ): Promise { - // Enqueue every episode synchronously: each mutation chains on - // the previous one's never-rejecting barrier, so the queue - // serializes the writes (incl. per-episode legacy-row cleanup) - // and reloads positions once after the whole chain drains. - const outcomes = await Promise.all( - request.requests.map((item) => - (item.nextPosition - ? this.persistSeriesPosition(playlistId, item.nextPosition) - : this.clearSeriesPosition(playlistId, item.contentXtreamId) - ).then( - () => true, - // The scoped watched row was saved and published — - // only the legacy-row cleanup failed. The episode IS - // watched, so it must not count against the batch. - (error: unknown) => - error instanceof StalkerSeriesPositionPartialSaveError - ) - ) - ); - - const failed = outcomes.filter((ok) => !ok).length; - const succeeded = outcomes.length - failed; - if (failed > 0) { - this.logger.error( - `Watched toggle: ${failed} of ${outcomes.length} episodes failed` - ); - } - if (succeeded > 0) { - // Partial successes changed rows too — the catalog badge - // must follow even when the user already moved on. A failed - // refresh must not break the feedback flow below. - await this.catalogFacade - ?.refreshPositions(playlistId) - .catch((error: unknown) => - this.logger.warn('Catalog position refresh failed', error) - ); - } - if (!stillCurrent()) { - return; - } - - if (failed === 0) { - this.notifySeasonWatchToggle( - request.markWatched ? feedback.marked : feedback.unmarked, - { count: succeeded } - ); - } else if (succeeded > 0) { - this.notifySeasonWatchToggle( - request.markWatched - ? feedback.partialMarked - : feedback.partialUnmarked, - { count: succeeded, failed } - ); - } else { - this.notifySeasonWatchToggle(feedback.failed); - } - } - - /** - * Maps the raw series position rows onto the currently mapped episodes - * (scoped rows plus compatible legacy promotions). Runs reactively from - * the constructor effect, and synchronously from the series-level watch - * toggle right after it hydrates lazy seasons — the effect only re-runs - * on the next change-detection tick, and enqueuing the batch against the - * stale maps would miss the hydrated episodes' legacy rows (an unwatch - * would leave rows behind that a later reconcile resurrects as watched). - */ - private applyReconciledSeriesPositions(): void { - const item = this.displayItem(); - const playlistId = this.stalkerStore.currentPlaylist()?._id; - const seriesXtreamId = this.toSeriesId(item?.id ?? 0); - const rawSeriesPositions = this.rawSeriesPositions(); - const episodesBySeason = this.mappedSeasons(); - - if (!item || !playlistId || seriesXtreamId <= 0) { - if (rawSeriesPositions.length > 0) { - this.rawSeriesPositions.set([]); - } - if (this.episodePlaybackPositions().size > 0) { - this.episodePlaybackPositions.set(new Map()); - } - if (this.legacyPositionByTrackingId().size > 0) { - this.legacyPositionByTrackingId.set(new Map()); - } - return; - } - - const reconciled = reconcileStalkerSeriesPositions({ - seriesXtreamId, - episodesBySeason, - seriesPositions: rawSeriesPositions, - }); - if ( - rawSeriesPositions.length === 0 && - reconciled.positionsByTrackingId.size === 0 && - untracked(() => this.episodePlaybackPositions().size) > 0 - ) { - return; - } - this.episodePlaybackPositions.set(reconciled.positionsByTrackingId); - this.legacyPositionByTrackingId.set( - reconciled.legacyPositionByTrackingId - ); - } - - private notifySeasonWatchToggle(key: string, params?: object): void { - this.snackBar.open( - this.translateService.instant(key, params), - undefined, - { duration: 5000 } - ); - } - - private async loadSeriesPositions( - context: SeriesPositionContext - ): Promise { - const generation = ++this.seriesPositionsLoadGeneration; - this.trackPendingSeriesPositionLoad(context, generation); - try { - await this.waitForSeriesPositionMutations(context.mutationKey); - - if ( - generation !== this.seriesPositionsLoadGeneration || - !this.isSeriesPositionContextActive(context) - ) { - return; - } - - const positions = - await this.playbackPositions.getSeriesPlaybackPositions( - context.playlistId, - context.seriesXtreamId - ); - - if ( - generation !== this.seriesPositionsLoadGeneration || - !this.isSeriesPositionContextActive(context) - ) { - return; - } - - 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 - ): SeriesPositionContext { - const context: SeriesPositionContext = { - generation: ++this.seriesPositionContextGeneration, - playlistId, - seriesXtreamId, - mutationKey: JSON.stringify([playlistId, seriesXtreamId]), - }; - this.activeSeriesPositionContext = context; - return context; - } - - private isSeriesPositionContextActive( - context: SeriesPositionContext - ): boolean { - const activeContext = this.activeSeriesPositionContext; - return ( - activeContext === context && - activeContext.generation === context.generation && - this.stalkerStore.currentPlaylist()?._id === context.playlistId && - this.toSeriesId(this.displayItem()?.id ?? 0) === - context.seriesXtreamId - ); - } - - private waitForSeriesPositionMutations(mutationKey: string): Promise { - return ( - this.seriesPositionMutationQueues.get(mutationKey) ?? - Promise.resolve() - ); - } - - private trackPendingSeriesPositionLoad( - context: SeriesPositionContext, - generation: number - ): void { - const generations = - this.pendingSeriesPositionLoads.get(context) ?? new Set(); - generations.add(generation); - this.pendingSeriesPositionLoads.set(context, generations); - } - - private untrackPendingSeriesPositionLoad( - context: SeriesPositionContext, - generation: number - ): void { - const generations = this.pendingSeriesPositionLoads.get(context); - generations?.delete(generation); - if (generations?.size === 0) { - this.pendingSeriesPositionLoads.delete(context); - } - } - - private hasCurrentPendingSeriesPositionLoad( - context: SeriesPositionContext - ): boolean { - return Boolean( - this.pendingSeriesPositionLoads - .get(context) - ?.has(this.seriesPositionsLoadGeneration) - ); - } - - private enqueueSeriesPositionMutation( - context: SeriesPositionContext, - operation: () => Promise - ): Promise { - if (this.hasCurrentPendingSeriesPositionLoad(context)) { - this.seriesPositionReloadKeys.add(context.mutationKey); - } - this.seriesPositionsLoadGeneration++; - const previous = this.waitForSeriesPositionMutations( - context.mutationKey - ); - const result = previous.then(operation); - const barrier = result.then( - () => undefined, - () => undefined - ); - this.seriesPositionMutationQueues.set(context.mutationKey, barrier); - void barrier.then(() => { - if ( - this.seriesPositionMutationQueues.get(context.mutationKey) === - barrier - ) { - this.seriesPositionMutationQueues.delete(context.mutationKey); - this.reloadSeriesPositionsAfterMutations(context.mutationKey); - } - }); - return result; - } - - private reloadSeriesPositionsAfterMutations(mutationKey: string): void { - if (!this.seriesPositionReloadKeys.delete(mutationKey)) { - return; - } - const context = this.activeSeriesPositionContext; - if ( - !context || - context.mutationKey !== mutationKey || - !this.isSeriesPositionContextActive(context) || - this.hasCurrentPendingSeriesPositionLoad(context) - ) { - return; - } - void this.loadSeriesPositions(context); - } - - private getSeriesPositionMutationContext( - playlistId: string, - seriesXtreamId?: number | null - ): SeriesPositionContext | null { - const context = this.activeSeriesPositionContext; - if ( - !context || - context.playlistId !== playlistId || - (seriesXtreamId != null && - context.seriesXtreamId !== seriesXtreamId) - ) { - return null; - } - return context; - } - - private persistSeriesPosition( - playlistId: string, - position: PlaybackPositionData - ): Promise { - const context = this.getSeriesPositionMutationContext( - playlistId, - position.seriesXtreamId - ); - if (!context) { - return Promise.resolve(); - } - const legacyPosition = this.legacyPositionByTrackingId().get( - position.contentXtreamId - ); - return this.enqueueSeriesPositionMutation(context, async () => { - let clearedLegacy: boolean; - try { - clearedLegacy = await saveStalkerSeriesPosition({ - repository: this.migrationPlaybackPositions, - playlistId, - position, - legacyPosition, - }); - } catch (error) { - if ( - error instanceof StalkerSeriesPositionPartialSaveError && - this.isSeriesPositionContextActive(context) - ) { - this.publishSavedSeriesPosition( - position, - legacyPosition, - false - ); - } - throw error; - } - if (!this.isSeriesPositionContextActive(context)) { - return; - } - this.publishSavedSeriesPosition( - position, - legacyPosition, - clearedLegacy - ); - }); - } - - private publishSavedSeriesPosition( - position: PlaybackPositionData, - legacyPosition: PlaybackPositionData | undefined, - clearedLegacy: boolean - ): void { - const removedTrackingIds = new Set([position.contentXtreamId]); - if (clearedLegacy && legacyPosition) { - removedTrackingIds.add(legacyPosition.contentXtreamId); - const legacyPositions = new Map(this.legacyPositionByTrackingId()); - legacyPositions.delete(position.contentXtreamId); - this.legacyPositionByTrackingId.set(legacyPositions); - } - - this.rawSeriesPositions.set([ - ...this.rawSeriesPositions().filter( - (candidate) => - !removedTrackingIds.has(candidate.contentXtreamId) - ), - position, - ]); - this.updateEpisodePlaybackPosition(position); - } - - private clearSeriesPosition( - playlistId: string, - contentXtreamId: number - ): Promise { - const context = this.getSeriesPositionMutationContext(playlistId); - if (!context) { - return Promise.resolve(); - } - const position = this.episodePlaybackPositions().get( - contentXtreamId - ) ?? { - contentXtreamId, - contentType: 'episode', - positionSeconds: 0, - playlistId, - seriesXtreamId: context.seriesXtreamId, - }; - const legacyPosition = - this.legacyPositionByTrackingId().get(contentXtreamId); - return this.enqueueSeriesPositionMutation(context, async () => { - const clearedLegacy = await clearStalkerSeriesPosition({ - repository: this.migrationPlaybackPositions, - playlistId, - position, - legacyPosition, - }); - if (!this.isSeriesPositionContextActive(context)) { - return; - } - this.publishClearedSeriesPosition( - contentXtreamId, - legacyPosition, - clearedLegacy - ); - }); - } - - private publishClearedSeriesPosition( - contentXtreamId: number, - legacyPosition: PlaybackPositionData | undefined, - clearedLegacy: boolean - ): void { - const removedTrackingIds = new Set([contentXtreamId]); - if (clearedLegacy && legacyPosition) { - removedTrackingIds.add(legacyPosition.contentXtreamId); - const legacyPositions = new Map(this.legacyPositionByTrackingId()); - legacyPositions.delete(contentXtreamId); - this.legacyPositionByTrackingId.set(legacyPositions); - } - - this.rawSeriesPositions.set( - this.rawSeriesPositions().filter( - (candidate) => - !removedTrackingIds.has(candidate.contentXtreamId) - ) - ); - this.removeEpisodePlaybackPosition(contentXtreamId); - } - private async loadAndPlayVodSeriesSeason( season: VodSeriesSeasonVm, visitedSeasonIds = new Set() @@ -2216,18 +1430,4 @@ export class StalkerSeriesViewComponent implements OnDestroy { ); } } - - private updateEpisodePlaybackPosition( - position: PlaybackPositionData - ): void { - const updated = new Map(this.episodePlaybackPositions()); - updated.set(position.contentXtreamId, position); - this.episodePlaybackPositions.set(updated); - } - - private removeEpisodePlaybackPosition(contentXtreamId: number): void { - const updated = new Map(this.episodePlaybackPositions()); - updated.delete(contentXtreamId); - this.episodePlaybackPositions.set(updated); - } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.spillover-prefetch.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.spillover-prefetch.spec.ts new file mode 100644 index 000000000..d7a506ff8 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.spillover-prefetch.spec.ts @@ -0,0 +1,281 @@ +import { ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { StalkerSeriesViewComponent } from './stalker-series-view.component'; +import { createStalkerSeriesViewHarness } from './stalker-series-view.test-harness'; +import { StubPortalInlinePlayerComponent } from './stalker-series-view.test-helpers'; + +/** + * The inline player's "Up Next" rail across lazy Ministra VOD-series seasons: + * the season after the playing one is fetched ahead so the rail can spill + * over into it. + */ +describe('StalkerSeriesViewComponent Up Next spillover prefetch', () => { + let fixture: ComponentFixture; + const { + selectedContentType, + selectedItem, + serialSeasonsResource, + vodSeriesSeasonsResource, + fetchVodSeriesEpisodes, + isEmbeddedPlayer, + createFixture, + } = createStalkerSeriesViewHarness(() => jest.fn()); + + async function stabilize(): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + } + + beforeEach(async () => { + fixture = await createFixture(); + }); + + afterEach(() => { + fixture?.destroy(); + }); + + it('prefetches the next unopened season so the Up Next rail can spill over', async () => { + selectedContentType.set('vod'); + selectedItem.set({ + id: '50001', + is_series: true, + info: { + name: 'VOD Flagged Series', + description: 'Lazy seasons', + movie_image: 'vod-series.jpg', + }, + }); + serialSeasonsResource.set([]); + vodSeriesSeasonsResource.set([ + { + id: 'season-1', + video_id: '50001', + season_number: '1', + name: 'Season 1', + }, + { + id: 'season-2', + video_id: '50001', + season_number: '2', + name: 'Season 2', + }, + ]); + isEmbeddedPlayer.mockReturnValue(true); + + await stabilize(); + + // Season 1 loaded, season 2 still empty — the state a user is in + // right after opening the series and starting the first episode. + fixture.componentInstance.vodSeriesSeasons.set([ + { + id: 'season-1', + video_id: '50001', + season_number: '1', + name: 'Season 1', + episodes: [ + { id: 'episode-1', series_number: 1, name: 'Pilot' }, + ], + isLoading: false, + isExpanded: false, + }, + { + id: 'season-2', + video_id: '50001', + season_number: '2', + name: 'Season 2', + episodes: [], + isLoading: false, + isExpanded: false, + }, + ]); + fetchVodSeriesEpisodes.mockClear(); + fetchVodSeriesEpisodes.mockResolvedValue([ + { id: 'episode-3', series_number: 1, name: 'Next Season' }, + ]); + + const firstEpisode = fixture.componentInstance.mappedSeasons()['1'][0]; + fixture.componentInstance.onEpisodeClicked(firstEpisode); + await fixture.whenStable(); + await stabilize(); + fixture.detectChanges(); + + expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith( + '50001', + 'season-2' + ); + + const inlinePlayer = fixture.debugElement.query( + By.directive(StubPortalInlinePlayerComponent) + ).componentInstance as StubPortalInlinePlayerComponent; + const railItems = inlinePlayer.upNextEpisodes() as Array<{ + label: string; + }>; + expect(railItems.map((item) => item.label)).toEqual([ + 'S01E01', + 'S02E01', + ]); + }); + + it('does not re-request a spillover season that came back empty', async () => { + selectedContentType.set('vod'); + selectedItem.set({ + id: '50001', + is_series: true, + info: { + name: 'VOD Flagged Series', + description: 'Lazy seasons', + movie_image: 'vod-series.jpg', + }, + }); + serialSeasonsResource.set([]); + vodSeriesSeasonsResource.set([ + { + id: 'season-1', + video_id: '50001', + season_number: '1', + name: 'Season 1', + }, + { + id: 'season-2', + video_id: '50001', + season_number: '2', + name: 'Season 2', + }, + ]); + isEmbeddedPlayer.mockReturnValue(true); + + await stabilize(); + + fixture.componentInstance.vodSeriesSeasons.set([ + { + id: 'season-1', + video_id: '50001', + season_number: '1', + name: 'Season 1', + episodes: [ + { id: 'episode-1', series_number: 1, name: 'Pilot' }, + { id: 'episode-2', series_number: 2, name: 'Second' }, + ], + isLoading: false, + isExpanded: false, + }, + { + id: 'season-2', + video_id: '50001', + season_number: '2', + name: 'Season 2', + episodes: [], + isLoading: false, + isExpanded: false, + }, + ]); + fetchVodSeriesEpisodes.mockClear(); + // Failed or genuinely empty season: isLoading returns to false while + // episodes stays empty — the retry trap. + fetchVodSeriesEpisodes.mockResolvedValue([]); + + const seasonOne = fixture.componentInstance.mappedSeasons()['1']; + fixture.componentInstance.onEpisodeClicked(seasonOne[0]); + await fixture.whenStable(); + await stabilize(); + fixture.detectChanges(); + + expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1); + + // Further playback activity in the same season must not retrigger it. + fixture.componentInstance.onEpisodeClicked(seasonOne[1]); + await fixture.whenStable(); + await stabilize(); + fixture.detectChanges(); + + expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1); + }); + + it('retries a failed spillover prefetch on the next episode', async () => { + selectedContentType.set('vod'); + selectedItem.set({ + id: '50001', + is_series: true, + info: { + name: 'VOD Flagged Series', + description: 'Lazy seasons', + movie_image: 'vod-series.jpg', + }, + }); + serialSeasonsResource.set([]); + vodSeriesSeasonsResource.set([ + { + id: 'season-1', + video_id: '50001', + season_number: '1', + name: 'Season 1', + }, + { + id: 'season-2', + video_id: '50001', + season_number: '2', + name: 'Season 2', + }, + ]); + isEmbeddedPlayer.mockReturnValue(true); + + await stabilize(); + + fixture.componentInstance.vodSeriesSeasons.set([ + { + id: 'season-1', + video_id: '50001', + season_number: '1', + name: 'Season 1', + episodes: [ + { id: 'episode-1', series_number: 1, name: 'Pilot' }, + { id: 'episode-2', series_number: 2, name: 'Second' }, + ], + isLoading: false, + isExpanded: false, + }, + { + id: 'season-2', + video_id: '50001', + season_number: '2', + name: 'Season 2', + episodes: [], + isLoading: false, + isExpanded: false, + }, + ]); + fetchVodSeriesEpisodes.mockClear(); + fetchVodSeriesEpisodes.mockRejectedValueOnce(new Error('network')); + + const seasonOne = fixture.componentInstance.mappedSeasons()['1']; + fixture.componentInstance.onEpisodeClicked(seasonOne[0]); + await fixture.whenStable(); + await stabilize(); + fixture.detectChanges(); + + // The failure must not loop while the same episode keeps playing. + expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1); + + // Moving to the next episode gives the transient failure a new chance. + fetchVodSeriesEpisodes.mockResolvedValue([ + { id: 'episode-3', series_number: 1, name: 'Next Season' }, + ]); + fixture.componentInstance.onEpisodeClicked(seasonOne[1]); + await fixture.whenStable(); + await stabilize(); + fixture.detectChanges(); + + expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(2); + + const inlinePlayer = fixture.debugElement.query( + By.directive(StubPortalInlinePlayerComponent) + ).componentInstance as StubPortalInlinePlayerComponent; + const railItems = inlinePlayer.upNextEpisodes() as Array<{ + label: string; + }>; + expect(railItems.map((item) => item.label)).toEqual([ + 'S01E02', + 'S02E01', + ]); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.test-harness.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.test-harness.ts new file mode 100644 index 000000000..d9b043755 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.test-harness.ts @@ -0,0 +1,273 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { MockPipe } from 'ng-mocks'; +import { SeasonContainerComponent } from '@iptvnator/ui/components'; +import { + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, +} from '@iptvnator/portal/shared/util'; +import { + StalkerStore, + StalkerVodSource, +} from '@iptvnator/portal/stalker/data-access'; +import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback'; +import { TmdbEnrichmentService } from '@iptvnator/services'; +import { EMPTY, of } from 'rxjs'; +import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component'; +import { StalkerSeriesViewComponent } from './stalker-series-view.component'; +import { + StubFavoritesButtonComponent, + StubPortalInlinePlayerComponent, + StubSeasonContainerComponent, +} from './stalker-series-view.test-helpers'; + +/** + * The slice of a Jest mock the harness drives. This file is part of the + * library's own type-check, which has no Jest globals, so a spec hands in + * the factory (`() => jest.fn()`) and gets its own mock type back. + */ +export interface StalkerSeriesViewTestMock { + mockReset(): this; + mockClear(): this; + mockReturnValue(value: unknown): this; + mockResolvedValue(value: unknown): this; + mockImplementation(implementation: (...args: never[]) => unknown): this; +} + +/** + * The TestBed the Stalker series-view component specs share: the store + * signals and collaborator mocks a case drives, and `createFixture()`, which + * puts them back to the state every case starts from (a regular series with + * one two-episode season, no embedded player) before building the component + * with its stubbed children. + * + * Call it once per `describe`; the mocks live as long as that block. + */ +export function createStalkerSeriesViewHarness< + TMock extends StalkerSeriesViewTestMock, +>(createMock: () => TMock) { + const selectedContentType = signal<'series' | 'vod'>('series'); + const selectedItem = signal(null); + const serialSeasonsResource = signal([]); + const vodSeriesSeasonsResource = signal([]); + const isSerialSeasonsLoading = signal(false); + const fetchVodSeriesEpisodes = createMock(); + const resolveVodPlayback = createMock(); + const getSeriesPlaybackPositions = createMock().mockResolvedValue([]); + const openResolvedPlayback = createMock(); + const openExternalPlayback = createMock(); + const isEmbeddedPlayer = createMock(); + const tmdbGetSeason = createMock(); + const fetchLinkToPlay = createMock(); + const currentPlaylist = signal({ + _id: 'stalker-1', + title: 'Living Room Portal', + portalUrl: 'https://stalker.example.test', + macAddress: '00:1A:79:12:34:56', + }); + + async function createFixture(): Promise< + ComponentFixture + > { + selectedContentType.set('series'); + selectedItem.set({ + id: '30001', + cmd: '/media/file_30001.mpg', + info: { + name: 'Regular Series', + description: 'Series description', + movie_image: 'poster.jpg', + }, + }); + serialSeasonsResource.set([ + { + id: 'season-1', + name: 'Season 1', + cmd: '/media/file_30001.mpg', + series: [1, 2], + }, + ]); + vodSeriesSeasonsResource.set([]); + isSerialSeasonsLoading.set(false); + fetchVodSeriesEpisodes.mockReset(); + resolveVodPlayback.mockReset(); + resolveVodPlayback.mockImplementation( + async ( + _cmd?: string, + title?: string, + thumbnail?: string, + _episodeNum?: number, + episodeId?: number, + startTime?: number + ) => ({ + streamUrl: 'http://stalker.example/episode.mpg', + title: title ?? 'Regular Series', + thumbnail: thumbnail ?? 'poster.jpg', + startTime, + contentInfo: { + playlistId: 'stalker-1', + contentXtreamId: + episodeId ?? Number(selectedItem()?.id ?? 0), + contentType: episodeId ? 'episode' : 'vod', + seriesXtreamId: episodeId + ? Number(selectedItem()?.id ?? 0) + : undefined, + }, + }) + ); + getSeriesPlaybackPositions.mockClear(); + getSeriesPlaybackPositions.mockResolvedValue([]); + openResolvedPlayback.mockClear(); + openExternalPlayback.mockReset(); + isEmbeddedPlayer.mockReset(); + isEmbeddedPlayer.mockReturnValue(false); + tmdbGetSeason.mockReset(); + tmdbGetSeason.mockResolvedValue({ + overview: 'Season overview from TMDB', + episodes: [], + }); + fetchLinkToPlay + .mockReset() + .mockResolvedValue('https://cdn.example.test/episode.mpg'); + currentPlaylist.set({ + _id: 'stalker-1', + title: 'Living Room Portal', + portalUrl: 'https://stalker.example.test', + macAddress: '00:1A:79:12:34:56', + }); + + await TestBed.configureTestingModule({ + imports: [StalkerSeriesViewComponent], + providers: [ + { + provide: StalkerStore, + useValue: { + selectedItem, + selectedContentType, + currentPlaylist, + getSerialSeasonsResource: () => serialSeasonsResource(), + getVodSeriesSeasonsResource: () => + vodSeriesSeasonsResource(), + isVodSeriesSeasonsLoading: signal(false), + isSerialSeasonsLoading, + fetchVodSeriesEpisodes, + resolveVodPlayback, + fetchLinkToPlay, + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { + activeSession: signal(null), + }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getSeriesPlaybackPositions, + savePlaybackPosition: createMock(), + clearPlaybackPosition: createMock(), + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer, + openResolvedPlayback, + openExternalPlayback, + }, + }, + { + provide: Router, + useValue: { + navigateByUrl: createMock(), + }, + }, + { + provide: TmdbEnrichmentService, + useValue: { + isEnabled: () => true, + getSeason: tmdbGetSeason, + }, + }, + { + provide: MatSnackBar, + useValue: { + open: createMock(), + }, + }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + currentLang: 'en', + defaultLang: 'en', + // EMPTY (not of(null)): the real TranslatePipe inside + // the detail shell reads `event.lang` from emissions. + onLangChange: EMPTY, + onTranslationChange: EMPTY, + onDefaultLangChange: EMPTY, + }, + }, + ], + }) + .overrideComponent(StalkerSeriesViewComponent, { + remove: { + imports: [ + FavoritesButtonComponent, + PortalInlinePlayerComponent, + SeasonContainerComponent, + TranslatePipe, + ], + }, + add: { + imports: [ + StubFavoritesButtonComponent, + StubPortalInlinePlayerComponent, + StubSeasonContainerComponent, + MockPipe( + TranslatePipe, + ( + value: string | null | undefined, + params?: Record + ) => { + if (value === 'XTREAM.PLAY_EPISODE') { + return `Play episode ${ + params?.['episode'] ?? '{{episode}}' + }`; + } + return value ?? ''; + } + ), + ], + }, + }) + .compileComponents(); + + return TestBed.createComponent(StalkerSeriesViewComponent); + } + + return { + selectedContentType, + selectedItem, + serialSeasonsResource, + vodSeriesSeasonsResource, + isSerialSeasonsLoading, + fetchVodSeriesEpisodes, + resolveVodPlayback, + getSeriesPlaybackPositions, + openResolvedPlayback, + openExternalPlayback, + isEmbeddedPlayer, + tmdbGetSeason, + fetchLinkToPlay, + currentPlaylist, + createFixture, + }; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-season.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-season.spec.ts new file mode 100644 index 000000000..3440d321c --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-season.spec.ts @@ -0,0 +1,192 @@ +import { ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { StalkerSeriesViewComponent } from './stalker-series-view.component'; +import { createStalkerSeriesViewHarness } from './stalker-series-view.test-harness'; +import { StubSeasonContainerComponent } from './stalker-series-view.test-helpers'; + +/** + * The lazy TMDB season fetch of the Stalker series view: when it runs + * relative to the show-level TMDB match and the season resource, and which + * TMDB season it asks for. + */ +describe('StalkerSeriesViewComponent TMDB season fetch', () => { + let fixture: ComponentFixture; + const { + selectedItem, + serialSeasonsResource, + isSerialSeasonsLoading, + tmdbGetSeason, + createFixture, + } = createStalkerSeriesViewHarness(() => jest.fn()); + + async function stabilize(): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + } + + beforeEach(async () => { + fixture = await createFixture(); + }); + + afterEach(() => { + fixture?.destroy(); + }); + + it('fetches the TMDB season once the show-level match arrives after auto-select', async () => { + await stabilize(); + fixture.detectChanges(); + + // Season tabs auto-select immediately — usually before the async + // show-level enrichment has written tmdb_id. + const seasonContainer = fixture.debugElement.query( + By.directive(StubSeasonContainerComponent) + ).componentInstance as StubSeasonContainerComponent; + seasonContainer.seasonSelected.emit('1'); + await stabilize(); + expect(tmdbGetSeason).not.toHaveBeenCalled(); + + // The TMDB match lands afterwards — the fetch must run now. + selectedItem.set({ + id: '30001', + cmd: '/media/file_30001.mpg', + info: { + name: 'Regular Series', + description: 'Series description', + movie_image: 'poster.jpg', + tmdb_id: 777, + }, + } as never); + await stabilize(); + + expect(tmdbGetSeason).toHaveBeenCalledWith(777, 1); + }); + + it('waits for the season map before fetching so a per-season slice gets the title-marked season', async () => { + serialSeasonsResource.set([]); + selectedItem.set({ + id: '30001', + cmd: '/media/file_30001.mpg', + info: { + name: 'Regular Series (2 season)', + description: 'Series description', + movie_image: 'poster.jpg', + tmdb_id: 777, + }, + } as never); + await stabilize(); + + fixture.componentInstance.onSeasonSelected('2'); + await stabilize(); + + // Season resource still loading — fetching now would pass a zero + // season count, suppress the title-marker override and cache the + // wrong season forever (fetchSeason is idempotent). + expect(tmdbGetSeason).not.toHaveBeenCalled(); + + serialSeasonsResource.set([ + { + id: 'season-1', + name: 'Season 1', + cmd: '/media/file_30001.mpg', + series: [1, 2], + }, + ]); + await stabilize(); + + // Single-season slice whose provider season is renumbered to 1: + // the title marker names the real TMDB season. + expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2); + }); + + it('gates the fetch on the reloading season resource during detail-to-detail navigation', async () => { + await stabilize(); + + fixture.componentInstance.onSeasonSelected('1'); + await stabilize(); + // No show-level TMDB match yet — nothing fetched for the first item + expect(tmdbGetSeason).not.toHaveBeenCalled(); + + // Detail-to-detail navigation reuses the component; the new item's + // TMDB match can arrive while the season resource reloads and the + // map still shows the previous series' seasons. + isSerialSeasonsLoading.set(true); + selectedItem.set({ + id: '30002', + cmd: '/media/file_30002.mpg', + info: { + name: 'Other Series (2 season)', + description: 'Other description', + movie_image: 'poster2.jpg', + tmdb_id: 888, + }, + } as never); + await stabilize(); + + // The new tmdb_id must NOT pair with the previous series' season + // context while the resource reloads. + expect(tmdbGetSeason).not.toHaveBeenCalled(); + + // Once the new item's own seasons land, the fetch runs without a + // stale-season fetch. The container selects the corrected key once + // the new season map is rendered. + serialSeasonsResource.set([ + { + id: 'season-1', + name: 'Season 1', + cmd: '/media/file_30002.mpg', + series: [1, 2], + }, + ]); + isSerialSeasonsLoading.set(false); + fixture.componentInstance.onSeasonSelected('2'); + await stabilize(); + expect(tmdbGetSeason).toHaveBeenCalledWith(888, 2); + }); + + it('enriches after equal-id navigation once the season resource settles', async () => { + await stabilize(); + fixture.componentInstance.onSeasonSelected('1'); + await stabilize(); + + // Distinct items can reuse a provider id; the loading gate (not an + // id comparison) keeps the stale map from being used. + isSerialSeasonsLoading.set(true); + selectedItem.set({ + id: '30001', + cmd: '/media/file_30001.mpg', + info: { + name: 'Different Series (3 season)', + description: 'Different description', + movie_image: 'poster3.jpg', + tmdb_id: 999, + }, + } as never); + await stabilize(); + expect(tmdbGetSeason).not.toHaveBeenCalled(); + + isSerialSeasonsLoading.set(false); + fixture.componentInstance.onSeasonSelected('3'); + await stabilize(); + expect(tmdbGetSeason).toHaveBeenCalledWith(999, 3); + }); + + it('reads the season marker from o_name when name is generic', async () => { + selectedItem.set({ + id: '30001', + cmd: '/media/file_30001.mpg', + info: { + name: 'Regular Series', + o_name: 'Regular Series (2 season)', + description: 'Series description', + movie_image: 'poster.jpg', + tmdb_id: 777, + }, + } as never); + await stabilize(); + + fixture.componentInstance.onSeasonSelected('2'); + await stabilize(); + + expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-watch-batch.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-watch-batch.ts new file mode 100644 index 000000000..73c4d510c --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-watch-batch.ts @@ -0,0 +1,113 @@ +import type { Logger } from '@iptvnator/portal/shared/util'; +import type { PlaybackPositionData } from '@iptvnator/shared/interfaces'; +import type { SeasonContainerSeriesPlaybackToggleRequest } from '@iptvnator/ui/components'; +import { StalkerSeriesPositionPartialSaveError } from './stalker-series-position-compatibility'; + +export interface StalkerWatchToggleFeedback { + readonly marked: string; + readonly unmarked: string; + readonly partialMarked: string; + readonly partialUnmarked: string; + readonly failed: string; +} + +// The marked and partial keys are scope-generic on purpose ("{{count}} +// episodes marked as watched", "{{count}} marked · {{failed}} failed"); +// only unmark-success and failure name their scope. +export const SEASON_WATCH_FEEDBACK: StalkerWatchToggleFeedback = { + marked: 'XTREAM.SEASON_MARKED_WATCHED', + unmarked: 'XTREAM.SEASON_MARKED_UNWATCHED', + partialMarked: 'XTREAM.SEASON_MARKED_WATCHED_PARTIAL', + partialUnmarked: 'XTREAM.SEASON_MARKED_UNWATCHED_PARTIAL', + failed: 'XTREAM.SEASON_WATCH_UPDATE_FAILED', +}; + +export const SERIES_WATCH_FEEDBACK: StalkerWatchToggleFeedback = { + marked: 'XTREAM.SEASON_MARKED_WATCHED', + unmarked: 'XTREAM.SERIES_MARKED_UNWATCHED', + partialMarked: 'XTREAM.SEASON_MARKED_WATCHED_PARTIAL', + partialUnmarked: 'XTREAM.SEASON_MARKED_UNWATCHED_PARTIAL', + failed: 'XTREAM.SERIES_WATCH_UPDATE_FAILED', +}; + +/** One watched/unwatched batch and what it needs from the page running it. */ +export interface StalkerWatchToggleBatch { + readonly request: SeasonContainerSeriesPlaybackToggleRequest; + readonly playlistId: string; + /** Whether the page still shows the series the batch was started on. */ + readonly stillCurrent: () => boolean; + readonly feedback: StalkerWatchToggleFeedback; + readonly persist: ( + playlistId: string, + position: PlaybackPositionData + ) => Promise; + readonly clear: ( + playlistId: string, + contentXtreamId: number + ) => Promise; + /** Re-reads the catalog grid's progress badges; must not reject. */ + readonly refreshCatalogPositions: ( + playlistId: string + ) => Promise | undefined; + /** Shows the translated feedback for `key`. */ + readonly notify: (key: string, params?: object) => void; + readonly logger: Logger; +} + +export async function runStalkerWatchToggleBatch( + batch: StalkerWatchToggleBatch +): Promise { + const { request, playlistId, feedback } = batch; + // Enqueue every episode synchronously: each mutation chains on + // the previous one's never-rejecting barrier, so the queue + // serializes the writes (incl. per-episode legacy-row cleanup) + // and reloads positions once after the whole chain drains. + const outcomes = await Promise.all( + request.requests.map((item) => + (item.nextPosition + ? batch.persist(playlistId, item.nextPosition) + : batch.clear(playlistId, item.contentXtreamId) + ).then( + () => true, + // The scoped watched row was saved and published — + // only the legacy-row cleanup failed. The episode IS + // watched, so it must not count against the batch. + (error: unknown) => + error instanceof StalkerSeriesPositionPartialSaveError + ) + ) + ); + + const failed = outcomes.filter((ok) => !ok).length; + const succeeded = outcomes.length - failed; + if (failed > 0) { + batch.logger.error( + `Watched toggle: ${failed} of ${outcomes.length} episodes failed` + ); + } + if (succeeded > 0) { + // Partial successes changed rows too — the catalog badge + // must follow even when the user already moved on. A failed + // refresh must not break the feedback flow below. + await batch.refreshCatalogPositions(playlistId); + } + if (!batch.stillCurrent()) { + return; + } + + if (failed === 0) { + batch.notify( + request.markWatched ? feedback.marked : feedback.unmarked, + { count: succeeded } + ); + } else if (succeeded > 0) { + batch.notify( + request.markWatched + ? feedback.partialMarked + : feedback.partialUnmarked, + { count: succeeded, failed } + ); + } else { + batch.notify(feedback.failed); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-watch-toggle.service.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-watch-toggle.service.ts new file mode 100644 index 000000000..98fdb20a8 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-watch-toggle.service.ts @@ -0,0 +1,323 @@ +import { Injectable, Signal, inject, signal } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { + createLogger, + isPortalPlaybackWatched, +} from '@iptvnator/portal/shared/util'; +import { + isVodSeasonHydrationPending, + StalkerStore, + type StalkerSelectedVodItem, + type VodSeriesSeasonVm, +} from '@iptvnator/portal/stalker/data-access'; +import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; +import { + buildSeriesWatchToggleRequest, + type SeasonContainerPlaybackToggleRequest, + type SeasonContainerSeasonPlaybackToggleRequest, + type SeasonContainerSeriesPlaybackToggleRequest, +} from '@iptvnator/ui/components'; +import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service'; +import { toStalkerSeriesId } from './stalker-series-id'; +import { StalkerSeriesPositionsService } from './stalker-series-positions.service'; +import { + runStalkerWatchToggleBatch, + SEASON_WATCH_FEEDBACK, + SERIES_WATCH_FEEDBACK, + type StalkerWatchToggleFeedback, +} from './stalker-series-watch-batch'; + +interface StalkerSeriesWatchToggleBindings { + readonly displayItem: Signal; + /** `playlist:series` of the series on screen. */ + readonly currentSeriesKey: Signal; + readonly isVodSeries: Signal; + readonly vodSeriesSeasons: Signal; + readonly mappedSeasons: Signal>; + /** Episodes playing or launching, which a series toggle never marks. */ + readonly excludedEpisodeIds: () => ReadonlySet; + /** Resolves true when the portal answered, false when the request failed. */ + readonly loadEpisodesForSeason: ( + season: VodSeriesSeasonVm + ) => Promise; +} + +/** + * Watched toggles of the Stalker series view: one episode, a season or the + * whole series (which also backs "Reset progress"). Component-scoped + * (provide it in the component's `providers`). + */ +@Injectable() +export class StalkerSeriesWatchToggleService { + private readonly stalkerStore = inject(StalkerStore); + private readonly positions = inject(StalkerSeriesPositionsService); + private readonly snackBar = inject(MatSnackBar); + private readonly translateService = inject(TranslateService); + // Optional: absent in collection-detail mounts outside the catalog. + private readonly catalogFacade = inject(StalkerCatalogFacadeService, { + optional: true, + }); + private readonly logger = createLogger('StalkerSeriesView'); + private readonly bindings = signal( + null + ); + readonly seasonWatchBatchRunning = signal(false); + /** + * The episode chosen while a watched/reset batch still rewrote the rows + * a start resumes from; the last choice plays once the batch settled, + * and only on the series it was made for. + */ + private choiceHeldForBatch: { + readonly seriesKey: string; + readonly play: () => void; + } | null = null; + + bind(bindings: StalkerSeriesWatchToggleBindings): void { + this.bindings.set(bindings); + } + + /** Holds `play` until the running batch settled, replacing an earlier choice. */ + holdChoice(play: () => void): void { + this.choiceHeldForBatch = { + seriesKey: this.currentSeriesKey(), + play, + }; + } + + async handlePlaybackToggleRequested( + request: SeasonContainerPlaybackToggleRequest + ): Promise { + const playlistId = this.stalkerStore.currentPlaylist()?._id; + if (!playlistId) { + return; + } + + if (request.nextPosition) { + await this.positions.persistSeriesPosition( + playlistId, + request.nextPosition + ); + } else { + await this.positions.clearSeriesPosition( + playlistId, + request.contentXtreamId + ); + } + // Keep the catalog grid's progress badge in sync (ownership-checked + // inside the facade; no-op outside the catalog context). A failed + // refresh keeps the cache populated-but-stale. + await this.catalogFacade + ?.refreshPositions(playlistId) + .catch((error: unknown) => + this.logger.warn('Catalog position refresh failed', error) + ); + } + + async handleSeasonPlaybackToggleRequested( + request: SeasonContainerSeasonPlaybackToggleRequest + ): Promise { + const playlistId = this.stalkerStore.currentPlaylist()?._id; + if ( + !playlistId || + request.requests.length === 0 || + this.seasonWatchBatchRunning() + ) { + return; + } + // The mutation context already keeps a stale batch out of the next + // series' state; the snackbars need the same ownership so feedback + // for the old season is not presented on a newly opened page. + const seriesXtreamId = this.shownSeriesId(); + const stillCurrent = () => + this.stalkerStore.currentPlaylist()?._id === playlistId && + this.shownSeriesId() === seriesXtreamId; + + this.seasonWatchBatchRunning.set(true); + try { + await this.runWatchToggleBatch( + request, + playlistId, + stillCurrent, + SEASON_WATCH_FEEDBACK + ); + } finally { + this.endWatchBatch(); + } + } + + async handleSeriesPlaybackToggleRequested( + request: SeasonContainerSeriesPlaybackToggleRequest + ): Promise { + const bindings = this.bindings(); + const playlistId = this.stalkerStore.currentPlaylist()?._id; + if (!bindings || !playlistId || this.seasonWatchBatchRunning()) { + return; + } + const pendingSeasons = bindings.isVodSeries() + ? bindings + .vodSeriesSeasons() + .filter((season) => isVodSeasonHydrationPending(season)) + : []; + // An empty request is only meaningful when unloaded seasons remain: + // every loaded episode is watched, so the container could not build + // a target list, but hydration below may still surface unwatched + // episodes to mark. + if (request.requests.length === 0 && pendingSeasons.length === 0) { + return; + } + + const seriesXtreamId = this.shownSeriesId(); + const stillCurrent = () => + this.stalkerStore.currentPlaylist()?._id === playlistId && + this.shownSeriesId() === seriesXtreamId; + + this.seasonWatchBatchRunning.set(true); + try { + let effective: SeasonContainerSeriesPlaybackToggleRequest | null = + request; + if (pendingSeasons.length > 0) { + const hydrated = await this.hydrateSeasonsForSeriesToggle( + bindings, + pendingSeasons, + stillCurrent + ); + if (hydrated !== 'complete') { + if (hydrated === 'failed' && stillCurrent()) { + this.notifySeasonWatchToggle( + SERIES_WATCH_FEEDBACK.failed + ); + } + return; + } + // The reconcile effect only flushes on the next change- + // detection tick; rebuild the maps synchronously so the + // batch below sees the hydrated episodes' scoped and legacy + // rows (see applyReconciledSeriesPositions). + this.positions.applyReconciledSeriesPositions(); + effective = buildSeriesWatchToggleRequest({ + seasons: bindings.mappedSeasons(), + seriesId: seriesXtreamId, + playlistId, + isEpisodeWatched: (episode) => + isPortalPlaybackWatched( + this.positions + .episodePlaybackPositions() + .get(Number(episode.id)) + ), + excludedEpisodeIds: bindings.excludedEpisodeIds(), + // Keep the direction the user clicked; re-inference over + // the now-complete data could flip a "mark" into an + // unwatch when everything turned out watched. + markWatched: request.markWatched, + }); + if (!effective) { + if (stillCurrent()) { + this.notifySeasonWatchToggle( + SERIES_WATCH_FEEDBACK.marked, + { count: 0 } + ); + } + return; + } + } + + await this.runWatchToggleBatch( + effective, + playlistId, + stillCurrent, + SERIES_WATCH_FEEDBACK + ); + } finally { + this.endWatchBatch(); + } + } + + private currentSeriesKey(): string { + return this.bindings()?.currentSeriesKey() ?? ''; + } + + private shownSeriesId(): number { + return toStalkerSeriesId(this.bindings()?.displayItem()?.id ?? 0); + } + + /** + * The batch settled: the choice held meanwhile goes through the usual + * gates, unless the viewer switched series since. Episode identities + * overlap across series, so it must never resolve against another one. + */ + private endWatchBatch(): void { + this.seasonWatchBatchRunning.set(false); + const held = this.choiceHeldForBatch; + this.choiceHeldForBatch = null; + if (held && held.seriesKey === this.currentSeriesKey()) { + held.play(); + } + } + + /** + * Sequential on purpose: loadEpisodesForSeason snapshots the season VM + * array before its writes, so concurrent calls clobber each other's + * loading flags; and one request at a time keeps the portal load bounded. + */ + private async hydrateSeasonsForSeriesToggle( + bindings: StalkerSeriesWatchToggleBindings, + pendingSeasons: readonly VodSeriesSeasonVm[], + stillCurrent: () => boolean + ): Promise<'complete' | 'failed' | 'superseded'> { + for (const season of pendingSeasons) { + // A tab click may have loaded this season meanwhile. + const current = bindings + .vodSeriesSeasons() + .find((candidate) => candidate.id === season.id); + if (!current || !isVodSeasonHydrationPending(current)) { + continue; + } + const answered = await bindings.loadEpisodesForSeason(current); + if (!stillCurrent()) { + return 'superseded'; + } + if (!answered) { + return 'failed'; + } + } + return 'complete'; + } + + private runWatchToggleBatch( + request: SeasonContainerSeriesPlaybackToggleRequest, + playlistId: string, + stillCurrent: () => boolean, + feedback: StalkerWatchToggleFeedback + ): Promise { + return runStalkerWatchToggleBatch({ + request, + playlistId, + stillCurrent, + feedback, + persist: (id, position) => + this.positions.persistSeriesPosition(id, position), + clear: (id, contentXtreamId) => + this.positions.clearSeriesPosition(id, contentXtreamId), + refreshCatalogPositions: (id) => + this.catalogFacade + ?.refreshPositions(id) + .catch((error: unknown) => + this.logger.warn( + 'Catalog position refresh failed', + error + ) + ), + notify: (key, params) => this.notifySeasonWatchToggle(key, params), + logger: this.logger, + }); + } + + private notifySeasonWatchToggle(key: string, params?: object): void { + this.snackBar.open( + this.translateService.instant(key, params), + undefined, + { duration: 5000 } + ); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-vod-season-episode-loader.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-vod-season-episode-loader.ts new file mode 100644 index 000000000..585f71e05 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-vod-season-episode-loader.ts @@ -0,0 +1,125 @@ +import type { WritableSignal } from '@angular/core'; +import { + getVodSeriesSeasonKey, + type VodSeriesSeasonVm, +} from '@iptvnator/portal/stalker/data-access'; + +/** What the loader needs from the page that shows the seasons. */ +export interface StalkerVodSeasonEpisodeLoaderHost { + /** Seasons of the series on screen; a load writes its loading flag and episodes here. */ + readonly seasons: WritableSignal; + /** Identity of the series on screen: part of the key in-flight loads are shared by. */ + readonly ownerKey: () => string; + readonly fetchEpisodes: ( + videoId: string, + seasonId: string + ) => Promise; + readonly logError: (message: string, error: unknown) => void; +} + +/** Lazy episode loads of Ministra VOD-series seasons (`is_series=1`). */ +export class StalkerVodSeasonEpisodeLoader { + private readonly loads = new Map< + string, + { season: VodSeriesSeasonVm; promise: Promise } + >(); + + constructor(private readonly host: StalkerVodSeasonEpisodeLoaderHost) {} + + /** + * Loads episodes for a specific VOD season. + * + * Single-flight per season: a tab click, the spillover prefetch, the + * quick-start recursion, and the series-toggle hydration can all ask for + * the same season — a second concurrent request would duplicate portal + * traffic, and its failure could abort a series toggle whose original + * request succeeded. + */ + /** Resolves true when the portal answered, false when the request failed. */ + load(season: VodSeriesSeasonVm): Promise { + const key = JSON.stringify([ + this.host.ownerKey(), + season.video_id, + season.id, + getVodSeriesSeasonKey(season), + ]); + const inFlight = this.loads.get(key); + if (inFlight && this.host.seasons().includes(inFlight.season)) { + return inFlight.promise; + } + const load = this.fetch(season).finally(() => { + if (this.loads.get(key)?.promise === load) { + this.loads.delete(key); + } + }); + const loadingSeason = this.host + .seasons() + .find( + (candidate) => + candidate.id === season.id && + candidate.video_id === season.video_id + ); + if (loadingSeason) { + this.loads.set(key, { + season: loadingSeason, + promise: load, + }); + } + return load; + } + + private async fetch(season: VodSeriesSeasonVm): Promise { + // Set loading state in local signal + const seasons = this.host.seasons(); + const index = seasons.findIndex( + (s) => + s.id === season.id && + s.video_id === season.video_id && + getVodSeriesSeasonKey(s) === getVodSeriesSeasonKey(season) + ); + if (index === -1) return false; + + const updatedSeasons = [...seasons]; + const loadingSeason = { ...updatedSeasons[index], isLoading: true }; + updatedSeasons[index] = loadingSeason; + this.host.seasons.set(updatedSeasons); + + try { + const episodes = await this.host.fetchEpisodes( + season.video_id, + season.id + ); + + // Update with loaded episodes + const newSeasons = [...this.host.seasons()]; + // Only the exact loading VM owns this response. A navigation or + // refresh can reuse provider ids while replacing the season list. + const newIndex = newSeasons.indexOf(loadingSeason); + if (newIndex !== -1) { + newSeasons[newIndex] = { + ...newSeasons[newIndex], + episodes: episodes, + // Even an EMPTY answer marks the season loaded: the + // portal spoke, so it must stop counting as "unloaded" + // (label/verdict gating and series-toggle hydration). + episodesLoaded: true, + isLoading: false, + }; + this.host.seasons.set(newSeasons); + } + return newIndex !== -1; + } catch (error) { + this.host.logError('Failed to load episodes', error); + const newSeasons = [...this.host.seasons()]; + const newIndex = newSeasons.indexOf(loadingSeason); + if (newIndex !== -1) { + newSeasons[newIndex] = { + ...newSeasons[newIndex], + isLoading: false, + }; + this.host.seasons.set(newSeasons); + } + return false; + } + } +} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts index 023dd595e..0ebc12109 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts @@ -23,7 +23,6 @@ import { ExternalPlayerName, ExternalPlayerSession, PlaybackPositionData, - PlayerContentInfo, ResolvedPortalPlayback, XtreamSerieDetails, XtreamSerieEpisode, @@ -37,7 +36,6 @@ import { getSeriesPlaybackNavigation, inlineSeriesEpisodeState, type PlaybackFallbackRequest, - resolveSeriesPlaybackEpisodeState, type SeriesPlaybackEpisodeState, } from '@iptvnator/ui/playback'; import { injectXtreamRecentHistory } from '../xtream-recent-history'; @@ -53,6 +51,8 @@ import { SerialDetailsSeasonWatchService, type SerialDetailsWatchScope, } from './serial-details-season-watch.service'; +import { SerialDetailsWatchToggles } from './serial-details-watch-toggles'; +import { buildSerialEpisodePlayback } from './serial-episode-playback'; export type XtreamSerieDetailsView = XtreamSerieDetails & { readonly series_id: number; @@ -97,6 +97,15 @@ export class SerialDetailsPlaybackService { ); private readonly playbackPositionState = new SerialDetailsPlaybackPositionState(); + private readonly watchToggles = new SerialDetailsWatchToggles({ + playbackPositions: this.playbackPositions, + seasonWatch: this.seasonWatch, + state: this.playbackPositionState, + playlistId: () => this.currentPlaylistId(), + seriesXtreamId: () => Number(this.selectedItem()?.series_id ?? 0), + reloadStorePositions: (playlistId) => + this.xtreamStore.loadAllPositions(playlistId), + }); private lastSaveTime = 0; readonly inlinePlayback = signal(null); @@ -229,32 +238,15 @@ export class SerialDetailsPlaybackService { contentType: 'series', backdropUrl: selectedItem.info?.backdrop_path?.[0], }); - const contentInfo: PlayerContentInfo = { - playlistId: playlist.id, - contentXtreamId: Number(episode.id), - contentType: 'episode', - seriesXtreamId: Number(selectedItem.series_id), - seasonNumber: Number(episode.season), - episodeNumber: Number(episode.episode_num), - }; - const position = this.episodePlaybackPositions().get( Number(episode.id) ); - - const playback: ResolvedPortalPlayback = { + const { playback, episodeState } = buildSerialEpisodePlayback({ + playlistId: playlist.id, + selectedItem, + episode, streamUrl, - title: episode.title, - thumbnail: selectedItem.info.cover, startTime: position?.positionSeconds, - contentInfo, - }; - - const episodeState = resolveSeriesPlaybackEpisodeState({ - episodesBySeason: selectedItem.episodes, - currentEpisodeId: episode.id, - fallbackSeasonNumber: Number(episode.season), - fallbackEpisodeNumber: Number(episode.episode_num), }); return this.startPlayback(playback, episodeState, player); } @@ -322,68 +314,17 @@ export class SerialDetailsPlaybackService { ); } - async handlePlaybackToggleRequested( + handlePlaybackToggleRequested( request: SeasonContainerPlaybackToggleRequest ): Promise { - const playlistId = this.currentPlaylistId(); - if (!playlistId) { - return; - } - - if (request.nextPosition) { - await this.playbackPositions.savePlaybackPosition( - playlistId, - request.nextPosition - ); - this.playbackPositionState.update(request.nextPosition); - } else { - await this.playbackPositions.clearPlaybackPosition( - playlistId, - request.contentXtreamId, - 'episode' - ); - this.playbackPositionState.remove(request.contentXtreamId); - } - await this.refreshStorePositions(playlistId); + return this.watchToggles.toggleEpisode(request); } - async handleWatchToggleRequested( + handleWatchToggleRequested( request: SeasonContainerSeriesPlaybackToggleRequest, scope: SerialDetailsWatchScope ): Promise { - const playlistId = this.currentPlaylistId(); - const seriesXtreamId = Number(this.selectedItem()?.series_id ?? 0); - const persisted = await this.seasonWatch.handle( - request, - playlistId, - this.playbackPositionState, - () => - this.currentPlaylistId() === playlistId && - Number(this.selectedItem()?.series_id ?? 0) === seriesXtreamId, - scope - ); - if (persisted) { - await this.refreshStorePositions(playlistId); - } - } - - /** - * The catalog reads series progress from XtreamStore, whose positions - * load once per playlist (XtreamCatalogFacadeService.initialize), so a - * toggle must push the change back or badges go stale on return. Skipped - * after a playlist switch — the store then holds the other playlist. - */ - private async refreshStorePositions(playlistId: string): Promise { - if (this.currentPlaylistId() !== playlistId) { - return; - } - try { - await this.xtreamStore.loadAllPositions(playlistId); - } catch (error) { - // The toggle itself succeeded; a failed refresh keeps the store - // populated-but-stale, which beats wiping it with a bad read. - this.logger.warn('Store position refresh failed', error); - } + return this.watchToggles.toggleBatch(request, scope); } async loadSeriesPlaybackPositions( diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-route.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-route.service.ts new file mode 100644 index 000000000..c819a3631 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-route.service.ts @@ -0,0 +1,75 @@ +import { computed, inject, Injectable, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute } from '@angular/router'; +import { isProviderOnlyDetailState } from '@iptvnator/portal/shared/util'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { SerialDetailsPlaybackService } from './serial-details-playback.service'; + +/** + * Route side of the series page: its params, the provider-only flag of the + * navigation that opened it, and the (re)load of the series the route + * addresses. + */ +@Injectable() +export class SerialDetailsRouteService { + private readonly route = inject(ActivatedRoute); + private readonly xtreamStore = inject(XtreamStore); + private readonly playback = inject(SerialDetailsPlaybackService); + /** `playlistId:categoryId:serialId` of the last initialized view */ + private readonly lastInitKey = signal(null); + + /** + * Reactive route params: the component is reused when navigating + * between two series details (e.g. via the Similar rail). + */ + readonly params = toSignal(this.route.params, { + initialValue: this.route.snapshot.params, + }); + readonly providerOnly = computed(() => { + this.params(); + return isProviderOnlyDetailState(window.history.state); + }); + + /** + * Initializes on first render and RE-initializes when the route params + * change while the component is reused (Similar rail). Reads signals, so + * the owning component runs it from an effect. + */ + loadAddressedSeries(): void { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + const { categoryId, serialId } = this.params(); + if (!playlistId || !serialId) { + return; + } + + const initKey = `${playlistId}:${categoryId}:${serialId}`; + if (this.lastInitKey() === initKey) { + return; + } + this.lastInitKey.set(initKey); + + this.playback.resetForNewSeries(); + this.initializeSerialDetails(playlistId, categoryId, serialId); + } + + private initializeSerialDetails( + playlistId: string, + categoryId: string | number, + serialId: string + ): void { + this.xtreamStore.fetchSerialDetailsWithMetadata({ + serialId, + categoryId: Number(categoryId), + }); + const serialXtreamId = Number(serialId); + this.xtreamStore.checkFavoriteStatus( + serialXtreamId, + playlistId, + 'series' + ); + void this.playback.loadSeriesPlaybackPositions( + playlistId, + serialXtreamId + ); + } +} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-seasons.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-seasons.service.ts new file mode 100644 index 000000000..7da3ed964 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-seasons.service.ts @@ -0,0 +1,75 @@ +import { + computed, + effect, + inject, + Injectable, + Signal, + signal, + untracked, +} from '@angular/core'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { buildSeasonDescriptions } from './season-descriptions.util'; +import { buildSeasonPosters } from './season-posters.util'; +import type { XtreamSerieDetailsView } from './serial-details-playback.service'; + +interface SerialDetailsSeasonsBindings { + readonly selectedItem: Signal; +} + +/** + * Season presentation of the series page: the descriptions and posters the + * season container and the inline player show, and the TMDB enrichment of + * the season the viewer selected. + */ +@Injectable() +export class SerialDetailsSeasonsService { + private readonly xtreamStore = inject(XtreamStore); + private readonly bindings = signal( + null + ); + private readonly selectedItem = computed( + () => this.bindings()?.selectedItem() ?? null + ); + + /** Season currently selected in the season container. */ + private readonly selectedSeasonKey = signal(null); + + /** Season descriptions (provider text, TMDB fallback, URL junk dropped). */ + readonly descriptions = computed>(() => + buildSeasonDescriptions(this.selectedItem()) + ); + + /** Season posters (TMDB season poster first, provider season cover next). */ + readonly posters = computed>(() => + buildSeasonPosters(this.selectedItem()) + ); + + constructor() { + // TMDB season enrichment, keyed on (tmdb_id, selected season). With + // season tabs the first seasonSelected fires as soon as seasons load — + // usually BEFORE the async show-level TMDB match has written + // info.tmdb_id, and enrichSelectedSerialSeason no-ops without it. So + // the call must re-run when the match arrives, not only on selection. + // The store-side enrichment is idempotent per (serial, season). + effect(() => { + const tmdbId = this.selectedItem()?.info?.tmdb_id; + const seasonKey = this.selectedSeasonKey(); + if (tmdbId && seasonKey) { + untracked(() => + this.xtreamStore.enrichSelectedSerialSeason(seasonKey) + ); + } + }); + } + + /** Connects the service to the owning component's reactive state. */ + bind(bindings: SerialDetailsSeasonsBindings): void { + this.bindings.set(bindings); + } + + select(seasonKey: string): void { + // The enrichment call itself runs from the constructor effect keyed + // on (tmdb_id, selectedSeasonKey) — see the race note there. + this.selectedSeasonKey.set(seasonKey); + } +} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-watch-toggles.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-watch-toggles.ts new file mode 100644 index 000000000..e3df80803 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-watch-toggles.ts @@ -0,0 +1,104 @@ +import { + createLogger, + type PortalPlaybackPositions, +} from '@iptvnator/portal/shared/util'; +import type { + SeasonContainerPlaybackToggleRequest, + SeasonContainerSeriesPlaybackToggleRequest, +} from '@iptvnator/ui/components'; +import type { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state'; +import type { + SerialDetailsSeasonWatchService, + SerialDetailsWatchScope, +} from './serial-details-season-watch.service'; + +const logger = createLogger('SerialDetailsPlayback'); + +/** What the watched toggles of the series page persist through and report to. */ +export interface SerialDetailsWatchToggleDeps { + readonly playbackPositions: Pick< + PortalPlaybackPositions, + 'savePlaybackPosition' | 'clearPlaybackPosition' + >; + readonly seasonWatch: Pick; + readonly state: SerialDetailsPlaybackPositionState; + /** Playlist on screen, empty while none is current. */ + readonly playlistId: () => string; + /** Series on screen, 0 while none is selected. */ + readonly seriesXtreamId: () => number; + /** Reloads the catalog store's positions of a playlist. */ + readonly reloadStorePositions: (playlistId: string) => Promise; +} + +/** + * Episode, season and series watched toggles of the series page: persists + * the change, mirrors it into the page's position state, and pushes it back + * to the catalog store. + */ +export class SerialDetailsWatchToggles { + constructor(private readonly deps: SerialDetailsWatchToggleDeps) {} + + async toggleEpisode( + request: SeasonContainerPlaybackToggleRequest + ): Promise { + const playlistId = this.deps.playlistId(); + if (!playlistId) { + return; + } + + if (request.nextPosition) { + await this.deps.playbackPositions.savePlaybackPosition( + playlistId, + request.nextPosition + ); + this.deps.state.update(request.nextPosition); + } else { + await this.deps.playbackPositions.clearPlaybackPosition( + playlistId, + request.contentXtreamId, + 'episode' + ); + this.deps.state.remove(request.contentXtreamId); + } + await this.refreshStorePositions(playlistId); + } + + async toggleBatch( + request: SeasonContainerSeriesPlaybackToggleRequest, + scope: SerialDetailsWatchScope + ): Promise { + const playlistId = this.deps.playlistId(); + const seriesXtreamId = this.deps.seriesXtreamId(); + const persisted = await this.deps.seasonWatch.handle( + request, + playlistId, + this.deps.state, + () => + this.deps.playlistId() === playlistId && + this.deps.seriesXtreamId() === seriesXtreamId, + scope + ); + if (persisted) { + await this.refreshStorePositions(playlistId); + } + } + + /** + * The catalog reads series progress from XtreamStore, whose positions + * load once per playlist (XtreamCatalogFacadeService.initialize), so a + * toggle must push the change back or badges go stale on return. Skipped + * after a playlist switch — the store then holds the other playlist. + */ + private async refreshStorePositions(playlistId: string): Promise { + if (this.deps.playlistId() !== playlistId) { + return; + } + try { + await this.deps.reloadStorePositions(playlistId); + } catch (error) { + // The toggle itself succeeded; a failed refresh keeps the store + // populated-but-stale, which beats wiping it with a bad read. + logger.warn('Store position refresh failed', error); + } + } +} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index 7b2b21177..6cb7cc12d 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -1,361 +1,46 @@ -import { - Component, - input, - output, - signal, - ChangeDetectionStrategy, -} from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { Location } from '@angular/common'; -import { MatIcon } from '@angular/material/icon'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { ActivatedRoute } from '@angular/router'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { MockPipe } from 'ng-mocks'; -import { - ContentHeroComponent, - SeasonContainerComponent, -} from '@iptvnator/ui/components'; -import { - PORTAL_EXTERNAL_PLAYBACK, - PORTAL_PLAYBACK_POSITIONS, - PORTAL_PLAYER, - SeriesResumeTarget, -} from '@iptvnator/portal/shared/util'; -import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access'; -import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; -import { - DownloadsService, - PlaybackPositionRuntimeBridgeService, - SettingsStore, -} from '@iptvnator/services'; +import { ContentHeroComponent } from '@iptvnator/ui/components'; import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; -import { - PlaybackPositionData, - VideoPlayer, -} from '@iptvnator/shared/interfaces'; -import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback'; -import { BehaviorSubject, EMPTY, of } from 'rxjs'; +import { PlaybackPositionData } from '@iptvnator/shared/interfaces'; import { SerialDetailsComponent } from './serial-details.component'; import { SerialDetailsMenuService } from './serial-details-menu.service'; import { SerialDetailsPlaybackService } from './serial-details-playback.service'; -import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token'; import { createPlaybackSessionKey } from '@iptvnator/playback/util'; - -@Component({ - selector: 'app-season-container', - standalone: true, - changeDetection: ChangeDetectionStrategy.Eager, - template: '
', -}) -class StubSeasonContainerComponent { - readonly seasons = input(null); - readonly seriesId = input(null); - readonly playlistId = input(''); - readonly seriesTitle = input(undefined); - readonly playbackPositions = input(null); - readonly downloadAdapter = input(null); - readonly downloadsEnabled = input(true); - readonly openingEpisodeId = input(null); - readonly activeEpisodeId = input(null); - readonly playingEpisodeId = input(null); - readonly seasonDescriptions = input(null); - readonly seasonPosters = input(null); - readonly seasonWatchBatchRunning = input(false); - readonly episodeClicked = output(); - readonly playbackToggleRequested = output(); - readonly seasonPlaybackToggleRequested = output(); -} - -@Component({ - selector: 'app-portal-inline-player', - standalone: true, - changeDetection: ChangeDetectionStrategy.Eager, - template: '', -}) -class StubPortalInlinePlayerComponent { - readonly playbackSessionKey = input.required(); - readonly playback = input(null); - readonly episodeMetadata = input(null); - readonly seriesTitle = input(null); - readonly seriesNavigation = input(null); - readonly upNextEpisodes = input(null); - readonly seriesEpisodes = input(null); - readonly seasonPosters = input(null); - readonly episodePlaybackPositions = input(null); - readonly seasonLoadStates = input(null); - readonly timeUpdate = output(); - readonly closed = output(); - readonly streamUrlCopied = output(); - readonly externalFallbackRequested = output(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); - readonly upNextEpisodeSelected = output(); - readonly episodePanelSeasonSelected = output(); -} - -@Component({ - selector: 'mat-icon', - standalone: true, - changeDetection: ChangeDetectionStrategy.Eager, - template: '', -}) -class StubMatIconComponent {} +import { + configureSerialDetailsTestBed, + createSerialDetailsStubs, + resetSerialDetailsStubs, +} from './serial-details.harness'; +import { + StubPortalInlinePlayerComponent, + StubSeasonContainerComponent, +} from './serial-details.test-stubs'; describe('SerialDetailsComponent', () => { let fixture: ComponentFixture; - const selectedItem = signal(null); - const selectedContentType = signal<'series'>('series'); - const isFavorite = signal(false); - const isLoadingDetails = signal(false); - const detailsError = signal(null); - const currentPlaylist = signal({ - id: 'xtream-1', - serverUrl: 'http://xtream.example', - username: 'user', - password: 'pass', - userAgent: 'ProtectedProvider/2.0', - referrer: 'https://referrer.example/series', - origin: 'https://origin.example', - }); - const fetchSerialDetailsWithMetadata = jest.fn(); - const cancelDetailsRequest = jest.fn(); - const checkFavoriteStatus = jest.fn(); - const constructEpisodeStreamUrl = jest.fn(); - const addRecentItem = jest.fn(); - const openResolvedPlayback = jest.fn(); - const openExternalPlayback = jest.fn(); - const savePlaybackPosition = jest.fn(); - const clearPlaybackPosition = jest.fn(); - const savePlaybackPositionsBatch = jest.fn(); - const clearPlaybackPositionsBatch = jest.fn(); - const loadAllPositions = jest.fn(); - const isEmbeddedPlayer = jest.fn(); - const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]); - let positionUpdateCallback: ((data: PlaybackPositionData) => void) | null = - null; - let seriesResumeTarget: ReturnType< - typeof signal - >; - let routeParams: BehaviorSubject<{ - categoryId: string; - serialId: string; - }>; + const stubs = createSerialDetailsStubs(); + const { + selectedItem, + currentPlaylist, + fetchSerialDetailsWithMetadata, + cancelDetailsRequest, + checkFavoriteStatus, + constructEpisodeStreamUrl, + addRecentItem, + openResolvedPlayback, + openExternalPlayback, + savePlaybackPosition, + isEmbeddedPlayer, + getSeriesPlaybackPositions, + seriesResumeTarget, + routeParams, + } = stubs; beforeEach(async () => { window.history.replaceState({}, '', window.location.href); - selectedItem.set({ - series_id: 103, - info: { - name: 'Series One', - plot: 'Series plot', - cover: 'cover.jpg', - backdrop_path: [], - genre: 'Drama', - category_id: '3', - tmdb_id: 901, - tmdb_cast: [{ name: 'Sienna Wave', character: 'Mara' }], - }, - episodes: { - '1': [ - { - id: '1001', - episode_num: 1, - title: 'Episode 1', - season: 1, - }, - { - id: '1002', - episode_num: 2, - title: 'Episode 2', - season: 1, - }, - ], - '2': [ - { - id: '2001', - episode_num: 1, - title: 'Season 2 Episode 1', - season: 2, - }, - ], - }, - }); - isFavorite.set(false); - isLoadingDetails.set(false); - detailsError.set(null); - fetchSerialDetailsWithMetadata.mockClear(); - cancelDetailsRequest.mockClear(); - checkFavoriteStatus.mockClear(); - constructEpisodeStreamUrl.mockReset(); - constructEpisodeStreamUrl.mockImplementation( - (episode: { id: string | number }) => - `http://xtream.example/series/${episode.id}.mp4` - ); - addRecentItem.mockClear(); - openResolvedPlayback.mockReset(); - openResolvedPlayback.mockResolvedValue(undefined); - openExternalPlayback.mockReset(); - openExternalPlayback.mockResolvedValue(undefined); - savePlaybackPosition.mockReset(); - savePlaybackPosition.mockResolvedValue(undefined); - clearPlaybackPosition.mockReset(); - clearPlaybackPosition.mockResolvedValue(undefined); - savePlaybackPositionsBatch.mockReset(); - savePlaybackPositionsBatch.mockResolvedValue(undefined); - clearPlaybackPositionsBatch.mockReset(); - clearPlaybackPositionsBatch.mockResolvedValue(undefined); - loadAllPositions.mockReset(); - loadAllPositions.mockResolvedValue(undefined); - positionUpdateCallback = null; - isEmbeddedPlayer.mockReset(); - isEmbeddedPlayer.mockReturnValue(false); - getSeriesPlaybackPositions.mockClear(); - getSeriesPlaybackPositions.mockResolvedValue([]); - seriesResumeTarget = signal(null); - routeParams = new BehaviorSubject({ - categoryId: '3', - serialId: '103', - }); - - await TestBed.configureTestingModule({ - imports: [SerialDetailsComponent], - providers: [ - { - provide: ActivatedRoute, - useValue: { - params: routeParams, - snapshot: { - params: { - categoryId: '3', - serialId: '103', - }, - }, - }, - }, - { - provide: XtreamStore, - useValue: { - selectedItem, - selectedContentType, - isFavorite, - isLoadingDetails, - detailsError, - currentPlaylist, - fetchSerialDetailsWithMetadata, - cancelDetailsRequest, - checkFavoriteStatus, - setSelectedItem: jest.fn((value: unknown) => - selectedItem.set(value) - ), - toggleFavorite: jest.fn(), - constructEpisodeStreamUrl, - addRecentItem, - backfillContentMetadata: jest.fn(), - loadAllPositions, - recentItems: signal([]), - serialCategories: signal([]), - loadRecentItems: jest.fn(), - }, - }, - { - provide: SettingsStore, - useValue: { player: signal(VideoPlayer.Html5Player) }, - }, - { - provide: DownloadsService, - useValue: { isAvailable: signal(false) }, - }, - { - provide: PORTAL_EXTERNAL_PLAYBACK, - useValue: { - activeSession: signal(null), - }, - }, - { - provide: PORTAL_PLAYBACK_POSITIONS, - useValue: { - getSeriesPlaybackPositions, - savePlaybackPosition, - clearPlaybackPosition, - savePlaybackPositionsBatch, - clearPlaybackPositionsBatch, - }, - }, - { - provide: PORTAL_PLAYER, - useValue: { - isEmbeddedPlayer, - openResolvedPlayback, - openExternalPlayback, - }, - }, - { - provide: PlaybackPositionRuntimeBridgeService, - useValue: { - onPlaybackPositionUpdate: ( - callback: (data: PlaybackPositionData) => void - ) => { - positionUpdateCallback = callback; - return () => { - positionUpdateCallback = null; - }; - }, - }, - }, - { - provide: XTREAM_SERIES_RESUME_TARGET, - useValue: seriesResumeTarget, - }, - { - provide: MatSnackBar, - useValue: { - open: jest.fn(), - }, - }, - { - provide: TranslateService, - useValue: { - instant: (key: string) => key, - get: (key: string) => of(key), - stream: (key: string) => of(key), - onLangChange: EMPTY, - onTranslationChange: EMPTY, - onDefaultLangChange: EMPTY, - }, - }, - { - provide: Location, - useValue: { - back: jest.fn(), - }, - }, - ], - }) - .overrideComponent(SerialDetailsComponent, { - remove: { - imports: [ - MatIcon, - PortalInlinePlayerComponent, - SeasonContainerComponent, - TranslatePipe, - ], - }, - add: { - imports: [ - StubMatIconComponent, - StubPortalInlinePlayerComponent, - StubSeasonContainerComponent, - MockPipe( - TranslatePipe, - (value: string | null | undefined) => value ?? '' - ), - ], - }, - }) - .compileComponents(); + resetSerialDetailsStubs(stubs); + await configureSerialDetailsTestBed(stubs); fixture = TestBed.createComponent(SerialDetailsComponent); }); @@ -779,6 +464,7 @@ describe('SerialDetailsComponent', () => { it('applies streamed playback-position updates for the selected series only', async () => { fixture.detectChanges(); await fixture.whenStable(); + const positionUpdateCallback = stubs.positionUpdates.callback; if (!positionUpdateCallback) { throw new Error('expected a playback-position subscription'); } @@ -936,238 +622,6 @@ describe('SerialDetailsComponent', () => { expect(savePlaybackPosition).not.toHaveBeenCalled(); }); - it('saves and clears positions for season-container toggle requests', async () => { - fixture.detectChanges(); - await fixture.whenStable(); - - const playbackService = fixture.debugElement.injector.get( - SerialDetailsPlaybackService - ); - await playbackService.handlePlaybackToggleRequested({ - contentXtreamId: 1001, - nextPosition: { - playlistId: 'xtream-1', - contentXtreamId: 1001, - contentType: 'episode', - seriesXtreamId: 103, - seasonNumber: 1, - episodeNumber: 1, - positionSeconds: 950, - durationSeconds: 1000, - }, - } as never); - expect(savePlaybackPosition).toHaveBeenCalledWith( - 'xtream-1', - expect.objectContaining({ contentXtreamId: 1001 }) - ); - - await playbackService.handlePlaybackToggleRequested({ - contentXtreamId: 1001, - nextPosition: null, - } as never); - expect(clearPlaybackPosition).toHaveBeenCalledWith( - 'xtream-1', - 1001, - 'episode' - ); - }); - - it('marks a season watched through one batch save and updates rendered positions', async () => { - fixture.detectChanges(); - await fixture.whenStable(); - - const playbackService = fixture.debugElement.injector.get( - SerialDetailsPlaybackService - ); - const snackBar = TestBed.inject(MatSnackBar); - const seasonPosition = ( - contentXtreamId: number, - episodeNumber: number - ) => ({ - playlistId: 'xtream-1', - contentXtreamId, - contentType: 'episode' as const, - seriesXtreamId: 103, - seasonNumber: 1, - episodeNumber, - positionSeconds: 1200, - durationSeconds: 1200, - }); - - await playbackService.handleWatchToggleRequested( - { - seasonKey: '1', - markWatched: true, - requests: [ - { - contentXtreamId: 1001, - nextPosition: seasonPosition(1001, 1), - }, - { - contentXtreamId: 1002, - nextPosition: seasonPosition(1002, 2), - }, - ], - } as never, - 'season' - ); - - expect(savePlaybackPositionsBatch).toHaveBeenCalledTimes(1); - expect(savePlaybackPositionsBatch).toHaveBeenCalledWith('xtream-1', [ - expect.objectContaining({ contentXtreamId: 1001 }), - expect.objectContaining({ contentXtreamId: 1002 }), - ]); - expect(savePlaybackPosition).not.toHaveBeenCalled(); - expect(playbackService.episodePlaybackPositions().get(1001)).toEqual( - expect.objectContaining({ positionSeconds: 1200 }) - ); - expect( - playbackService.episodePlaybackPositions().get(1002) - ).toBeDefined(); - expect(snackBar.open).toHaveBeenCalledWith( - 'XTREAM.SEASON_MARKED_WATCHED', - undefined, - { duration: 5000 } - ); - // The catalog badge source must follow the batch. - expect(loadAllPositions).toHaveBeenCalledWith('xtream-1'); - expect(playbackService.seasonWatchBatchRunning()).toBe(false); - }); - - it('unwatches a season through one batch clear', async () => { - fixture.detectChanges(); - await fixture.whenStable(); - - const playbackService = fixture.debugElement.injector.get( - SerialDetailsPlaybackService - ); - await playbackService.handleWatchToggleRequested( - { - seasonKey: '1', - markWatched: false, - requests: [ - { contentXtreamId: 1001, nextPosition: null }, - { contentXtreamId: 1002, nextPosition: null }, - ], - } as never, - 'season' - ); - - expect(clearPlaybackPositionsBatch).toHaveBeenCalledTimes(1); - expect(clearPlaybackPositionsBatch).toHaveBeenCalledWith('xtream-1', [ - { contentXtreamId: 1001, contentType: 'episode' }, - { contentXtreamId: 1002, contentType: 'episode' }, - ]); - expect(clearPlaybackPosition).not.toHaveBeenCalled(); - expect(playbackService.episodePlaybackPositions().has(1001)).toBe( - false - ); - }); - - it('does not write a stale season batch into another playlist state', async () => { - fixture.detectChanges(); - await fixture.whenStable(); - - let resolveBatch!: () => void; - savePlaybackPositionsBatch.mockImplementation( - () => - new Promise((resolve) => { - resolveBatch = resolve; - }) - ); - const playbackService = fixture.debugElement.injector.get( - SerialDetailsPlaybackService - ); - const pending = playbackService.handleWatchToggleRequested( - { - seasonKey: '1', - markWatched: true, - requests: [ - { - contentXtreamId: 1001, - nextPosition: { - playlistId: 'xtream-1', - contentXtreamId: 1001, - contentType: 'episode', - seriesXtreamId: 103, - seasonNumber: 1, - episodeNumber: 1, - positionSeconds: 1200, - durationSeconds: 1200, - }, - }, - ], - } as never, - 'season' - ); - - // The user navigates to another playlist while the batch is pending. - const initialPlaylist = currentPlaylist(); - currentPlaylist.set({ ...initialPlaylist, id: 'xtream-2' }); - resolveBatch(); - await pending; - - expect(savePlaybackPositionsBatch).toHaveBeenCalledWith( - 'xtream-1', - expect.anything() - ); - expect(playbackService.episodePlaybackPositions().has(1001)).toBe( - false - ); - expect(TestBed.inject(MatSnackBar).open).not.toHaveBeenCalled(); - // The store now belongs to the other playlist — no stale refresh. - expect(loadAllPositions).not.toHaveBeenCalled(); - expect(playbackService.seasonWatchBatchRunning()).toBe(false); - currentPlaylist.set(initialPlaylist); - }); - - it('keeps rendered positions and reports the error when the season batch fails', async () => { - const consoleError = jest - .spyOn(console, 'error') - .mockImplementation(() => undefined); - savePlaybackPositionsBatch.mockRejectedValue(new Error('batch failed')); - fixture.detectChanges(); - await fixture.whenStable(); - - const playbackService = fixture.debugElement.injector.get( - SerialDetailsPlaybackService - ); - const snackBar = TestBed.inject(MatSnackBar); - await playbackService.handleWatchToggleRequested( - { - seasonKey: '1', - markWatched: true, - requests: [ - { - contentXtreamId: 1001, - nextPosition: { - playlistId: 'xtream-1', - contentXtreamId: 1001, - contentType: 'episode', - seriesXtreamId: 103, - seasonNumber: 1, - episodeNumber: 1, - positionSeconds: 1200, - durationSeconds: 1200, - }, - }, - ], - } as never, - 'season' - ); - - expect(playbackService.episodePlaybackPositions().has(1001)).toBe( - false - ); - expect(snackBar.open).toHaveBeenCalledWith( - 'XTREAM.SEASON_WATCH_UPDATE_FAILED', - undefined, - { duration: 5000 } - ); - expect(playbackService.seasonWatchBatchRunning()).toBe(false); - consoleError.mockRestore(); - }); - it('passes inline episode metadata and autoplays only inside the current season', async () => { isEmbeddedPlayer.mockReturnValue(true); fixture.detectChanges(); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index 9c40d2b12..c29a4e087 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -6,13 +6,11 @@ import { inject, OnDestroy, signal, - untracked, ChangeDetectionStrategy, viewChild, } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { CastCrewRowComponent, @@ -49,24 +47,15 @@ import { XtreamSerieEpisode, XtreamSerieInfo, } from '@iptvnator/shared/interfaces'; -import { buildSeasonDescriptions } from './season-descriptions.util'; -import { buildSeasonPosters } from './season-posters.util'; -import { - createDiscoverFacetNavigation, - isProviderOnlyDetailState, -} from '@iptvnator/portal/shared/util'; -import { - CrossPortalSimilarItem, - CrossPortalSimilarService, - TmdbEnrichmentService, -} from '@iptvnator/services'; +import { injectXtreamDetailNavigation } from '../xtream-detail-navigation'; import { SerialDetailsPlaybackService, type XtreamSerieDetailsView, } from './serial-details-playback.service'; +import { SerialDetailsRouteService } from './serial-details-route.service'; import { SerialDetailsSeasonWatchService } from './serial-details-season-watch.service'; +import { SerialDetailsSeasonsService } from './serial-details-seasons.service'; import { SerialDetailsSimilarService } from './serial-details-similar.service'; -import { SimilarCatalogItem } from '../tmdb-similar.util'; import { createSerialPlaybackSessionKey } from './serial-playback-session-key'; import { SerialDetailsHeroPresenter } from './serial-details-hero.presenter'; import { SerialDetailsMenuService } from './serial-details-menu.service'; @@ -90,7 +79,9 @@ import { SerialDetailsDownloadAdapterService } from './serial-details-download-a ], providers: [ SerialDetailsPlaybackService, + SerialDetailsRouteService, SerialDetailsSeasonWatchService, + SerialDetailsSeasonsService, SerialDetailsSimilarService, SerialDetailsHeroPresenter, SerialDetailsMenuService, @@ -118,8 +109,7 @@ import { SerialDetailsDownloadAdapterService } from './serial-details-download-a export class SerialDetailsComponent implements OnDestroy { private readonly location = inject(Location); private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - private readonly crossPortalSimilar = inject(CrossPortalSimilarService); + private readonly navigation = injectXtreamDetailNavigation('tv'); private readonly xtreamStore = inject(XtreamStore); private readonly playback = inject(SerialDetailsPlaybackService); @@ -142,20 +132,9 @@ export class SerialDetailsComponent implements OnDestroy { SerialDetailsDownloadAdapterService ); readonly episodeDownloadAdapter = this.downloadAdapter.adapter; - /** `playlistId:categoryId:serialId` of the last initialized view */ - private readonly lastInitKey = signal(null); - - /** - * Reactive route params: the component is reused when navigating - * between two series details (e.g. via the Similar rail). - */ - private readonly routeParams = toSignal(this.route.params, { - initialValue: this.route.snapshot.params, - }); - readonly providerOnly = computed(() => { - this.routeParams(); - return isProviderOnlyDetailState(window.history.state); - }); + private readonly routeState = inject(SerialDetailsRouteService); + private readonly routeParams = this.routeState.params; + readonly providerOnly = this.routeState.providerOnly; // Episode playback state, re-exposed for the template. readonly inlinePlayback = this.playback.inlinePlayback; @@ -182,27 +161,27 @@ export class SerialDetailsComponent implements OnDestroy { }) ); - /** Season currently selected in the season container. */ - private readonly selectedSeasonKey = signal(null); - - /** Season descriptions (provider text, TMDB fallback, URL junk dropped). */ - readonly seasonDescriptions = computed>(() => - buildSeasonDescriptions(this.selectedItem()) - ); - - /** Season posters (TMDB season poster first, provider season cover next). */ - readonly seasonPosters = computed>(() => - buildSeasonPosters(this.selectedItem()) - ); - /** The "Similar" rail: catalog matches plus cross-portal matches */ private readonly similar = inject(SerialDetailsSimilarService); readonly similarItems = this.similar.similarItems; readonly similarInPortals = this.similar.similarInPortals; + /** + * Season descriptions and posters, and the selected season's enrichment. + * Injected after the other services that register effects: its enrichment + * effect then runs after theirs and before the constructor's. + */ + private readonly seasons = inject(SerialDetailsSeasonsService); + readonly seasonDescriptions = this.seasons.descriptions; + readonly seasonPosters = this.seasons.posters; + + /** Clickable year/genre/country chips (Discover pages) */ + readonly discover = this.navigation.discover; + constructor() { this.playback.bind({ selectedItem: this.selectedItem }); this.similar.bind({ selectedItem: this.selectedItem }); + this.seasons.bind({ selectedItem: this.selectedItem }); this.downloadAdapter.bind(this.selectedItem); this.heroPresenter.bind({ selectedItem: this.selectedItem, @@ -210,8 +189,9 @@ export class SerialDetailsComponent implements OnDestroy { yearLabel: (releaseDate) => this.discover.yearLabel(releaseDate), similarItems: this.similarItems, similarInPortals: this.similarInPortals, - openSimilar: (item) => this.openSimilar(item), - openSimilarInPortals: (item) => this.openSimilarInPortals(item), + openSimilar: (item) => this.navigation.openSimilar(item), + openSimilarInPortals: (item) => + this.navigation.openSimilarInPortals(item), }); this.menu.bind({ selectedItem: this.selectedItem, @@ -235,22 +215,6 @@ export class SerialDetailsComponent implements OnDestroy { this.playback.playEpisode(episode, player), }); - // TMDB season enrichment, keyed on (tmdb_id, selected season). With - // season tabs the first seasonSelected fires as soon as seasons load — - // usually BEFORE the async show-level TMDB match has written - // info.tmdb_id, and enrichSelectedSerialSeason no-ops without it. So - // the call must re-run when the match arrives, not only on selection. - // The store-side enrichment is idempotent per (serial, season). - effect(() => { - const tmdbId = this.selectedItem()?.info?.tmdb_id; - const seasonKey = this.selectedSeasonKey(); - if (tmdbId && seasonKey) { - untracked(() => - this.xtreamStore.enrichSelectedSerialSeason(seasonKey) - ); - } - }); - effect(() => { const item = this.xtreamStore.selectedItem() as unknown as | (XtreamSerieDetails & { @@ -274,22 +238,7 @@ export class SerialDetailsComponent implements OnDestroy { // Initializes on first render and RE-initializes when the route // params change while the component is reused (Similar rail). - effect(() => { - const playlistId = this.xtreamStore.currentPlaylist()?.id; - const { categoryId, serialId } = this.routeParams(); - if (!playlistId || !serialId) { - return; - } - - const initKey = `${playlistId}:${categoryId}:${serialId}`; - if (this.lastInitKey() === initKey) { - return; - } - this.lastInitKey.set(initKey); - - this.playback.resetForNewSeries(); - this.initializeSerialDetails(playlistId, categoryId, serialId); - }); + effect(() => this.routeState.loadAddressedSeries()); registerContentMetadataBackfill({ store: this.xtreamStore, @@ -306,10 +255,6 @@ export class SerialDetailsComponent implements OnDestroy { this.xtreamStore.setSelectedItem(null); } - openSimilarInPortals(item: CrossPortalSimilarItem): void { - void this.router.navigate(this.crossPortalSimilar.buildLink(item)); - } - /** Clears every saved episode position of the series. */ resetProgress(): Promise { const request = @@ -319,41 +264,12 @@ export class SerialDetailsComponent implements OnDestroy { : Promise.resolve(); } - openSimilar(item: SimilarCatalogItem): void { - void this.router.navigate(['../..', item.categoryId, item.id], { - relativeTo: this.route, - }); - } - openActor(member: TmdbEnrichedCastMember): void { - const playlistId = this.xtreamStore.currentPlaylist()?.id; - if (!playlistId || !member.tmdbPersonId) { - return; - } - void this.router.navigate([ - '/workspace/xtreams', - playlistId, - 'actor', - member.tmdbPersonId, - ]); + this.navigation.openActor(member); } - /** Clickable year/genre/country chips (Discover pages) */ - private readonly tmdbEnrichment = inject(TmdbEnrichmentService); - - readonly discover = createDiscoverFacetNavigation(() => { - const playlistId = this.xtreamStore.currentPlaylist()?.id; - // Discover reads its results from TMDB, so a chip must not offer a - // page that enrichment cannot fill - return playlistId && this.tmdbEnrichment.isEnabled() - ? { portal: 'xtream', mediaType: 'tv', playlistId } - : null; - }); - onSeasonSelected(seasonKey: string): void { - // The enrichment call itself runs from the constructor effect keyed - // on (tmdb_id, selectedSeasonKey) — see the race note there. - this.selectedSeasonKey.set(seasonKey); + this.seasons.select(seasonKey); } playEpisode(episode: XtreamSerieEpisode): void { @@ -445,25 +361,4 @@ export class SerialDetailsComponent implements OnDestroy { } ); } - - private initializeSerialDetails( - playlistId: string, - categoryId: string | number, - serialId: string - ): void { - this.xtreamStore.fetchSerialDetailsWithMetadata({ - serialId, - categoryId: Number(categoryId), - }); - const serialXtreamId = Number(serialId); - this.xtreamStore.checkFavoriteStatus( - serialXtreamId, - playlistId, - 'series' - ); - void this.playback.loadSeriesPlaybackPositions( - playlistId, - serialXtreamId - ); - } } diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.harness.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.harness.ts new file mode 100644 index 000000000..5a57feb7f --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.harness.ts @@ -0,0 +1,314 @@ +import { Location } from '@angular/common'; +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatIcon } from '@angular/material/icon'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { ActivatedRoute } from '@angular/router'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { MockPipe } from 'ng-mocks'; +import { + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, + SeriesResumeTarget, +} from '@iptvnator/portal/shared/util'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { + DownloadsService, + PlaybackPositionRuntimeBridgeService, + SettingsStore, +} from '@iptvnator/services'; +import { + PlaybackPositionData, + VideoPlayer, +} from '@iptvnator/shared/interfaces'; +import { SeasonContainerComponent } from '@iptvnator/ui/components'; +import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback'; +import { BehaviorSubject, EMPTY, of } from 'rxjs'; +import { SerialDetailsComponent } from './serial-details.component'; +import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token'; +import { + StubMatIconComponent, + StubPortalInlinePlayerComponent, + StubSeasonContainerComponent, +} from './serial-details.test-stubs'; + +/** + * The TestBed every series-details component spec needs. + * + * The page pulls in the store, the playback ports, the position bridge, + * downloads, settings and the router, so standing it up costs ~200 lines. + * One harness keeps the specs split by concern from carrying a copy each. + */ + +/** The series every spec starts from: two seasons, three episodes. */ +function createSeriesFixture(): unknown { + return { + series_id: 103, + info: { + name: 'Series One', + plot: 'Series plot', + cover: 'cover.jpg', + backdrop_path: [], + genre: 'Drama', + category_id: '3', + tmdb_id: 901, + tmdb_cast: [{ name: 'Sienna Wave', character: 'Mara' }], + }, + episodes: { + '1': [ + { + id: '1001', + episode_num: 1, + title: 'Episode 1', + season: 1, + }, + { + id: '1002', + episode_num: 2, + title: 'Episode 2', + season: 1, + }, + ], + '2': [ + { + id: '2001', + episode_num: 1, + title: 'Season 2 Episode 1', + season: 2, + }, + ], + }, + }; +} + +const initialPlaylist = { + id: 'xtream-1', + serverUrl: 'http://xtream.example', + username: 'user', + password: 'pass', + userAgent: 'ProtectedProvider/2.0', + referrer: 'https://referrer.example/series', + origin: 'https://origin.example', +}; + +/** Every stub the harness installs, so specs can drive and assert on them. */ +export function createSerialDetailsStubs() { + return { + selectedItem: signal(null), + selectedContentType: signal<'series'>('series'), + isFavorite: signal(false), + isLoadingDetails: signal(false), + detailsError: signal(null), + currentPlaylist: signal(initialPlaylist), + fetchSerialDetailsWithMetadata: jest.fn(), + cancelDetailsRequest: jest.fn(), + checkFavoriteStatus: jest.fn(), + constructEpisodeStreamUrl: jest.fn(), + addRecentItem: jest.fn(), + openResolvedPlayback: jest.fn(), + openExternalPlayback: jest.fn(), + savePlaybackPosition: jest.fn(), + clearPlaybackPosition: jest.fn(), + savePlaybackPositionsBatch: jest.fn(), + clearPlaybackPositionsBatch: jest.fn(), + loadAllPositions: jest.fn(), + isEmbeddedPlayer: jest.fn(), + getSeriesPlaybackPositions: jest.fn().mockResolvedValue([]), + seriesResumeTarget: signal(null), + routeParams: new BehaviorSubject({ + categoryId: '3', + serialId: '103', + }), + /** The page's subscription to streamed position updates, once made. */ + positionUpdates: { + callback: null as ((data: PlaybackPositionData) => void) | null, + }, + }; +} + +export type SerialDetailsStubs = ReturnType; + +/** Back to the state a fresh `beforeEach` expects. */ +export function resetSerialDetailsStubs(stubs: SerialDetailsStubs): void { + stubs.selectedItem.set(createSeriesFixture()); + stubs.isFavorite.set(false); + stubs.isLoadingDetails.set(false); + stubs.detailsError.set(null); + stubs.currentPlaylist.set(initialPlaylist); + stubs.fetchSerialDetailsWithMetadata.mockClear(); + stubs.cancelDetailsRequest.mockClear(); + stubs.checkFavoriteStatus.mockClear(); + stubs.constructEpisodeStreamUrl.mockReset(); + stubs.constructEpisodeStreamUrl.mockImplementation( + (episode: { id: string | number }) => + `http://xtream.example/series/${episode.id}.mp4` + ); + stubs.addRecentItem.mockClear(); + stubs.openResolvedPlayback.mockReset(); + stubs.openResolvedPlayback.mockResolvedValue(undefined); + stubs.openExternalPlayback.mockReset(); + stubs.openExternalPlayback.mockResolvedValue(undefined); + stubs.savePlaybackPosition.mockReset(); + stubs.savePlaybackPosition.mockResolvedValue(undefined); + stubs.clearPlaybackPosition.mockReset(); + stubs.clearPlaybackPosition.mockResolvedValue(undefined); + stubs.savePlaybackPositionsBatch.mockReset(); + stubs.savePlaybackPositionsBatch.mockResolvedValue(undefined); + stubs.clearPlaybackPositionsBatch.mockReset(); + stubs.clearPlaybackPositionsBatch.mockResolvedValue(undefined); + stubs.loadAllPositions.mockReset(); + stubs.loadAllPositions.mockResolvedValue(undefined); + stubs.positionUpdates.callback = null; + stubs.isEmbeddedPlayer.mockReset(); + stubs.isEmbeddedPlayer.mockReturnValue(false); + stubs.getSeriesPlaybackPositions.mockClear(); + stubs.getSeriesPlaybackPositions.mockResolvedValue([]); + stubs.seriesResumeTarget.set(null); + stubs.routeParams.next({ categoryId: '3', serialId: '103' }); +} + +export async function configureSerialDetailsTestBed( + stubs: SerialDetailsStubs +): Promise { + await TestBed.configureTestingModule({ + imports: [SerialDetailsComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + params: stubs.routeParams, + snapshot: { + params: { + categoryId: '3', + serialId: '103', + }, + }, + }, + }, + { + provide: XtreamStore, + useValue: { + selectedItem: stubs.selectedItem, + selectedContentType: stubs.selectedContentType, + isFavorite: stubs.isFavorite, + isLoadingDetails: stubs.isLoadingDetails, + detailsError: stubs.detailsError, + currentPlaylist: stubs.currentPlaylist, + fetchSerialDetailsWithMetadata: + stubs.fetchSerialDetailsWithMetadata, + cancelDetailsRequest: stubs.cancelDetailsRequest, + checkFavoriteStatus: stubs.checkFavoriteStatus, + setSelectedItem: jest.fn((value: unknown) => + stubs.selectedItem.set(value) + ), + toggleFavorite: jest.fn(), + constructEpisodeStreamUrl: stubs.constructEpisodeStreamUrl, + addRecentItem: stubs.addRecentItem, + backfillContentMetadata: jest.fn(), + loadAllPositions: stubs.loadAllPositions, + recentItems: signal([]), + serialCategories: signal([]), + loadRecentItems: jest.fn(), + }, + }, + { + provide: SettingsStore, + useValue: { player: signal(VideoPlayer.Html5Player) }, + }, + { + provide: DownloadsService, + useValue: { isAvailable: signal(false) }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { + activeSession: signal(null), + }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getSeriesPlaybackPositions: + stubs.getSeriesPlaybackPositions, + savePlaybackPosition: stubs.savePlaybackPosition, + clearPlaybackPosition: stubs.clearPlaybackPosition, + savePlaybackPositionsBatch: + stubs.savePlaybackPositionsBatch, + clearPlaybackPositionsBatch: + stubs.clearPlaybackPositionsBatch, + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: stubs.isEmbeddedPlayer, + openResolvedPlayback: stubs.openResolvedPlayback, + openExternalPlayback: stubs.openExternalPlayback, + }, + }, + { + provide: PlaybackPositionRuntimeBridgeService, + useValue: { + onPlaybackPositionUpdate: ( + callback: (data: PlaybackPositionData) => void + ) => { + stubs.positionUpdates.callback = callback; + return () => { + stubs.positionUpdates.callback = null; + }; + }, + }, + }, + { + provide: XTREAM_SERIES_RESUME_TARGET, + useValue: stubs.seriesResumeTarget, + }, + { + provide: MatSnackBar, + useValue: { + open: jest.fn(), + }, + }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + onLangChange: EMPTY, + onTranslationChange: EMPTY, + onDefaultLangChange: EMPTY, + }, + }, + { + provide: Location, + useValue: { + back: jest.fn(), + }, + }, + ], + }) + .overrideComponent(SerialDetailsComponent, { + remove: { + imports: [ + MatIcon, + PortalInlinePlayerComponent, + SeasonContainerComponent, + TranslatePipe, + ], + }, + add: { + imports: [ + StubMatIconComponent, + StubPortalInlinePlayerComponent, + StubSeasonContainerComponent, + MockPipe( + TranslatePipe, + (value: string | null | undefined) => value ?? '' + ), + ], + }, + }) + .compileComponents(); +} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.season-watch.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.season-watch.spec.ts new file mode 100644 index 000000000..a4e9252b2 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.season-watch.spec.ts @@ -0,0 +1,267 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { SerialDetailsComponent } from './serial-details.component'; +import { SerialDetailsPlaybackService } from './serial-details-playback.service'; +import { + configureSerialDetailsTestBed, + createSerialDetailsStubs, + resetSerialDetailsStubs, +} from './serial-details.harness'; + +describe('SerialDetailsComponent watched toggles', () => { + let fixture: ComponentFixture; + const stubs = createSerialDetailsStubs(); + const { + currentPlaylist, + savePlaybackPosition, + clearPlaybackPosition, + savePlaybackPositionsBatch, + clearPlaybackPositionsBatch, + loadAllPositions, + } = stubs; + + beforeEach(async () => { + window.history.replaceState({}, '', window.location.href); + resetSerialDetailsStubs(stubs); + await configureSerialDetailsTestBed(stubs); + + fixture = TestBed.createComponent(SerialDetailsComponent); + }); + + afterEach(() => { + window.history.replaceState({}, '', window.location.href); + fixture?.destroy(); + }); + + it('saves and clears positions for season-container toggle requests', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + const playbackService = fixture.debugElement.injector.get( + SerialDetailsPlaybackService + ); + await playbackService.handlePlaybackToggleRequested({ + contentXtreamId: 1001, + nextPosition: { + playlistId: 'xtream-1', + contentXtreamId: 1001, + contentType: 'episode', + seriesXtreamId: 103, + seasonNumber: 1, + episodeNumber: 1, + positionSeconds: 950, + durationSeconds: 1000, + }, + } as never); + expect(savePlaybackPosition).toHaveBeenCalledWith( + 'xtream-1', + expect.objectContaining({ contentXtreamId: 1001 }) + ); + + await playbackService.handlePlaybackToggleRequested({ + contentXtreamId: 1001, + nextPosition: null, + } as never); + expect(clearPlaybackPosition).toHaveBeenCalledWith( + 'xtream-1', + 1001, + 'episode' + ); + }); + + it('marks a season watched through one batch save and updates rendered positions', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + const playbackService = fixture.debugElement.injector.get( + SerialDetailsPlaybackService + ); + const snackBar = TestBed.inject(MatSnackBar); + const seasonPosition = ( + contentXtreamId: number, + episodeNumber: number + ) => ({ + playlistId: 'xtream-1', + contentXtreamId, + contentType: 'episode' as const, + seriesXtreamId: 103, + seasonNumber: 1, + episodeNumber, + positionSeconds: 1200, + durationSeconds: 1200, + }); + + await playbackService.handleWatchToggleRequested( + { + seasonKey: '1', + markWatched: true, + requests: [ + { + contentXtreamId: 1001, + nextPosition: seasonPosition(1001, 1), + }, + { + contentXtreamId: 1002, + nextPosition: seasonPosition(1002, 2), + }, + ], + } as never, + 'season' + ); + + expect(savePlaybackPositionsBatch).toHaveBeenCalledTimes(1); + expect(savePlaybackPositionsBatch).toHaveBeenCalledWith('xtream-1', [ + expect.objectContaining({ contentXtreamId: 1001 }), + expect.objectContaining({ contentXtreamId: 1002 }), + ]); + expect(savePlaybackPosition).not.toHaveBeenCalled(); + expect(playbackService.episodePlaybackPositions().get(1001)).toEqual( + expect.objectContaining({ positionSeconds: 1200 }) + ); + expect( + playbackService.episodePlaybackPositions().get(1002) + ).toBeDefined(); + expect(snackBar.open).toHaveBeenCalledWith( + 'XTREAM.SEASON_MARKED_WATCHED', + undefined, + { duration: 5000 } + ); + // The catalog badge source must follow the batch. + expect(loadAllPositions).toHaveBeenCalledWith('xtream-1'); + expect(playbackService.seasonWatchBatchRunning()).toBe(false); + }); + + it('unwatches a season through one batch clear', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + const playbackService = fixture.debugElement.injector.get( + SerialDetailsPlaybackService + ); + await playbackService.handleWatchToggleRequested( + { + seasonKey: '1', + markWatched: false, + requests: [ + { contentXtreamId: 1001, nextPosition: null }, + { contentXtreamId: 1002, nextPosition: null }, + ], + } as never, + 'season' + ); + + expect(clearPlaybackPositionsBatch).toHaveBeenCalledTimes(1); + expect(clearPlaybackPositionsBatch).toHaveBeenCalledWith('xtream-1', [ + { contentXtreamId: 1001, contentType: 'episode' }, + { contentXtreamId: 1002, contentType: 'episode' }, + ]); + expect(clearPlaybackPosition).not.toHaveBeenCalled(); + expect(playbackService.episodePlaybackPositions().has(1001)).toBe( + false + ); + }); + + it('does not write a stale season batch into another playlist state', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + let resolveBatch!: () => void; + savePlaybackPositionsBatch.mockImplementation( + () => + new Promise((resolve) => { + resolveBatch = resolve; + }) + ); + const playbackService = fixture.debugElement.injector.get( + SerialDetailsPlaybackService + ); + const pending = playbackService.handleWatchToggleRequested( + { + seasonKey: '1', + markWatched: true, + requests: [ + { + contentXtreamId: 1001, + nextPosition: { + playlistId: 'xtream-1', + contentXtreamId: 1001, + contentType: 'episode', + seriesXtreamId: 103, + seasonNumber: 1, + episodeNumber: 1, + positionSeconds: 1200, + durationSeconds: 1200, + }, + }, + ], + } as never, + 'season' + ); + + // The user navigates to another playlist while the batch is pending. + const initialPlaylist = currentPlaylist(); + currentPlaylist.set({ ...initialPlaylist, id: 'xtream-2' }); + resolveBatch(); + await pending; + + expect(savePlaybackPositionsBatch).toHaveBeenCalledWith( + 'xtream-1', + expect.anything() + ); + expect(playbackService.episodePlaybackPositions().has(1001)).toBe( + false + ); + expect(TestBed.inject(MatSnackBar).open).not.toHaveBeenCalled(); + // The store now belongs to the other playlist — no stale refresh. + expect(loadAllPositions).not.toHaveBeenCalled(); + expect(playbackService.seasonWatchBatchRunning()).toBe(false); + currentPlaylist.set(initialPlaylist); + }); + + it('keeps rendered positions and reports the error when the season batch fails', async () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + savePlaybackPositionsBatch.mockRejectedValue(new Error('batch failed')); + fixture.detectChanges(); + await fixture.whenStable(); + + const playbackService = fixture.debugElement.injector.get( + SerialDetailsPlaybackService + ); + const snackBar = TestBed.inject(MatSnackBar); + await playbackService.handleWatchToggleRequested( + { + seasonKey: '1', + markWatched: true, + requests: [ + { + contentXtreamId: 1001, + nextPosition: { + playlistId: 'xtream-1', + contentXtreamId: 1001, + contentType: 'episode', + seriesXtreamId: 103, + seasonNumber: 1, + episodeNumber: 1, + positionSeconds: 1200, + durationSeconds: 1200, + }, + }, + ], + } as never, + 'season' + ); + + expect(playbackService.episodePlaybackPositions().has(1001)).toBe( + false + ); + expect(snackBar.open).toHaveBeenCalledWith( + 'XTREAM.SEASON_WATCH_UPDATE_FAILED', + undefined, + { duration: 5000 } + ); + expect(playbackService.seasonWatchBatchRunning()).toBe(false); + consoleError.mockRestore(); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.test-stubs.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.test-stubs.ts new file mode 100644 index 000000000..a093e78f9 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.test-stubs.ts @@ -0,0 +1,70 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core'; +import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access'; + +/** Stand-ins for the heavy children of the series-details page in specs. */ + +@Component({ + selector: 'app-season-container', + standalone: true, + changeDetection: ChangeDetectionStrategy.Eager, + template: '
', +}) +export class StubSeasonContainerComponent { + readonly seasons = input(null); + readonly seriesId = input(null); + readonly playlistId = input(''); + readonly seriesTitle = input(undefined); + readonly playbackPositions = input(null); + readonly downloadAdapter = input(null); + readonly downloadsEnabled = input(true); + readonly openingEpisodeId = input(null); + readonly activeEpisodeId = input(null); + readonly playingEpisodeId = input(null); + readonly seasonDescriptions = input(null); + readonly seasonPosters = input(null); + readonly seasonWatchBatchRunning = input(false); + readonly episodeClicked = output(); + readonly playbackToggleRequested = output(); + readonly seasonPlaybackToggleRequested = output(); +} + +@Component({ + selector: 'app-portal-inline-player', + standalone: true, + changeDetection: ChangeDetectionStrategy.Eager, + template: '', +}) +export class StubPortalInlinePlayerComponent { + readonly playbackSessionKey = input.required(); + readonly playback = input(null); + readonly episodeMetadata = input(null); + readonly seriesTitle = input(null); + readonly seriesNavigation = input(null); + readonly upNextEpisodes = input(null); + readonly seriesEpisodes = input(null); + readonly seasonPosters = input(null); + readonly episodePlaybackPositions = input(null); + readonly seasonLoadStates = input(null); + readonly timeUpdate = output(); + readonly closed = output(); + readonly streamUrlCopied = output(); + readonly externalFallbackRequested = output(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); + readonly upNextEpisodeSelected = output(); + readonly episodePanelSeasonSelected = output(); +} + +@Component({ + selector: 'mat-icon', + standalone: true, + changeDetection: ChangeDetectionStrategy.Eager, + template: '', +}) +export class StubMatIconComponent {} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-episode-playback.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-episode-playback.ts new file mode 100644 index 000000000..28766621f --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-episode-playback.ts @@ -0,0 +1,58 @@ +import type { + PlayerContentInfo, + ResolvedPortalPlayback, + XtreamSerieEpisode, +} from '@iptvnator/shared/interfaces'; +import { + resolveSeriesPlaybackEpisodeState, + type SeriesPlaybackEpisodeState, +} from '@iptvnator/ui/playback'; +import type { XtreamSerieDetailsView } from './serial-details-playback.service'; + +export interface SerialEpisodePlaybackRequest { + readonly playlistId: string; + readonly selectedItem: XtreamSerieDetailsView; + readonly episode: XtreamSerieEpisode; + readonly streamUrl: string; + /** The episode's saved offset, when it has one. */ + readonly startTime: number | undefined; +} + +export interface SerialEpisodePlayback { + readonly playback: ResolvedPortalPlayback; + readonly episodeState: SeriesPlaybackEpisodeState | null; +} + +/** + * The playback an episode of the series on screen resolves to, and its place + * among the series' episodes for the inline player's navigation. + */ +export function buildSerialEpisodePlayback( + request: SerialEpisodePlaybackRequest +): SerialEpisodePlayback { + const { episode, selectedItem } = request; + const contentInfo: PlayerContentInfo = { + playlistId: request.playlistId, + contentXtreamId: Number(episode.id), + contentType: 'episode', + seriesXtreamId: Number(selectedItem.series_id), + seasonNumber: Number(episode.season), + episodeNumber: Number(episode.episode_num), + }; + + const playback: ResolvedPortalPlayback = { + streamUrl: request.streamUrl, + title: episode.title, + thumbnail: selectedItem.info.cover, + startTime: request.startTime, + contentInfo, + }; + + const episodeState = resolveSeriesPlaybackEpisodeState({ + episodesBySeason: selectedItem.episodes, + currentEpisodeId: episode.id, + fallbackSeasonNumber: Number(episode.season), + fallbackEpisodeNumber: Number(episode.episode_num), + }); + return { playback, episodeState }; +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts index 653897d79..7535070e5 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts @@ -2,6 +2,7 @@ import { Injectable, Signal, computed, inject, signal } from '@angular/core'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { createMovieDownloadSnapshot } from '@iptvnator/portal/shared/util'; import { DownloadsService } from '@iptvnator/services'; +import { DialogService } from '@iptvnator/ui/components'; import { resolveXtreamVodPlaybackSource } from '@iptvnator/portal/xtream/data-access'; import { getXtreamVodInfo, @@ -54,6 +55,7 @@ export class VodDetailsDownloadsService { private readonly downloadsService = inject(DownloadsService); private readonly xtreamStore = inject(XtreamStore); private readonly translateService = inject(TranslateService); + private readonly dialogService = inject(DialogService); private routeContentId: Signal = signal(NaN); @@ -124,6 +126,20 @@ export class VodDetailsDownloadsService { : null; }); + /** 2πr of the r=15.5 progress-ring circle in its 36×36 viewBox. */ + readonly ringCircumference = 2 * Math.PI * 15.5; + + /** + * Dash offset that leaves the arc at the real percent — or a fixed + * quarter arc when the total size is unknown and the ring spins instead. + */ + readonly ringOffset = computed(() => { + const percent = this.downloadPercent(); + return percent === null + ? this.ringCircumference * 0.75 + : this.ringCircumference * (1 - percent / 100); + }); + /** Cancel whatever download is running or queued for this movie. */ async cancelActive(): Promise { const item = this.activeDownload(); @@ -132,6 +148,28 @@ export class VodDetailsDownloadsService { } } + /** + * A running download is destroyed by one click, so the icon button asks + * first — there is no label left to warn what the click does. + */ + promptCancel(): void { + this.dialogService.openConfirmDialog({ + title: this.translateService.instant( + 'DOWNLOADS.CANCEL_CONFIRM_TITLE' + ), + message: this.translateService.instant( + 'DOWNLOADS.CANCEL_CONFIRM_MESSAGE' + ), + confirmLabel: this.translateService.instant( + 'DOWNLOADS.CANCEL_CONFIRM_TITLE' + ), + // "Cancel" next to "Cancel download" would read as the same action. + cancelLabel: this.translateService.instant('CLOSE'), + tone: 'destructive', + onConfirm: () => void this.cancelActive(), + }); + } + /** Open the finished file's location in the system file manager. */ async revealDownloaded(): Promise { const playlistId = this.xtreamStore.currentPlaylist()?.id; diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-launch.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-launch.ts index 7575eee04..233353a99 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-launch.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-launch.ts @@ -1,4 +1,9 @@ -import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces'; +import { signal } from '@angular/core'; +import type { + ExternalPlayerSession, + ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import type { ExternalLaunchOwner } from './vod-details-external-launch-owner'; interface OwnedExternalLaunchOptions { launch: Promise; @@ -63,3 +68,44 @@ function isClosableExternalLaunch( (session.status === 'error' && session.canClose)) ); } + +/** + * The MPV/VLC launch a VOD page made last and the one it still waits on. + * + * What was launched is remembered independently of the controller's active + * source, so a refresh or an overlapping handoff cannot make the exact + * process this page launched look foreign before its teardown has been + * confirmed. Its route owner prevents a reused component from attributing + * that process to a different movie. + */ +export class VodExternalLaunchClaim { + private launchedGeneration = 0; + /** Start generation of the launch still awaiting its player, if any. */ + readonly pendingGeneration = signal(null); + + constructor(private readonly owner: ExternalLaunchOwner) {} + + claim(playback: ResolvedPortalPlayback, generation: number): void { + this.owner.set(playback.contentInfo); + this.launchedGeneration = generation; + this.pendingGeneration.set(generation); + } + + /** An inline start took over: nothing external is owned or awaited. */ + release(): void { + this.owner.clear(); + this.pendingGeneration.set(null); + } + + clearOwnership(generation: number): void { + if (this.launchedGeneration === generation) { + this.owner.clear(); + } + } + + clearPending(generation: number): void { + if (this.pendingGeneration() === generation) { + this.pendingGeneration.set(null); + } + } +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-hero.presenter.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-hero.presenter.ts index 5093b4cdb..6ef9f4c61 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-hero.presenter.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-hero.presenter.ts @@ -10,17 +10,20 @@ import { type RemainingTimeLabel, } from '@iptvnator/portal/shared/util'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; -import type { - PlaybackPositionData, - TmdbCountryFacet, - TmdbEnrichedCastMember, - TmdbGenreFacet, - XtreamVodInfo, +import { + youtubeEmbedUrl, + type PlaybackPositionData, + type TmdbCountryFacet, + type TmdbEnrichedCastMember, + type TmdbGenreFacet, + type XtreamVodInfo, } from '@iptvnator/shared/interfaces'; +import { SettingsStore } from '@iptvnator/services'; import type { CrossPortalSimilarItem } from '@iptvnator/services'; import { castMembersFromNames, splitPeopleNames, + TrailerDialogService, type DetailActionButtonState, type SimilarRailItem, } from '@iptvnator/ui/components'; @@ -54,17 +57,21 @@ interface VodDetailsHeroBindings { readonly formatPosition: () => string; readonly similarItems: Signal; readonly similarInPortals: Signal; + readonly openSimilar: (item: SimilarCatalogItem) => void; + readonly openSimilarInPortals: (item: CrossPortalSimilarItem) => void; } /** * Presentation of the Xtream movie hero: the kind label, the chips, the - * primary button's two lines, the resume bar, the credits and the Similar - * rail, all derived from the route's selected movie. + * primary button's two lines, the resume bar, the credits, the trailer and + * the Similar rail, all derived from the route's selected movie. */ @Injectable() export class VodDetailsHeroPresenter { private readonly translate = inject(TranslateService); private readonly xtreamStore = inject(XtreamStore); + private readonly settingsStore = inject(SettingsStore); + private readonly trailerDialog = inject(TrailerDialogService); private readonly bindings = signal(null); bind(bindings: VodDetailsHeroBindings): void { @@ -208,6 +215,45 @@ export class VodDetailsHeroPresenter { return [...local, ...crossPortal]; }); + readonly trailerEmbedUrl = computed(() => + youtubeEmbedUrl(this.info()?.youtube_trailer) + ); + /** Settings → Playback → Play trailers in details background. */ + readonly trailerBackdropUrl = computed(() => + this.settingsStore.detailTrailerBackdrop?.() === true + ? this.trailerEmbedUrl() + : null + ); + + openTrailer(): void { + const embedUrl = this.trailerEmbedUrl(); + const title = this.info()?.name; + if (embedUrl) { + this.trailerDialog.open({ embedUrl, title: title ?? '' }); + } + } + + openSimilarRailItem(item: SimilarRailItem): void { + const bindings = this.bindings(); + const local = bindings + ?.similarItems() + .find((candidate) => `c${candidate.id}` === item.key); + if (local) { + bindings?.openSimilar(local); + return; + } + const crossPortal = bindings + ?.similarInPortals() + .find( + (candidate) => + `x${candidate.match.playlistId}-${candidate.match.xtreamId}` === + item.key + ); + if (crossPortal) { + bindings?.openSimilarInPortals(crossPortal); + } + } + private label(label: RemainingTimeLabel | null): string | null { return label ? this.translate.instant(label.key, label.params) : null; } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index b43a64d66..8984fe555 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -32,7 +32,6 @@ import { import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback'; import { closeRunningExternalSession, - ownsContent, runningExternalSession, } from './vod-details-external-session'; import { @@ -40,13 +39,16 @@ import { startRouteOwnedPlayback, } from './vod-details-external-launch-owner'; import { injectXtreamRecentHistory } from '../xtream-recent-history'; -import { settleOwnedExternalLaunch } from './vod-details-external-launch'; +import { + settleOwnedExternalLaunch, + VodExternalLaunchClaim, +} from './vod-details-external-launch'; import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation'; +import { VodDetailsPositionState } from './vod-details-position-state'; import { hasVodResetTarget, type VodResetTarget, } from './vod-details-reset-target'; -import { isResumablePosition } from './vod-primary-action-position'; export interface VodDetailsPlaybackBindings { /** Current vod id resolved from the route */ @@ -83,8 +85,6 @@ export class VodDetailsPlaybackService { PlaybackPositionRuntimeBridgeService ); private readonly logger = createLogger('VodDetailsPlayback'); - private positionLoadGeneration = 0; - private destroyed = false; /** Signals bound from the host component via `bind()` */ private readonly bindings = signal(null); @@ -92,27 +92,23 @@ export class VodDetailsPlaybackService { () => this.xtreamStore.currentPlaylist()?.id, () => this.bindings()?.vodId() ); + /** What this page launched externally, see {@link VodExternalLaunchClaim}. */ + private readonly externalLaunch = new VodExternalLaunchClaim( + this.externalLaunchOwner + ); + + /** Stored positions of the page, see {@link VodDetailsPositionState}. */ + private readonly positions = new VodDetailsPositionState({ + playlistId: () => this.xtreamStore.currentPlaylist()?.id, + contentId: () => this.bindings()?.vodId(), + alternative: () => this.bindings()?.activeSource?.() ?? null, + }); readonly inlinePlayback = signal(null); - /** - * The LAST position seen, whichever copy produced it. - * - * Multi-source can put playback on a copy in another playlist, and this - * follows it — the progress bar and the switch feed both want the stream - * on screen, not the one the route happens to address. - */ - readonly vodPlaybackPosition = signal(null); - - /** - * The ROUTE copy's own row. - * - * Everything that acts on the route's stream — Resume, its label, its - * timecode — has to read this instead. Positions are keyed by (playlist, - * stream), so once an alternative has played, `vodPlaybackPosition` names - * a different film's row entirely and resuming from it would jump the - * route copy to a timecode nobody reached in it. - */ - readonly routePlaybackPosition = signal(null); + /** The LAST position seen, whichever copy produced it. */ + readonly vodPlaybackPosition = this.positions.last; + /** The ROUTE copy's own row: what Resume, its label and its timecode read. */ + readonly routePlaybackPosition = this.positions.route; private readonly externalButton = createExternalPlaybackButtonState({ session: this.externalPlayback.activeSession, @@ -131,59 +127,27 @@ export class VodDetailsPlaybackService { readonly externalPrimaryIcon = this.externalButton.primaryIcon; readonly isExternalLaunchPending = computed( () => - this.externalLaunchGeneration() !== null || + this.externalLaunch.pendingGeneration() !== null || this.externalButton.isLaunchPending() ); readonly isExternalStopAction = this.externalButton.isStopAction; readonly externalPrimaryButtonState = this.externalButton.buttonState; - /** Mirrors an incoming position into the route's row when it owns it. */ - private trackPosition(position: PlaybackPositionData | null): void { - this.vodPlaybackPosition.set(position); - if (this.isRouteContent(position)) { - this.routePlaybackPosition.set(position); - } - } - - private isRouteContent(position: PlaybackPositionData | null): boolean { - return ( - !!position && - position.playlistId === this.xtreamStore.currentPlaylist()?.id && - position.contentXtreamId === this.bindings()?.vodId() - ); - } /** Whether the ROUTE copy has somewhere to resume from. */ - readonly hasPlaybackPosition = computed(() => - isResumablePosition(this.routePlaybackPosition()) - ); + readonly hasPlaybackPosition = this.positions.hasResumable; constructor() { const unsubscribePositionUpdates = this.playbackPositionBridge.onPlaybackPositionUpdate( - (data: PlaybackPositionData) => { - // An external player on an ALTERNATIVE reports under - // that playlist's ids; dropping those rewinds a switch. - if (this.ownsContent(data)) { - this.trackPosition(data); - } - } + (data: PlaybackPositionData) => this.positions.trackOwned(data) ) ?? null; inject(DestroyRef).onDestroy(() => { - this.destroyed = true; - this.positionLoadGeneration++; + this.positions.destroy(); unsubscribePositionUpdates?.(); }); } - private ownsContent(info: Parameters[0]): boolean { - return ownsContent(info, { - routePlaylistId: this.xtreamStore.currentPlaylist()?.id, - routeContentId: this.bindings()?.vodId(), - alternative: this.bindings()?.activeSource?.() ?? null, - }); - } - /** Wires the host component's context signals. Call once at construction. */ bind(bindings: VodDetailsPlaybackBindings): void { this.bindings.set(bindings); @@ -285,7 +249,7 @@ export class VodDetailsPlaybackService { playlistId, position ), - onSaved: (position) => this.trackPosition(position), + onSaved: (position) => this.positions.track(position), }); /** @@ -318,7 +282,7 @@ export class VodDetailsPlaybackService { const routeIdentity = this.externalLaunchOwner.captureRoute(); this.bindings()?.supersedePendingSwitch(); const generation = ++this.startGeneration; - this.claimExternalLaunch(playback, generation); + this.externalLaunch.claim(playback, generation); const launch = this.portalPlayer.openExternalPlayback(playback, player); void this.settleExternalLaunch( generation, @@ -334,32 +298,16 @@ export class VodDetailsPlaybackService { * answer that read started from. */ discardPendingPositionLoads(): void { - this.positionLoadGeneration++; + this.positions.discardPendingLoads(); } /** The route copy's row is in hand; fails closed while a read is in flight. */ - readonly positionLoaded = signal(false); + readonly positionLoaded = this.positions.loaded; - async loadPosition(playlistId: string, vodId: number): Promise { - const generation = ++this.positionLoadGeneration; - this.positionLoaded.set(false); - const position = await this.playbackPositions.getPlaybackPosition( - playlistId, - vodId, - 'vod' + loadPosition(playlistId: string, vodId: number): Promise { + return this.positions.load(playlistId, vodId, () => + this.playbackPositions.getPlaybackPosition(playlistId, vodId, 'vod') ); - if ( - this.destroyed || - generation !== this.positionLoadGeneration || - this.xtreamStore.currentPlaylist()?.id !== playlistId || - this.bindings()?.vodId() !== vodId - ) { - return; - } - - this.vodPlaybackPosition.set(position); - this.routePlaybackPosition.set(position); - this.positionLoaded.set(true); } /** @@ -374,18 +322,6 @@ export class VodDetailsPlaybackService { /** Bumped by every start; only the newest may launch after its close. */ private startGeneration = 0; - /** - * What we last launched externally, remembered independently of the - * controller's active source. - * - * Kept independently of the controller so a refresh or an overlapping - * handoff cannot make the exact process this page launched look foreign - * before its teardown has been confirmed. Its route owner prevents a - * reused component from attributing that process to a different movie. - */ - private launchedExternallyGeneration = 0; - private readonly externalLaunchGeneration = signal(null); - /** * Starts still between the click and `inlinePlayback` / the external * launch (closing the previous player is a round-trip). The watched @@ -421,7 +357,10 @@ export class VodDetailsPlaybackService { isCurrent: () => boolean = () => true, player?: ExternalPlayerName ): Promise { - if (this.externalLaunchGeneration() !== null || this.resetPending()) { + if ( + this.externalLaunch.pendingGeneration() !== null || + this.resetPending() + ) { return false; } const runningSession = runningExternalSession( @@ -497,28 +436,18 @@ export class VodDetailsPlaybackService { this.positionWriter.reset(); if (!player && this.portalPlayer.isEmbeddedPlayer()) { this.inlinePlayback.set(playback); - this.externalLaunchOwner.clear(); - this.externalLaunchGeneration.set(null); + this.externalLaunch.release(); return true; } this.closeInlinePlayer(); - this.claimExternalLaunch(playback, generation); + this.externalLaunch.claim(playback, generation); const launch = player ? this.portalPlayer.openExternalPlayback(playback, player) : this.portalPlayer.openResolvedPlayback(playback, true); return await this.settleExternalLaunch(generation, isCurrent, launch); } - private claimExternalLaunch( - playback: ResolvedPortalPlayback, - generation: number - ): void { - this.externalLaunchOwner.set(playback.contentInfo); - this.launchedExternallyGeneration = generation; - this.externalLaunchGeneration.set(generation); - } - private async settleExternalLaunch( generation: number, isCurrent: () => boolean, @@ -535,20 +464,9 @@ export class VodDetailsPlaybackService { ), reportLaunchFailure: (error) => this.bindings()?.reportExternalLaunchFailure?.(error), - clearPending: () => this.clearExternalLaunchPending(generation), - clearOwnership: () => this.clearExternalLaunchOwnership(generation), + clearPending: () => this.externalLaunch.clearPending(generation), + clearOwnership: () => + this.externalLaunch.clearOwnership(generation), }); } - - private clearExternalLaunchOwnership(generation: number): void { - if (this.launchedExternallyGeneration === generation) { - this.externalLaunchOwner.clear(); - } - } - - private clearExternalLaunchPending(generation: number): void { - if (this.externalLaunchGeneration() === generation) { - this.externalLaunchGeneration.set(null); - } - } } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-position-state.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-position-state.ts new file mode 100644 index 000000000..24571b753 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-position-state.ts @@ -0,0 +1,121 @@ +import { computed, signal } from '@angular/core'; +import type { + PlaybackPositionData, + PlayerContentInfo, +} from '@iptvnator/shared/interfaces'; +import { ownsContent } from './vod-details-external-session'; +import { isResumablePosition } from './vod-primary-action-position'; + +/** The copies the page's position state belongs to. */ +export interface VodDetailsPositionRoute { + readonly playlistId: () => string | undefined; + readonly contentId: () => number | undefined; + /** The alternative currently in use, if it is not the route's own. */ + readonly alternative: () => PlayerContentInfo | null; +} + +/** + * Stored playback positions of the VOD details page: the last one seen, the + * route copy's own row, and the guarded read that fills both. + */ +export class VodDetailsPositionState { + private loadGeneration = 0; + private destroyed = false; + + /** + * The LAST position seen, whichever copy produced it. + * + * Multi-source can put playback on a copy in another playlist, and this + * follows it — the progress bar and the switch feed both want the stream + * on screen, not the one the route happens to address. + */ + readonly last = signal(null); + + /** + * The ROUTE copy's own row. + * + * Everything that acts on the route's stream — Resume, its label, its + * timecode — has to read this instead. Positions are keyed by (playlist, + * stream), so once an alternative has played, `last` names a different + * film's row entirely and resuming from it would jump the route copy to + * a timecode nobody reached in it. + */ + readonly route = signal(null); + + /** Whether the ROUTE copy has somewhere to resume from. */ + readonly hasResumable = computed(() => isResumablePosition(this.route())); + + /** The route copy's row is in hand; fails closed while a read is in flight. */ + readonly loaded = signal(false); + + constructor(private readonly routeCopy: VodDetailsPositionRoute) {} + + /** Mirrors an incoming position into the route's row when it owns it. */ + track(position: PlaybackPositionData | null): void { + this.last.set(position); + if (this.isRouteContent(position)) { + this.route.set(position); + } + } + + /** + * Takes a streamed position when the page owns its content. An external + * player on an ALTERNATIVE reports under that playlist's ids; dropping + * those rewinds a switch. + */ + trackOwned(position: PlaybackPositionData): void { + const owned = { + routePlaylistId: this.routeCopy.playlistId(), + routeContentId: this.routeCopy.contentId(), + alternative: this.routeCopy.alternative(), + }; + if (ownsContent(position, owned)) { + this.track(position); + } + } + + /** + * Retires every stored-position read still in flight. A row written + * since (the manual watched toggle) must not be overwritten by the older + * answer that read started from. + */ + discardPendingLoads(): void { + this.loadGeneration++; + } + + /** The page is gone: no read still in flight may land. */ + destroy(): void { + this.destroyed = true; + this.loadGeneration++; + } + + async load( + playlistId: string, + vodId: number, + read: () => Promise + ): Promise { + const generation = ++this.loadGeneration; + this.loaded.set(false); + const position = await read(); + if ( + this.destroyed || + generation !== this.loadGeneration || + this.routeCopy.playlistId() !== playlistId || + this.routeCopy.contentId() !== vodId + ) { + return; + } + + this.last.set(position); + this.route.set(position); + this.loaded.set(true); + } + + private isRouteContent(position: PlaybackPositionData | null): boolean { + return ( + !!position && + position.playlistId === this.routeCopy.playlistId() && + position.contentXtreamId === this.routeCopy.contentId() + ); + } +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index ffa450009..68b956e63 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -8,13 +8,10 @@ import { effect, inject, signal, - untracked, } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; import { MatIcon } from '@angular/material/icon'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { MatTooltip } from '@angular/material/tooltip'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { CastCrewRowComponent, @@ -24,90 +21,40 @@ import { DetailIconButtonComponent, DetailMetaTemplateDirective, DetailTagsTemplateDirective, - DialogService, MetaChipComponent, PortalDetailShellComponent, SimilarRailComponent, type SimilarRailItem, - TrailerDialogService, ViewInPortalActionComponent, VodMoreMenuComponent, + scrollToCastCrewRow, } from '@iptvnator/ui/components'; -import { - createDiscoverFacetNavigation, - createLogger, - isProviderOnlyDetailState, -} from '@iptvnator/portal/shared/util'; +import { createLogger } from '@iptvnator/portal/shared/util'; import { registerContentMetadataBackfill, - resolveXtreamVodPlaybackSource, XtreamStore, } from '@iptvnator/portal/xtream/data-access'; import { type PlaybackFallbackRequest, PortalInlinePlayerComponent, } from '@iptvnator/ui/playback'; +import { DownloadsService, SettingsStore } from '@iptvnator/services'; import { - CrossPortalSimilarItem, - CrossPortalSimilarService, - DownloadsService, - SettingsStore, - TmdbEnrichmentService, -} from '@iptvnator/services'; -import { - getXtreamVodInfo, - normalizeTitleKeys, - playlistDisplayLabel, - reportsPlaybackFailures, TmdbEnrichedCastMember, - XtreamCategory, XtreamVodDetails, XtreamVodInfo, - XtreamVodStream, - youtubeEmbedUrl, type ExternalPlayerName, - type PlaybackPositionData, - type VodSourceCandidate, - type VodSourceDescriptor, } from '@iptvnator/shared/interfaces'; -import { - SimilarCatalogItem, - matchRecommendationsToCatalog, -} from '../tmdb-similar.util'; -import { - buildXtreamVodFallbackViewModel, - hasUsableXtreamVodMetadata, -} from './vod-details-fallback.util'; +import { injectXtreamDetailNavigation } from '../xtream-detail-navigation'; import { VodDetailsPlaybackService } from './vod-details-playback.service'; import { VodDetailsMultiSourceUiService } from './vod-details-multi-source-ui.service'; import { VodDetailsDownloadsService } from './vod-details-downloads.service'; import { VodDetailsWatchedService } from './vod-details-watched.service'; import { VodDetailsHeroPresenter } from './vod-details-hero.presenter'; import { VodDetailsMenuService } from './vod-details-menu.service'; +import { VodDetailsSelectionService } from './vod-details-selection.service'; import { VodDetailsSimilarService } from './vod-details-similar.service'; import { VodMultiSourceHostService } from './vod-multi-source-host.service'; -import { resolveVodMultiSourceMovie } from './vod-multi-source-identity'; -import { createPlaybackSessionKey } from '@iptvnator/playback/util'; - -type XtreamVodIdentityItem = XtreamVodDetails & { - readonly id?: number | string; - readonly stream_id?: number | string; - readonly xtream_id?: number | string; -}; - -function resolveVodIdentity(item: XtreamVodDetails): number | null { - const candidate = item as XtreamVodIdentityItem; - const value = - item.movie_data?.stream_id ?? - candidate.xtream_id ?? - candidate.stream_id ?? - candidate.id; - const id = typeof value === 'string' ? Number(value) : value; - - return typeof id === 'number' && Number.isSafeInteger(id) && id > 0 - ? id - : null; -} @Component({ templateUrl: './vod-details-route.component.html', @@ -117,6 +64,7 @@ function resolveVodIdentity(item: XtreamVodDetails): number | null { ], changeDetection: ChangeDetectionStrategy.OnPush, providers: [ + VodDetailsSelectionService, VodDetailsPlaybackService, VodMultiSourceHostService, VodDetailsMultiSourceUiService, @@ -131,7 +79,6 @@ function resolveVodIdentity(item: XtreamVodDetails): number | null { DetailMetaTemplateDirective, DetailTagsTemplateDirective, MatIcon, - MatTooltip, NgTemplateOutlet, PortalDetailShellComponent, ViewInPortalActionComponent, @@ -151,11 +98,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { private readonly location = inject(Location); private readonly settingsStore = inject(SettingsStore); private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - private readonly crossPortalSimilar = inject(CrossPortalSimilarService); + private readonly navigation = injectXtreamDetailNavigation('movie'); private readonly xtreamStore = inject(XtreamStore); private readonly downloadsService = inject(DownloadsService); - private readonly dialogService = inject(DialogService); private readonly snackBar = inject(MatSnackBar); private readonly translateService = inject(TranslateService); private readonly playback = inject(VodDetailsPlaybackService); @@ -167,7 +112,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { private readonly watched = inject(VodDetailsWatchedService); readonly hero = inject(VodDetailsHeroPresenter); readonly menu = inject(VodDetailsMenuService); - private readonly trailerDialog = inject(TrailerDialogService); + /** The movie the route addresses, see {@link VodDetailsSelectionService}. */ + private readonly selection = inject(VodDetailsSelectionService); private readonly logger = createLogger('VodDetailsRoute'); /** `playlistId:vodId` of the last initialized detail view */ private readonly lastInitKey = signal(null); @@ -176,167 +122,22 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { /** The route copy's own row — what Resume acts on. */ readonly routePlaybackPosition = this.playback.routePlaybackPosition; - /** - * Reactive route params: the component is reused when navigating - * between two VOD details (e.g. via the Similar rail), so computeds - * must not read the one-shot snapshot. - */ - private readonly routeParams = toSignal(this.route.params, { - initialValue: this.route.snapshot.params, - }); - readonly theme = this.settingsStore.theme; readonly isElectron = this.downloadsService.isAvailable; readonly isFavorite = this.xtreamStore.isFavorite; readonly isWatched = this.watched.isWatched; readonly canToggleWatched = this.watched.canToggle; - readonly selectedVodId = computed(() => - Number(this.routeParams()['vodId']) - ); - readonly playbackSessionKey = computed(() => { - const sourceId = this.xtreamStore.currentPlaylist()?.id; - const contentId = this.selectedVodId(); - return sourceId && Number.isFinite(contentId) && contentId > 0 - ? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId }) - : ''; - }); - readonly providerOnly = computed(() => { - this.routeParams(); - return isProviderOnlyDetailState(window.history.state); - }); - readonly selectedItem = computed(() => { - const item = - this.xtreamStore.selectedItem() as unknown as XtreamVodDetails | null; - - return item && resolveVodIdentity(item) === this.selectedVodId() - ? item - : null; - }); - private readonly scopedVodCategories = computed(() => { - const playlistId = this.xtreamStore.currentPlaylist()?.id; - return playlistId && - this.xtreamStore.vodCategoriesPlaylistId() === playlistId - ? this.xtreamStore.vodCategories() - : []; - }); - private readonly scopedVodStreams = computed(() => { - const playlistId = this.xtreamStore.currentPlaylist()?.id; - return playlistId && - this.xtreamStore.vodStreamsPlaylistId() === playlistId - ? this.xtreamStore.vodStreams() - : []; - }); - readonly selectedCategory = computed | null>(() => { - const categoryId = this.routeParams()['categoryId']; - if (!categoryId) { - return null; - } - - return ( - this.scopedVodCategories().find( - (category) => - String( - ( - category as XtreamCategory & { - id?: string | number; - } - ).category_id ?? - ( - category as XtreamCategory & { - id?: string | number; - } - ).id - ) === String(categoryId) - ) ?? null - ); - }); - readonly selectedCatalogItem = computed< - | (Partial & { - id?: string | number; - poster_url?: string; - title?: string; - xtream_id?: string | number; - }) - | null - >(() => { - const vodId = this.selectedVodId(); - if (!Number.isFinite(vodId) || vodId <= 0) { - return null; - } - - return ( - this.scopedVodStreams().find( - (item) => - Number( - ( - item as XtreamVodStream & { - id?: string | number; - xtream_id?: string | number; - } - ).xtream_id ?? - ( - item as XtreamVodStream & { - id?: string | number; - } - ).stream_id ?? - ( - item as XtreamVodStream & { - id?: string | number; - } - ).id - ) === vodId - ) ?? null - ); - }); - /** Movie identity for multi-source discovery; null until a title exists */ - private readonly multiSourceMovie = computed(() => { - // Electron stores categories under `name`, the live API under - // `category_name` — the same duality the fallback view reads. - const category = this.selectedCategory() as { - name?: string; - category_name?: string; - } | null; - - return resolveVodMultiSourceMovie({ - playlistId: this.xtreamStore.currentPlaylist()?.id, - // `title` is the alias the Xtream data source actually writes - // (createPlaylist maps name -> title), so reading only `name` - // would fall back to the raw playlist UUID in the sources list. - playlistName: - this.xtreamStore.currentPlaylist()?.name ?? - this.xtreamStore.currentPlaylist()?.title, - vodId: this.selectedVodId(), - vodInfo: this.selectedVodInfo(), - catalogItem: this.selectedCatalogItem(), - containerExtension: - this.selectedItem()?.movie_data?.container_extension, - categoryName: category?.name ?? category?.category_name ?? null, - }); - }); - readonly selectedVodInfo = computed(() => { - const item = this.selectedItem(); - return item && hasUsableXtreamVodMetadata(item) - ? getXtreamVodInfo(item) - : null; - }); - readonly playableVodItem = computed(() => { - const item = this.selectedItem(); - return item && resolveXtreamVodPlaybackSource(item) ? item : null; - }); - readonly fallbackView = computed(() => { - const item = this.selectedItem(); - if (!item || this.selectedVodInfo()) { - return null; - } - - return buildXtreamVodFallbackViewModel({ - vodDetails: item, - catalogItem: this.selectedCatalogItem(), - category: this.selectedCategory(), - vodId: this.selectedVodId(), - }); - }); + readonly selectedVodId = this.selection.selectedVodId; + readonly playbackSessionKey = this.selection.playbackSessionKey; + readonly providerOnly = this.selection.providerOnly; + readonly selectedItem = this.selection.selectedItem; + readonly selectedCategory = this.selection.selectedCategory; + readonly selectedCatalogItem = this.selection.selectedCatalogItem; + private readonly multiSourceMovie = this.selection.multiSourceMovie; + readonly selectedVodInfo = this.selection.selectedVodInfo; + readonly playableVodItem = this.selection.playableVodItem; + readonly fallbackView = this.selection.fallbackView; readonly isLoadingDetails = this.xtreamStore.isLoadingDetails; readonly detailsError = this.xtreamStore.detailsError; readonly matchedExternalPlayback = this.playback.matchedExternalPlayback; @@ -362,38 +163,19 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.isDownloaded() && this.externalPrimaryButtonState() === 'idle' ); - /** 2πr of the r=15.5 progress-ring circle in its 36×36 viewBox. */ - readonly downloadRingCircumference = 2 * Math.PI * 15.5; - - /** - * Dash offset that leaves the arc at the real percent — or a fixed - * quarter arc when the total size is unknown and the ring spins instead. - */ - readonly downloadRingOffset = computed(() => { - const percent = this.downloadPercent(); - return percent === null - ? this.downloadRingCircumference * 0.75 - : this.downloadRingCircumference * (1 - percent / 100); - }); + readonly downloadRingCircumference = this.downloads.ringCircumference; + readonly downloadRingOffset = this.downloads.ringOffset; /** Drives the heart's brief scale pulse when favoriting toggles. */ readonly favoritePulse = signal(false); private favoritePulseTimer: ReturnType | null = null; - readonly trailerEmbedUrl = computed(() => - youtubeEmbedUrl(this.selectedVodInfo()?.youtube_trailer) - ); - /** `playlist:vod`: the hero keys its one-time layout decision on it. */ - readonly contentKey = computed( - () => - `xtream-vod:${this.xtreamStore.currentPlaylist()?.id ?? ''}:${this.selectedVodId()}` - ); - /** Settings → Playback → Play trailers in details background. */ - readonly trailerBackdropUrl = computed(() => - this.settingsStore.detailTrailerBackdrop?.() === true - ? this.trailerEmbedUrl() - : null - ); + readonly trailerEmbedUrl = this.hero.trailerEmbedUrl; + readonly contentKey = this.selection.contentKey; + readonly trailerBackdropUrl = this.hero.trailerBackdropUrl; + readonly scrollToCast = scrollToCastCrewRow; + /** Clickable year/genre/country chips (Discover pages) */ + readonly discover = this.navigation.discover; readonly similarItems = this.similar.similarItems; readonly similarInPortals = this.similar.similarInPortals; @@ -511,6 +293,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { formatPosition: () => this.formatPosition(), similarItems: this.similarItems, similarInPortals: this.similarInPortals, + openSimilar: (item) => this.navigation.openSimilar(item), + openSimilarInPortals: (item) => + this.navigation.openSimilarInPortals(item), }); this.menu.bind({ item: this.playableVodItem, @@ -538,73 +323,18 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { } } - openSimilarInPortals(item: CrossPortalSimilarItem): void { - void this.router.navigate(this.crossPortalSimilar.buildLink(item)); - } - - openSimilar(item: SimilarCatalogItem): void { - void this.router.navigate(['../..', item.categoryId, item.id], { - relativeTo: this.route, - }); - } - openSimilarRailItem(item: SimilarRailItem): void { - const local = this.similarItems().find( - (candidate) => `c${candidate.id}` === item.key - ); - if (local) { - this.openSimilar(local); - return; - } - const crossPortal = this.similarInPortals().find( - (candidate) => - `x${candidate.match.playlistId}-${candidate.match.xtreamId}` === - item.key - ); - if (crossPortal) { - this.openSimilarInPortals(crossPortal); - } + this.hero.openSimilarRailItem(item); } openTrailer(): void { - const embedUrl = this.trailerEmbedUrl(); - const title = this.selectedVodInfo()?.name; - if (embedUrl) { - this.trailerDialog.open({ embedUrl, title: title ?? '' }); - } - } - - scrollToCast(): void { - document - .getElementById('detail-cast-crew') - ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + this.hero.openTrailer(); } openActor(member: TmdbEnrichedCastMember): void { - const playlistId = this.xtreamStore.currentPlaylist()?.id; - if (!playlistId || !member.tmdbPersonId) { - return; - } - void this.router.navigate([ - '/workspace/xtreams', - playlistId, - 'actor', - member.tmdbPersonId, - ]); + this.navigation.openActor(member); } - /** Clickable year/genre/country chips (Discover pages) */ - private readonly tmdbEnrichment = inject(TmdbEnrichmentService); - - readonly discover = createDiscoverFacetNavigation(() => { - const playlistId = this.xtreamStore.currentPlaylist()?.id; - // Discover reads its results from TMDB, so a chip must not offer a - // page that enrichment cannot fill - return playlistId && this.tmdbEnrichment.isEnabled() - ? { portal: 'xtream', mediaType: 'movie', playlistId } - : null; - }); - ngOnDestroy(): void { if (this.favoritePulseTimer) { clearTimeout(this.favoritePulseTimer); @@ -869,26 +599,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { return this.downloads.resumePaused(); } - /** - * A running download is destroyed by one click, so the icon button asks - * first — there is no label left to warn what the click does. - */ promptCancelDownload(): void { - this.dialogService.openConfirmDialog({ - title: this.translateService.instant( - 'DOWNLOADS.CANCEL_CONFIRM_TITLE' - ), - message: this.translateService.instant( - 'DOWNLOADS.CANCEL_CONFIRM_MESSAGE' - ), - confirmLabel: this.translateService.instant( - 'DOWNLOADS.CANCEL_CONFIRM_TITLE' - ), - // "Cancel" next to "Cancel download" would read as the same action. - cancelLabel: this.translateService.instant('CLOSE'), - tone: 'destructive', - onConfirm: () => void this.downloads.cancelActive(), - }); + this.downloads.promptCancel(); } revealDownloadedFile(): Promise { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-selection.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-selection.service.ts new file mode 100644 index 000000000..cc4458b67 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-selection.service.ts @@ -0,0 +1,213 @@ +import { Injectable, computed, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute } from '@angular/router'; +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; +import { isProviderOnlyDetailState } from '@iptvnator/portal/shared/util'; +import { + resolveXtreamVodPlaybackSource, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; +import { + getXtreamVodInfo, + XtreamCategory, + XtreamVodDetails, + XtreamVodStream, +} from '@iptvnator/shared/interfaces'; +import { + buildXtreamVodFallbackViewModel, + hasUsableXtreamVodMetadata, +} from './vod-details-fallback.util'; +import { resolveVodMultiSourceMovie } from './vod-multi-source-identity'; + +type XtreamVodIdentityItem = XtreamVodDetails & { + readonly id?: number | string; + readonly stream_id?: number | string; + readonly xtream_id?: number | string; +}; + +function resolveVodIdentity(item: XtreamVodDetails): number | null { + const candidate = item as XtreamVodIdentityItem; + const value = + item.movie_data?.stream_id ?? + candidate.xtream_id ?? + candidate.stream_id ?? + candidate.id; + const id = typeof value === 'string' ? Number(value) : value; + + return typeof id === 'number' && Number.isSafeInteger(id) && id > 0 + ? id + : null; +} + +/** + * What the VOD details route selects: the movie the params address, as the + * store, the loaded catalog and the route's category describe it. Read-only + * derivations of the route and the store, shared by the page and the + * services it binds. + */ +@Injectable() +export class VodDetailsSelectionService { + private readonly route = inject(ActivatedRoute); + private readonly xtreamStore = inject(XtreamStore); + + /** + * Reactive route params: the component is reused when navigating + * between two VOD details (e.g. via the Similar rail), so computeds + * must not read the one-shot snapshot. + */ + private readonly routeParams = toSignal(this.route.params, { + initialValue: this.route.snapshot.params, + }); + + readonly selectedVodId = computed(() => + Number(this.routeParams()['vodId']) + ); + readonly playbackSessionKey = computed(() => { + const sourceId = this.xtreamStore.currentPlaylist()?.id; + const contentId = this.selectedVodId(); + return sourceId && Number.isFinite(contentId) && contentId > 0 + ? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId }) + : ''; + }); + readonly providerOnly = computed(() => { + this.routeParams(); + return isProviderOnlyDetailState(window.history.state); + }); + readonly selectedItem = computed(() => { + const item = + this.xtreamStore.selectedItem() as unknown as XtreamVodDetails | null; + + return item && resolveVodIdentity(item) === this.selectedVodId() + ? item + : null; + }); + private readonly scopedVodCategories = computed(() => { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + return playlistId && + this.xtreamStore.vodCategoriesPlaylistId() === playlistId + ? this.xtreamStore.vodCategories() + : []; + }); + private readonly scopedVodStreams = computed(() => { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + return playlistId && + this.xtreamStore.vodStreamsPlaylistId() === playlistId + ? this.xtreamStore.vodStreams() + : []; + }); + readonly selectedCategory = computed | null>(() => { + const categoryId = this.routeParams()['categoryId']; + if (!categoryId) { + return null; + } + + return ( + this.scopedVodCategories().find( + (category) => + String( + ( + category as XtreamCategory & { + id?: string | number; + } + ).category_id ?? + ( + category as XtreamCategory & { + id?: string | number; + } + ).id + ) === String(categoryId) + ) ?? null + ); + }); + readonly selectedCatalogItem = computed< + | (Partial & { + id?: string | number; + poster_url?: string; + title?: string; + xtream_id?: string | number; + }) + | null + >(() => { + const vodId = this.selectedVodId(); + if (!Number.isFinite(vodId) || vodId <= 0) { + return null; + } + + return ( + this.scopedVodStreams().find( + (item) => + Number( + ( + item as XtreamVodStream & { + id?: string | number; + xtream_id?: string | number; + } + ).xtream_id ?? + ( + item as XtreamVodStream & { + id?: string | number; + } + ).stream_id ?? + ( + item as XtreamVodStream & { + id?: string | number; + } + ).id + ) === vodId + ) ?? null + ); + }); + /** Movie identity for multi-source discovery; null until a title exists */ + readonly multiSourceMovie = computed(() => { + // Electron stores categories under `name`, the live API under + // `category_name` — the same duality the fallback view reads. + const category = this.selectedCategory() as { + name?: string; + category_name?: string; + } | null; + + return resolveVodMultiSourceMovie({ + playlistId: this.xtreamStore.currentPlaylist()?.id, + // `title` is the alias the Xtream data source actually writes + // (createPlaylist maps name -> title), so reading only `name` + // would fall back to the raw playlist UUID in the sources list. + playlistName: + this.xtreamStore.currentPlaylist()?.name ?? + this.xtreamStore.currentPlaylist()?.title, + vodId: this.selectedVodId(), + vodInfo: this.selectedVodInfo(), + catalogItem: this.selectedCatalogItem(), + containerExtension: + this.selectedItem()?.movie_data?.container_extension, + categoryName: category?.name ?? category?.category_name ?? null, + }); + }); + readonly selectedVodInfo = computed(() => { + const item = this.selectedItem(); + return item && hasUsableXtreamVodMetadata(item) + ? getXtreamVodInfo(item) + : null; + }); + readonly playableVodItem = computed(() => { + const item = this.selectedItem(); + return item && resolveXtreamVodPlaybackSource(item) ? item : null; + }); + readonly fallbackView = computed(() => { + const item = this.selectedItem(); + if (!item || this.selectedVodInfo()) { + return null; + } + + return buildXtreamVodFallbackViewModel({ + vodDetails: item, + catalogItem: this.selectedCatalogItem(), + category: this.selectedCategory(), + vodId: this.selectedVodId(), + }); + }); + /** `playlist:vod`: the hero keys its one-time layout decision on it. */ + readonly contentKey = computed( + () => + `xtream-vod:${this.xtreamStore.currentPlaylist()?.id ?? ''}:${this.selectedVodId()}` + ); +} diff --git a/libs/portal/xtream/feature/src/lib/xtream-detail-navigation.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-detail-navigation.spec.ts new file mode 100644 index 000000000..aa6cc02c7 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-detail-navigation.spec.ts @@ -0,0 +1,114 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { + CrossPortalSimilarService, + TmdbEnrichmentService, +} from '@iptvnator/services'; +import { + injectXtreamDetailNavigation, + type XtreamDetailNavigation, +} from './xtream-detail-navigation'; + +describe('injectXtreamDetailNavigation', () => { + const route = { snapshot: { params: {} } }; + const navigate = jest.fn(); + const buildLink = jest.fn(); + const currentPlaylist = signal<{ id: string } | null>(null); + const enrichmentEnabled = signal(true); + + function create(mediaType: 'movie' | 'tv'): XtreamDetailNavigation { + return TestBed.runInInjectionContext(() => + injectXtreamDetailNavigation(mediaType) + ); + } + + beforeEach(() => { + navigate.mockReset(); + buildLink.mockReset(); + currentPlaylist.set({ id: 'xtream-1' }); + enrichmentEnabled.set(true); + TestBed.configureTestingModule({ + providers: [ + { provide: Router, useValue: { navigate } }, + { provide: ActivatedRoute, useValue: route }, + { provide: XtreamStore, useValue: { currentPlaylist } }, + { provide: CrossPortalSimilarService, useValue: { buildLink } }, + { + provide: TmdbEnrichmentService, + useValue: { isEnabled: () => enrichmentEnabled() }, + }, + ], + }); + }); + + it('opens the actor page of a TMDB-matched cast member', () => { + create('movie').openActor({ name: 'Mara', tmdbPersonId: 77 } as never); + + expect(navigate).toHaveBeenCalledWith([ + '/workspace/xtreams', + 'xtream-1', + 'actor', + 77, + ]); + }); + + it('opens no actor page without a playlist or a TMDB person', () => { + const navigation = create('movie'); + + navigation.openActor({ name: 'Mara' } as never); + currentPlaylist.set(null); + navigation.openActor({ name: 'Mara', tmdbPersonId: 77 } as never); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('opens a similar title of this playlist relative to the details route', () => { + create('tv').openSimilar({ categoryId: 12, id: 345 } as never); + + expect(navigate).toHaveBeenCalledWith(['../..', 12, 345], { + relativeTo: route, + }); + }); + + it('opens a similar title of another portal through its link', () => { + const item = { match: { playlistId: 'xtream-2' } } as never; + const link = ['/workspace/xtreams', 'xtream-2', 'series', '3', '9']; + buildLink.mockReturnValue(link); + + create('tv').openSimilarInPortals(item); + + expect(buildLink).toHaveBeenCalledWith(item); + expect(navigate).toHaveBeenCalledWith(link); + }); + + it.each(['movie', 'tv'] as const)( + 'sends the facet chips to the %s Discover page of the playlist', + (mediaType) => { + const { discover } = create(mediaType); + + expect(discover.canOpenYear('1999-03-31')).toBe(true); + discover.openYear('1999-03-31'); + + expect(navigate).toHaveBeenCalledWith( + ['/workspace', 'xtreams', 'xtream-1', 'discover'], + { queryParams: { type: mediaType, year: '1999' } } + ); + } + ); + + it('offers no facet chip while enrichment cannot fill Discover', () => { + const { discover } = create('movie'); + + enrichmentEnabled.set(false); + expect(discover.canOpenYear('1999-03-31')).toBe(false); + + enrichmentEnabled.set(true); + currentPlaylist.set(null); + expect(discover.canOpenYear('1999-03-31')).toBe(false); + discover.openYear('1999-03-31'); + + expect(navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/xtream-detail-navigation.ts b/libs/portal/xtream/feature/src/lib/xtream-detail-navigation.ts new file mode 100644 index 000000000..f0c2d93a2 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-detail-navigation.ts @@ -0,0 +1,73 @@ +import { inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + createDiscoverFacetNavigation, + type DiscoverFacetNavigation, +} from '@iptvnator/portal/shared/util'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { + CrossPortalSimilarItem, + CrossPortalSimilarService, + TmdbEnrichmentService, +} from '@iptvnator/services'; +import type { TmdbEnrichedCastMember } from '@iptvnator/shared/interfaces'; +import type { SimilarCatalogItem } from './tmdb-similar.util'; + +/** Where an Xtream movie or series details page can send the viewer. */ +export interface XtreamDetailNavigation { + /** Clickable year/genre/country chips (Discover pages) */ + readonly discover: DiscoverFacetNavigation; + /** The actor page of a TMDB-matched cast member. */ + openActor(member: TmdbEnrichedCastMember): void; + /** A Similar-rail title of this playlist: the same route, another id. */ + openSimilar(item: SimilarCatalogItem): void; + /** A Similar-rail title found in another of the user's portals. */ + openSimilarInPortals(item: CrossPortalSimilarItem): void; +} + +/** + * Outbound navigation shared by the Xtream movie (`movie`) and series (`tv`) + * details pages. + * + * Must run in an injection context. + */ +export function injectXtreamDetailNavigation( + mediaType: 'movie' | 'tv' +): XtreamDetailNavigation { + const router = inject(Router); + const route = inject(ActivatedRoute); + const xtreamStore = inject(XtreamStore); + const crossPortalSimilar = inject(CrossPortalSimilarService); + const tmdbEnrichment = inject(TmdbEnrichmentService); + + return { + discover: createDiscoverFacetNavigation(() => { + const playlistId = xtreamStore.currentPlaylist()?.id; + // Discover reads its results from TMDB, so a chip must not offer a + // page that enrichment cannot fill + return playlistId && tmdbEnrichment.isEnabled() + ? { portal: 'xtream', mediaType, playlistId } + : null; + }), + openActor(member) { + const playlistId = xtreamStore.currentPlaylist()?.id; + if (!playlistId || !member.tmdbPersonId) { + return; + } + void router.navigate([ + '/workspace/xtreams', + playlistId, + 'actor', + member.tmdbPersonId, + ]); + }, + openSimilar(item) { + void router.navigate(['../..', item.categoryId, item.id], { + relativeTo: route, + }); + }, + openSimilarInPortals(item) { + void router.navigate(crossPortalSimilar.buildLink(item)); + }, + }; +} diff --git a/libs/ui/components/src/lib/season-container/episode-subline.util.ts b/libs/ui/components/src/lib/season-container/episode-subline.util.ts new file mode 100644 index 000000000..2b73f8b50 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/episode-subline.util.ts @@ -0,0 +1,37 @@ +import type { TranslateService } from '@ngx-translate/core'; +import { + formatDurationLabel, + formatRemainingLabel, + isPortalPlaybackWatched, +} from '@iptvnator/portal/shared/util'; +import { + PlaybackPositionData, + XtreamSerieEpisodeInfo, +} from '@iptvnator/shared/interfaces'; +import { + episodeRuntimeSeconds, + formatEpisodePositionText, +} from './episode-progress.util'; + +/** + * The line under an episode card's title, built from the episode's runtime + * and its saved playback position: + * "42 min · 18m left", "42 min · watched", "42 min" — or null. + */ +export function buildEpisodeSubline( + info: XtreamSerieEpisodeInfo | undefined, + position: PlaybackPositionData | undefined, + translate: Pick +): string | null { + const duration = formatDurationLabel(episodeRuntimeSeconds(info)); + const remaining = formatRemainingLabel(position); + const parts = [ + duration ? translate.instant(duration.key, duration.params) : null, + isPortalPlaybackWatched(position) + ? translate.instant('PORTALS.DETAIL.WATCHED') + : remaining + ? translate.instant(remaining.key, remaining.params) + : formatEpisodePositionText(position), + ].filter((part): part is string => !!part); + return parts.length ? parts.join(' · ') : null; +} diff --git a/libs/ui/components/src/lib/season-container/season-auto-select.state.ts b/libs/ui/components/src/lib/season-container/season-auto-select.state.ts new file mode 100644 index 000000000..d7a4ce22e --- /dev/null +++ b/libs/ui/components/src/lib/season-container/season-auto-select.state.ts @@ -0,0 +1,136 @@ +import { + type Signal, + type WritableSignal, + computed, + effect, + untracked, +} from '@angular/core'; +import { + PlaybackPositionData, + XtreamSerieEpisode, +} from '@iptvnator/shared/interfaces'; +import { resolveAutoSelectedSeason } from './season-auto-select.util'; + +export interface SeasonAutoSelectSources { + /** + * The container's selection (see `SeasonContainerComponent.selectedSeason`). + * Auto-select writes to it; user tab clicks write to it too and stick + * until the auto-select key changes. + */ + readonly selectedSeason: WritableSignal; + readonly seasons: Signal>; + /** Season keys in display order. */ + readonly sortedSeasonKeys: Signal; + /** Season key of the inline-playing episode, if it is in the loaded set. */ + readonly playingSeasonKey: Signal; + readonly playbackPositions: Signal< + ReadonlyMap + >; + readonly positionOf: ( + episode: XtreamSerieEpisode + ) => PlaybackPositionData | undefined; + /** Stalker lazy-VOD: some seasons' episode lists are not loaded yet. */ + readonly hasUnloadedSeasons: Signal; + readonly episodeCounts: Signal>; + readonly watchedCounts: Signal>; + readonly emitSeasonSelected: (seasonKey: string) => void; +} + +export interface SeasonAutoSelectState { + /** + * Record that this session toggled watched state itself: the positions + * flip that follows is then not treated as an initial load. + */ + markLocalWatchedMutation(): void; +} + +/** Key of the loaded season that holds the episode with this content id. */ +export function findSeasonOfEpisode( + seasons: Record, + episodeId: number +): string | null { + for (const [key, episodes] of Object.entries(seasons)) { + if (episodes?.some((episode) => Number(episode.id) === episodeId)) { + return key; + } + } + return null; +} + +/** + * Season auto-selection and `seasonSelected` emission of + * `SeasonContainerComponent`. The selection re-resolves when the season key + * set changes or when playback positions first arrive; ongoing position + * saves do not reset it — only the empty→loaded transition of the positions + * map does, and even that is ignored once this session toggled watched state + * itself. + * + * Registers two effects, so it must be called in an injection context (the + * container's constructor). + */ +export function createSeasonAutoSelectState( + sources: SeasonAutoSelectSources +): SeasonAutoSelectState { + const autoSelectKey = computed( + () => + `${sources.sortedSeasonKeys().join('|')}::${ + sources.playbackPositions().size > 0 ? '1' : '0' + }` + ); + let lastAutoSelectKey: string | null = null; + let lastAutoSelectSeasonSet: string | null = null; + /** + * True once this session toggled watched state itself. From then on an + * empty↔loaded flip of the positions map is the echo of that action, not + * an async initial load — re-resolving on it would yank the user off the + * season they just marked (e.g. all-watched season 1 → jump to season 2). + */ + let hasLocalWatchedMutation = false; + let lastEmittedSeason: string | undefined; + + /** Auto-select rules live in season-auto-select.util.ts. */ + const resolveAutoSeason = (): string | undefined => + resolveAutoSelectedSeason({ + keys: sources.sortedSeasonKeys(), + playingSeasonKey: sources.playingSeasonKey(), + seasons: sources.seasons(), + positionOf: sources.positionOf, + hasUnloadedSeasons: sources.hasUnloadedSeasons(), + episodeCounts: sources.episodeCounts(), + watchedCounts: sources.watchedCounts(), + }); + + effect(() => { + const key = autoSelectKey(); + if (key === lastAutoSelectKey) { + return; + } + const seasonSet = untracked(() => sources.sortedSeasonKeys().join('|')); + const seasonSetUnchanged = seasonSet === lastAutoSelectSeasonSet; + lastAutoSelectKey = key; + lastAutoSelectSeasonSet = seasonSet; + // A positions-emptiness flip after a local watched toggle keeps + // the current selection; only the async initial positions load + // (or a season-set change) re-resolves the season. + if (seasonSetUnchanged && hasLocalWatchedMutation) { + return; + } + sources.selectedSeason.set(untracked(() => resolveAutoSeason())); + }); + + // Fire the lazy-load/enrichment hooks for auto-selected seasons too — + // with tabs there is no initial "pick a season" click anymore. + effect(() => { + const selected = sources.selectedSeason(); + if (selected && selected !== lastEmittedSeason) { + lastEmittedSeason = selected; + sources.emitSeasonSelected(selected); + } + }); + + return { + markLocalWatchedMutation: () => { + hasLocalWatchedMutation = true; + }, + }; +} diff --git a/libs/ui/components/src/lib/season-container/season-container.component.ts b/libs/ui/components/src/lib/season-container/season-container.component.ts index 26b265c13..c857f5530 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.ts +++ b/libs/ui/components/src/lib/season-container/season-container.component.ts @@ -4,12 +4,10 @@ import { ElementRef, OnInit, computed, - effect, inject, input, output, signal, - untracked, } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; @@ -21,8 +19,6 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { type SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access'; import { createLogger, - formatDurationLabel, - formatRemainingLabel, getPortalPlaybackProgressPercent, isPortalPlaybackInProgress, isPortalPlaybackWatched, @@ -38,11 +34,13 @@ import { EpisodeInfoDialogComponent, buildEpisodeInfoDialogData, } from './episode-info-dialog.component'; +import { formatEpisodePositionText } from './episode-progress.util'; +import { buildEpisodeSubline } from './episode-subline.util'; import { - formatEpisodePositionText, - episodeRuntimeSeconds, -} from './episode-progress.util'; -import { resolveAutoSelectedSeason } from './season-auto-select.util'; + type SeasonAutoSelectState, + createSeasonAutoSelectState, + findSeasonOfEpisode, +} from './season-auto-select.state'; import { SeasonDownloadPresenter } from './season-download-presenter'; import { SeasonTabsComponent } from './season-tabs.component'; import { SeasonWatchPresenter } from './season-watch-presenter'; @@ -80,7 +78,8 @@ export class SeasonContainerComponent implements OnInit { private readonly translate = inject(TranslateService); private readonly host = inject>(ElementRef); private readonly logger = createLogger('SeasonContainer'); - private lastEmittedSeason: string | undefined; + /** Auto-select + `seasonSelected` emission; wired in the constructor. */ + private readonly autoSelect: SeasonAutoSelectState; readonly downloadPresenter = inject(SeasonDownloadPresenter); readonly watchPresenter = inject(SeasonWatchPresenter); @@ -148,9 +147,12 @@ export class SeasonContainerComponent implements OnInit { }); /** Season key of the inline-playing episode, if it is in the loaded set. */ - readonly playingSeasonKey = computed(() => - this.findSeasonOfEpisode(this.playingEpisodeId()) - ); + readonly playingSeasonKey = computed(() => { + const episodeId = this.playingEpisodeId(); + return episodeId === null + ? null + : findSeasonOfEpisode(this.seasons(), episodeId); + }); /** * Selected season. Auto-resolves when the season key set changes or when @@ -171,22 +173,6 @@ export class SeasonContainerComponent implements OnInit { return selected ? (this.seasons()[selected] ?? []) : []; }); - private readonly autoSelectKey = computed( - () => - `${this.sortedSeasonKeys().join('|')}::${ - this.playbackPositions().size > 0 ? '1' : '0' - }` - ); - private lastAutoSelectKey: string | null = null; - private lastAutoSelectSeasonSet: string | null = null; - /** - * True once this session toggled watched state itself. From then on an - * empty↔loaded flip of the positions map is the echo of that action, not - * an async initial load — re-resolving on it would yank the user off the - * season they just marked (e.g. all-watched season 1 → jump to season 2). - */ - private hasLocalWatchedMutation = false; - /** * Show thumbnails in the list view only when episodes have genuinely * distinct stills (TMDB or per-episode provider art). When every episode @@ -254,44 +240,29 @@ export class SeasonContainerComponent implements OnInit { openingEpisodeId: this.openingEpisodeId, isEpisodeWatched: (episode) => this.isEpisodeWatched(episode), emitSeasonToggle: (request) => { - this.hasLocalWatchedMutation = true; + this.autoSelect.markLocalWatchedMutation(); this.seasonPlaybackToggleRequested.emit(request); }, emitSeriesToggle: (request) => { - this.hasLocalWatchedMutation = true; + this.autoSelect.markLocalWatchedMutation(); this.seriesPlaybackToggleRequested.emit(request); }, }); - effect(() => { - const key = this.autoSelectKey(); - if (key === this.lastAutoSelectKey) { - return; - } - const seasonSet = untracked(() => - this.sortedSeasonKeys().join('|') - ); - const seasonSetUnchanged = - seasonSet === this.lastAutoSelectSeasonSet; - this.lastAutoSelectKey = key; - this.lastAutoSelectSeasonSet = seasonSet; - // A positions-emptiness flip after a local watched toggle keeps - // the current selection; only the async initial positions load - // (or a season-set change) re-resolves the season. - if (seasonSetUnchanged && this.hasLocalWatchedMutation) { - return; - } - this.selectedSeason.set(untracked(() => this.resolveAutoSeason())); - }); - - // Fire the lazy-load/enrichment hooks for auto-selected seasons too — - // with tabs there is no initial "pick a season" click anymore. - effect(() => { - const selected = this.selectedSeason(); - if (selected && selected !== this.lastEmittedSeason) { - this.lastEmittedSeason = selected; - this.seasonSelected.emit(selected); - } + // Auto-select rules and the effects driving them live in + // season-auto-select.state.ts / season-auto-select.util.ts. + this.autoSelect = createSeasonAutoSelectState({ + selectedSeason: this.selectedSeason, + seasons: this.seasons, + sortedSeasonKeys: this.sortedSeasonKeys, + playingSeasonKey: this.playingSeasonKey, + playbackPositions: this.playbackPositions, + positionOf: (episode) => this.getEpisodePosition(episode), + hasUnloadedSeasons: this.hasUnloadedSeasons, + episodeCounts: this.episodeCounts, + watchedCounts: this.watchedCounts, + emitSeasonSelected: (seasonKey) => + this.seasonSelected.emit(seasonKey), }); } @@ -376,7 +347,7 @@ export class SeasonContainerComponent implements OnInit { this.logger.warn('Cannot toggle watched: no playlist ID'); return; } - this.hasLocalWatchedMutation = true; + this.autoSelect.markLocalWatchedMutation(); const contentXtreamId = this.getEpisodeContentId(episode); const currentPosition = this.getEpisodePosition(episode); @@ -434,21 +405,11 @@ export class SeasonContainerComponent implements OnInit { /** "42 min · 18m left", "42 min · watched", "42 min" — or null. */ getEpisodeSubline(episode: XtreamSerieEpisode): string | null { - const info = this.getEpisodeInfo(episode); - const duration = formatDurationLabel(episodeRuntimeSeconds(info)); - const position = this.playbackPositions().get(Number(episode.id)); - const remaining = formatRemainingLabel(position); - const parts = [ - duration - ? this.translate.instant(duration.key, duration.params) - : null, - this.isEpisodeWatched(episode) - ? this.translate.instant('PORTALS.DETAIL.WATCHED') - : remaining - ? this.translate.instant(remaining.key, remaining.params) - : this.getEpisodePositionText(episode), - ].filter((part): part is string => !!part); - return parts.length ? parts.join(' · ') : null; + return buildEpisodeSubline( + this.getEpisodeInfo(episode), + this.getEpisodePosition(episode), + this.translate + ); } getEpisodePositionText(episode: XtreamSerieEpisode): string | null { @@ -464,33 +425,4 @@ export class SeasonContainerComponent implements OnInit { ): PlaybackPositionData | undefined { return this.playbackPositions().get(this.getEpisodeContentId(episode)); } - - private findSeasonOfEpisode(episodeId: number | null): string | null { - if (episodeId === null) { - return null; - } - for (const [key, episodes] of Object.entries(this.seasons())) { - if ( - episodes?.some( - (episode) => this.getEpisodeContentId(episode) === episodeId - ) - ) { - return key; - } - } - return null; - } - - /** Auto-select rules live in season-auto-select.util.ts. */ - private resolveAutoSeason(): string | undefined { - return resolveAutoSelectedSeason({ - keys: this.sortedSeasonKeys(), - playingSeasonKey: this.playingSeasonKey(), - seasons: this.seasons(), - positionOf: (episode) => this.getEpisodePosition(episode), - hasUnloadedSeasons: this.hasUnloadedSeasons(), - episodeCounts: this.episodeCounts(), - watchedCounts: this.watchedCounts(), - }); - } } diff --git a/libs/ui/playback/src/lib/vod-details/vod-details-navigation.util.ts b/libs/ui/playback/src/lib/vod-details/vod-details-navigation.util.ts new file mode 100644 index 000000000..0e5ddea55 --- /dev/null +++ b/libs/ui/playback/src/lib/vod-details/vod-details-navigation.util.ts @@ -0,0 +1,43 @@ +import type { CrossPortalSimilarItem } from '@iptvnator/services'; +import type { + TmdbEnrichedCastMember, + VodDetailsItem, +} from '@iptvnator/shared/interfaces'; + +/** + * Pure helpers resolving where the VOD details page's extras rows (cast & + * crew, Similar rail) lead. The component owns the actual navigation. + */ + +/** + * Router commands of the actor page for a cast/crew member, inside the + * item's own portal workspace. Null when the member has no TMDB person id + * (names parsed from provider text): there is no actor page to open. + */ +export function buildVodActorRoute( + item: VodDetailsItem, + member: TmdbEnrichedCastMember +): (string | number)[] | null { + if (!member.tmdbPersonId) { + return null; + } + const basePath = + item.type === 'stalker' ? '/workspace/stalker' : '/workspace/xtreams'; + return [basePath, item.playlistId, 'actor', member.tmdbPersonId]; +} + +/** + * The cross-portal match behind a Similar-rail card. Rail keys are built by + * `buildSimilarRailItems` (vod-details-presentation.ts) as + * `x-`. + */ +export function findSimilarByRailKey( + items: readonly CrossPortalSimilarItem[], + railKey: string +): CrossPortalSimilarItem | undefined { + return items.find( + (candidate) => + `x${candidate.match.playlistId}-${candidate.match.xtreamId}` === + railKey + ); +} diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts index a2d6eb523..4d3b658e2 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts @@ -1,12 +1,9 @@ import { Component, computed, - effect, inject, input, output, - signal, - untracked, ChangeDetectionStrategy, } from '@angular/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; @@ -53,9 +50,14 @@ import { } from '@iptvnator/services'; import { VOD_DETAILS_MENU_ACTION } from './vod-details-presentation'; import { createVodDetailsHeroState } from './vod-details-hero.state'; +import { + buildVodActorRoute, + findSimilarByRailKey, +} from './vod-details-navigation.util'; import type { PlaybackFallbackRequest } from '@iptvnator/playback/util'; import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component'; -import { createVodDownloadState } from './vod-download-state.util'; +import { createVodLocalDownloadState } from './vod-download-state.util'; +import { createVodSimilarInPortals } from './vod-similar-in-portals.state'; /** * Unified VOD details component for both Xtream and Stalker portals. @@ -231,38 +233,12 @@ export class VodDetailsComponent { * TMDB recommendations found in the user's OTHER portals (batched DB * match, Electron only). Loaded async — the section appears when * resolved; staleness-guarded against item changes in flight. + * Filtered on read: a relock hides matches cached while unlocked. */ - private readonly similarInPortalsMatched = signal( - [] + readonly similarInPortals = createVodSimilarInPortals( + this.normalizedMeta, + this.crossPortalSimilar ); - /** Filtered on read: a relock hides matches cached while unlocked. */ - readonly similarInPortals = computed(() => - this.crossPortalSimilar.visible(this.similarInPortalsMatched()) - ); - - private readonly loadSimilarInPortals = effect(() => { - const meta = this.normalizedMeta(); - const recommendations = meta.tmdbRecommendations; - untracked(() => { - this.similarInPortalsMatched.set([]); - if ( - !recommendations?.length || - !this.crossPortalSimilar.isAvailable - ) { - return; - } - void this.crossPortalSimilar - .matchRecommendations(recommendations, 'movie') - .then((items) => { - if ( - this.normalizedMeta().tmdbRecommendations === - recommendations - ) { - this.similarInPortalsMatched.set(items); - } - }); - }); - }); openSimilarInPortals(item: CrossPortalSimilarItem): void { void this.router.navigate(this.crossPortalSimilar.buildLink(item)); @@ -276,19 +252,14 @@ export class VodDetailsComponent { () => (this.playbackPosition() ?? 0) > 0 && !this.isWatched() ); - private readonly downloadState = createVodDownloadState( - this.downloadsService, - this.item - ); - readonly isDownloaded = computed( - () => !this.providerOnly() && this.downloadState.isDownloaded() - ); - readonly isDownloading = computed( - () => !this.providerOnly() && this.downloadState.isDownloading() - ); - readonly isPausedDownload = computed( - () => !this.providerOnly() && this.downloadState.isPausedDownload() - ); + private readonly localDownload = createVodLocalDownloadState({ + downloadsService: this.downloadsService, + item: this.item, + providerOnly: this.providerOnly, + }); + readonly isDownloaded = this.localDownload.isDownloaded; + readonly isDownloading = this.localDownload.isDownloading; + readonly isPausedDownload = this.localDownload.isPausedDownload; private readonly externalButton = createExternalPlaybackButtonState({ session: this.externalPlayback, @@ -360,11 +331,7 @@ export class VodDetailsComponent { readonly scrollToCast = scrollToCastCrewRow; openSimilarRailItem(item: SimilarRailItem): void { - const match = this.similarInPortals().find( - (candidate) => - `x${candidate.match.playlistId}-${candidate.match.xtreamId}` === - item.key - ); + const match = findSimilarByRailKey(this.similarInPortals(), item.key); if (match) { this.openSimilarInPortals(match); } @@ -450,20 +417,10 @@ export class VodDetailsComponent { /** Handle back navigation - emit event for parent to handle */ openActor(member: TmdbEnrichedCastMember): void { - if (!member.tmdbPersonId) { - return; + const commands = buildVodActorRoute(this.item(), member); + if (commands) { + void this.router.navigate(commands); } - const item = this.item(); - const basePath = - item.type === 'stalker' - ? '/workspace/stalker' - : '/workspace/xtreams'; - void this.router.navigate([ - basePath, - item.playlistId, - 'actor', - member.tmdbPersonId, - ]); } goBack(): void { @@ -494,13 +451,8 @@ export class VodDetailsComponent { } /** Resume the paused download of this VOD */ - async resumePausedDownload(): Promise { - const item = this.item(); - await this.downloadsService.resumeDownloadByContent( - getVodNumericId(item), - item.playlistId, - 'vod' - ); + resumePausedDownload(): Promise { + return this.localDownload.resumePausedDownload(); } onInlineTimeUpdate(event: { currentTime: number; duration: number }): void { @@ -526,18 +478,7 @@ export class VodDetailsComponent { } /** Play from local downloaded file */ - async playFromLocal(): Promise { - const item = this.item(); - const vodId = getVodNumericId(item); - - const filePath = this.downloadsService.getDownloadedFilePath( - vodId, - item.playlistId, - 'vod' - ); - - if (filePath) { - await this.downloadsService.playDownload(filePath); - } + playFromLocal(): Promise { + return this.localDownload.playFromLocal(); } } diff --git a/libs/ui/playback/src/lib/vod-details/vod-download-state.util.ts b/libs/ui/playback/src/lib/vod-details/vod-download-state.util.ts index 7adf76ba5..b17070ec8 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-download-state.util.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-download-state.util.ts @@ -32,3 +32,58 @@ export function createVodDownloadState( ), }; } + +export interface VodLocalDownloadStateDeps { + readonly downloadsService: DownloadsService; + readonly item: Signal; + /** Provider detail handoff hides local/download presentation only. */ + readonly providerOnly: Signal; +} + +/** + * The VOD details page's view of its own download: the download-state + * signals (always false in provider-only mode) and the two actions that + * act on the local copy. + */ +export function createVodLocalDownloadState(deps: VodLocalDownloadStateDeps) { + const { downloadsService, item, providerOnly } = deps; + const downloadState = createVodDownloadState(downloadsService, item); + + return { + isDownloaded: computed( + () => !providerOnly() && downloadState.isDownloaded() + ), + isDownloading: computed( + () => !providerOnly() && downloadState.isDownloading() + ), + isPausedDownload: computed( + () => !providerOnly() && downloadState.isPausedDownload() + ), + + /** Resume the paused download of this VOD */ + async resumePausedDownload(): Promise { + const currentItem = item(); + await downloadsService.resumeDownloadByContent( + getVodNumericId(currentItem), + currentItem.playlistId, + 'vod' + ); + }, + + /** Play from local downloaded file */ + async playFromLocal(): Promise { + const currentItem = item(); + const vodId = getVodNumericId(currentItem); + + const filePath = downloadsService.getDownloadedFilePath( + vodId, + currentItem.playlistId, + 'vod' + ); + + if (filePath) { + await downloadsService.playDownload(filePath); + } + }, + }; +} diff --git a/libs/ui/playback/src/lib/vod-details/vod-similar-in-portals.state.ts b/libs/ui/playback/src/lib/vod-details/vod-similar-in-portals.state.ts new file mode 100644 index 000000000..5e3855a89 --- /dev/null +++ b/libs/ui/playback/src/lib/vod-details/vod-similar-in-portals.state.ts @@ -0,0 +1,51 @@ +import { + type Signal, + computed, + effect, + signal, + untracked, +} from '@angular/core'; +import type { + CrossPortalSimilarItem, + CrossPortalSimilarService, +} from '@iptvnator/services'; +import type { NormalizedVodMeta } from '@iptvnator/shared/interfaces'; + +/** + * TMDB recommendations found in the user's OTHER portals (batched DB + * match, Electron only). Loaded async — the section appears when + * resolved; staleness-guarded against item changes in flight. + * + * Registers the loading effect, so call it from a field initializer + * (injection context). + */ +export function createVodSimilarInPortals( + normalizedMeta: Signal, + crossPortalSimilar: CrossPortalSimilarService +): Signal { + const matched = signal([]); + /** Filtered on read: a relock hides matches cached while unlocked. */ + const visible = computed(() => crossPortalSimilar.visible(matched())); + + effect(() => { + const meta = normalizedMeta(); + const recommendations = meta.tmdbRecommendations; + untracked(() => { + matched.set([]); + if (!recommendations?.length || !crossPortalSimilar.isAvailable) { + return; + } + void crossPortalSimilar + .matchRecommendations(recommendations, 'movie') + .then((items) => { + if ( + normalizedMeta().tmdbRecommendations === recommendations + ) { + matched.set(items); + } + }); + }); + }); + + return visible; +}