mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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;
|
||||
|
||||
+12
-3
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
-11
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user