feat(xtream): enhance portal features with improved state management and search functionality

- Added handling for missing expiration date in withPortal feature.
- Introduced request versioning in withSearch feature to prevent stale search results.
- Created test setup for Jest in data-access and feature libraries.
- Added Jest configuration and TypeScript configuration for testing in feature library.
- Removed GlobalRecentlyViewedComponent and its associated files to streamline the codebase.
- Updated search results component to ignore stale responses and improve search handling.
This commit is contained in:
4gray committed 2026-03-28 15:40:53 +01:00
1 parent 456173b772
commit 70b4d29b4b
22 files changed
+585 -976

No files matched your search

@@ -0,0 +1,21 @@
export default {
displayName: 'portal-xtream-data-access',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/portal/xtream/data-access',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};
@@ -6,6 +6,14 @@
"projectType": "library",
"tags": ["scope:portal", "domain:xtream", "type:data-access"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/portal/xtream/data-access/jest.config.ts",
"tsConfig": "libs/portal/xtream/data-access/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
@@ -19,6 +19,7 @@ import {
IXtreamDataSource,
mapCategoryTypeToDbType,
ProgressCallback,
XtreamOperationOptions,
XtreamCategoryFromDb,
XtreamContentItem,
XtreamPlaylistData,
@@ -195,7 +196,8 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
credentials: XtreamCredentials,
type: StreamType,
onProgress?: (count: number) => void,
onTotal?: (total: number) => void
onTotal?: (total: number) => void,
options?: XtreamOperationOptions
): Promise<XtreamContentItem[]> {
// Fetch from DB directly — avoids a separate 'has' round-trip.
// An empty result means the cache is cold; proceed to fetch from API.
@@ -221,7 +223,8 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
| XtreamVodStream[]
| XtreamSerieItem[],
type,
onProgress
onProgress,
options
);
}
@@ -237,13 +240,15 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
| XtreamSerieItem[]
| XtreamContentItem[],
type: 'live' | 'movie' | 'series',
onProgress?: ProgressCallback
onProgress?: ProgressCallback,
options?: XtreamOperationOptions
): Promise<number> {
return this.dbService.saveXtreamContent(
playlistId,
streams,
type,
onProgress
onProgress,
options
);
}
@@ -407,12 +412,14 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
async restoreUserData(
playlistId: string,
favoritedXtreamIds: number[],
recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[]
recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[],
options?: XtreamOperationOptions
): Promise<void> {
await this.dbService.restoreXtreamUserData(
playlistId,
favoritedXtreamIds,
recentlyViewedXtreamIds
recentlyViewedXtreamIds,
options
);
}
}
@@ -17,6 +17,7 @@ import {
DbCategoryType,
IXtreamDataSource,
ProgressCallback,
XtreamOperationOptions,
XtreamCategoryFromDb,
XtreamContentItem,
XtreamPlaylistData,
@@ -204,8 +205,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
credentials: XtreamCredentials,
type: StreamType,
onProgress?: (count: number) => void,
onTotal?: (total: number) => void
onTotal?: (total: number) => void,
options?: XtreamOperationOptions
): Promise<XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[]> {
void options;
const cacheKey = `${playlistId}-${type}-content`;
// Check in-memory cache first
@@ -242,8 +245,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
| XtreamSerieItem[]
| XtreamContentItem[],
type: 'live' | 'movie' | 'series',
onProgress?: ProgressCallback
onProgress?: ProgressCallback,
options?: XtreamOperationOptions
): Promise<number> {
void options;
// In PWA mode, we just cache in memory
const cacheKey = `${playlistId}-${type}-content`;
this.contentCache.set(cacheKey, streams);
@@ -646,8 +651,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
async restoreUserData(
playlistId: string,
favoritedXtreamIds: number[],
recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[]
recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[],
options?: XtreamOperationOptions
): Promise<void> {
void options;
// Restore favorites
const favorites = this.getFavoritesFromStorage();
favorites[playlistId] = favoritedXtreamIds;
@@ -6,6 +6,7 @@ import {
XtreamSerieItem,
XtreamVodStream,
} from 'shared-interfaces';
import type { DbOperationEvent } from 'services';
import {
CategoryType,
StreamType,
@@ -116,6 +117,11 @@ export function mapStreamTypeToDbType(
*/
export type ProgressCallback = (count: number) => void;
export interface XtreamOperationOptions {
operationId?: string;
onEvent?: (event: DbOperationEvent) => void;
}
/**
* Abstract interface for Xtream data source.
* Allows different implementations for Electron (DB-first) and PWA (API-only).
@@ -214,7 +220,8 @@ export interface IXtreamDataSource {
credentials: XtreamCredentials,
type: StreamType,
onProgress?: ProgressCallback,
onTotal?: (total: number) => void
onTotal?: (total: number) => void,
options?: XtreamOperationOptions
): Promise<
| XtreamLiveStream[]
| XtreamVodStream[]
@@ -233,7 +240,8 @@ export interface IXtreamDataSource {
| XtreamSerieItem[]
| XtreamContentItem[],
type: 'live' | 'movie' | 'series',
onProgress?: ProgressCallback
onProgress?: ProgressCallback,
options?: XtreamOperationOptions
): Promise<number>;
// =========================================================================
@@ -389,7 +397,8 @@ export interface IXtreamDataSource {
restoreUserData(
playlistId: string,
favoritedXtreamIds: number[],
recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[]
recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[],
options?: XtreamOperationOptions
): Promise<void>;
}
@@ -0,0 +1,256 @@
import { TestBed } from '@angular/core/testing';
import { signalStore, withState } from '@ngrx/signals';
import { DatabaseService } from 'services';
import {
XTREAM_DATA_SOURCE,
XtreamPlaylistData,
} from '../../data-sources/xtream-data-source.interface';
import { withContent } from './with-content.feature';
jest.mock('@iptvnator/portal/shared/util', () => ({
createLogger: () => ({
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}),
}));
type ContentType = 'live' | 'movie' | 'series';
const PLAYLIST: XtreamPlaylistData = {
id: 'playlist-1',
name: 'Test Xtream',
serverUrl: 'http://localhost:8080',
username: 'demo',
password: 'secret',
type: 'xtream',
};
const TestContentStore = signalStore(
withState({
playlistId: PLAYLIST.id,
currentPlaylist: PLAYLIST,
}),
withContent()
);
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createAbortError(): Error {
const error = new Error('Import cancelled');
error.name = 'AbortError';
return error;
}
describe('withContent import state', () => {
let store: InstanceType<typeof TestContentStore>;
let dataSource: {
getCategories: jest.Mock;
getContent: jest.Mock;
restoreUserData: jest.Mock;
};
let databaseService: {
cancelOperation: jest.Mock;
supportsDbOperationCancellation: jest.Mock;
};
beforeEach(() => {
localStorage.clear();
dataSource = {
getCategories: jest.fn().mockResolvedValue([]),
getContent: jest.fn(),
restoreUserData: jest.fn().mockResolvedValue(undefined),
};
databaseService = {
cancelOperation: jest.fn().mockResolvedValue(true),
supportsDbOperationCancellation: jest.fn().mockReturnValue(true),
};
TestBed.configureTestingModule({
providers: [
TestContentStore,
{
provide: XTREAM_DATA_SOURCE,
useValue: dataSource,
},
{
provide: DatabaseService,
useValue: databaseService,
},
],
});
store = TestBed.inject(TestContentStore);
});
afterEach(() => {
localStorage.clear();
});
it('tracks aggregated import progress while content is loading', async () => {
const pending = {
live: createDeferred<any[]>(),
movie: createDeferred<any[]>(),
series: createDeferred<any[]>(),
};
const optionsByType = new Map<
ContentType,
{ onEvent?: (event: any) => void } | undefined
>();
const totals: Record<ContentType, number> = {
live: 2,
movie: 3,
series: 4,
};
dataSource.getContent.mockImplementation(
(
_playlistId: string,
_credentials: unknown,
type: ContentType,
onProgress?: (count: number) => void,
onTotal?: (total: number) => void,
options?: { onEvent?: (event: any) => void }
) => {
optionsByType.set(type, options);
onTotal?.(totals[type]);
options?.onEvent?.({
operation: 'save-content',
operationId: `${type}-op`,
status: 'started',
phase: 'saving-content',
});
onProgress?.(1);
options?.onEvent?.({
operation: 'save-content',
operationId: `${type}-op`,
status: 'progress',
phase: 'saving-content',
current: 1,
total: totals[type],
});
return pending[type].promise;
}
);
const initialization = store.initializeContent();
await Promise.resolve();
expect(store.isImporting()).toBe(true);
expect(store.importCount()).toBe(3);
expect(store.itemsToImport()).toBe(9);
expect(store.activeImportOperationIds().sort()).toEqual([
'live-op',
'movie-op',
'series-op',
]);
expect(store.importPhase()).toBe('saving-content');
(['live', 'movie', 'series'] as ContentType[]).forEach((type) => {
optionsByType.get(type)?.onEvent?.({
operation: 'save-content',
operationId: `${type}-op`,
status: 'completed',
phase: 'saving-content',
current: totals[type],
total: totals[type],
});
pending[type].resolve([]);
});
await initialization;
expect(store.isImporting()).toBe(false);
expect(store.isContentInitialized()).toBe(true);
expect(store.activeImportOperationIds()).toEqual([]);
expect(store.importPhase()).toBeNull();
expect(store.importCount()).toBe(0);
expect(store.itemsToImport()).toBe(0);
});
it('cancels active imports and clears stale progress after worker aborts', async () => {
const pending = {
live: createDeferred<any[]>(),
movie: createDeferred<any[]>(),
series: createDeferred<any[]>(),
};
const optionsByType = new Map<
ContentType,
{ onEvent?: (event: any) => void } | undefined
>();
dataSource.getContent.mockImplementation(
(
_playlistId: string,
_credentials: unknown,
type: ContentType,
onProgress?: (count: number) => void,
onTotal?: (total: number) => void,
options?: { onEvent?: (event: any) => void }
) => {
optionsByType.set(type, options);
onTotal?.(5);
options?.onEvent?.({
operation: 'save-content',
operationId: `${type}-op`,
status: 'started',
phase: 'saving-content',
});
onProgress?.(2);
options?.onEvent?.({
operation: 'save-content',
operationId: `${type}-op`,
status: 'progress',
phase: 'saving-content',
current: 2,
total: 5,
});
return pending[type].promise;
}
);
const initialization = store.initializeContent();
await Promise.resolve();
await store.cancelImport();
expect(store.isCancellingImport()).toBe(true);
expect(databaseService.cancelOperation).toHaveBeenCalledTimes(3);
expect(databaseService.cancelOperation).toHaveBeenCalledWith('live-op');
expect(databaseService.cancelOperation).toHaveBeenCalledWith('movie-op');
expect(databaseService.cancelOperation).toHaveBeenCalledWith('series-op');
(['live', 'movie', 'series'] as ContentType[]).forEach((type) => {
optionsByType.get(type)?.onEvent?.({
operation: 'save-content',
operationId: `${type}-op`,
status: 'cancelled',
phase: 'saving-content',
});
pending[type].reject(createAbortError());
});
await expect(initialization).resolves.toBeUndefined();
expect(store.isImporting()).toBe(false);
expect(store.isCancellingImport()).toBe(false);
expect(store.isContentInitialized()).toBe(false);
expect(store.activeImportOperationIds()).toEqual([]);
expect(store.importPhase()).toBeNull();
expect(store.importCount()).toBe(0);
expect(store.itemsToImport()).toBe(0);
});
});
@@ -13,6 +13,11 @@ import {
XtreamVodStream,
} from 'shared-interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
DatabaseService,
DbOperationEvent,
isDbAbortError,
} from 'services';
import {
XTREAM_DATA_SOURCE,
XtreamCategoryFromDb,
@@ -33,8 +38,11 @@ export interface ContentState {
isLoadingCategories: boolean;
isLoadingContent: boolean;
isImporting: boolean;
isCancellingImport: boolean;
importCount: number;
importPhase: string | null;
itemsToImport: number;
activeImportOperationIds: string[];
isContentInitialized: boolean;
}
@@ -51,8 +59,11 @@ const initialContentState: ContentState = {
isLoadingCategories: false,
isLoadingContent: false,
isImporting: false,
isCancellingImport: false,
importCount: 0,
importPhase: null,
itemsToImport: 0,
activeImportOperationIds: [],
isContentInitialized: false,
};
@@ -121,13 +132,15 @@ export function withContent() {
isContentImporting: computed(() => store.isImporting()),
/**
* Check if content is already initialized
* Current import phase label key source
*/
isContentInitialized: computed(() => store.isContentInitialized()),
currentImportPhase: computed(() => store.importPhase()),
})),
withMethods((store) => {
const dataSource = inject(XTREAM_DATA_SOURCE);
const databaseService = inject(DatabaseService);
/**
* Helper to get credentials from parent store
@@ -156,6 +169,32 @@ export function withContent() {
};
};
const trackImportEvent = (event: DbOperationEvent): void => {
const operationId = event.operationId;
patchState(store, (state) => ({
importPhase: event.phase ?? state.importPhase,
activeImportOperationIds:
operationId == null
? state.activeImportOperationIds
: event.status === 'completed' ||
event.status === 'cancelled' ||
event.status === 'error'
? state.activeImportOperationIds.filter(
(id) => id !== operationId
)
: state.activeImportOperationIds.includes(
operationId
)
? state.activeImportOperationIds
: [...state.activeImportOperationIds, operationId],
isCancellingImport:
event.status === 'cancelled'
? false
: state.isCancellingImport,
}));
};
return {
/**
* Fetch all categories in parallel
@@ -192,8 +231,11 @@ export function withContent() {
isLoadingCategories: false,
});
} catch (error) {
logger.error('Error fetching categories', error);
if (!isDbAbortError(error)) {
logger.error('Error fetching categories', error);
}
patchState(store, { isLoadingCategories: false });
throw error;
}
},
@@ -227,21 +269,24 @@ export function withContent() {
ctx.credentials,
'live',
onProgress,
onTotal
onTotal,
{ onEvent: trackImportEvent }
),
dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'movie',
onProgress,
onTotal
onTotal,
{ onEvent: trackImportEvent }
),
dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'series',
onProgress,
onTotal
onTotal,
{ onEvent: trackImportEvent }
),
]);
@@ -252,8 +297,11 @@ export function withContent() {
isLoadingContent: false,
});
} catch (error) {
logger.error('Error fetching content', error);
if (!isDbAbortError(error)) {
logger.error('Error fetching content', error);
}
patchState(store, { isLoadingContent: false });
throw error;
}
},
@@ -271,8 +319,11 @@ export function withContent() {
patchState(store, {
isImporting: true,
isCancellingImport: false,
importCount: 0,
importPhase: null,
itemsToImport: 0,
activeImportOperationIds: [],
});
try {
@@ -294,21 +345,58 @@ export function withContent() {
await dataSource.restoreUserData(
ctx.playlistId,
favoritedXtreamIds,
recentlyViewedXtreamIds
recentlyViewedXtreamIds,
{ onEvent: trackImportEvent }
);
localStorage.removeItem(restoreKey);
} catch (err) {
logger.error('Error restoring user data', err);
if (!isDbAbortError(err)) {
logger.error(
'Error restoring user data',
err
);
}
}
}
// Mark as initialized so next routings won't re-trigger it
patchState(store, { isContentInitialized: true });
} catch (error) {
if (!isDbAbortError(error)) {
logger.error('Error initializing content', error);
}
} finally {
patchState(store, { isImporting: false });
patchState(store, {
isImporting: false,
isCancellingImport: false,
importCount: 0,
importPhase: null,
itemsToImport: 0,
activeImportOperationIds: [],
});
}
},
async cancelImport(): Promise<void> {
if (
!databaseService.supportsDbOperationCancellation() ||
store.activeImportOperationIds().length === 0 ||
store.isCancellingImport()
) {
return;
}
patchState(store, { isCancellingImport: true });
await Promise.all(
store
.activeImportOperationIds()
.map((operationId) =>
databaseService.cancelOperation(operationId)
)
);
},
/**
* Reload categories from database (after visibility changes)
*/
@@ -130,8 +130,13 @@ export function withPortal() {
}
if (response.user_info.status === 'Active') {
if (!response.user_info.exp_date) {
patchState(store, { portalStatus: 'active' });
return;
}
const expDate = new Date(
parseInt(response.user_info.exp_date) * 1000
parseInt(response.user_info.exp_date, 10) * 1000
);
if (expDate < new Date()) {
patchState(store, { portalStatus: 'expired' });
@@ -69,6 +69,7 @@ export function withSearch() {
withMethods((store) => {
const dataSource = inject(XTREAM_DATA_SOURCE);
let searchRequestVersion = 0;
return {
/**
@@ -82,9 +83,13 @@ export function withSearch() {
// Access parent store's playlistId (from withPortal)
const storeAny = store as ParentSearchStoreLike;
const playlistId = storeAny.playlistId?.();
const requestVersion = ++searchRequestVersion;
if (!playlistId || !searchTerm.trim()) {
patchState(store, { searchResults: [] });
patchState(store, {
searchResults: [],
isSearching: false,
});
return [];
}
@@ -98,6 +103,10 @@ export function withSearch() {
excludeHidden
);
if (requestVersion !== searchRequestVersion) {
return results;
}
patchState(store, {
searchResults: results,
isSearching: false,
@@ -106,6 +115,11 @@ export function withSearch() {
return results;
} catch (error) {
logger.error('Error searching content', error);
if (requestVersion !== searchRequestVersion) {
return [];
}
patchState(store, {
searchResults: [],
isSearching: false,
@@ -165,6 +179,7 @@ export function withSearch() {
* Clear search results
*/
resetSearchResults(): void {
searchRequestVersion++;
patchState(store, initialSearchState);
},
};
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
+21
View File
@@ -0,0 +1,21 @@
export default {
displayName: 'portal-xtream-feature',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/portal/xtream/feature',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};
+8
View File
@@ -6,6 +6,14 @@
"projectType": "library",
"tags": ["scope:portal", "domain:xtream", "type:feature"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/portal/xtream/feature/jest.config.ts",
"tsConfig": "libs/portal/xtream/feature/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
-2
View File
@@ -1,13 +1,11 @@
export * from './lib/account-info/account-info.component';
export * from './lib/category-management-dialog/category-management-dialog.component';
export * from './lib/global-recently-viewed/global-recently-viewed.component';
export * from './lib/global-search-results/global-search-results.component';
export * from './lib/live-stream-layout/live-stream-layout.component';
export * from './lib/loading-overlay.component';
export * from './lib/player-dialog/player-dialog.component';
export * from './lib/portal-channels-list/portal-channels-list.component';
export * from './lib/recently-added/recently-added.component';
export * from './lib/recently-viewed/recently-viewed.component';
export * from './lib/search-results/search-results.component';
export * from './lib/serial-details/serial-details.component';
export * from './lib/vod-details/vod-details-route.component';
@@ -1,11 +0,0 @@
import { Component } from '@angular/core';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RecentlyViewedComponent } from '../recently-viewed/recently-viewed.component';
@Component({
selector: 'app-global-recently-viewed',
imports: [RecentlyViewedComponent],
providers: [XtreamStore],
template: '<app-recently-viewed/>',
})
export class GlobalRecentlyViewedComponent {}
@@ -1,189 +0,0 @@
@if (isGlobal) {
<div class="recent-container">
<div class="header">
<h2>Recently Viewed</h2>
<div class="header-actions">
@if (recentItems()?.length) {
<button mat-button color="warn" (click)="clearHistory()">
<mat-icon>delete</mat-icon>
Clear History
</button>
}
<button mat-icon-button (click)="dialogRef.close()">
<mat-icon>close</mat-icon>
</button>
</div>
</div>
@if (recentItems()?.length) {
<div class="recent-search">
<mat-icon>search</mat-icon>
<input
#recentSearchInput
type="search"
spellcheck="false"
autocomplete="off"
[value]="recentSearchTerm()"
placeholder="Search only in recently watched items"
(input)="updateRecentSearchTerm(recentSearchInput.value)"
/>
</div>
<div class="global-recent-subheader">
<div class="global-recent-subheader-left">
<div class="global-recent-note">
<mat-icon>info_outline</mat-icon>
<span>Includes Xtream, Stalker, and M3U items</span>
</div>
<div class="global-recent-filters">
<mat-checkbox
[ngModel]="typeFilters().live"
(ngModelChange)="toggleTypeFilter('live', $event)"
>
Live
</mat-checkbox>
<mat-checkbox
[ngModel]="typeFilters().movie"
(ngModelChange)="toggleTypeFilter('movie', $event)"
>
Movies
</mat-checkbox>
<mat-checkbox
[ngModel]="typeFilters().series"
(ngModelChange)="toggleTypeFilter('series', $event)"
>
Series
</mat-checkbox>
</div>
</div>
<mat-checkbox
[ngModel]="groupByPlaylist()"
(ngModelChange)="toggleGroupByPlaylist($event)"
>
Group by playlist
</mat-checkbox>
</div>
@if (groupByPlaylist()) {
<div class="playlist-section">
<h2 class="section-title">
Found {{ visibleRecentItems().length }} items across
{{ (getGroupedItems() | keyvalue).length }} playlists
</h2>
@for (group of getGroupedItems() | keyvalue; track group.key) {
<div class="playlist-group">
<h3 class="playlist-title">
<mat-icon>playlist_play</mat-icon>
{{ group.key }}
<span class="item-count"
>({{ $any(group).value.length }} items)</span
>
</h3>
<div class="items-grid">
@for (item of $any(group).value; track item.id) {
<app-content-card
[posterUrl]="item.poster_url"
[title]="item.title"
[type]="item.type"
[date]="item.viewed_at"
[showRemoveButton]="true"
[removeTooltip]="'Remove from history'"
(cardClick)="openItem(item)"
(remove)="onRemoveItem(item)"
/>
}
</div>
</div>
}
</div>
} @else {
<div class="items-grid">
@for (item of visibleRecentItems(); track item.id) {
<app-content-card
[posterUrl]="item.poster_url"
[title]="item.title"
[type]="item.type"
[date]="item.viewed_at"
[showRemoveButton]="true"
[removeTooltip]="'Remove from history'"
(cardClick)="openItem(item)"
(remove)="onRemoveItem(item)"
/>
}
</div>
}
} @else {
<div class="no-items">
<mat-icon>history</mat-icon>
<p>No recently viewed items</p>
</div>
}
</div>
} @else {
@if (isLiveCategory()) {
<app-portal-collection-live-shell
[title]="'PORTALS.SIDEBAR.RECENT' | translate"
[subtitle]="'PORTALS.SIDEBAR.LIVE_TV' | translate"
[count]="liveItemsToShow().length"
[streamUrl]="liveStreamUrl()"
[isEmbeddedPlayer]="isEmbeddedPlayer()"
[hasSelection]="!!selectedLiveItem()"
[epgItems]="epgItems()"
[isLoadingEpg]="isLoadingEpg()"
>
<ng-container liveHeaderActions>
<button
mat-icon-button
[matMenuTriggerFor]="channelSortMenu"
[matTooltip]="'Sort: ' + liveChannelSortLabel()"
aria-label="Sort channels"
>
<mat-icon>sort_by_alpha</mat-icon>
</button>
<mat-menu #channelSortMenu="matMenu">
<button mat-menu-item (click)="setLiveChannelSortMode('server')">
<span>Server Order</span>
@if (liveChannelSortMode() === 'server') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setLiveChannelSortMode('name-asc')">
<span>Name A-Z</span>
@if (liveChannelSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setLiveChannelSortMode('name-desc')"
>
<span>Name Z-A</span>
@if (liveChannelSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
</mat-menu>
</ng-container>
<app-portal-channels-list
liveChannels
[sortMode]="liveChannelSortMode()"
[channelsOverride]="liveItemsToShow()"
(playClicked)="selectLiveItem($event)"
/>
</app-portal-collection-live-shell>
} @else {
<app-portal-collection-shell
[categories]="categories()"
[items]="nonGlobalItemsToShow()"
[playlistSubtitle]="playlistSubtitle"
[playlistTitle]="playlistTitle()"
[selectedCategoryId]="selectedCategoryId()"
[layout]="nonGlobalLayout"
[mode]="nonGlobalMode()"
(categoryClicked)="setCategoryId($event)"
(removeItem)="onRemoveItem($event)"
(headerActionClicked)="clearHistory()"
(openItem)="openItem($event)"
/>
}
}
@@ -1,182 +0,0 @@
@use '../../../../../shared/ui/src/lib/styles/content-grid' as grid;
:host {
display: flex;
flex-direction: column;
overflow-y: auto;
height: 100%;
width: 100%;
}
.recent-container {
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 1.2rem;
font-weight: 650;
letter-spacing: -0.02em;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
.recent-search {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 1rem;
padding: 0 12px;
height: 42px;
border-radius: 12px;
border: 1px solid var(--app-separator);
background: var(--mat-sys-surface-container-low);
mat-icon {
opacity: 0.6;
font-size: 18px;
width: 18px;
height: 18px;
}
input {
width: 100%;
border: 0;
background: transparent;
color: inherit;
font: inherit;
opacity: 0.95;
&::placeholder {
opacity: 0.5;
}
}
}
.items-grid {
@include grid.content-grid(140px, 20px);
}
.no-items {
text-align: center;
padding: 40px;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
margin-bottom: 16px;
}
}
.playlist-section {
.section-title {
margin: 0 0 1.5rem;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
padding: 0 8px;
}
}
.playlist-group {
margin-bottom: 2rem;
.playlist-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 1rem 0;
padding: 8px;
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.01em;
mat-icon {
opacity: 0.7;
}
.item-count {
margin-left: auto;
font-size: 0.85rem;
opacity: 0.7;
}
}
}
// Subheader row for global recently viewed (note + grouping toggle)
.global-recent-subheader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin: 0 0 1rem;
.global-recent-subheader-left {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.global-recent-note {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
opacity: 0.55;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
flex-shrink: 0;
}
}
.global-recent-filters {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
::ng-deep mat-checkbox {
flex-shrink: 0;
}
}
:host-context(.global-search-overlay) {
// Prevent Electron drag-region from blocking click interactions.
app-region: no-drag;
.recent-container {
background: var(--background);
height: 100vh;
}
.header-actions,
.header-actions button {
app-region: no-drag;
}
.playlist-group {
.playlist-title {
background: var(--surface);
}
}
}
@@ -1,565 +0,0 @@
import { KeyValuePipe } from '@angular/common';
import {
Component,
computed,
effect,
inject,
Optional,
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { firstValueFrom } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import { ContentCardComponent, FavoriteLayoutItem } from '@iptvnator/portal/shared/ui';
import {
PortalCollectionLiveShellComponent,
} from '@iptvnator/portal/shared/ui';
import {
PortalCollectionMode,
PortalCollectionShellComponent,
PortalCollectionShellLayout,
} from '@iptvnator/portal/shared/ui';
import {
buildXtreamNavigationTarget,
getRecentItemNavigation,
isWorkspaceLayoutRoute,
queryParamSignal,
} from '@iptvnator/portal/shared/util';
import { createPortalCollectionContext } from '@iptvnator/portal/shared/util';
import {
buildStandardCollectionCategories,
filterCollectionBucket,
} from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util';
import { PortalCollectionContextService } from '@iptvnator/portal/shared/util';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
const XTREAM_RECENT_LAYOUT: Omit<
PortalCollectionShellLayout,
'showHeaderAction'
> = {
titleTranslationKey: 'PORTALS.SIDEBAR.RECENT',
removeTooltip: 'Remove from history',
emptyIcon: 'history',
headerActionIcon: 'delete_sweep',
headerActionTooltip: 'Clear history',
};
const XTREAM_COLLECTION_LABELS = {
all: 'All',
movie: 'Movies',
live: 'Live TV',
series: 'Series',
};
type LiveChannelSortMode = 'server' | 'name-asc' | 'name-desc';
const XTREAM_RECENT_LIVE_SORT_STORAGE_KEY =
'xtream-recent-live-channel-sort-mode';
type XtreamRecentContentType = 'live' | 'movie' | 'series';
interface XtreamRecentViewItem extends FavoriteLayoutItem {
readonly id: string | number;
readonly playlist_id: string;
readonly title: string;
readonly type: XtreamRecentContentType;
readonly xtream_id: string | number;
}
interface XtreamRecentLiveItem {
readonly id?: string | number;
readonly poster_url?: string;
readonly stream_icon?: string;
readonly title?: string;
readonly xtream_id: number;
}
function groupItemsByPlaylistName(
items: XtreamRecentViewItem[]
): Record<string, XtreamRecentViewItem[]> {
return items.reduce<Record<string, XtreamRecentViewItem[]>>(
(groups, item) => {
const key = String(item.playlist_name);
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(item);
return groups;
},
{}
);
}
function isXtreamRecentViewItem(
item: FavoriteLayoutItem
): item is XtreamRecentViewItem {
return (
(typeof item.id === 'number' || typeof item.id === 'string') &&
typeof item.playlist_id === 'string' &&
typeof item.title === 'string' &&
typeof item.xtream_id !== 'undefined' &&
(item.type === 'live' ||
item.type === 'movie' ||
item.type === 'series')
);
}
@Component({
selector: 'app-recently-viewed',
imports: [
ContentCardComponent,
FormsModule,
KeyValuePipe,
MatButton,
MatCheckboxModule,
MatIcon,
MatIconButton,
MatMenuModule,
MatTooltipModule,
PortalChannelsListComponent,
PortalCollectionLiveShellComponent,
PortalCollectionShellComponent,
TranslatePipe,
],
providers: [],
templateUrl: './recently-viewed.component.html',
styleUrl: './recently-viewed.component.scss',
})
export class RecentlyViewedComponent {
private static readonly GROUP_BY_STORAGE_KEY =
'global-recent-group-by-playlist';
private static readonly TYPE_FILTERS_STORAGE_KEY =
'global-recent-type-filters';
private static readonly MAX_FLAT_GLOBAL_ITEMS = 250;
private xtreamStore = inject(XtreamStore);
private activatedRoute = inject(ActivatedRoute);
private router = inject(Router);
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private dialogData = inject(MAT_DIALOG_DATA, { optional: true });
private readonly logger = createLogger('XtreamRecentlyViewed');
private readonly collectionCtx = inject(PortalCollectionContextService);
readonly isGlobal = this.dialogData?.isGlobal ?? false;
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
readonly nonGlobalLayout: PortalCollectionShellLayout = {
...XTREAM_RECENT_LAYOUT,
showHeaderAction: !this.isWorkspaceLayout,
};
readonly playlistSubtitle = 'Xtream Code';
private readonly _groupByPlaylist = (() => {
const saved = localStorage.getItem(
RecentlyViewedComponent.GROUP_BY_STORAGE_KEY
);
return signal(saved !== 'false');
})();
readonly groupByPlaylist = computed(() => this._groupByPlaylist());
private readonly _typeFilters = (() => {
const saved = localStorage.getItem(
RecentlyViewedComponent.TYPE_FILTERS_STORAGE_KEY
);
if (!saved) {
return signal({ live: true, movie: true, series: true });
}
try {
const parsed = JSON.parse(saved) as {
live?: boolean;
movie?: boolean;
series?: boolean;
};
return signal({
live: parsed.live !== false,
movie: parsed.movie !== false,
series: parsed.series !== false,
});
} catch {
return signal({ live: true, movie: true, series: true });
}
})();
readonly typeFilters = computed(() => this._typeFilters());
readonly recentItems = computed<XtreamRecentViewItem[]>(() =>
(this.isGlobal
? this.xtreamStore.globalRecentItems()
: this.xtreamStore.recentItems()) as XtreamRecentViewItem[]
);
readonly categories = computed(() => {
const items = this.recentItems();
const movies = items.filter((item) => item.type === 'movie').length;
const live = items.filter((item) => item.type === 'live').length;
const series = items.filter((item) => item.type === 'series').length;
return buildStandardCollectionCategories({
labels: XTREAM_COLLECTION_LABELS,
counts: {
all: items.length,
movie: movies,
live,
series,
},
includeLive: true,
});
});
readonly collectionContext = createPortalCollectionContext({
ctx: this.collectionCtx,
categories: this.categories,
enabled: () => !this.isGlobal,
});
readonly selectedLiveItem = signal<XtreamRecentLiveItem | null>(null);
readonly liveStreamUrl = signal('');
readonly nonGlobalMode = computed<PortalCollectionMode>(() =>
this.isLiveCategory() ? 'live' : 'grid'
);
readonly selectedCategoryId = this.collectionContext.selectedCategoryId;
readonly recentSearchTerm = signal('');
readonly workspaceSearchTerm = queryParamSignal(
this.activatedRoute,
'q',
(value) => (value ?? '').trim().toLowerCase()
);
readonly filteredRecentItems = computed(() => {
const term =
this.isWorkspaceLayout && !this.isGlobal
? this.workspaceSearchTerm()
: this.recentSearchTerm().trim().toLowerCase();
const typeFilters = this.typeFilters();
const items = this.recentItems().filter((item) => {
if (item.type === 'live') return typeFilters.live;
if (item.type === 'movie') return typeFilters.movie;
if (item.type === 'series') return typeFilters.series;
return true;
});
if (!term) return items;
return items.filter((item) =>
`${item.title} ${item.playlist_name ?? ''}`
.toLowerCase()
.includes(term)
);
});
readonly visibleRecentItems = computed(() => {
const items = this.filteredRecentItems();
if (this.isGlobal && !this.groupByPlaylist()) {
return items.slice(
0,
RecentlyViewedComponent.MAX_FLAT_GLOBAL_ITEMS
);
}
return items;
});
readonly nonGlobalItemsToShow = computed(() => {
const items = this.visibleRecentItems();
return filterCollectionBucket({
selectedCategoryId: this.selectedCategoryId(),
allItems: items,
buckets: {
movie: items.filter((item) => item.type === 'movie'),
live: items.filter((item) => item.type === 'live'),
series: items.filter((item) => item.type === 'series'),
},
textOf: (item) => `${item.title} ${item.playlist_name ?? ''}`,
});
});
readonly liveItemsToShow = computed<XtreamRecentLiveItem[]>(() =>
this.visibleRecentItems()
.filter((item) => {
if (item.type !== 'live') {
return false;
}
const term =
this.isWorkspaceLayout && !this.isGlobal
? this.workspaceSearchTerm()
: this.recentSearchTerm().trim().toLowerCase();
if (!term) {
return true;
}
return `${item.title} ${item.playlist_name ?? ''}`
.toLowerCase()
.includes(term);
})
.map((item) => ({
id: item.id,
poster_url: item.poster_url,
stream_icon: item.stream_icon,
title: item.title,
xtream_id: Number(item.xtream_id),
}))
);
readonly isLiveCategory = computed(() => this.selectedCategoryId() === 'live');
readonly isEmbeddedPlayer = computed(() =>
this.portalPlayer.isEmbeddedPlayer()
);
readonly liveChannelSortMode = signal<LiveChannelSortMode>('server');
readonly liveChannelSortLabel = computed(() => {
const mode = this.liveChannelSortMode();
if (mode === 'name-asc') return 'Name A-Z';
if (mode === 'name-desc') return 'Name Z-A';
return 'Server Order';
});
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
readonly playlistTitle = computed(
() =>
this.currentPlaylist()?.name ||
this.currentPlaylist()?.title ||
'Playlist'
);
readonly epgItems = this.xtreamStore.epgItems;
readonly isLoadingEpg = this.xtreamStore.isLoadingEpg;
constructor(
@Optional() public dialogRef?: MatDialogRef<RecentlyViewedComponent>
) {
this.xtreamStore.setSelectedContentType(undefined);
const savedSortMode = localStorage.getItem(
XTREAM_RECENT_LIVE_SORT_STORAGE_KEY
);
if (
savedSortMode === 'server' ||
savedSortMode === 'name-asc' ||
savedSortMode === 'name-desc'
) {
this.liveChannelSortMode.set(savedSortMode);
}
effect(() => {
if (this.isGlobal) {
return;
}
const selectedItem = this.selectedLiveItem();
if (!selectedItem) {
return;
}
const stillExists = this.liveItemsToShow().some(
(item) =>
Number(item.xtream_id ?? item.id) ===
Number(selectedItem.xtream_id ?? selectedItem.id)
);
if (!stillExists) {
this.clearLiveSelection();
}
});
effect(() => {
if (this.isGlobal || this.isLiveCategory()) {
return;
}
this.clearLiveSelection();
});
if (this.isGlobal) {
this.loadGlobalItems();
} else if (this.currentPlaylist()) {
this.xtreamStore.loadRecentItems(this.currentPlaylist);
}
}
private async loadGlobalItems() {
try {
await this.xtreamStore.loadGlobalRecentItems();
} catch (error) {
this.logger.error('Error loading global items', error);
}
}
clearHistory() {
if (this.isGlobal) {
this.xtreamStore.clearGlobalRecentlyViewed();
} else {
this.xtreamStore.clearRecentItems(this.xtreamStore.currentPlaylist);
}
}
openItem(item: FavoriteLayoutItem) {
if (!isXtreamRecentViewItem(item)) {
return;
}
const source = item.source ?? 'xtream';
if ((source === 'stalker' || source === 'm3u') && this.isGlobal) {
this.dialogRef?.close();
const navigation = getRecentItemNavigation({
id: item.id,
title: item.title,
type: item.type,
playlist_id: item.playlist_id,
poster_url: item.poster_url ?? '',
xtream_id: item.xtream_id,
category_id: item.category_id ?? item.type,
source,
stalker_item: item.stalker_item,
viewed_at: item.viewed_at ?? '',
});
void this.router.navigate(navigation.link, {
state: navigation.state,
});
return;
}
const type = item.type === 'movie' ? 'vod' : item.type;
this.xtreamStore.setSelectedContentType(type);
if (!this.isGlobal && type === 'live') {
this.setCategoryId('live');
this.selectLiveItem({
id: item.id,
poster_url: item.poster_url,
stream_icon: item.stream_icon,
title: item.title,
xtream_id: Number(item.xtream_id),
});
return;
}
if (this.isGlobal) {
this.dialogRef?.close();
const navigation = buildXtreamNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
itemId: item.xtream_id,
title: item.title,
imageUrl: item.poster_url,
});
void this.router.navigate(navigation.link, {
state: navigation.state,
});
} else {
const navigation = buildXtreamNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
itemId: item.xtream_id,
title: item.title,
imageUrl: item.poster_url,
});
void this.router.navigate(navigation.link, {
state: navigation.state,
});
}
}
async onRemoveItem(item: FavoriteLayoutItem) {
if (!isXtreamRecentViewItem(item)) {
return;
}
try {
if (this.isGlobal) {
if (item.source === 'stalker') {
await firstValueFrom(
this.playlistsService.removeFromPortalRecentlyViewed(
item.playlist_id,
item.id
)
);
} else if (item.source === 'm3u') {
await firstValueFrom(
this.playlistsService.removeFromM3uRecentlyViewed(
item.playlist_id,
String(item.xtream_id ?? item.id)
)
);
} else {
await this.dbService.removeRecentItem(
Number(item.id),
item.playlist_id
);
}
await this.loadGlobalItems();
return;
}
const playlist = this.currentPlaylist();
if (!playlist) {
return;
}
this.xtreamStore.removeRecentItem({
itemId: Number(item.id),
playlistId: playlist.id,
});
} catch (error) {
this.logger.error('Error removing recent item', error);
}
}
getGroupedItems() {
const items = this.visibleRecentItems();
if (!this.isGlobal) return { default: items };
return groupItemsByPlaylistName(items);
}
updateRecentSearchTerm(term: string) {
this.recentSearchTerm.set(term);
}
toggleGroupByPlaylist(value: boolean) {
this._groupByPlaylist.set(value);
localStorage.setItem(
RecentlyViewedComponent.GROUP_BY_STORAGE_KEY,
String(value)
);
}
toggleTypeFilter(type: 'live' | 'movie' | 'series', value: boolean) {
const next = {
...this._typeFilters(),
[type]: value,
};
this._typeFilters.set(next);
localStorage.setItem(
RecentlyViewedComponent.TYPE_FILTERS_STORAGE_KEY,
JSON.stringify(next)
);
}
setCategoryId(categoryId: string): void {
this.collectionContext.setCategoryId(categoryId);
}
selectLiveItem(item: XtreamRecentLiveItem): void {
this.xtreamStore.setSelectedContentType('live');
this.selectedLiveItem.set(item);
const streamUrl = this.xtreamStore.constructStreamUrl(item);
this.liveStreamUrl.set(streamUrl);
if (this.isEmbeddedPlayer()) {
return;
}
this.xtreamStore.openPlayer(
streamUrl,
item.title,
item.poster_url || item.stream_icon || null
);
}
setLiveChannelSortMode(mode: LiveChannelSortMode): void {
this.liveChannelSortMode.set(mode);
localStorage.setItem(XTREAM_RECENT_LIVE_SORT_STORAGE_KEY, mode);
}
private clearLiveSelection(): void {
this.selectedLiveItem.set(null);
this.liveStreamUrl.set('');
this.xtreamStore.clearEpg();
this.xtreamStore.setSelectedItem(null);
}
}
@@ -10,6 +10,11 @@ import {
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
jest.mock('@iptvnator/portal/shared/ui', () => ({
ContentCardComponent: class {},
SearchLayoutComponent: class {},
}));
const DEFAULT_SEARCH_FILTERS: SearchFilters = {
live: true,
movie: true,
@@ -32,6 +37,17 @@ function createSearchItem(
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
class MockXtreamStore {
readonly searchTerm = signal('');
readonly searchFilters = signal(DEFAULT_SEARCH_FILTERS);
@@ -79,6 +95,7 @@ describe('SearchResultsComponent initialQuery contract', () => {
useValue: {
snapshot: {
data: {},
queryParamMap: convertToParamMap({ q: '' }),
},
queryParamMap: of(convertToParamMap({ q: '' })),
},
@@ -100,7 +117,8 @@ describe('SearchResultsComponent initialQuery contract', () => {
{
isGlobalSearch: true,
initialQuery: 'matrix',
}
},
undefined
)
);
@@ -115,7 +133,8 @@ describe('SearchResultsComponent initialQuery contract', () => {
new SearchResultsComponent(
{
isGlobalSearch: true,
}
},
undefined
)
);
@@ -129,7 +148,8 @@ describe('SearchResultsComponent initialQuery contract', () => {
new SearchResultsComponent(
{
isGlobalSearch: true,
}
},
undefined
)
);
@@ -146,4 +166,38 @@ describe('SearchResultsComponent initialQuery contract', () => {
}
);
});
it('ignores stale global search responses once a newer search has completed', async () => {
const staleSearch = createDeferred<XtreamContentItem[]>();
const freshResults = [
createSearchItem({ xtream_id: 777, title: 'Fresh result' }),
];
const databaseService = TestBed.inject(DatabaseService) as {
globalSearchContent: jest.Mock;
};
databaseService.globalSearchContent
.mockReturnValueOnce(staleSearch.promise)
.mockResolvedValueOnce(freshResults);
const component = TestBed.runInInjectionContext(
() =>
new SearchResultsComponent({
isGlobalSearch: true,
}, undefined)
);
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
const stalePromise = component.searchGlobal('mat', ['movie']);
const freshPromise = component.searchGlobal('matrix', ['movie']);
await freshPromise;
expect(store.searchResults()).toEqual(freshResults);
staleSearch.resolve([
createSearchItem({ xtream_id: 1, title: 'Stale result' }),
]);
await stalePromise;
expect(store.searchResults()).toEqual(freshResults);
});
});
@@ -92,6 +92,7 @@ export class SearchResultsComponent implements AfterViewInit {
/** Search filters from store */
readonly filters = this.xtreamStore.searchFilters;
private globalSearchRequestVersion = 0;
private static readonly GROUP_BY_STORAGE_KEY =
'global-search-group-by-playlist';
@@ -249,10 +250,13 @@ export class SearchResultsComponent implements AfterViewInit {
* Clear only the results, not the search term/filters
*/
private clearResultsOnly() {
this.globalSearchRequestVersion++;
this.xtreamStore.setIsSearching(false);
this.xtreamStore.setGlobalSearchResults([]);
}
async searchGlobal(term: string, types: string[], excludeHidden?: boolean) {
const requestVersion = ++this.globalSearchRequestVersion;
this.xtreamStore.setIsSearching(true);
try {
const results = await this.databaseService.globalSearchContent(
@@ -260,12 +264,21 @@ export class SearchResultsComponent implements AfterViewInit {
types,
excludeHidden
);
if (requestVersion !== this.globalSearchRequestVersion) {
return;
}
if (results && Array.isArray(results)) {
this.xtreamStore.setGlobalSearchResults(results);
} else {
this.xtreamStore.setIsSearching(false);
}
} catch (error) {
if (requestVersion !== this.globalSearchRequestVersion) {
return;
}
this.logger.error('Error in global search', error);
this.xtreamStore.resetSearchResults();
}
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}