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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-04 10:08:36 +02:00
1 parent f70f679b9f
commit d54e6026d7
7 files changed
+845 -590

No files matched your search

@@ -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<PlaybackPositionData | null>(
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<number | null>(
() => this.selectedVodPosition()?.durationSeconds ?? null
() => this.vodPosition.position()?.durationSeconds ?? null
);
readonly sourceLabel = computed(
() => this.catalog.playlist()?.title ?? null
);
readonly selectedVodPlaybackPosition = computed<number | null>(
() => 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<void> {
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<void> {
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,
});
}
}
@@ -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<ResolvedPortalPlayback | null>;
closeInlinePlayer(): void;
}
interface StalkerCatalogVodPlaybackDeps {
readonly resolvePlayback: () => Promise<ResolvedPortalPlayback>;
readonly portalPlayer: Pick<
PortalPlayer,
'isEmbeddedPlayer' | 'openResolvedPlayback'
>;
/** Clears the inline position writer for the playback about to mount. */
readonly resetPositionWriter: () => void;
readonly logger: Pick<Logger, 'error'>;
readonly translate: Pick<TranslateService, 'instant'>;
readonly snackBar: Pick<MatSnackBar, 'open'>;
}
/**
* 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<void> {
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);
}
}
@@ -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<StalkerSelectedVodItem | null>;
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<PlaybackPositionData | null>(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<void> {
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);
}
}
@@ -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<string>;
selectedFilterType: Signal<StalkerSearchContentType>;
/** The portal being searched; null while none is resolved. */
currentPlaylist: Signal<PlaylistMeta | null>;
dataService: DataService;
parentalLock: ParentalLockService;
stalkerSession: StalkerSessionService;
portalRepair: StalkerPortalRepairService;
logger: Logger;
/** Closes the host's open detail when its genre became withheld. */
closeWithheldDetail: (withheldCategoryIds: ReadonlySet<string>) => 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<string>();
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<StalkerVodSource[]>([]);
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<StalkerSearchResponse>(
{
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<void> {
// 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<string>,
contentType: Parameters<typeof withoutWithheldStalkerItems>[1]
): void {
this.config.closeWithheldDetail(withheldCategoryIds);
this.accumulatedSearchResults.set(
withoutWithheldStalkerItems(
this.accumulatedSearchResults(),
contentType,
withheldCategoryIds
)
);
}
}
@@ -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<string>();
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<string> {
return !parentalLock.active()
? new Set<string>()
: 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<string, string | number> {
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<string>
): 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;
}
}
@@ -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');
});
});
@@ -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<string>();
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<string>();
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<StalkerVodSource[]>([]);
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<string> =
!this.parentalLock.active()
? new Set<string>()
: 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<string, string | number> = {
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<StalkerSearchResponse>(
{
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<void> {
// 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<boolean>(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<string>,
contentType: Parameters<typeof withoutWithheldStalkerItems>[1]
): void {
this.closeWithheldDetail(withheldCategoryIds);
this.accumulatedSearchResults.set(
withoutWithheldStalkerItems(
this.accumulatedSearchResults(),
contentType,
withheldCategoryIds
)
);
}
closeWithheldDetail(withheldCategoryIds: ReadonlySet<string>): 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(