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; +}