From d54e6026d7571aba2ac817e38704a67f3c4f6216 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 4 Oct 2026 10:08:36 +0200 Subject: [PATCH] refactor(stalker): extract catalog-detail position and search paging - stalker-catalog-detail.component.ts (397): the stored VOD position and its runtime updates become StalkerCatalogVodPosition, the Play/Resume start becomes startStalkerCatalogVodPlayback - stalker-search.component.ts (776, baselined): the paging resource, the accumulated results and the parental-lock bookkeeping become StalkerSearchPagingController, with pure helpers in stalker-search-results.util.ts. The spec reaches the moved members through component.paging; its cases and assertions are unchanged. Counted lines: catalog detail 397 -> 317, search 776 -> 482. Co-Authored-By: Claude Fable 5.1 --- .../stalker-catalog-detail.component.ts | 161 ++----- .../stalker-catalog-vod-playback.ts | 84 ++++ .../stalker-catalog-vod-position.ts | 110 +++++ .../stalker-search-paging.controller.ts | 391 +++++++++++++++ .../stalker-search-results.util.ts | 138 ++++++ .../stalker-search.component.spec.ts | 101 ++-- .../stalker-search.component.ts | 450 +----------------- 7 files changed, 845 insertions(+), 590 deletions(-) create mode 100644 libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-vod-playback.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-vod-position.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-search/stalker-search-paging.controller.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-search/stalker-search-results.util.ts 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(