mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
f70f679b9f
commit
d54e6026d7
7 files changed
+845
-590
No files matched your search
+35
-126
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+84
@@ -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);
|
||||
}
|
||||
}
|
||||
+110
@@ -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);
|
||||
}
|
||||
}
|
||||
+391
@@ -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;
|
||||
}
|
||||
}
|
||||
+66
-35
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user