feat: enhance EPG and portal features with error suppression and status resolution

- Added suppressErrorLog option to EPG API calls in withEpg feature.
- Introduced resolvePortalStatus function to streamline portal status checks in withPortal feature.
- Updated checkPortalStatus to return PortalStatusType instead of void.
- Enhanced XtreamStore to preserve cancelled content initialization block.
- Expanded XtreamState to include contentInitBlockReason for better state management.
- Added XtreamContentGateComponent to manage content initialization states.
- Implemented tests for XtreamContentGateComponent to ensure correct rendering based on contentInitBlockReason.
- Created PlaylistRefreshService to handle playlist refresh operations and events.
- Introduced new IPC commands for playlist refresh functionality.
- Added interfaces for playlist refresh events and payloads to improve type safety.
This commit is contained in:
4gray committed 2026-04-04 13:40:34 +02:00
1 parent 4e0afe8b20
commit 91cd650c1a
55 files changed
+3485 -397

No files matched your search

Vendored
+22
View File
@@ -1,5 +1,9 @@
import 'jest-extended';
import { ExternalPlayerSession } from './libs/shared/interfaces/src/lib/external-player-session.interface';
import {
PlaylistRefreshEvent,
PlaylistRefreshPayload,
} from './libs/shared/interfaces/src/lib/playlist-refresh.interface';
import { Playlist } from './libs/shared/interfaces/src/lib/playlist.interface';
declare module 'video.js' {
@@ -23,6 +27,9 @@ declare global {
interface Window {
electron: {
onPlaylistRefreshEvent?: (
callback: (data: PlaylistRefreshEvent) => void
) => () => void;
getAppVersion: () => Promise<string>;
platform: string;
fetchPlaylistByUrl: (
@@ -113,7 +120,18 @@ declare global {
url: string;
params: Record<string, string>;
requestId?: string;
sessionId?: string;
suppressErrorLog?: boolean;
}) => Promise<{ payload: any; action: string }>;
xtreamCancelSession: (
sessionId: string
) => Promise<{ success: boolean; cancelled: number }>;
refreshPlaylist: (
payload: PlaylistRefreshPayload
) => Promise<Playlist>;
cancelPlaylistRefresh: (
operationId: string
) => Promise<{ success: boolean }>;
// Database operations
dbCreatePlaylist: (playlist: any) => Promise<{ success: boolean }>;
dbGetPlaylist: (playlistId: string) => Promise<any>;
@@ -190,6 +208,10 @@ declare global {
type: string,
operationId?: string
) => Promise<{ success: boolean; count: number }>;
dbClearXtreamImportCache: (
playlistId: string,
type: 'live' | 'movie' | 'series'
) => Promise<{ success: boolean }>;
dbSearchContent: (
playlistId: string,
searchTerm: string,
@@ -0,0 +1,44 @@
import { createReducer } from '@ngrx/store';
import { PlaylistActions } from '../actions';
import { playlistsAdapter } from '../playlists.state';
import { initialState } from '../state';
import { playlistReducers } from './playlist.reducers';
import { PlaylistMeta } from 'shared-interfaces';
const reducer = createReducer(initialState, ...playlistReducers);
describe('playlistReducers', () => {
it('persists updateDate when playlist meta is updated', () => {
const existingPlaylist: PlaylistMeta = {
_id: 'playlist-1',
title: 'Xtream Playlist',
count: 0,
importDate: '2026-03-28T00:00:00.000Z',
autoRefresh: false,
serverUrl: 'http://localhost:8080',
username: 'demo',
password: 'secret',
};
const state = {
...initialState,
playlists: playlistsAdapter.addOne(
existingPlaylist,
initialState.playlists
),
};
const nextState = reducer(
state,
PlaylistActions.updatePlaylistMeta({
playlist: {
...existingPlaylist,
updateDate: 1712145600000,
},
})
);
expect(nextState.playlists.entities['playlist-1']?.updateDate).toBe(
1712145600000
);
});
});
@@ -122,6 +122,9 @@ export const playlistReducers = [
...(p.recentlyViewed != null
? { recentlyViewed: p.recentlyViewed }
: {}),
...(p.updateDate !== undefined
? { updateDate: p.updateDate }
: {}),
},
},
state.playlists
@@ -288,6 +288,53 @@ describe('RecentPlaylistsComponent busy state', () => {
expect(router.navigate).not.toHaveBeenCalled();
});
it('dispatches playlist meta with updateDate after Xtream refresh setup succeeds', async () => {
const item = createPlaylistMeta({ _id: 'playlist-refresh-success-1' });
let confirmPromise: Promise<void> | undefined;
const dateNowSpy = jest
.spyOn(Date, 'now')
.mockReturnValue(1712145600000);
dialogService.openConfirmDialog.mockImplementation(
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
confirmPromise = onConfirm?.();
}
);
databaseService.deleteXtreamPlaylistContent.mockResolvedValue({
success: true,
favoritedXtreamIds: [101, 202],
recentlyViewedXtreamIds: [
{
xtreamId: 303,
viewedAt: '2026-04-03T11:15:00.000Z',
},
],
hiddenCategories: [{ xtreamId: 404, type: 'live' }],
});
component.refreshXtreamPlaylist(item);
await confirmPromise;
expect(databaseService.updateXtreamPlaylistDetails).toHaveBeenCalledWith(
{
id: item._id,
updateDate: 1712145600000,
}
);
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
playlist: { ...item, updateDate: 1712145600000 },
})
);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
item._id,
]);
dateNowSpy.mockRestore();
});
it('uses the legacy IPC refresh flow for non-Xtream playlists', () => {
const item = createPlaylistMeta({
_id: 'playlist-m3u-1',
@@ -25,10 +25,15 @@ import {
DataService,
DbOperationEvent,
isDbAbortError,
PlaylistRefreshService,
SortBy,
SortService,
} from 'services';
import { PLAYLIST_UPDATE, PlaylistMeta } from 'shared-interfaces';
import {
PLAYLIST_UPDATE,
PlaylistMeta,
PlaylistRefreshEvent,
} from 'shared-interfaces';
import { EmptyStateComponent } from './empty-state/empty-state.component';
import { PlaylistInfoComponent } from './playlist-info/playlist-info.component';
@@ -59,6 +64,7 @@ export class RecentPlaylistsComponent {
private readonly dialog = inject(MatDialog);
private readonly dialogService = inject(DialogService);
private readonly dataService = inject(DataService);
private readonly playlistRefreshService = inject(PlaylistRefreshService);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly sortService = inject(SortService);
@@ -280,6 +286,8 @@ export class RecentPlaylistsComponent {
if (item.serverUrl) {
// For Xtream playlists, delete and re-import
this.refreshXtreamPlaylist(item);
} else if (window.electron && (item.url || item.filePath)) {
void this.refreshM3uPlaylist(item);
} else {
// For M3U playlists, use existing refresh logic
this.dataService.sendIpcEvent(PLAYLIST_UPDATE, {
@@ -399,6 +407,76 @@ export class RecentPlaylistsComponent {
});
}
private async refreshM3uPlaylist(item: PlaylistMeta): Promise<void> {
if (
this.isDeletePending(item._id) ||
this.isRefreshPending(item._id)
) {
return;
}
this.setPendingRefresh(item._id, true);
const operationId = this.databaseService.createOperationId(
'playlist-refresh'
);
try {
const refreshedPlaylist = await this.playlistRefreshService.refreshPlaylist(
{
operationId,
playlistId: item._id,
title: item.title,
url: item.url,
filePath: item.filePath,
},
{
onEvent: (event) =>
this.updateBusyOperation(
item._id,
this.toPlaylistRefreshBusyEvent(event)
),
}
);
this.updateBusyOperation(item._id, {
operationId,
operation: 'playlist-refresh',
playlistId: item._id,
phase: 'saving',
status: 'progress',
});
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlist: {
...refreshedPlaylist,
_id: item._id,
},
playlistId: item._id,
})
);
this.clearBusyOperation(item._id);
this.snackBar.open(
this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'),
null,
{ duration: 2000 }
);
} catch (error) {
if (!isDbAbortError(error)) {
console.error('Error refreshing playlist:', error);
this.snackBar.open(
this.getPlaylistRefreshErrorMessage(error, item),
this.translate.instant('CLOSE'),
{ duration: 5000 }
);
}
} finally {
this.clearBusyOperation(item._id);
this.setPendingRefresh(item._id, false);
}
}
isDeletePending(playlistId: string): boolean {
return this.pendingDeletionIds().has(playlistId);
}
@@ -418,6 +496,8 @@ export class RecentPlaylistsComponent {
return this.translateDeletePhase(operation.phase);
case 'delete-xtream-content':
return this.translateRefreshPhase(operation.phase);
case 'playlist-refresh':
return this.translatePlaylistRefreshPhase(operation.phase);
default:
return '';
}
@@ -442,7 +522,15 @@ export class RecentPlaylistsComponent {
canCancelBusyOperation(item: PlaylistMeta): boolean {
const operation = this.busyOperations().get(item._id);
return Boolean(item.serverUrl && operation?.operationId);
if (!operation?.operationId) {
return false;
}
if (operation.operation === 'playlist-refresh') {
return true;
}
return Boolean(item.serverUrl);
}
async cancelBusyOperation(item: PlaylistMeta): Promise<void> {
@@ -451,6 +539,11 @@ export class RecentPlaylistsComponent {
return;
}
if (operation.operation === 'playlist-refresh') {
await this.playlistRefreshService.cancelRefresh(operation.operationId);
return;
}
await this.databaseService.cancelOperation(operation.operationId);
}
@@ -514,6 +607,19 @@ export class RecentPlaylistsComponent {
});
}
private toPlaylistRefreshBusyEvent(
event: PlaylistRefreshEvent
): DbOperationEvent {
return {
operationId: event.operationId,
operation: 'playlist-refresh',
playlistId: event.playlistId,
phase: event.phase,
status: event.status,
error: event.error,
};
}
private translateDeletePhase(phase?: string): string {
switch (phase) {
case 'deleting-favorites':
@@ -571,4 +677,61 @@ export class RecentPlaylistsComponent {
);
}
}
private translatePlaylistRefreshPhase(phase?: string): string {
switch (phase) {
case 'fetching':
return this.translateWithFallback(
'HOME.PLAYLISTS.REFRESH_FETCHING',
'Fetching playlist...'
);
case 'reading-file':
return this.translateWithFallback(
'HOME.PLAYLISTS.REFRESH_READING_FILE',
'Reading playlist file...'
);
case 'parsing':
return this.translateWithFallback(
'HOME.PLAYLISTS.REFRESH_PARSING',
'Parsing playlist...'
);
case 'saving':
return this.translateWithFallback(
'HOME.PLAYLISTS.REFRESH_SAVING',
'Saving playlist...'
);
default:
return this.translate.instant('HOME.PLAYLISTS.REFRESH');
}
}
private getPlaylistRefreshErrorMessage(
error: unknown,
item: PlaylistMeta
): string {
if (item.filePath) {
const message = String((error as { message?: string })?.message ?? error);
if (/(ENOENT|no such file or directory|not found)/i.test(message)) {
return this.translateWithFallback(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND',
'Playlist refresh failed. The local file is no longer available. Check the file path or re-import the playlist.'
);
}
if (/(EACCES|EPERM|permission denied)/i.test(message)) {
return this.translateWithFallback(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_ACCESS_ERROR',
'Playlist refresh failed. The app can no longer access the local file.'
);
}
}
return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR');
}
private translateWithFallback(key: string, fallback: string): string {
const translated = this.translate.instant(key);
return translated === key ? fallback : translated;
}
}
+1
View File
@@ -1,2 +1,3 @@
export * from './lib/playlist-player-actions';
export * from './lib/playlist-context.facade';
export * from './lib/playlist-refresh-action.service';
@@ -0,0 +1,200 @@
import { inject, Injectable, signal } from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { DialogService } from 'components';
import {
DatabaseService,
isDbAbortError,
PlaylistRefreshService,
} from 'services';
import { PlaylistActions } from 'm3u-state';
import { PlaylistMeta } from 'shared-interfaces';
@Injectable({ providedIn: 'root' })
export class PlaylistRefreshActionService {
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar);
private readonly dialogService = inject(DialogService);
private readonly databaseService = inject(DatabaseService);
private readonly playlistRefreshService = inject(PlaylistRefreshService);
readonly isRefreshing = signal(false);
canRefresh(playlist: PlaylistMeta | null): boolean {
if (!playlist || !window.electron) {
return false;
}
return Boolean(
playlist.serverUrl || playlist.url || playlist.filePath
);
}
refresh(playlist: PlaylistMeta): void {
if (this.isRefreshing()) {
return;
}
if (playlist.serverUrl) {
this.refreshXtream(playlist);
} else if (playlist.url || playlist.filePath) {
void this.refreshM3u(playlist);
}
}
private refreshXtream(item: PlaylistMeta): void {
this.dialogService.openConfirmDialog({
title: this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.TITLE'
),
message: this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.MESSAGE'
),
onConfirm: async () => {
if (this.isRefreshing()) {
return;
}
this.isRefreshing.set(true);
const operationId =
this.databaseService.createOperationId('xtream-refresh');
try {
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.STARTED'
),
undefined,
{ duration: 2000 }
);
const updateDate = Date.now();
const [
{
favoritedXtreamIds,
recentlyViewedXtreamIds,
hiddenCategories,
},
] = await Promise.all([
this.databaseService.deleteXtreamPlaylistContent(
item._id,
{ operationId }
),
this.databaseService.updateXtreamPlaylistDetails({
id: item._id,
updateDate,
}),
]);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: { ...item, updateDate },
})
);
const restoreKey = `xtream-restore-${item._id}`;
localStorage.setItem(
restoreKey,
JSON.stringify({
favoritedXtreamIds,
recentlyViewedXtreamIds,
hiddenCategories,
})
);
await this.router.navigate([
'/workspace',
'xtreams',
item._id,
]);
} catch (error) {
if (!isDbAbortError(error)) {
console.error(
'Error refreshing Xtream playlist:',
error
);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.ERROR'
),
undefined,
{ duration: 3000 }
);
}
} finally {
this.isRefreshing.set(false);
}
},
});
}
private async refreshM3u(item: PlaylistMeta): Promise<void> {
this.isRefreshing.set(true);
try {
const refreshedPlaylist =
await this.playlistRefreshService.refreshPlaylist({
operationId:
this.databaseService.createOperationId(
'playlist-refresh'
),
playlistId: item._id,
title: item.title,
url: item.url,
filePath: item.filePath,
});
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlist: {
...refreshedPlaylist,
_id: item._id,
},
playlistId: item._id,
})
);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
undefined,
{ duration: 2000 }
);
} catch (error) {
if (!isDbAbortError(error)) {
console.error('Error refreshing playlist:', error);
this.snackBar.open(
this.getRefreshErrorMessage(error, item),
this.translate.instant('CLOSE'),
{ duration: 5000 }
);
}
} finally {
this.isRefreshing.set(false);
}
}
private getRefreshErrorMessage(
error: unknown,
item: PlaylistMeta
): string {
if (
error instanceof Error &&
error.message?.includes('ENOENT') &&
item.filePath
) {
return this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND'
);
}
return this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR'
);
}
}
@@ -12,7 +12,10 @@ import {
PlaylistErrorViewComponent,
} from '@iptvnator/portal/shared/ui';
import {
clearNavigationStateKeys,
getOpenStalkerItemState,
PortalCatalogFacade,
OPEN_STALKER_ITEM_STATE_KEY,
PORTAL_CATALOG_DETAIL_COMPONENT,
PORTAL_CATALOG_FACADE,
PortalCatalogSortMode,
@@ -89,6 +92,7 @@ export class CategoryContentViewComponent implements OnInit {
ngOnInit(): void {
const { categoryId } = this.activatedRoute.snapshot.params;
this.catalog.initialize(categoryId ?? null);
this.openStalkerItemFromNavigationState();
}
onPageChange(event: PageEvent): void {
@@ -104,4 +108,22 @@ export class CategoryContentViewComponent implements OnInit {
});
}
}
private openStalkerItemFromNavigationState(): void {
if (this.catalog.provider !== 'stalker') {
return;
}
const item = getOpenStalkerItemState(window.history.state);
if (!item) {
return;
}
this.catalog.selectItem(item as CategoryContentItem);
clearNavigationStateKeys([
OPEN_STALKER_ITEM_STATE_KEY,
'openFavoriteItem',
'openRecentItem',
]);
}
}
+21
View File
@@ -0,0 +1,21 @@
export default {
displayName: 'portal-shared-ui',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/portal/shared/ui',
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:portal-shared", "type:ui"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/portal/shared/ui/jest.config.ts",
"tsConfig": "libs/portal/shared/ui/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
@@ -118,9 +118,11 @@
[items]="liveItems()"
[mode]="mode()"
[searchTerm]="workspaceSearchTerm()"
[autoOpenItem]="pendingAutoOpenLiveItem()"
(removeItem)="onRemoveItem($event)"
(reorderItems)="onReorder($event)"
(itemPlayed)="onItemPlayed($event)"
(autoOpenHandled)="onLiveAutoOpenHandled()"
/>
}
}
@@ -0,0 +1,187 @@
import {
ChangeDetectionStrategy,
Component,
input,
output,
signal,
} from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { convertToParamMap, ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import {
CollectionScope,
ScopeToggleService,
UnifiedCollectionItem,
UnifiedFavoritesDataService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/util';
import {
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from 'm3u-state';
import { of } from 'rxjs';
import { PlaylistMeta } from 'shared-interfaces';
import { UnifiedCollectionPageComponent } from './unified-collection-page.component';
import { UnifiedGridTabComponent } from './unified-grid-tab.component';
import { UnifiedLiveTabComponent } from './unified-live-tab.component';
@Component({
selector: 'app-unified-live-tab',
template: '',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubUnifiedLiveTabComponent {
readonly items = input.required<UnifiedCollectionItem[]>();
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly searchTerm = input('');
readonly autoOpenItem = input<unknown>(null);
readonly removeItem = output<UnifiedCollectionItem>();
readonly reorderItems = output<UnifiedCollectionItem[]>();
readonly itemPlayed = output<UnifiedCollectionItem>();
readonly autoOpenHandled = output<void>();
}
@Component({
selector: 'app-unified-grid-tab',
template: '',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubUnifiedGridTabComponent {
readonly items = input.required<UnifiedCollectionItem[]>();
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly contentType = input<'movie' | 'series'>('movie');
readonly searchTerm = input('');
readonly removeItem = output<UnifiedCollectionItem>();
}
describe('UnifiedCollectionPageComponent', () => {
let fixture: ComponentFixture<UnifiedCollectionPageComponent>;
const playlistsLoaded = signal(false);
const playlists = signal<PlaylistMeta[]>([]);
const favoritesData = {
getFavorites: jest.fn().mockResolvedValue([]),
removeFavorite: jest.fn(),
reorder: jest.fn(),
};
const recentData = {
getRecentItems: jest.fn().mockResolvedValue([]),
removeRecentItem: jest.fn(),
clearRecentItems: jest.fn(),
};
beforeEach(async () => {
playlistsLoaded.set(false);
playlists.set([]);
jest.clearAllMocks();
await TestBed.configureTestingModule({
imports: [
UnifiedCollectionPageComponent,
TranslateModule.forRoot(),
],
providers: [
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap({}),
queryParams: {},
params: {},
data: {},
parent: null,
},
queryParamMap: of(convertToParamMap({})),
pathFromRoot: [
{
snapshot: {
data: { layout: 'workspace' },
},
},
],
},
},
{
provide: Store,
useValue: {
selectSignal: jest.fn((selector: unknown) => {
if (selector === selectAllPlaylistsMeta) {
return playlists;
}
if (selector === selectPlaylistsLoadingFlag) {
return playlistsLoaded;
}
return signal(null);
}),
},
},
{
provide: ScopeToggleService,
useValue: {
getScope: jest.fn(() => signal<CollectionScope>('all')),
setScope: jest.fn(),
},
},
{
provide: UnifiedFavoritesDataService,
useValue: favoritesData,
},
{
provide: UnifiedRecentDataService,
useValue: recentData,
},
],
})
.overrideComponent(UnifiedCollectionPageComponent, {
remove: {
imports: [
UnifiedGridTabComponent,
UnifiedLiveTabComponent,
],
},
add: {
imports: [
StubUnifiedGridTabComponent,
StubUnifiedLiveTabComponent,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(UnifiedCollectionPageComponent);
fixture.componentRef.setInput('mode', 'favorites');
fixture.componentRef.setInput('defaultScope', 'all');
});
it('reloads favorites after playlist hydration completes', async () => {
fixture.detectChanges();
await fixture.whenStable();
expect(favoritesData.getFavorites).toHaveBeenCalledTimes(1);
playlists.set([
{
_id: 'xtream-1',
title: 'Xtream One',
count: 1,
importDate: '2026-04-03T10:00:00.000Z',
autoRefresh: false,
serverUrl: 'https://example.com',
favorites: ['fav-1'],
} as PlaylistMeta,
]);
playlistsLoaded.set(true);
fixture.detectChanges();
await fixture.whenStable();
expect(favoritesData.getFavorites).toHaveBeenCalledTimes(2);
expect(favoritesData.getFavorites).toHaveBeenLastCalledWith(
'all',
undefined,
undefined
);
});
});
@@ -13,17 +13,25 @@ import { MatIconButton } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import {
clearNavigationStateKeys,
CollectionContentType,
CollectionScope,
getOpenLiveCollectionItemState,
isWorkspaceLayoutRoute,
OPEN_LIVE_COLLECTION_ITEM_STATE_KEY,
queryParamSignal,
ScopeToggleService,
UnifiedCollectionItem,
UnifiedFavoritesDataService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/util';
import {
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from 'm3u-state';
import { UnifiedLiveTabComponent } from './unified-live-tab.component';
import { UnifiedGridTabComponent } from './unified-grid-tab.component';
@@ -48,9 +56,14 @@ export class UnifiedCollectionPageComponent implements OnInit {
readonly defaultScope = input<CollectionScope>();
private readonly route = inject(ActivatedRoute);
private readonly store = inject(Store);
private readonly scopeService = inject(ScopeToggleService);
private readonly favoritesData = inject(UnifiedFavoritesDataService);
private readonly recentData = inject(UnifiedRecentDataService);
private readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
private readonly playlistsLoaded = this.store.selectSignal(
selectPlaylistsLoadingFlag
);
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
private readonly routeSearchTerm = queryParamSignal(
this.route,
@@ -64,6 +77,9 @@ export class UnifiedCollectionPageComponent implements OnInit {
readonly isLoading = signal(true);
readonly allItems = signal<UnifiedCollectionItem[]>([]);
readonly selectedContentType = signal<CollectionContentType>('live');
readonly pendingAutoOpenLiveItem = signal(
getOpenLiveCollectionItemState(window.history.state)
);
readonly skeletonRows = Array.from({ length: 12 }, (_, i) => i);
readonly skeletonCards = Array.from({ length: 8 }, (_, i) => i);
@@ -118,12 +134,35 @@ export class UnifiedCollectionPageComponent implements OnInit {
: 'PORTALS.RECENTLY_VIEWED';
});
private readonly favoritesReloadKey = computed(() => {
if (this.mode() !== 'favorites') {
return 'recent';
}
if (!this.playlistsLoaded()) {
return null;
}
return this.playlists()
.map((playlist) =>
[
playlist._id,
playlist.serverUrl ? 'xtream' : playlist.macAddress ? 'stalker' : 'm3u',
JSON.stringify(playlist.favorites ?? []),
].join('::')
)
.join('|');
});
private loadRequestId = 0;
constructor() {
effect(() => {
this.mode();
this.portalType();
this.playlistId();
this.effectiveScope();
this.favoritesReloadKey();
void this.loadData();
});
}
@@ -205,7 +244,11 @@ export class UnifiedCollectionPageComponent implements OnInit {
}
private async loadData(): Promise<void> {
this.isLoading.set(true);
const requestId = ++this.loadRequestId;
if (this.allItems().length === 0) {
this.isLoading.set(true);
}
try {
const s = this.effectiveScope();
const pid = this.playlistId();
@@ -214,12 +257,26 @@ export class UnifiedCollectionPageComponent implements OnInit {
this.mode() === 'favorites'
? await this.favoritesData.getFavorites(s, pid, pt)
: await this.recentData.getRecentItems(s, pid, pt);
if (requestId !== this.loadRequestId) {
return;
}
this.allItems.set(items);
this.autoSelectContentType();
if (
this.pendingAutoOpenLiveItem() &&
items.some((item) => item.contentType === 'live')
) {
this.selectedContentType.set('live');
}
} catch {
if (requestId !== this.loadRequestId) {
return;
}
this.allItems.set([]);
} finally {
this.isLoading.set(false);
if (requestId === this.loadRequestId) {
this.isLoading.set(false);
}
}
}
@@ -229,4 +286,9 @@ export class UnifiedCollectionPageComponent implements OnInit {
this.selectedContentType.set(types[0]);
}
}
onLiveAutoOpenHandled(): void {
this.pendingAutoOpenLiveItem.set(null);
clearNavigationStateKeys([OPEN_LIVE_COLLECTION_ITEM_STATE_KEY]);
}
}
@@ -12,6 +12,7 @@ import { TranslatePipe } from '@ngx-translate/core';
import {
CollectionContentType,
getUnifiedCollectionNavigation,
STALKER_RETURN_TO_STATE_KEY,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { ContentCardComponent } from '../content-card/content-card.component';
@@ -51,8 +52,16 @@ export class UnifiedGridTabComponent {
return;
}
const state =
item.sourceType === 'stalker' && item.contentType !== 'live'
? {
...(navigation.state ?? {}),
[STALKER_RETURN_TO_STATE_KEY]: this.router.url,
}
: navigation.state;
void this.router.navigate(navigation.link, {
state: navigation.state,
state,
});
}
@@ -13,6 +13,8 @@ import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import {
matchesOpenLiveCollectionItem,
OpenLiveCollectionItemState,
PORTAL_PLAYER,
ResolvedLiveCollectionDetail,
StreamResolverService,
@@ -54,10 +56,12 @@ export class UnifiedLiveTabComponent {
readonly items = input.required<UnifiedCollectionItem[]>();
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly searchTerm = input('');
readonly autoOpenItem = input<OpenLiveCollectionItemState | null>(null);
readonly removeItem = output<UnifiedCollectionItem>();
readonly reorderItems = output<UnifiedCollectionItem[]>();
readonly itemPlayed = output<UnifiedCollectionItem>();
readonly autoOpenHandled = output<void>();
private readonly streamResolver = inject(StreamResolverService);
private readonly recentData = inject(UnifiedRecentDataService);
@@ -166,6 +170,28 @@ export class UnifiedLiveTabComponent {
}
});
effect(() => {
const target = this.autoOpenItem();
const items = this.items();
if (!target || items.length === 0) {
return;
}
const matchedItem = items.find((item) =>
matchesOpenLiveCollectionItem(item, target)
);
if (!matchedItem) {
return;
}
if (this.activeUid() === matchedItem.uid && this.activeDetail()) {
this.autoOpenHandled.emit();
return;
}
void this.activateItem(matchedItem, true);
});
const tickInterval = setInterval(
() => this.progressTick.update((tick) => tick + 1),
30_000
@@ -178,9 +204,47 @@ export class UnifiedLiveTabComponent {
if (!item) {
return;
}
await this.activateItem(item);
}
if (this.activeUid() === item.uid) {
this.onClose();
onFavoriteToggled(channel: UnifiedFavoriteChannel): void {
const item = this.items().find((candidate) => candidate.uid === channel.uid);
if (item) {
this.removeItem.emit(item);
}
}
onReorder(channels: UnifiedFavoriteChannel[]): void {
const reordered = channels
.map((channel) =>
this.items().find((candidate) => candidate.uid === channel.uid)
)
.filter(Boolean) as UnifiedCollectionItem[];
this.reorderItems.emit(reordered);
}
onClose(): void {
this.selectionRequestId += 1;
this.isSelecting.set(false);
this.activeDetail.set(null);
this.activeUid.set(null);
}
private async loadEpgMap(
items: UnifiedCollectionItem[]
): Promise<void> {
const epgMap = await this.streamResolver.loadEpgForItems(items);
this.epgMap.set(epgMap);
}
private async activateItem(
item: UnifiedCollectionItem,
isAutoOpen = false
): Promise<void> {
if (this.activeUid() === item.uid && this.activeDetail()) {
if (isAutoOpen) {
this.autoOpenHandled.emit();
}
return;
}
@@ -217,6 +281,10 @@ export class UnifiedLiveTabComponent {
} catch {
// Keep playback/EPG visible even if history persistence fails.
}
if (requestId === this.selectionRequestId && isAutoOpen) {
this.autoOpenHandled.emit();
}
} catch {
if (requestId === this.selectionRequestId) {
this.activeDetail.set(null);
@@ -229,36 +297,6 @@ export class UnifiedLiveTabComponent {
}
}
onFavoriteToggled(channel: UnifiedFavoriteChannel): void {
const item = this.items().find((candidate) => candidate.uid === channel.uid);
if (item) {
this.removeItem.emit(item);
}
}
onReorder(channels: UnifiedFavoriteChannel[]): void {
const reordered = channels
.map((channel) =>
this.items().find((candidate) => candidate.uid === channel.uid)
)
.filter(Boolean) as UnifiedCollectionItem[];
this.reorderItems.emit(reordered);
}
onClose(): void {
this.selectionRequestId += 1;
this.isSelecting.set(false);
this.activeDetail.set(null);
this.activeUid.set(null);
}
private async loadEpgMap(
items: UnifiedCollectionItem[]
): Promise<void> {
const epgMap = await this.streamResolver.loadEpgForItems(items);
this.epgMap.set(epgMap);
}
private async hydrateSelectedM3uPrograms(
item: UnifiedCollectionItem,
detail: ResolvedLiveCollectionDetail,
+6
View File
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
+17
View File
@@ -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"
]
}
@@ -217,6 +217,91 @@ describe('StreamResolverService', () => {
'https://xtream.example.com/live/1'
);
expect(detail.epgItems).toHaveLength(1);
expect(xtreamApi.getShortEpg).toHaveBeenCalledWith(
{
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
},
1,
10,
{
suppressErrorLog: true,
}
);
});
it('reuses cached empty Xtream preview EPG results instead of refetching immediately', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'xtream-1',
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
} satisfies Partial<Playlist>)
);
xtreamApi.getShortEpg.mockResolvedValue([]);
const items = [
{
uid: 'xtream::xtream-1::1',
name: 'Xtream Live',
contentType: 'live',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream',
xtreamId: 1,
} satisfies UnifiedCollectionItem,
];
await service.loadEpgForItems(items);
await service.loadEpgForItems(items);
expect(xtreamApi.getShortEpg).toHaveBeenCalledTimes(1);
expect(xtreamApi.getShortEpg).toHaveBeenCalledWith(
{
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
},
1,
2,
{
suppressErrorLog: true,
}
);
});
it('backs off repeated Xtream detail EPG failures during the cooldown window', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'xtream-1',
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
} satisfies Partial<Playlist>)
);
xtreamUrl.constructLiveUrl.mockReturnValue(
'https://xtream.example.com/live/1'
);
xtreamApi.getShortEpg.mockRejectedValue(new Error('EPG failed'));
const item = {
uid: 'xtream::xtream-1::1',
name: 'Xtream Live',
contentType: 'live',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream',
xtreamId: 1,
} satisfies UnifiedCollectionItem;
const firstDetail = await service.resolveLiveDetail(item);
const secondDetail = await service.resolveLiveDetail(item);
expect(firstDetail.epgItems).toEqual([]);
expect(secondDetail.epgItems).toEqual([]);
expect(xtreamApi.getShortEpg).toHaveBeenCalledTimes(1);
});
it('loads current Stalker EPG previews for live collection rows', async () => {
@@ -40,6 +40,11 @@ interface StalkerEpgResponse {
readonly js?: StalkerEpgEntry[] | { readonly data?: StalkerEpgEntry[] };
}
interface XtreamEpgCacheEntry {
readonly data: EpgItem[];
readonly timestamp: number;
}
export interface ResolvedLiveCollectionDetail {
readonly playback: ResolvedPortalPlayback;
readonly epgMode: 'm3u' | 'portal';
@@ -56,6 +61,10 @@ export class StreamResolverService {
private readonly dataService = inject(DataService);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly m3uEpgTimeoutMs = 3000;
private readonly xtreamEpgCache = new Map<string, XtreamEpgCacheEntry>();
private readonly xtreamEpgFailureTimestamps = new Map<string, number>();
private readonly xtreamEpgCacheTtlMs = 60 * 1000;
private readonly xtreamEpgFailureCooldownMs = 60 * 1000;
async resolvePlayback(
item: UnifiedCollectionItem
@@ -296,7 +305,12 @@ export class StreamResolverService {
return [];
}
return await this.xtreamApi.getShortEpg(creds, item.xtreamId, 10);
return await this.fetchXtreamEpgItems(
item.playlistId,
creds,
item.xtreamId,
10
);
} catch {
return [];
}
@@ -440,7 +454,8 @@ export class StreamResolverService {
}
try {
const items = await this.xtreamApi.getShortEpg(
const items = await this.fetchXtreamEpgItems(
playlistId,
creds,
channel.xtreamId,
2
@@ -485,6 +500,88 @@ export class StreamResolverService {
);
}
private getXtreamEpgCacheKey(
playlistId: string,
streamId: number,
limit: number
): string {
return `${playlistId}:${streamId}:${limit}`;
}
private getCachedXtreamEpgItems(cacheKey: string): EpgItem[] | null {
const entry = this.xtreamEpgCache.get(cacheKey);
if (!entry) {
return null;
}
if (Date.now() - entry.timestamp > this.xtreamEpgCacheTtlMs) {
this.xtreamEpgCache.delete(cacheKey);
return null;
}
return entry.data;
}
private isXtreamEpgFailureCoolingDown(cacheKey: string): boolean {
const timestamp = this.xtreamEpgFailureTimestamps.get(cacheKey);
if (timestamp == null) {
return false;
}
if (Date.now() - timestamp > this.xtreamEpgFailureCooldownMs) {
this.xtreamEpgFailureTimestamps.delete(cacheKey);
return false;
}
return true;
}
private async fetchXtreamEpgItems(
playlistId: string,
credentials: {
serverUrl: string;
username: string;
password: string;
},
streamId: number,
limit: number
): Promise<EpgItem[]> {
const cacheKey = this.getXtreamEpgCacheKey(
playlistId,
streamId,
limit
);
const cached = this.getCachedXtreamEpgItems(cacheKey);
if (cached !== null) {
return cached;
}
if (this.isXtreamEpgFailureCoolingDown(cacheKey)) {
return [];
}
try {
const items = await this.xtreamApi.getShortEpg(
credentials,
streamId,
limit,
{
suppressErrorLog: true,
}
);
this.xtreamEpgCache.set(cacheKey, {
data: items,
timestamp: Date.now(),
});
this.xtreamEpgFailureTimestamps.delete(cacheKey);
return items;
} catch {
this.xtreamEpgFailureTimestamps.set(cacheKey, Date.now());
return [];
}
}
private async loadStalkerEpgBatch(
playlistId: string,
channels: UnifiedCollectionItem[],
@@ -14,6 +14,10 @@ import { UnifiedFavoritesDataService } from './unified-favorites-data.service';
describe('UnifiedFavoritesDataService', () => {
let service: UnifiedFavoritesDataService;
let databaseService: {
getAllGlobalFavorites: jest.Mock;
getFavorites: jest.Mock;
};
let playlistsService: {
getPlaylistById: jest.Mock;
setFavorites: jest.Mock;
@@ -75,11 +79,22 @@ describe('UnifiedFavoritesDataService', () => {
];
beforeEach(() => {
Object.defineProperty(window, 'electron', {
value: {
dbGetAllGlobalFavorites: jest.fn(),
} as Window['electron'],
configurable: true,
});
playlistsService = {
getPlaylistById: jest.fn(),
setFavorites: jest.fn().mockReturnValue(of({})),
setPortalFavorites: jest.fn().mockReturnValue(of({})),
};
databaseService = {
getAllGlobalFavorites: jest.fn().mockResolvedValue([]),
getFavorites: jest.fn().mockResolvedValue([]),
};
TestBed.configureTestingModule({
providers: [
@@ -109,10 +124,7 @@ describe('UnifiedFavoritesDataService', () => {
},
{
provide: DatabaseService,
useValue: {
getAllGlobalFavorites: jest.fn().mockResolvedValue([]),
getFavorites: jest.fn().mockResolvedValue([]),
},
useValue: databaseService,
},
{
provide: PlaylistsService,
@@ -130,6 +142,72 @@ describe('UnifiedFavoritesDataService', () => {
service = TestBed.inject(UnifiedFavoritesDataService);
});
it('maps global Xtream favorites across live, movie, and series content types', async () => {
databaseService.getAllGlobalFavorites.mockResolvedValue([
{
id: 10,
category_id: 1,
playlist_id: 'xtream-1',
playlist_name: 'Xtream One',
xtream_id: 101,
title: 'Live One',
type: 'live',
poster_url: 'live.png',
added_at: '2026-03-26T09:00:00.000Z',
position: 0,
},
{
id: 11,
category_id: 2,
playlist_id: 'xtream-1',
playlist_name: 'Xtream One',
xtream_id: 102,
title: 'Movie One',
type: 'movie',
poster_url: 'movie.png',
added_at: '2026-03-26T08:00:00.000Z',
position: 1,
},
{
id: 12,
category_id: 3,
playlist_id: 'xtream-1',
playlist_name: 'Xtream One',
xtream_id: 103,
title: 'Series One',
type: 'series',
poster_url: 'series.png',
added_at: '2026-03-26T07:00:00.000Z',
position: 2,
},
]);
const items = await service.getFavorites('all');
expect(items).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'Live One',
contentType: 'live',
logo: 'live.png',
posterUrl: null,
}),
expect.objectContaining({
name: 'Movie One',
contentType: 'movie',
logo: null,
posterUrl: 'movie.png',
}),
expect.objectContaining({
name: 'Series One',
contentType: 'series',
logo: null,
posterUrl: 'series.png',
}),
])
);
});
it('preserves persisted M3U favorites order when extracting playlist favorites', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
@@ -1,8 +1,16 @@
import {
buildStalkerStateItem,
buildXtreamItemLink,
getGlobalFavoriteNavigation,
getOpenLiveCollectionItemState,
getOpenStalkerItemState,
getRecentItemNavigation,
getStalkerReturnToState,
getUnifiedCollectionNavigation,
matchesOpenLiveCollectionItem,
OPEN_LIVE_COLLECTION_ITEM_STATE_KEY,
OPEN_STALKER_ITEM_STATE_KEY,
STALKER_RETURN_TO_STATE_KEY,
} from './workspace-portal-navigation';
describe('workspace-portal-navigation', () => {
@@ -35,49 +43,171 @@ describe('workspace-portal-navigation', () => {
]);
});
it('keeps M3U favorites on the playlist favorites route', () => {
expect(
getGlobalFavoriteNavigation({
id: 'channel-1',
title: 'Channel One',
type: 'live',
playlist_id: 'm3u-1',
category_id: 'live',
xtream_id: 'channel-1',
source: 'm3u',
poster_url: 'https://example.com/logo.png',
added_at: '2026-03-01T00:00:00.000Z',
})
).toEqual({
link: ['/workspace', 'playlists', 'm3u-1', 'favorites'],
it('routes live M3U favorites to the playlist favorites collection with auto-open state', () => {
const navigation = getGlobalFavoriteNavigation({
id: 'channel-1',
title: 'Channel One',
type: 'live',
playlist_id: 'm3u-1',
category_id: 'live',
xtream_id: 'channel-1',
source: 'm3u',
poster_url: 'https://example.com/logo.png',
added_at: '2026-03-01T00:00:00.000Z',
});
expect(navigation).toEqual({
link: ['/workspace', 'playlists', 'm3u-1', 'favorites'],
state: {
openLiveCollectionItem: {
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
itemId: 'channel-1',
title: 'Channel One',
imageUrl: 'https://example.com/logo.png',
},
},
});
expect(
getOpenLiveCollectionItemState(navigation.state)
).toEqual(navigation.state?.[OPEN_LIVE_COLLECTION_ITEM_STATE_KEY]);
});
it('builds recent-item state for Stalker recent entries', () => {
expect(
getRecentItemNavigation({
id: 'stalker-1',
title: 'Movie One',
it('routes Stalker movie recents into the source category route with detail state', () => {
const navigation =
getGlobalFavoriteNavigation({
id: 'movie-7',
title: 'Movie Seven',
type: 'movie',
playlist_id: 'stalker-1',
category_id: 'vod',
xtream_id: 'stalker-1',
category_id: '17',
xtream_id: 'movie-7',
source: 'stalker',
poster_url: 'https://example.com/poster.png',
added_at: '2026-03-01T00:00:00.000Z',
});
expect(navigation).toEqual({
link: ['/workspace', 'stalker', 'stalker-1', 'vod', '17'],
state: {
openStalkerItem: expect.objectContaining({
category_id: '17',
id: 'movie-7',
title: 'Movie Seven',
}),
stalkerReturnTo: '/workspace/dashboard',
},
});
expect(getOpenStalkerItemState(navigation.state)).toEqual(
navigation.state?.[OPEN_STALKER_ITEM_STATE_KEY]
);
expect(getStalkerReturnToState(navigation.state)).toBe(
navigation.state?.[STALKER_RETURN_TO_STATE_KEY]
);
});
it('routes Stalker live recents to the collection route with auto-open state', () => {
const navigation =
getRecentItemNavigation({
id: 'stalker-live-1',
title: 'Live One',
type: 'live',
playlist_id: 'stalker-1',
category_id: 'itv',
xtream_id: 'stalker-live-1',
source: 'stalker',
poster_url: 'https://example.com/poster.png',
viewed_at: '2026-03-01T00:00:00.000Z',
})
).toEqual({
});
expect(navigation).toEqual({
link: ['/workspace', 'stalker', 'stalker-1', 'recent'],
state: {
openRecentItem: expect.objectContaining({
category_id: 'vod',
id: 'stalker-1',
title: 'Movie One',
openLiveCollectionItem: {
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
itemId: 'stalker-live-1',
title: 'Live One',
imageUrl: 'https://example.com/poster.png',
},
},
});
});
it('matches collection live state against multiple live item identifiers', () => {
expect(
matchesOpenLiveCollectionItem(
{
uid: 'm3u::m3u-1::https://example.com/live.m3u8',
name: 'Channel One',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
playlistName: 'M3U Playlist',
streamUrl: 'https://example.com/live.m3u8',
channelId: 'channel-1',
},
{
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
itemId: 'channel-1',
title: 'Channel One',
}
)
).toBe(true);
});
it('builds unified Stalker grid navigation with detail state', () => {
expect(
getUnifiedCollectionNavigation({
uid: 'stalker::stalker-1::series-9',
name: 'Series Nine',
contentType: 'series',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker Playlist',
stalkerId: 'series-9',
categoryId: '44',
posterUrl: 'https://example.com/poster.png',
})
).toEqual({
link: ['/workspace', 'stalker', 'stalker-1', 'series', '44'],
state: {
openStalkerItem: expect.objectContaining({
category_id: '44',
id: 'series-9',
title: 'Series Nine',
}),
},
});
});
it('preserves concrete Stalker category ids when building detail state', () => {
expect(
buildStalkerStateItem(
{
id: '77',
category_id: '101',
title: 'Concrete Category Movie',
},
{
id: '77',
title: 'Concrete Category Movie',
type: 'movie',
}
)
).toEqual(
expect.objectContaining({
id: '77',
category_id: '101',
title: 'Concrete Category Movie',
})
);
});
it('builds unified Xtream grid navigation from category-aware items', () => {
expect(
getUnifiedCollectionNavigation({
@@ -4,32 +4,51 @@ import {
PortalRecentItem,
StalkerPortalItem,
} from 'shared-interfaces';
import { UnifiedCollectionItem } from '../collection/unified-collection-item.interface';
import {
CollectionSourceType,
UnifiedCollectionItem,
} from '../collection/unified-collection-item.interface';
export interface WorkspaceNavigationTarget {
link: string[];
state?: Record<string, unknown>;
}
export const OPEN_LIVE_COLLECTION_ITEM_STATE_KEY = 'openLiveCollectionItem';
export const OPEN_STALKER_ITEM_STATE_KEY = 'openStalkerItem';
export const STALKER_RETURN_TO_STATE_KEY = 'stalkerReturnTo';
export interface OpenLiveCollectionItemState {
contentType: 'live';
sourceType: CollectionSourceType;
playlistId: string;
itemId: string;
title?: string;
imageUrl?: string | null;
}
export function getRecentItemNavigation(
item: PortalRecentItem
): WorkspaceNavigationTarget {
if (item.source === 'stalker') {
return {
link: ['/workspace', 'stalker', item.playlist_id, 'recent'],
state: {
openRecentItem: buildStalkerStateItem(item.stalker_item, item),
},
};
if (item.type === 'live') {
return buildLiveCollectionNavigationTarget({
mode: 'recent',
sourceType: item.source,
playlistId: item.playlist_id,
itemId: item.xtream_id ?? item.id,
title: item.title,
imageUrl: item.poster_url,
});
}
if (item.source === 'm3u') {
return {
link: ['/workspace', 'playlists', item.playlist_id, 'recent'],
state: {
openRecentChannelUrl: String(item.xtream_id ?? item.id ?? ''),
},
};
if (item.source === 'stalker') {
return buildStalkerDetailNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
item: buildStalkerStateItem(item.stalker_item, item),
returnTo: '/workspace/dashboard',
});
}
return buildXtreamNavigationTarget({
@@ -45,22 +64,25 @@ export function getRecentItemNavigation(
export function getGlobalFavoriteNavigation(
item: PortalFavoriteItem
): WorkspaceNavigationTarget {
if (item.source === 'stalker') {
return {
link: ['/workspace', 'stalker', item.playlist_id, 'favorites'],
state: {
openFavoriteItem: buildStalkerStateItem(
item.stalker_item,
item
),
},
};
if (item.type === 'live') {
return buildLiveCollectionNavigationTarget({
mode: 'favorites',
sourceType: item.source,
playlistId: item.playlist_id,
itemId: item.xtream_id ?? item.id,
title: item.title,
imageUrl: item.poster_url,
});
}
if (item.source === 'm3u') {
return {
link: ['/workspace', 'playlists', item.playlist_id, 'favorites'],
};
if (item.source === 'stalker') {
return buildStalkerDetailNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
item: buildStalkerStateItem(item.stalker_item, item),
returnTo: '/workspace/dashboard',
});
}
return buildXtreamNavigationTarget({
@@ -103,6 +125,82 @@ export function buildXtreamNavigationTarget(params: {
};
}
export function buildLiveCollectionNavigationTarget(params: {
mode: 'favorites' | 'recent';
sourceType: CollectionSourceType;
playlistId: string;
itemId?: string | number | null;
title?: string;
imageUrl?: string | null;
}): WorkspaceNavigationTarget {
return {
link: buildCollectionRoute(params.sourceType, params.playlistId, params.mode),
state: {
[OPEN_LIVE_COLLECTION_ITEM_STATE_KEY]: buildOpenLiveCollectionItemState(
params
),
},
};
}
export function buildOpenLiveCollectionItemState(params: {
sourceType: CollectionSourceType;
playlistId: string;
itemId?: string | number | null;
title?: string;
imageUrl?: string | null;
}): OpenLiveCollectionItemState {
return {
contentType: 'live',
sourceType: params.sourceType,
playlistId: params.playlistId,
itemId: toPathSegment(params.itemId),
title: params.title,
imageUrl: params.imageUrl,
};
}
export function getOpenLiveCollectionItemState(
state: unknown
): OpenLiveCollectionItemState | null {
const record = toStateRecord(state);
const candidate = record?.[OPEN_LIVE_COLLECTION_ITEM_STATE_KEY];
if (!candidate || typeof candidate !== 'object') {
return null;
}
const sourceType = toPathSegment(
(candidate as Record<string, unknown>)['sourceType']
) as CollectionSourceType;
const playlistId = toPathSegment(
(candidate as Record<string, unknown>)['playlistId']
);
const itemId = toPathSegment((candidate as Record<string, unknown>)['itemId']);
if (
!playlistId ||
!itemId ||
(sourceType !== 'm3u' &&
sourceType !== 'xtream' &&
sourceType !== 'stalker')
) {
return null;
}
return {
contentType: 'live',
sourceType,
playlistId,
itemId,
title: toOptionalPathSegment(
(candidate as Record<string, unknown>)['title']
),
imageUrl: toOptionalPathSegment(
(candidate as Record<string, unknown>)['imageUrl']
),
};
}
export function buildXtreamItemLink(params: {
playlistId: string;
type: PortalActivityType;
@@ -156,13 +254,165 @@ export function getUnifiedCollectionNavigation(
}
if (item.sourceType === 'stalker') {
const section = item.contentType === 'movie' ? 'vod' : item.contentType;
const categoryId = toPathSegment(item.categoryId);
const link = categoryId
? ['/workspace', 'stalker', item.playlistId, section, categoryId]
: ['/workspace', 'stalker', item.playlistId, section];
if (item.contentType === 'live') {
return buildLiveCollectionNavigationTarget({
mode: 'favorites',
sourceType: item.sourceType,
playlistId: item.playlistId,
itemId: item.stalkerId,
title: item.name,
imageUrl: item.logo ?? item.posterUrl ?? null,
});
}
return { link };
return buildStalkerDetailNavigationTarget({
playlistId: item.playlistId,
type: item.contentType,
categoryId: item.categoryId,
item: buildStalkerStateItem(
item.stalkerItem as StalkerPortalItem | undefined,
{
id:
item.stalkerId ??
getLastSegment(item.uid, '::') ??
'',
title: item.name,
type: item.contentType,
category_id: item.categoryId,
poster_url: item.posterUrl ?? item.logo ?? undefined,
}
),
});
}
return null;
}
export function buildStalkerDetailNavigationTarget(params: {
playlistId: string;
type: Exclude<PortalActivityType, 'live'>;
categoryId?: string | number | null;
item: Record<string, unknown>;
returnTo?: string | string[] | null;
}): WorkspaceNavigationTarget {
const section = params.type === 'movie' ? 'vod' : 'series';
const normalizedCategoryId = toPathSegment(
params.categoryId ?? toStalkerCategoryId(params.type)
);
const link = normalizedCategoryId
? ['/workspace', 'stalker', params.playlistId, section, normalizedCategoryId]
: ['/workspace', 'stalker', params.playlistId, section];
const state: Record<string, unknown> = {
[OPEN_STALKER_ITEM_STATE_KEY]: params.item,
};
const returnTo = normalizeReturnToState(params.returnTo);
if (returnTo) {
state[STALKER_RETURN_TO_STATE_KEY] = returnTo;
}
return {
link,
state,
};
}
export function getOpenStalkerItemState(
state: unknown
): Record<string, unknown> | null {
const record = toStateRecord(state);
const candidate =
record?.[OPEN_STALKER_ITEM_STATE_KEY] ??
record?.['openFavoriteItem'] ??
record?.['openRecentItem'];
return candidate && typeof candidate === 'object'
? (candidate as Record<string, unknown>)
: null;
}
export function getStalkerReturnToState(state: unknown): string | null {
const record = toStateRecord(state);
const candidate = record?.[STALKER_RETURN_TO_STATE_KEY];
return typeof candidate === 'string' && candidate.trim().length > 0
? candidate.trim()
: null;
}
export function clearNavigationStateKeys(keys: string[]): void {
try {
const state = toStateRecord(window.history.state);
if (!state) {
return;
}
const nextState = { ...state };
let changed = false;
keys.forEach((key) => {
if (key in nextState) {
delete nextState[key];
changed = true;
}
});
if (changed) {
window.history.replaceState(nextState, document.title);
}
} catch {
// no-op
}
}
export function matchesOpenLiveCollectionItem(
item: UnifiedCollectionItem,
target: OpenLiveCollectionItemState
): boolean {
if (
item.contentType !== 'live' ||
item.sourceType !== target.sourceType ||
item.playlistId !== target.playlistId
) {
return false;
}
const targetId = toPathSegment(target.itemId);
const sourceItemId = getLastSegment(item.uid, '::') ?? '';
const candidates = [
sourceItemId,
item.streamUrl,
item.channelId,
item.xtreamId,
item.stalkerId,
item.contentId,
]
.map((value) => toPathSegment(value))
.filter(Boolean);
if (candidates.includes(targetId)) {
return true;
}
const normalizedTargetTitle = target.title?.trim().toLowerCase();
return Boolean(
normalizedTargetTitle &&
item.name.trim().toLowerCase() === normalizedTargetTitle
);
}
function getLastSegment(value: string, separator: string): string | undefined {
const segments = value.split(separator);
return segments.length > 0 ? segments[segments.length - 1] : undefined;
}
function normalizeReturnToState(
value: string | string[] | null | undefined
): string | null {
if (Array.isArray(value)) {
const normalized = value.join('/');
return normalized.trim() ? normalized : null;
}
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim();
}
return null;
@@ -174,12 +424,14 @@ export function buildStalkerStateItem(
id: string | number;
title: string;
type: PortalActivityType;
category_id?: string | number | null;
poster_url?: string;
}
): Record<string, unknown> {
const normalizedCategory = toStalkerCategoryId(
rawItem?.category_id ?? fallback.type
);
const normalizedCategory =
toOptionalPathSegment(rawItem?.category_id) ??
toOptionalPathSegment(fallback.category_id) ??
toStalkerCategoryId(fallback.type);
if (rawItem) {
return {
...(rawItem as Record<string, unknown>),
@@ -215,3 +467,32 @@ export function toStalkerCategoryId(value: unknown): 'vod' | 'series' | 'itv' {
function toPathSegment(value: unknown): string {
return String(value ?? '').trim();
}
function toOptionalPathSegment(value: unknown): string | undefined {
const normalized = toPathSegment(value);
return normalized ? normalized : undefined;
}
function buildCollectionRoute(
sourceType: CollectionSourceType,
playlistId: string,
mode: 'favorites' | 'recent'
): string[] {
if (sourceType === 'xtream') {
return ['/workspace', 'xtreams', playlistId, mode];
}
if (sourceType === 'stalker') {
return ['/workspace', 'stalker', playlistId, mode];
}
return ['/workspace', 'playlists', playlistId, mode];
}
function toStateRecord(
state: unknown
): Record<string, unknown> | null {
return state && typeof state === 'object'
? (state as Record<string, unknown>)
: null;
}
@@ -1,6 +1,7 @@
import { VodDetailsItem } from 'shared-interfaces';
import { StalkerFavoriteItem } from './models';
import {
buildStalkerFavoritePayload,
createStalkerInlineDetailState,
createStalkerDetailViewState,
normalizeStalkerFavoriteItem,
@@ -110,6 +111,32 @@ describe('stalker-vod.utils regressions', () => {
expect(onComplete).toHaveBeenCalledTimes(1);
});
it('preserves the concrete category id in favorite payloads', () => {
expect(
buildStalkerFavoritePayload({
id: '42',
cmd: '/media/file_42.mpg',
info: {
name: 'Movie',
movie_image: '',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
category_id: '17',
} as unknown as Parameters<typeof buildStalkerFavoritePayload>[0])
).toEqual(
expect.objectContaining({
id: '42',
category_id: '17',
})
);
});
it('normalizes nested favorite details for the shared inline detail shell', () => {
const detailState = createStalkerInlineDetailState(
normalizeStalkerFavoriteItem({
@@ -73,9 +73,14 @@ export function isStalkerSeriesItem(item: {
export function buildStalkerFavoritePayload(
data: StalkerVodDetails
): Record<string, unknown> {
const categoryId = String(
(data as StalkerVodDetails & { category_id?: string | number })
.category_id ?? 'vod'
).trim();
return {
...data,
category_id: 'vod',
category_id: categoryId || 'vod',
title: data.info?.name,
cover: data.info?.movie_image,
added_at: new Date().toISOString(),
@@ -17,6 +17,14 @@ type FavoritePayload = StalkerPortalItem & {
id?: string | number;
};
function resolveCategoryId(
value: unknown,
fallback: string
): string {
const normalized = String(value ?? '').trim();
return normalized || fallback;
}
/**
* Favorites concern methods.
*/
@@ -37,7 +45,10 @@ export function withStalkerFavorites() {
playlistService
.addPortalFavorite(portalId, {
...item,
category_id: storeContext.selectedContentType(),
category_id: resolveCategoryId(
item.category_id,
storeContext.selectedContentType()
),
added_at: Date.now(),
id: item.stream_id ?? item.id,
})
@@ -58,6 +58,10 @@ interface StalkerResponse {
*/
export function withStalkerPlayer() {
const logger = createLogger('withStalkerPlayer');
const resolveCategoryId = (value: unknown, fallback: string): string => {
const normalized = String(value ?? '').trim();
return normalized || fallback;
};
return signalStoreFeature(
withMethods(
(
@@ -404,7 +408,10 @@ export function withStalkerPlayer() {
cmd,
cover,
title,
category_id: storeState.selectedContentType(),
category_id: resolveCategoryId(
item.category_id,
storeState.selectedContentType()
),
added_at: Date.now(),
};
playlistService
@@ -16,6 +16,14 @@ type RecentlyViewedPayload = StalkerPortalItem & {
title?: string;
};
function resolveCategoryId(
value: unknown,
fallback: string
): string {
const normalized = String(value ?? '').trim();
return normalized || fallback;
}
/**
* Recently-viewed concern methods.
*/
@@ -35,7 +43,10 @@ export function withStalkerRecent() {
const portalId = storeContext.currentPlaylist()?._id;
const recentItem = {
...item,
category_id: storeContext.selectedContentType(),
category_id: resolveCategoryId(
item.category_id,
storeContext.selectedContentType()
),
added_at: Date.now(),
id: item.id ?? item.stream_id ?? '',
title:
@@ -7,8 +7,10 @@ import {
signal,
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import {
getStalkerReturnToState,
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
@@ -63,6 +65,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
private readonly catalog = inject(StalkerCatalogFacadeService);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly router = inject(Router);
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
@@ -206,8 +209,13 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
}
onVodBack(): void {
const returnTo = getStalkerReturnToState(window.history.state);
this.closeInlinePlayer();
this.catalog.clearSelectedItem();
if (returnTo) {
void this.router.navigateByUrl(returnTo);
}
}
handleInlineTimeUpdate(event: {
@@ -9,6 +9,7 @@ import {
signal,
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component';
import {
@@ -26,6 +27,7 @@ import {
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
createLogger,
getStalkerReturnToState,
} from '@iptvnator/portal/shared/util';
import {
getVodSeriesSeasonKey,
@@ -68,6 +70,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
readonly stalkerStore = inject(StalkerStore);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly router = inject(Router);
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly downloadsService = inject(DownloadsService);
private readonly snackBar = inject(MatSnackBar);
@@ -415,9 +418,14 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
goBack() {
const returnTo = getStalkerReturnToState(window.history.state);
this.closeInlinePlayer();
this.backClicked.emit();
this.stalkerStore.clearSelectedItem();
if (returnTo) {
void this.router.navigateByUrl(returnTo);
}
}
toSeriesId(id: string | number): number {
@@ -1,5 +1,9 @@
import { inject, Injectable } from '@angular/core';
import { DatabaseService, PlaybackPositionService } from 'services';
import {
DatabaseService,
PlaybackPositionService,
XtreamImportStatus,
} from 'services';
import {
PlaybackPositionData,
PlaylistMeta,
@@ -43,6 +47,26 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
Promise<XtreamContentItem[]>
>();
private mapCategoryTypeToImportType(
type: CategoryType
): 'live' | 'movie' | 'series' {
switch (type) {
case 'live':
return 'live';
case 'vod':
return 'movie';
case 'series':
return 'series';
}
}
private async getImportStatus(
playlistId: string,
type: 'live' | 'movie' | 'series'
): Promise<XtreamImportStatus> {
return this.dbService.getXtreamImportStatus(playlistId, type);
}
// =========================================================================
// Playlist Operations
// =========================================================================
@@ -93,7 +117,8 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
async getCategories(
playlistId: string,
credentials: XtreamCredentials,
type: CategoryType
type: CategoryType,
options?: XtreamOperationOptions
): Promise<XtreamCategoryFromDb[]> {
const dbType = mapCategoryTypeToDbType(type);
const requestKey = `${playlistId}:${dbType}`;
@@ -103,8 +128,13 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
return inFlightRequest;
}
const request = this.loadCategories(playlistId, credentials, type, dbType)
.finally(() => {
const request = this.loadCategories(
playlistId,
credentials,
type,
dbType,
options
).finally(() => {
this.categoryRequests.delete(requestKey);
});
@@ -116,23 +146,29 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
playlistId: string,
credentials: XtreamCredentials,
type: CategoryType,
dbType: DbCategoryType
dbType: DbCategoryType,
options?: XtreamOperationOptions
): Promise<XtreamCategoryFromDb[]> {
const importType = this.mapCategoryTypeToImportType(type);
const importStatus = await this.getImportStatus(playlistId, importType);
// Fetch from DB directly — avoids a separate 'has' round-trip.
// An empty result means the cache is cold; proceed to fetch from API.
const cached = await this.dbService.getXtreamCategories(
playlistId,
dbType
);
if (cached.length > 0) {
if (importStatus === 'completed' && cached.length > 0) {
return cached;
}
// Fetch from API and cache
options?.onPhaseChange?.('loading-categories');
const remoteData = await this.apiService.getCategories(
credentials,
type
type,
{
sessionId: options?.sessionId,
}
);
if (remoteData && Array.isArray(remoteData) && remoteData.length > 0) {
@@ -142,6 +178,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
dbType
);
options?.onPhaseChange?.('saving-categories');
await this.dbService.saveXtreamCategories(
playlistId,
remoteData,
@@ -259,15 +296,25 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
onTotal?: (total: number) => void,
options?: XtreamOperationOptions
): Promise<XtreamContentItem[]> {
const importStatus = await this.getImportStatus(playlistId, type);
// Fetch from DB directly — avoids a separate 'has' round-trip.
// An empty result means the cache is cold; proceed to fetch from API.
const cached = await this.dbService.getXtreamContent(playlistId, type);
if (cached.length > 0) {
if (importStatus === 'completed' && cached.length > 0) {
return cached;
}
// Fetch from API
const remoteData = await this.apiService.getStreams(credentials, type);
options?.onPhaseChange?.(
type === 'live'
? 'loading-live'
: type === 'movie'
? 'loading-movies'
: 'loading-series'
);
const remoteData = await this.apiService.getStreams(credentials, type, {
sessionId: options?.sessionId,
});
if (remoteData && Array.isArray(remoteData) && remoteData.length > 0) {
// Report total items to import
@@ -136,8 +136,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
async getCategories(
playlistId: string,
credentials: XtreamCredentials,
type: CategoryType
type: CategoryType,
options?: XtreamOperationOptions
): Promise<XtreamCategory[]> {
void options;
const cacheKey = `${playlistId}-${type}-categories`;
// Check in-memory cache first
@@ -120,7 +120,9 @@ export type ProgressCallback = (count: number) => void;
export interface XtreamOperationOptions {
operationId?: string;
sessionId?: string;
onEvent?: (event: DbOperationEvent) => void;
onPhaseChange?: (phase: string) => void;
}
/**
@@ -171,7 +173,8 @@ export interface IXtreamDataSource {
getCategories(
playlistId: string,
credentials: XtreamCredentials,
type: CategoryType
type: CategoryType,
options?: XtreamOperationOptions
): Promise<XtreamCategory[] | XtreamCategoryFromDb[]>;
/**
@@ -0,0 +1,85 @@
import { TestBed } from '@angular/core/testing';
import { EpgQueueService } from './epg-queue.service';
import { XtreamApiService } from './xtream-api.service';
describe('EpgQueueService', () => {
let service: EpgQueueService;
let xtreamApi: { getShortEpg: jest.Mock };
const credentials = {
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
};
beforeEach(() => {
jest.useFakeTimers();
xtreamApi = {
getShortEpg: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
EpgQueueService,
{
provide: XtreamApiService,
useValue: xtreamApi,
},
],
});
service = TestBed.inject(EpgQueueService);
});
afterEach(() => {
jest.useRealTimers();
});
it('caches empty EPG responses and does not immediately refetch them', async () => {
xtreamApi.getShortEpg.mockResolvedValue([]);
await (service as unknown as { fetchEpg: (credentials: typeof credentials, streamId: number) => Promise<void> }).fetchEpg(
credentials,
101
);
expect(service.getCached(101)).toEqual([]);
expect(xtreamApi.getShortEpg).toHaveBeenCalledWith(
credentials,
101,
3,
{
suppressErrorLog: true,
}
);
expect(
(service as unknown as { shouldFetch: (streamId: number) => boolean }).shouldFetch(
101
)
).toBe(false);
});
it('applies a cooldown after EPG request failures', async () => {
xtreamApi.getShortEpg.mockRejectedValue(new Error('EPG failed'));
await (service as unknown as { fetchEpg: (credentials: typeof credentials, streamId: number) => Promise<void> }).fetchEpg(
credentials,
202
);
expect(
(service as unknown as { shouldFetch: (streamId: number) => boolean }).shouldFetch(
202
)
).toBe(false);
jest.advanceTimersByTime(60_001);
expect(
(service as unknown as { shouldFetch: (streamId: number) => boolean }).shouldFetch(
202
)
).toBe(true);
});
});
@@ -26,10 +26,12 @@ export class EpgQueueService implements OnDestroy {
private readonly inFlight = new Set<number>();
private visibleSet = new Set<number>();
private processing = false;
private readonly failureTimestamps = new Map<number, number>();
private readonly maxConcurrency = 2;
private readonly delayMs = 200;
private readonly cacheTtlMs = 5 * 60 * 1000; // 5 minutes
private readonly failureCooldownMs = 60 * 1000;
/** Emits EPG results as they arrive. */
readonly epgResult$ = new Subject<{ streamId: number; items: EpgItem[] }>();
@@ -45,6 +47,28 @@ export class EpgQueueService implements OnDestroy {
return entry.data;
}
private isFailureCoolingDown(streamId: number): boolean {
const timestamp = this.failureTimestamps.get(streamId);
if (timestamp == null) {
return false;
}
if (Date.now() - timestamp > this.failureCooldownMs) {
this.failureTimestamps.delete(streamId);
return false;
}
return true;
}
private shouldFetch(streamId: number): boolean {
return (
this.getCached(streamId) === null &&
!this.isFailureCoolingDown(streamId) &&
!this.inFlight.has(streamId)
);
}
/**
* Enqueue stream IDs for EPG fetching.
*
@@ -60,9 +84,7 @@ export class EpgQueueService implements OnDestroy {
this.visibleSet = visibleIds;
const toFetch = streamIds.filter(
(id) =>
!this.getCached(id) &&
!this.inFlight.has(id)
(id) => this.shouldFetch(id)
);
// Replace the queue – any previously queued but now-invisible IDs
@@ -92,8 +114,8 @@ export class EpgQueueService implements OnDestroy {
// Drop stale entries that are no longer visible
if (!this.visibleSet.has(streamId)) continue;
// Skip if it got cached while queued (e.g. duplicate)
if (this.getCached(streamId)) continue;
// Skip if it got cached or cooled down while queued (e.g. duplicate/failure)
if (!this.shouldFetch(streamId)) continue;
this.inFlight.add(streamId);
this.fetchEpg(credentials, streamId);
@@ -113,18 +135,19 @@ export class EpgQueueService implements OnDestroy {
const items = await this.apiService.getShortEpg(
credentials,
streamId,
this.previewLimit
this.previewLimit,
{
suppressErrorLog: true,
}
);
if (items.length > 0) {
this.cache.set(streamId, {
data: items,
timestamp: Date.now(),
});
} else {
this.cache.delete(streamId);
}
this.cache.set(streamId, {
data: items,
timestamp: Date.now(),
});
this.failureTimestamps.delete(streamId);
this.epgResult$.next({ streamId, items });
} catch (error) {
this.failureTimestamps.set(streamId, Date.now());
this.logger.error(
`Failed to load EPG for stream ${streamId}`,
error
@@ -32,6 +32,12 @@ export type CategoryType = 'live' | 'vod' | 'series';
*/
export type StreamType = 'live' | 'movie' | 'series';
export interface XtreamRequestOptions {
requestId?: string;
sessionId?: string;
suppressErrorLog?: boolean;
}
/**
* Raw EPG listing from API (before decoding)
*/
@@ -60,11 +66,26 @@ interface EpgResponse {
export class XtreamApiService {
private readonly dataService = inject(DataService);
async cancelSession(sessionId: string): Promise<boolean> {
if (!sessionId || typeof window.electron?.xtreamCancelSession !== 'function') {
return false;
}
try {
const result = await window.electron.xtreamCancelSession(sessionId);
return result.success;
} catch (error) {
console.error('Failed to cancel Xtream session:', error);
return false;
}
}
/**
* Get account/portal status
*/
async getAccountInfo(
credentials: XtreamCredentials
credentials: XtreamCredentials,
options?: XtreamRequestOptions
): Promise<XtreamAccountInfo> {
return this.sendRequest(
credentials.serverUrl,
@@ -72,7 +93,8 @@ export class XtreamApiService {
username: credentials.username,
password: credentials.password,
action: XtreamCodeActions.GetAccountInfo,
}
},
options
);
}
@@ -81,7 +103,8 @@ export class XtreamApiService {
*/
async getCategories(
credentials: XtreamCredentials,
type: CategoryType
type: CategoryType,
options?: XtreamRequestOptions
): Promise<XtreamCategory[]> {
const actionMap: Record<CategoryType, XtreamCodeActions> = {
live: XtreamCodeActions.GetLiveCategories,
@@ -95,7 +118,8 @@ export class XtreamApiService {
action: actionMap[type],
username: credentials.username,
password: credentials.password,
}
},
options
);
return Array.isArray(response) ? response : [];
@@ -105,7 +129,8 @@ export class XtreamApiService {
* Get live streams
*/
async getLiveStreams(
credentials: XtreamCredentials
credentials: XtreamCredentials,
options?: XtreamRequestOptions
): Promise<XtreamLiveStream[]> {
const response = await this.sendRequest(
credentials.serverUrl,
@@ -113,7 +138,8 @@ export class XtreamApiService {
action: XtreamCodeActions.GetLiveStreams,
username: credentials.username,
password: credentials.password,
}
},
options
);
return Array.isArray(response) ? response : [];
@@ -123,7 +149,8 @@ export class XtreamApiService {
* Get VOD streams
*/
async getVodStreams(
credentials: XtreamCredentials
credentials: XtreamCredentials,
options?: XtreamRequestOptions
): Promise<XtreamVodStream[]> {
const response = await this.sendRequest(
credentials.serverUrl,
@@ -131,7 +158,8 @@ export class XtreamApiService {
action: XtreamCodeActions.GetVodStreams,
username: credentials.username,
password: credentials.password,
}
},
options
);
return Array.isArray(response) ? response : [];
@@ -141,7 +169,8 @@ export class XtreamApiService {
* Get series items
*/
async getSeriesStreams(
credentials: XtreamCredentials
credentials: XtreamCredentials,
options?: XtreamRequestOptions
): Promise<XtreamSerieItem[]> {
const response = await this.sendRequest(
credentials.serverUrl,
@@ -149,7 +178,8 @@ export class XtreamApiService {
action: XtreamCodeActions.GetSeries,
username: credentials.username,
password: credentials.password,
}
},
options
);
return Array.isArray(response) ? response : [];
@@ -160,15 +190,16 @@ export class XtreamApiService {
*/
async getStreams(
credentials: XtreamCredentials,
type: StreamType
type: StreamType,
options?: XtreamRequestOptions
): Promise<XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[]> {
switch (type) {
case 'live':
return this.getLiveStreams(credentials);
return this.getLiveStreams(credentials, options);
case 'movie':
return this.getVodStreams(credentials);
return this.getVodStreams(credentials, options);
case 'series':
return this.getSeriesStreams(credentials);
return this.getSeriesStreams(credentials, options);
}
}
@@ -177,7 +208,8 @@ export class XtreamApiService {
*/
async getVodInfo(
credentials: XtreamCredentials,
vodId: string | number
vodId: string | number,
options?: XtreamRequestOptions
): Promise<XtreamVodDetails> {
return this.sendRequest(
credentials.serverUrl,
@@ -186,7 +218,8 @@ export class XtreamApiService {
username: credentials.username,
password: credentials.password,
vod_id: vodId,
}
},
options
);
}
@@ -195,7 +228,8 @@ export class XtreamApiService {
*/
async getSeriesInfo(
credentials: XtreamCredentials,
seriesId: string | number
seriesId: string | number,
options?: XtreamRequestOptions
): Promise<XtreamSerieDetails> {
return this.sendRequest(
credentials.serverUrl,
@@ -204,7 +238,8 @@ export class XtreamApiService {
username: credentials.username,
password: credentials.password,
series_id: seriesId,
}
},
options
);
}
@@ -215,7 +250,8 @@ export class XtreamApiService {
async getShortEpg(
credentials: XtreamCredentials,
streamId: number,
limit = 10
limit = 10,
options?: XtreamRequestOptions
): Promise<EpgItem[]> {
const response: EpgResponse = await this.sendRequest(
credentials.serverUrl,
@@ -225,7 +261,8 @@ export class XtreamApiService {
password: credentials.password,
stream_id: streamId,
limit,
}
},
options
);
if (!response?.epg_listings || !Array.isArray(response.epg_listings)) {
@@ -263,7 +300,8 @@ export class XtreamApiService {
*/
private async sendRequest<TResponse>(
url: string,
params: Record<string, string | number>
params: Record<string, string | number>,
options?: XtreamRequestOptions
): Promise<TResponse> {
const serializedParams: Record<string, string> = {};
Object.entries(params).forEach(([key, value]) => {
@@ -273,6 +311,9 @@ export class XtreamApiService {
const response = (await this.dataService.sendIpcEvent(XTREAM_REQUEST, {
url,
params: serializedParams,
requestId: options?.requestId,
sessionId: options?.sessionId,
suppressErrorLog: options?.suppressErrorLog,
})) as {
message?: string;
payload?: unknown;
@@ -1,10 +1,12 @@
import { TestBed } from '@angular/core/testing';
import { signalStore, withState } from '@ngrx/signals';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { DatabaseService } from 'services';
import {
XTREAM_DATA_SOURCE,
XtreamPlaylistData,
} from '../../data-sources/xtream-data-source.interface';
import { XtreamApiService } from '../../services/xtream-api.service';
import { PortalStatusType } from '../../xtream-state';
import { withContent } from './with-content.feature';
jest.mock('@iptvnator/portal/shared/util', () => ({
@@ -27,11 +29,21 @@ const PLAYLIST: XtreamPlaylistData = {
type: 'xtream',
};
let checkPortalStatusMock: jest.Mock<Promise<PortalStatusType>, []>;
const TestContentStore = signalStore(
withState({
playlistId: PLAYLIST.id,
currentPlaylist: PLAYLIST,
portalStatus: 'active' as PortalStatusType,
}),
withMethods((store) => ({
async checkPortalStatus(): Promise<PortalStatusType> {
const status = await checkPortalStatusMock();
patchState(store, { portalStatus: status });
return status;
},
})),
withContent()
);
@@ -76,22 +88,43 @@ describe('withContent import state', () => {
restoreUserData: jest.Mock;
};
let databaseService: {
clearXtreamImportCache: jest.Mock;
cancelOperation: jest.Mock;
createOperationId: jest.Mock;
setXtreamImportStatus: jest.Mock;
supportsDbOperationCancellation: jest.Mock;
};
let xtreamApiService: {
cancelSession: jest.Mock;
};
beforeEach(() => {
localStorage.clear();
let operationCounter = 0;
dataSource = {
getCategories: jest.fn().mockResolvedValue([]),
getContent: jest.fn(),
restoreUserData: jest.fn().mockResolvedValue(undefined),
};
databaseService = {
clearXtreamImportCache: jest.fn().mockResolvedValue(true),
cancelOperation: jest.fn().mockResolvedValue(true),
createOperationId: jest.fn().mockImplementation((prefix?: string) => {
if (prefix === 'xtream-import-session') {
return 'xtream-import-session';
}
operationCounter += 1;
return `${prefix ?? 'db-op'}-${operationCounter}`;
}),
setXtreamImportStatus: jest.fn().mockResolvedValue(true),
supportsDbOperationCancellation: jest.fn().mockReturnValue(true),
};
xtreamApiService = {
cancelSession: jest.fn().mockResolvedValue(true),
};
checkPortalStatusMock = jest.fn().mockResolvedValue('active');
TestBed.configureTestingModule({
providers: [
@@ -104,6 +137,10 @@ describe('withContent import state', () => {
provide: DatabaseService,
useValue: databaseService,
},
{
provide: XtreamApiService,
useValue: xtreamApiService,
},
],
});
@@ -127,7 +164,7 @@ describe('withContent import state', () => {
};
const optionsByType = new Map<
ContentType,
{ onEvent?: (event: any) => void } | undefined
{ onEvent?: (event: any) => void; operationId?: string } | undefined
>();
const totals: Record<ContentType, number> = {
live: 2,
@@ -153,16 +190,17 @@ describe('withContent import state', () => {
) => {
optionsByType.set(type, options);
onTotal?.(totals[type]);
const operationId = options?.operationId ?? `${type}-op`;
options?.onEvent?.({
operation: 'save-content',
operationId: `${type}-op`,
operationId,
status: 'started',
phase: 'saving-content',
});
onProgress?.(1);
options?.onEvent?.({
operation: 'save-content',
operationId: `${type}-op`,
operationId,
status: 'progress',
phase: 'saving-content',
current: 1,
@@ -179,34 +217,63 @@ describe('withContent import state', () => {
pendingCategories.live.resolve([]);
pendingCategories.vod.resolve([]);
pendingCategories.series.resolve([]);
await waitForCondition(() => store.importCount() === 3);
await waitForCondition(() => store.importCount() === 1);
const liveOperationId =
optionsByType.get('live')?.operationId ?? 'live-op';
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.importCount()).toBe(1);
expect(store.itemsToImport()).toBe(2);
expect(store.activeImportOperationIds()).toEqual([liveOperationId]);
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([]);
optionsByType.get('live')?.onEvent?.({
operation: 'save-content',
operationId: liveOperationId,
status: 'completed',
phase: 'saving-content',
current: totals.live,
total: totals.live,
});
pending.live.resolve([]);
await waitForCondition(() => store.importCount() === 2);
const movieOperationId =
optionsByType.get('movie')?.operationId ?? 'movie-op';
expect(store.itemsToImport()).toBe(5);
expect(store.activeImportOperationIds()).toEqual([movieOperationId]);
optionsByType.get('movie')?.onEvent?.({
operation: 'save-content',
operationId: movieOperationId,
status: 'completed',
phase: 'saving-content',
current: totals.movie,
total: totals.movie,
});
pending.movie.resolve([]);
await waitForCondition(() => store.importCount() === 3);
const seriesOperationId =
optionsByType.get('series')?.operationId ?? 'series-op';
expect(store.itemsToImport()).toBe(9);
expect(store.activeImportOperationIds()).toEqual([seriesOperationId]);
optionsByType.get('series')?.onEvent?.({
operation: 'save-content',
operationId: seriesOperationId,
status: 'completed',
phase: 'saving-content',
current: totals.series,
total: totals.series,
});
pending.series.resolve([]);
await initialization;
expect(store.isImporting()).toBe(false);
expect(store.isContentInitialized()).toBe(true);
expect(store.contentInitBlockReason()).toBeNull();
expect(store.activeImportOperationIds()).toEqual([]);
expect(store.importPhase()).toBeNull();
expect(store.importCount()).toBe(0);
@@ -244,6 +311,28 @@ describe('withContent import state', () => {
expect(dataSource.getContent).toHaveBeenCalledTimes(3);
});
it('reuses cached content without clearing the playlist import cache', async () => {
dataSource.getCategories.mockResolvedValue([]);
dataSource.getContent.mockResolvedValue([]);
const initialization = store.initializeContent();
await Promise.resolve();
expect(store.isImporting()).toBe(false);
await initialization;
expect(store.isImporting()).toBe(false);
expect(store.isContentInitialized()).toBe(true);
expect(store.contentInitBlockReason()).toBeNull();
expect(databaseService.clearXtreamImportCache).not.toHaveBeenCalled();
expect(databaseService.setXtreamImportStatus).not.toHaveBeenCalledWith(
PLAYLIST.id,
expect.anything(),
'importing'
);
});
it('ignores concurrent initializeContent calls while an import is already running', async () => {
const pendingCategories = {
live: createDeferred<any[]>(),
@@ -264,7 +353,7 @@ describe('withContent import state', () => {
const secondInitialization = store.initializeContent();
await Promise.resolve();
expect(store.isImporting()).toBe(true);
expect(store.activeImportSessionId()).toBe('xtream-import-session');
expect(dataSource.getCategories).toHaveBeenCalledTimes(3);
expect(dataSource.getContent).not.toHaveBeenCalled();
@@ -278,6 +367,122 @@ describe('withContent import state', () => {
expect(dataSource.getContent).toHaveBeenCalledTimes(3);
});
it('blocks auto restart after cancelling during category loading until retry is explicit', async () => {
const pendingCategories = {
live: createDeferred<any[]>(),
vod: createDeferred<any[]>(),
series: createDeferred<any[]>(),
};
dataSource.getCategories.mockImplementation(
(
_playlistId: string,
_credentials: unknown,
type: 'live' | 'vod' | 'series'
) => pendingCategories[type].promise
);
dataSource.getContent.mockResolvedValue([]);
const initialization = store.initializeContent();
await waitForCondition(() => Boolean(store.activeImportSessionId()));
await store.cancelImport();
expect(store.isCancellingImport()).toBe(true);
expect(store.contentInitBlockReason()).toBe('cancelled');
expect(databaseService.cancelOperation).not.toHaveBeenCalled();
expect(xtreamApiService.cancelSession).toHaveBeenCalledWith(
'xtream-import-session'
);
pendingCategories.live.reject(createAbortError());
pendingCategories.vod.reject(createAbortError());
pendingCategories.series.reject(createAbortError());
await expect(initialization).resolves.toBeUndefined();
expect(store.isImporting()).toBe(false);
expect(store.isCancellingImport()).toBe(false);
expect(store.isContentInitialized()).toBe(false);
expect(store.contentInitBlockReason()).toBe('cancelled');
await store.initializeContent();
expect(dataSource.getCategories).toHaveBeenCalledTimes(3);
expect(dataSource.getContent).not.toHaveBeenCalled();
dataSource.getCategories.mockResolvedValue([]);
dataSource.getContent.mockResolvedValue([]);
await store.retryContentInitialization();
expect(checkPortalStatusMock).toHaveBeenCalledTimes(1);
expect(store.contentInitBlockReason()).toBeNull();
expect(store.isContentInitialized()).toBe(true);
expect(dataSource.getCategories).toHaveBeenCalledTimes(6);
expect(dataSource.getContent).toHaveBeenCalledTimes(3);
});
it('stops before content fetch if cancel lands between categories and content phases', async () => {
const pendingCategories = {
live: createDeferred<any[]>(),
vod: createDeferred<any[]>(),
series: createDeferred<any[]>(),
};
dataSource.getCategories.mockImplementation(
(
_playlistId: string,
_credentials: unknown,
type: 'live' | 'vod' | 'series'
) => pendingCategories[type].promise
);
dataSource.getContent.mockResolvedValue([]);
const initialization = store.initializeContent();
await waitForCondition(() => Boolean(store.activeImportSessionId()));
pendingCategories.live.resolve([]);
pendingCategories.vod.resolve([]);
pendingCategories.series.resolve([]);
await store.cancelImport();
await expect(initialization).resolves.toBeUndefined();
expect(store.contentInitBlockReason()).toBe('cancelled');
expect(store.isContentInitialized()).toBe(false);
expect(dataSource.getContent).not.toHaveBeenCalled();
});
it('keeps the blocked state when retry finds a non-active portal status', async () => {
checkPortalStatusMock.mockResolvedValue('expired');
store.setContentInitBlockReason('cancelled');
await store.retryContentInitialization();
expect(checkPortalStatusMock).toHaveBeenCalledTimes(1);
expect(store.contentInitBlockReason()).toBe('expired');
expect(store.isContentInitialized()).toBe(false);
expect(dataSource.getCategories).not.toHaveBeenCalled();
expect(dataSource.getContent).not.toHaveBeenCalled();
});
it('keeps a cancelled block sticky until retry or reset clears it', async () => {
store.setContentInitBlockReason('cancelled');
store.setContentInitBlockReason(null);
expect(store.contentInitBlockReason()).toBe('cancelled');
dataSource.getCategories.mockResolvedValue([]);
dataSource.getContent.mockResolvedValue([]);
await store.retryContentInitialization();
expect(store.contentInitBlockReason()).toBeNull();
expect(store.isContentInitialized()).toBe(true);
});
it('cancels active imports and clears stale progress after worker aborts', async () => {
const pendingCategories = {
live: createDeferred<any[]>(),
@@ -291,7 +496,7 @@ describe('withContent import state', () => {
};
const optionsByType = new Map<
ContentType,
{ onEvent?: (event: any) => void } | undefined
{ onEvent?: (event: any) => void; operationId?: string } | undefined
>();
dataSource.getCategories.mockImplementation(
@@ -312,16 +517,17 @@ describe('withContent import state', () => {
) => {
optionsByType.set(type, options);
onTotal?.(5);
const operationId = options?.operationId ?? `${type}-op`;
options?.onEvent?.({
operation: 'save-content',
operationId: `${type}-op`,
operationId,
status: 'started',
phase: 'saving-content',
});
onProgress?.(2);
options?.onEvent?.({
operation: 'save-content',
operationId: `${type}-op`,
operationId,
status: 'progress',
phase: 'saving-content',
current: 2,
@@ -339,36 +545,36 @@ describe('withContent import state', () => {
pendingCategories.vod.resolve([]);
pendingCategories.series.resolve([]);
await waitForCondition(
() => store.activeImportOperationIds().length === 3
() => store.activeImportOperationIds().length === 1
);
const liveOperationId =
optionsByType.get('live')?.operationId ?? 'live-op';
await store.cancelImport();
expect(store.isCancellingImport()).toBe(true);
expect(databaseService.cancelOperation).toHaveBeenCalledTimes(3);
expect(databaseService.cancelOperation).toHaveBeenCalledWith('live-op');
expect(databaseService.cancelOperation).toHaveBeenCalledTimes(1);
expect(databaseService.cancelOperation).toHaveBeenCalledWith(
'movie-op'
liveOperationId
);
expect(databaseService.cancelOperation).toHaveBeenCalledWith(
'series-op'
expect(xtreamApiService.cancelSession).toHaveBeenCalledWith(
'xtream-import-session'
);
(['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());
optionsByType.get('live')?.onEvent?.({
operation: 'save-content',
operationId: liveOperationId,
status: 'cancelled',
phase: 'saving-content',
});
pending.live.reject(createAbortError());
await expect(initialization).resolves.toBeUndefined();
expect(store.isImporting()).toBe(false);
expect(store.isCancellingImport()).toBe(false);
expect(store.isContentInitialized()).toBe(false);
expect(store.contentInitBlockReason()).toBe('cancelled');
expect(store.activeImportOperationIds()).toEqual([]);
expect(store.importPhase()).toBeNull();
expect(store.importCount()).toBe(0);
@@ -13,13 +13,68 @@ import {
XtreamVodStream,
} from 'shared-interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DatabaseService, DbOperationEvent, isDbAbortError } from 'services';
import {
DatabaseService,
DbOperationEvent,
isDbAbortError,
XtreamImportStatus,
} from 'services';
import {
XTREAM_DATA_SOURCE,
XtreamCategoryFromDb,
} from '../../data-sources/xtream-data-source.interface';
import { XtreamCredentials } from '../../services/xtream-api.service';
import { ContentType } from '../../xtream-state';
import {
XtreamApiService,
XtreamCredentials,
} from '../../services/xtream-api.service';
import {
ContentType,
PortalStatusType,
XtreamContentInitBlockReason,
} from '../../xtream-state';
const cancelledPlaylistInitializationLockKey = (
playlistId: string
): string => `xtream-init-cancelled:${playlistId}`;
const hasCancelledPlaylistInitializationLock = (
playlistId: string
): boolean => {
try {
return (
localStorage.getItem(
cancelledPlaylistInitializationLockKey(playlistId)
) === 'true'
);
} catch {
return false;
}
};
const setCancelledPlaylistInitializationLock = (
playlistId: string
): void => {
try {
localStorage.setItem(
cancelledPlaylistInitializationLockKey(playlistId),
'true'
);
} catch {
// Ignore storage write failures; runtime state still carries the block.
}
};
const clearCancelledPlaylistInitializationLock = (
playlistId: string
): void => {
try {
localStorage.removeItem(
cancelledPlaylistInitializationLockKey(playlistId)
);
} catch {
// Ignore storage write failures; retry still clears the in-memory block.
}
};
/**
* Content state for managing categories and streams
@@ -38,8 +93,10 @@ export interface ContentState {
importCount: number;
importPhase: string | null;
itemsToImport: number;
activeImportSessionId: string | null;
activeImportOperationIds: string[];
isContentInitialized: boolean;
contentInitBlockReason: XtreamContentInitBlockReason | null;
}
/**
@@ -59,8 +116,10 @@ const initialContentState: ContentState = {
importCount: 0,
importPhase: null,
itemsToImport: 0,
activeImportSessionId: null,
activeImportOperationIds: [],
isContentInitialized: false,
contentInitBlockReason: null,
};
/**
@@ -79,6 +138,8 @@ export function withContent() {
username: string;
} | null;
playlistId?: () => string | null;
portalStatus?: () => PortalStatusType;
checkPortalStatus?: () => Promise<PortalStatusType>;
};
return signalStoreFeature(
@@ -136,6 +197,43 @@ export function withContent() {
withMethods((store) => {
const dataSource = inject(XTREAM_DATA_SOURCE);
const databaseService = inject(DatabaseService);
const xtreamApiService = inject(XtreamApiService);
const importTypes: ContentType[] = ['live', 'vod', 'series'];
let activeInitializationPromise: Promise<void> | null = null;
const resolveInitBlockReason = (
portalStatus: PortalStatusType | null | undefined
): XtreamContentInitBlockReason | null => {
switch (portalStatus) {
case 'expired':
case 'inactive':
case 'unavailable':
return portalStatus;
default:
return null;
}
};
const getPortalStore = (): ParentPortalStoreLike =>
store as ParentPortalStoreLike;
const createImportAbortError = (): Error => {
const error = new Error('Xtream import cancelled');
error.name = 'AbortError';
return error;
};
const throwIfImportCancelled = (
expectedImportSessionId?: string | null
): void => {
if (
store.contentInitBlockReason() === 'cancelled' ||
(expectedImportSessionId != null &&
store.activeImportSessionId() !== expectedImportSessionId)
) {
throw createImportAbortError();
}
};
/**
* Helper to get credentials from parent store
@@ -146,7 +244,7 @@ export function withContent() {
credentials: XtreamCredentials;
} | null => {
// Access parent store state (currentPlaylist is from withPortal)
const storeAny = store as ParentPortalStoreLike;
const storeAny = getPortalStore();
const playlist = storeAny.currentPlaylist?.();
const playlistId = storeAny.playlistId?.();
@@ -167,6 +265,25 @@ export function withContent() {
const trackImportEvent = (event: DbOperationEvent): void => {
const operationId = event.operationId;
if (
store.contentInitBlockReason() === 'cancelled' &&
event.status !== 'cancelled' &&
event.status !== 'error' &&
event.status !== 'completed'
) {
return;
}
if (
event.status === 'started' ||
event.status === 'progress'
) {
patchState(store, (state) => ({
isImporting: true,
importPhase: event.phase ?? state.importPhase,
}));
}
patchState(store, (state) => ({
importPhase: event.phase ?? state.importPhase,
activeImportOperationIds:
@@ -186,18 +303,240 @@ export function withContent() {
...state.activeImportOperationIds,
operationId,
],
isCancellingImport:
event.status === 'cancelled'
? false
: state.isCancellingImport,
isCancellingImport: state.isCancellingImport,
}));
};
return {
const registerImportOperation = (operationId: string): void => {
patchState(store, (state) => ({
activeImportOperationIds: state.activeImportOperationIds.includes(
operationId
)
? state.activeImportOperationIds
: [...state.activeImportOperationIds, operationId],
}));
};
const setImportStatus = async (
playlistId: string,
type: ContentType,
status: XtreamImportStatus
): Promise<void> => {
const importType = type === 'vod' ? 'movie' : type;
await databaseService.setXtreamImportStatus(
playlistId,
importType,
status
);
};
const clearImportCache = async (
playlistId: string,
type: ContentType
): Promise<void> => {
const importType = type === 'vod' ? 'movie' : type;
await databaseService.clearXtreamImportCache(
playlistId,
importType
);
};
const finalizePendingImportTypes = async (
playlistId: string,
completedTypes: Set<ContentType>,
status: XtreamImportStatus
): Promise<void> => {
for (const type of importTypes) {
if (completedTypes.has(type)) {
continue;
}
await setImportStatus(playlistId, type, status);
await clearImportCache(playlistId, type);
}
};
const executeContentInitialization = async (
ignoreBlockedState = false
): Promise<void> => {
const ctx = getCredentialsFromStore();
if (!ctx) return;
if (
!ignoreBlockedState &&
hasCancelledPlaylistInitializationLock(ctx.playlistId)
) {
patchState(store, {
contentInitBlockReason: 'cancelled',
});
return;
}
// Skip duplicate route-session triggers while initialization is
// already running. The workspace session currently syncs from
// multiple entry points during bootstrap, and without this guard
// Electron can duplicate the same Xtream load work.
if (
(!ignoreBlockedState && store.contentInitBlockReason()) ||
store.isCancellingImport() ||
store.isContentInitialized() ||
store.activeImportSessionId()
) {
return;
}
const importSessionId = databaseService.createOperationId(
'xtream-import-session'
);
patchState(store, {
isImporting: false,
isCancellingImport: false,
importCount: 0,
importPhase: null,
itemsToImport: 0,
activeImportSessionId: importSessionId,
activeImportOperationIds: [],
});
const completedTypes = new Set<ContentType>();
try {
// Electron content persistence maps remote category IDs
// to internal DB category rows, so categories must exist
// before content import starts.
await methods.fetchAllCategories({
sessionId: importSessionId,
});
throwIfImportCancelled(importSessionId);
await methods.fetchAllContent({
importSessionId,
sessionId: importSessionId,
completedTypes,
});
throwIfImportCancelled(importSessionId);
// Restore user data if needed
const restoreKey = `xtream-restore-${ctx.playlistId}`;
const restoreData = localStorage.getItem(restoreKey);
if (restoreData) {
try {
throwIfImportCancelled(importSessionId);
const {
favoritedXtreamIds,
recentlyViewedXtreamIds,
} = JSON.parse(restoreData);
const restoreOperationId =
databaseService.createOperationId(
'xtream-restore'
);
registerImportOperation(restoreOperationId);
patchState(store, {
importPhase: 'restoring-favorites',
});
await dataSource.restoreUserData(
ctx.playlistId,
favoritedXtreamIds,
recentlyViewedXtreamIds,
{
onEvent: trackImportEvent,
operationId: restoreOperationId,
}
);
throwIfImportCancelled(importSessionId);
localStorage.removeItem(restoreKey);
} catch (err) {
if (!isDbAbortError(err)) {
logger.error(
'Error restoring user data',
err
);
}
}
}
throwIfImportCancelled(importSessionId);
// Mark as initialized so next routings won't re-trigger it
clearCancelledPlaylistInitializationLock(ctx.playlistId);
patchState(store, {
isContentInitialized: true,
contentInitBlockReason: null,
});
} catch (error) {
if (store.isImporting()) {
await finalizePendingImportTypes(
ctx.playlistId,
completedTypes,
isDbAbortError(error) ? 'cancelled' : 'failed'
);
}
if (isDbAbortError(error)) {
patchState(store, (state) => ({
contentInitBlockReason:
state.contentInitBlockReason ?? 'cancelled',
}));
} else {
patchState(store, {
contentInitBlockReason:
resolveInitBlockReason(
getPortalStore().portalStatus?.()
) ?? 'error',
});
logger.error('Error initializing content', error);
}
} finally {
patchState(store, {
isImporting: false,
isCancellingImport: false,
importCount: 0,
importPhase: null,
itemsToImport: 0,
activeImportSessionId: null,
activeImportOperationIds: [],
});
}
};
const runContentInitialization = async (
ignoreBlockedState = false
): Promise<void> => {
if (activeInitializationPromise) {
return activeInitializationPromise;
}
const initializationPromise = executeContentInitialization(
ignoreBlockedState
).finally(() => {
if (activeInitializationPromise === initializationPromise) {
activeInitializationPromise = null;
}
});
activeInitializationPromise = initializationPromise;
return initializationPromise;
};
const methods = {
setContentInitBlockReason(
reason: XtreamContentInitBlockReason | null
): void {
patchState(store, (state) => ({
contentInitBlockReason:
reason === null &&
state.contentInitBlockReason === 'cancelled'
? state.contentInitBlockReason
: reason,
}));
},
/**
* Fetch all categories in parallel
*/
async fetchAllCategories(): Promise<void> {
async fetchAllCategories(
options?: { sessionId?: string }
): Promise<void> {
const ctx = getCredentialsFromStore();
if (!ctx) return;
@@ -208,17 +547,41 @@ export function withContent() {
dataSource.getCategories(
ctx.playlistId,
ctx.credentials,
'live'
'live',
{
sessionId: options?.sessionId,
onPhaseChange: (phase) =>
patchState(store, {
isImporting: true,
importPhase: phase,
}),
}
),
dataSource.getCategories(
ctx.playlistId,
ctx.credentials,
'vod'
'vod',
{
sessionId: options?.sessionId,
onPhaseChange: (phase) =>
patchState(store, {
isImporting: true,
importPhase: phase,
}),
}
),
dataSource.getCategories(
ctx.playlistId,
ctx.credentials,
'series'
'series',
{
sessionId: options?.sessionId,
onPhaseChange: (phase) =>
patchState(store, {
isImporting: true,
importPhase: phase,
}),
}
),
]);
@@ -238,9 +601,15 @@ export function withContent() {
},
/**
* Fetch all content/streams in parallel with progress tracking
* Fetch all content/streams with shared progress tracking
*/
async fetchAllContent(): Promise<void> {
async fetchAllContent(
options?: {
importSessionId?: string;
sessionId?: string;
completedTypes?: Set<ContentType>;
}
): Promise<void> {
const ctx = getCredentialsFromStore();
if (!ctx) return;
@@ -261,37 +630,90 @@ export function withContent() {
};
try {
const [live, vod, series] = await Promise.all([
dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'live',
onProgress,
onTotal,
{ onEvent: trackImportEvent }
),
dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'movie',
onProgress,
onTotal,
{ onEvent: trackImportEvent }
),
dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'series',
onProgress,
onTotal,
{ onEvent: trackImportEvent }
),
]);
throwIfImportCancelled(options?.importSessionId);
const liveOperationId = databaseService.createOperationId(
'db-save-content'
);
registerImportOperation(liveOperationId);
const live = (await dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'live',
onProgress,
onTotal,
{
operationId: liveOperationId,
sessionId: options?.sessionId,
onEvent: trackImportEvent,
onPhaseChange: (phase) =>
patchState(store, {
isImporting: true,
importPhase: phase,
}),
}
)) as XtreamLiveStream[];
throwIfImportCancelled(options?.importSessionId);
await setImportStatus(ctx.playlistId, 'live', 'completed');
options?.completedTypes?.add('live');
throwIfImportCancelled(options?.importSessionId);
const vodOperationId = databaseService.createOperationId(
'db-save-content'
);
registerImportOperation(vodOperationId);
const vod = (await dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'movie',
onProgress,
onTotal,
{
operationId: vodOperationId,
sessionId: options?.sessionId,
onEvent: trackImportEvent,
onPhaseChange: (phase) =>
patchState(store, {
isImporting: true,
importPhase: phase,
}),
}
)) as XtreamVodStream[];
throwIfImportCancelled(options?.importSessionId);
await setImportStatus(ctx.playlistId, 'vod', 'completed');
options?.completedTypes?.add('vod');
throwIfImportCancelled(options?.importSessionId);
const seriesOperationId =
databaseService.createOperationId(
'db-save-content'
);
registerImportOperation(seriesOperationId);
const series = (await dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'series',
onProgress,
onTotal,
{
operationId: seriesOperationId,
sessionId: options?.sessionId,
onEvent: trackImportEvent,
onPhaseChange: (phase) =>
patchState(store, {
isImporting: true,
importPhase: phase,
}),
}
)) as XtreamSerieItem[];
throwIfImportCancelled(options?.importSessionId);
await setImportStatus(ctx.playlistId, 'series', 'completed');
options?.completedTypes?.add('series');
patchState(store, {
liveStreams: live as XtreamLiveStream[],
vodStreams: vod as XtreamVodStream[],
serialStreams: series as XtreamSerieItem[],
liveStreams: live,
vodStreams: vod,
serialStreams: series,
isLoadingContent: false,
});
} catch (error) {
@@ -307,94 +729,64 @@ export function withContent() {
* Initialize content (fetch categories and content)
*/
async initializeContent(): Promise<void> {
const ctx = getCredentialsFromStore();
if (!ctx) return;
await runContentInitialization();
},
// Skip duplicate route-session triggers while an import is already
// running. The workspace session currently syncs from multiple
// entry points during bootstrap, and without this guard Electron
// can save the same Xtream categories/content multiple times.
if (store.isContentInitialized() || store.isImporting()) {
async retryContentInitialization(): Promise<void> {
const portalStatus =
(await getPortalStore().checkPortalStatus?.()) ??
getPortalStore().portalStatus?.() ??
'unavailable';
const blockReason = resolveInitBlockReason(portalStatus);
if (blockReason) {
patchState(store, {
contentInitBlockReason: blockReason,
});
return;
}
patchState(store, {
isImporting: true,
isCancellingImport: false,
importCount: 0,
importPhase: null,
itemsToImport: 0,
activeImportOperationIds: [],
contentInitBlockReason: null,
isContentInitialized: false,
});
try {
// Electron content persistence maps remote category IDs
// to internal DB category rows, so categories must exist
// before content import starts.
await this.fetchAllCategories();
await this.fetchAllContent();
// Restore user data if needed
const restoreKey = `xtream-restore-${ctx.playlistId}`;
const restoreData = localStorage.getItem(restoreKey);
if (restoreData) {
try {
const {
favoritedXtreamIds,
recentlyViewedXtreamIds,
} = JSON.parse(restoreData);
await dataSource.restoreUserData(
ctx.playlistId,
favoritedXtreamIds,
recentlyViewedXtreamIds,
{ onEvent: trackImportEvent }
);
localStorage.removeItem(restoreKey);
} catch (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,
isCancellingImport: false,
importCount: 0,
importPhase: null,
itemsToImport: 0,
activeImportOperationIds: [],
});
const ctx = getCredentialsFromStore();
if (ctx) {
clearCancelledPlaylistInitializationLock(
ctx.playlistId
);
}
await runContentInitialization(true);
},
async cancelImport(): Promise<void> {
if (
!databaseService.supportsDbOperationCancellation() ||
store.activeImportOperationIds().length === 0 ||
store.isCancellingImport()
) {
const activeImportSessionId = store.activeImportSessionId();
const activeImportOperationIds =
store.activeImportOperationIds();
if (!activeImportSessionId || store.isCancellingImport()) {
return;
}
patchState(store, { isCancellingImport: true });
patchState(store, {
isCancellingImport: true,
contentInitBlockReason: 'cancelled',
activeImportSessionId: null,
});
const ctx = getCredentialsFromStore();
if (ctx) {
setCancelledPlaylistInitializationLock(
ctx.playlistId
);
}
await xtreamApiService.cancelSession(activeImportSessionId);
await Promise.all(
store
.activeImportOperationIds()
.map((operationId) =>
databaseService.cancelOperation(operationId)
)
activeImportOperationIds.map((operationId) =>
databaseService.cancelOperation(operationId)
)
);
},
@@ -454,6 +846,8 @@ export function withContent() {
patchState(store, initialContentState);
},
};
return methods;
})
);
}
@@ -97,7 +97,10 @@ export function withEpg() {
const epgItems = await apiService.getShortEpg(
credentials,
selectedItem.xtream_id,
10
10,
{
suppressErrorLog: true,
}
);
patchState(store, {
@@ -129,7 +132,10 @@ export function withEpg() {
return await apiService.getShortEpg(
credentials,
streamId,
1
1,
{
suppressErrorLog: true,
}
);
} catch (error) {
logger.error('Error loading channel EPG', error);
@@ -43,6 +43,31 @@ const initialPortalState: PortalState = {
*/
export function withPortal() {
const logger = createLogger('withPortal');
const resolvePortalStatus = (response: {
user_info?: {
exp_date?: string;
status?: string;
};
} | null): PortalStatusType => {
if (!response?.user_info?.status) {
return 'unavailable';
}
if (response.user_info.status === 'Active') {
if (!response.user_info.exp_date) {
return 'active';
}
const expDate = new Date(
parseInt(response.user_info.exp_date, 10) * 1000
);
return expDate < new Date() ? 'expired' : 'active';
}
return 'inactive';
};
return signalStoreFeature(
withState<PortalState>(initialPortalState),
@@ -98,11 +123,11 @@ export function withPortal() {
/**
* Check portal status via API
*/
async checkPortalStatus(): Promise<void> {
async checkPortalStatus(): Promise<PortalStatusType> {
const playlist = store.currentPlaylist();
if (!playlist) {
patchState(store, { portalStatus: 'unavailable' });
return;
return 'unavailable';
}
const credentials: XtreamCredentials = {
@@ -114,32 +139,13 @@ export function withPortal() {
try {
const response =
await apiService.getAccountInfo(credentials);
if (!response?.user_info?.status) {
patchState(store, { portalStatus: 'unavailable' });
return;
}
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, 10) * 1000
);
if (expDate < new Date()) {
patchState(store, { portalStatus: 'expired' });
} else {
patchState(store, { portalStatus: 'active' });
}
} else {
patchState(store, { portalStatus: 'inactive' });
}
const portalStatus = resolvePortalStatus(response);
patchState(store, { portalStatus });
return portalStatus;
} catch (error) {
logger.error('Error checking portal status', error);
patchState(store, { portalStatus: 'unavailable' });
return 'unavailable';
}
},
@@ -96,6 +96,10 @@ export const XtreamStore = signalStore(
// Clear the session cache for the playlist we're leaving so
// stale data cannot bleed into the new playlist (PWA path).
const leavingPlaylistId = store.playlistId();
const preserveCancelledBlock =
Boolean(newPlaylistId) &&
leavingPlaylistId === newPlaylistId &&
store.contentInitBlockReason() === 'cancelled';
if (leavingPlaylistId) {
dataSource.clearSessionCache(leavingPlaylistId);
}
@@ -110,6 +114,10 @@ export const XtreamStore = signalStore(
if (newPlaylistId) {
store.setPlaylistId(newPlaylistId);
}
if (preserveCancelledBlock) {
store.setContentInitBlockReason('cancelled');
}
},
/**
@@ -19,6 +19,13 @@ export type PortalStatusType =
| 'expired'
| 'unavailable';
export type XtreamContentInitBlockReason =
| 'cancelled'
| 'expired'
| 'inactive'
| 'unavailable'
| 'error';
export interface XtreamPortalStatus {
status: 'active' | 'inactive' | 'expired' | 'unavailable';
message?: string;
@@ -46,6 +53,7 @@ export interface XtreamState {
epgItems: EpgItem[];
hideExternalInfoDialog: boolean;
portalStatus: PortalStatusType;
contentInitBlockReason: XtreamContentInitBlockReason | null;
globalSearchResults: GlobalSearchResult[];
streamUrl: string;
playlistId: string | null;
+1
View File
@@ -9,6 +9,7 @@ export * from './lib/recently-added/recently-added.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';
export * from './lib/xtream-content-gate.component';
export * from './lib/xtream-catalog-facade.service';
export * from './lib/xtream-feature.routes';
export * from './lib/xtream-workspace-route-session.service';
@@ -206,12 +206,18 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
// Apply cached results immediately
for (const channel of channels) {
const cached = this.epgQueueService.getCached(channel.xtream_id);
const previewProgram = this.pickPreviewProgram(cached ?? []);
if (previewProgram) {
if (!this.epgPrograms.has(channel.xtream_id)) {
this.applyProgram(channel.xtream_id, previewProgram);
if (cached !== null) {
const previewProgram = this.pickPreviewProgram(cached);
if (previewProgram) {
if (!this.epgPrograms.has(channel.xtream_id)) {
this.applyProgram(channel.xtream_id, previewProgram);
}
}
} else if (!this.epgPrograms.has(channel.xtream_id)) {
continue;
}
if (!this.epgPrograms.has(channel.xtream_id)) {
uncachedIds.push(channel.xtream_id);
}
}
@@ -0,0 +1,120 @@
import { Component, input, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { provideRouter, RouterOutlet } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import {
XtreamContentInitBlockReason,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { XtreamContentGateComponent } from './xtream-content-gate.component';
@Component({
selector: 'app-playlist-error-view',
standalone: true,
template: `
<div class="mock-error">
<span class="mock-error__title">{{ title() }}</span>
<span class="mock-error__description">{{ description() }}</span>
</div>
`,
})
class MockPlaylistErrorViewComponent {
readonly title = input<string>('');
readonly description = input<string | undefined>(undefined);
}
describe('XtreamContentGateComponent', () => {
let fixture: ComponentFixture<XtreamContentGateComponent>;
const contentInitBlockReason =
signal<XtreamContentInitBlockReason | null>(null);
const retryContentInitialization = jest.fn().mockResolvedValue(undefined);
beforeEach(async () => {
contentInitBlockReason.set(null);
retryContentInitialization.mockClear();
await TestBed.configureTestingModule({
imports: [XtreamContentGateComponent],
providers: [
provideRouter([]),
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
currentLang: 'en',
defaultLang: 'en',
},
},
{
provide: XtreamStore,
useValue: {
contentInitBlockReason,
retryContentInitialization,
},
},
],
})
.overrideComponent(XtreamContentGateComponent, {
set: {
imports: [
MatButtonModule,
MatIconModule,
MockPlaylistErrorViewComponent,
RouterOutlet,
TranslatePipe,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(XtreamContentGateComponent);
});
it.each([
['cancelled', 'PORTALS.ERROR_VIEW.IMPORT_CANCELLED.TITLE'],
['expired', 'PORTALS.ERROR_VIEW.ACCOUNT_EXPIRED.TITLE'],
['inactive', 'PORTALS.ERROR_VIEW.ACCOUNT_INACTIVE.TITLE'],
['unavailable', 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE'],
['error', 'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.TITLE'],
] as const)(
'renders the blocked error state for %s imports',
(reason, expectedTitleKey) => {
contentInitBlockReason.set(reason);
fixture.detectChanges();
const title = fixture.nativeElement.querySelector(
'.mock-error__title'
) as HTMLElement | null;
expect(title?.textContent?.trim()).toBe(expectedTitleKey);
}
);
it('keeps the child outlet available when there is no block reason', () => {
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.mock-error')
).toBeNull();
expect(fixture.nativeElement.querySelector('router-outlet')).not.toBeNull();
});
it('retries content initialization from the blocked state', () => {
contentInitBlockReason.set('cancelled');
fixture.detectChanges();
const retryButton = fixture.nativeElement.querySelector(
'button'
) as HTMLButtonElement | null;
retryButton?.click();
expect(retryContentInitialization).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,94 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { RouterOutlet } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import {
PlaylistErrorViewComponent,
} from '@iptvnator/portal/shared/ui';
import {
XtreamContentInitBlockReason,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
@Component({
selector: 'app-xtream-content-gate',
standalone: true,
imports: [
MatButtonModule,
MatIconModule,
PlaylistErrorViewComponent,
RouterOutlet,
TranslatePipe,
],
template: `
@if (contentInitBlockReason(); as blockReason) {
<div class="xtream-content-gate">
<app-playlist-error-view
[title]="titleKey() | translate"
[description]="descriptionKey() | translate"
/>
<div class="xtream-content-gate__actions">
<button
mat-flat-button
color="primary"
type="button"
(click)="retryContentInitialization()"
>
<mat-icon>refresh</mat-icon>
{{ 'DOWNLOADS.RETRY' | translate }}
</button>
</div>
</div>
} @else {
<router-outlet />
}
`,
styles: [
`
.xtream-content-gate {
display: grid;
gap: 16px;
justify-items: center;
padding: 24px;
}
.xtream-content-gate__actions {
display: flex;
justify-content: center;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class XtreamContentGateComponent {
private readonly xtreamStore = inject(XtreamStore);
readonly contentInitBlockReason = this.xtreamStore.contentInitBlockReason;
private readonly errorViewKey = computed(() => {
switch (this.contentInitBlockReason()) {
case 'cancelled':
return 'IMPORT_CANCELLED';
case 'expired':
return 'ACCOUNT_EXPIRED';
case 'inactive':
return 'ACCOUNT_INACTIVE';
case 'unavailable':
return 'PORTAL_UNAVAILABLE';
case 'error':
default:
return 'UNKNOWN_ERROR';
}
});
readonly titleKey = computed(
() => `PORTALS.ERROR_VIEW.${this.errorViewKey()}.TITLE`
);
readonly descriptionKey = computed(
() => `PORTALS.ERROR_VIEW.${this.errorViewKey()}.DESCRIPTION`
);
retryContentInitialization(): void {
void this.xtreamStore.retryContentInitialization();
}
}
@@ -0,0 +1,28 @@
import { createXtreamRoutes } from './xtream-feature.routes';
describe('createXtreamRoutes', () => {
it('keeps import-driven routes behind the content gate and leaves collections outside it', () => {
const [xtreamRoute] = createXtreamRoutes();
const gateRoute = xtreamRoute.children?.find(
(route) => route.path === '' && typeof route.loadComponent === 'function'
);
expect(gateRoute?.children?.map((route) => route.path)).toEqual(
expect.arrayContaining([
'live',
'live/:categoryId',
'vod',
'series',
'search',
'recently-added',
])
);
expect(xtreamRoute.children?.find((route) => route.path === 'favorites'))
.toMatchObject({ path: 'favorites' });
expect(xtreamRoute.children?.find((route) => route.path === 'recent'))
.toMatchObject({ path: 'recent' });
expect(xtreamRoute.children?.find((route) => route.path === 'downloads'))
.toMatchObject({ path: 'downloads' });
});
});
@@ -9,6 +9,11 @@ const loadDownloadsComponent: ComponentLoader = () =>
(c) => c.DownloadsComponent
);
const loadXtreamContentGateComponent: ComponentLoader = () =>
import('./xtream-content-gate.component').then(
(c) => c.XtreamContentGateComponent
);
const loadLiveStreamLayoutComponent: ComponentLoader = () =>
import('./live-stream-layout/live-stream-layout.component').then(
(c) => c.LiveStreamLayoutComponent
@@ -56,46 +61,60 @@ export function createXtreamRoutes(): Route[] {
pathMatch: 'full',
},
{
path: 'live',
loadComponent: loadLiveStreamLayoutComponent,
},
{
path: 'live/:categoryId',
loadComponent: loadLiveStreamLayoutComponent,
},
{
path: 'vod',
providers: provideXtreamCatalogFacade(),
path: '',
loadComponent: loadXtreamContentGateComponent,
children: [
{
path: '',
loadComponent: loadCategoryContentViewComponent,
path: 'live',
loadComponent: loadLiveStreamLayoutComponent,
},
{
path: ':categoryId',
loadComponent: loadCategoryContentViewComponent,
path: 'live/:categoryId',
loadComponent: loadLiveStreamLayoutComponent,
},
{
path: ':categoryId/:vodId',
loadComponent: loadVodDetailsRouteComponent,
},
],
},
{
path: 'series',
providers: provideXtreamCatalogFacade(),
children: [
{
path: '',
loadComponent: loadCategoryContentViewComponent,
path: 'vod',
providers: provideXtreamCatalogFacade(),
children: [
{
path: '',
loadComponent: loadCategoryContentViewComponent,
},
{
path: ':categoryId',
loadComponent: loadCategoryContentViewComponent,
},
{
path: ':categoryId/:vodId',
loadComponent: loadVodDetailsRouteComponent,
},
],
},
{
path: ':categoryId',
loadComponent: loadCategoryContentViewComponent,
path: 'series',
providers: provideXtreamCatalogFacade(),
children: [
{
path: '',
loadComponent: loadCategoryContentViewComponent,
},
{
path: ':categoryId',
loadComponent: loadCategoryContentViewComponent,
},
{
path: ':categoryId/:serialId',
loadComponent: loadSerialDetailsComponent,
},
],
},
{
path: ':categoryId/:serialId',
loadComponent: loadSerialDetailsComponent,
path: 'search',
loadComponent: loadSearchResultsComponent,
},
{
path: 'recently-added',
loadComponent: loadRecentlyAddedComponent,
},
],
},
@@ -109,14 +128,6 @@ export function createXtreamRoutes(): Route[] {
loadComponent: loadXtreamCollectionRouteComponent,
data: { mode: 'recent', portalType: 'xtream' },
},
{
path: 'search',
loadComponent: loadSearchResultsComponent,
},
{
path: 'recently-added',
loadComponent: loadRecentlyAddedComponent,
},
{
path: 'downloads',
loadComponent: loadDownloadsComponent,
@@ -0,0 +1,179 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { NavigationEnd, Router } from '@angular/router';
import { Subject } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
PortalStatusType,
XtreamContentInitBlockReason,
XtreamPlaylistData,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { PlaylistMeta } from 'shared-interfaces';
import { XtreamWorkspaceRouteSession } from './xtream-workspace-route-session.service';
const PLAYLIST_ID = 'playlist-1';
const ACTIVE_PLAYLIST: PlaylistMeta = {
_id: PLAYLIST_ID,
filename: 'xtream.m3u',
password: 'secret',
serverUrl: 'http://localhost:3211',
title: 'Test Xtream',
username: 'user1',
} as PlaylistMeta;
const XTREAM_PLAYLIST: XtreamPlaylistData = {
id: PLAYLIST_ID,
name: 'Test Xtream',
title: 'Test Xtream',
serverUrl: 'http://localhost:3211',
username: 'user1',
password: 'secret',
type: 'xtream',
};
async function flushEffects(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe('XtreamWorkspaceRouteSession', () => {
const routerEvents = new Subject<NavigationEnd>();
const routeProvider = signal<'xtreams' | null>('xtreams');
const routePlaylistId = signal<string | null>(PLAYLIST_ID);
const activePlaylist = signal<PlaylistMeta | null>(ACTIVE_PLAYLIST);
const currentPlaylist = signal<XtreamPlaylistData | null>(XTREAM_PLAYLIST);
const playlistId = signal<string | null>(PLAYLIST_ID);
const portalStatus = signal<PortalStatusType>('active');
const contentInitBlockReason =
signal<XtreamContentInitBlockReason | null>(null);
const playlistContext = {
routeProvider,
routePlaylistId,
activePlaylist,
syncFromUrl: jest.fn(),
};
const xtreamStore = {
currentPlaylist,
playlistId,
portalStatus,
resetStore: jest.fn((nextPlaylistId?: string) => {
playlistId.set(nextPlaylistId ?? null);
currentPlaylist.set(null);
}),
setCurrentPlaylist: jest.fn((playlist: XtreamPlaylistData | null) => {
currentPlaylist.set(playlist);
}),
fetchXtreamPlaylist: jest.fn().mockResolvedValue(undefined),
checkPortalStatus: jest.fn(),
contentInitBlockReason,
initializeContent: jest.fn().mockResolvedValue(undefined),
setSelectedContentType: jest.fn(),
setContentInitBlockReason: jest.fn(
(reason: XtreamContentInitBlockReason | null) => {
contentInitBlockReason.set(reason);
}
),
};
const router = {
url: `/workspace/xtreams/${PLAYLIST_ID}/vod`,
events: routerEvents.asObservable(),
};
beforeEach(async () => {
router.url = `/workspace/xtreams/${PLAYLIST_ID}/vod`;
routeProvider.set('xtreams');
routePlaylistId.set(PLAYLIST_ID);
activePlaylist.set(ACTIVE_PLAYLIST);
currentPlaylist.set(XTREAM_PLAYLIST);
playlistId.set(PLAYLIST_ID);
portalStatus.set('active');
contentInitBlockReason.set(null);
playlistContext.syncFromUrl.mockImplementation((url: string) => ({
inWorkspace: true,
provider: 'xtreams',
playlistId: PLAYLIST_ID,
section: url.endsWith('/favorites') ? 'favorites' : 'vod',
}));
xtreamStore.resetStore.mockClear();
xtreamStore.setCurrentPlaylist.mockClear();
xtreamStore.fetchXtreamPlaylist.mockClear();
xtreamStore.checkPortalStatus.mockReset();
xtreamStore.initializeContent.mockClear();
xtreamStore.setSelectedContentType.mockClear();
xtreamStore.setContentInitBlockReason.mockClear();
await TestBed.configureTestingModule({
providers: [
XtreamWorkspaceRouteSession,
{
provide: PlaylistContextFacade,
useValue: playlistContext,
},
{
provide: Router,
useValue: router,
},
{
provide: XtreamStore,
useValue: xtreamStore,
},
],
});
});
it('initializes content for active import-driven routes', async () => {
xtreamStore.checkPortalStatus.mockImplementation(async () => {
portalStatus.set('active');
return 'active';
});
TestBed.inject(XtreamWorkspaceRouteSession);
await flushEffects();
expect(xtreamStore.checkPortalStatus).toHaveBeenCalled();
expect(xtreamStore.setContentInitBlockReason).toHaveBeenCalledWith(null);
expect(xtreamStore.setSelectedContentType).toHaveBeenCalledWith('vod');
expect(xtreamStore.initializeContent).toHaveBeenCalled();
});
it.each(['expired', 'inactive', 'unavailable'] as const)(
'blocks %s portals before import-driven initialization starts',
async (status) => {
xtreamStore.checkPortalStatus.mockImplementation(async () => {
portalStatus.set(status);
return status;
});
TestBed.inject(XtreamWorkspaceRouteSession);
await flushEffects();
expect(xtreamStore.checkPortalStatus).toHaveBeenCalled();
expect(xtreamStore.setContentInitBlockReason).toHaveBeenCalledWith(
status
);
expect(xtreamStore.initializeContent).not.toHaveBeenCalled();
}
);
it('does not clear a cancelled block while an active portal bootstrap finishes', async () => {
xtreamStore.checkPortalStatus.mockImplementation(async () => {
portalStatus.set('active');
return 'active';
});
contentInitBlockReason.set('cancelled');
TestBed.inject(XtreamWorkspaceRouteSession);
await flushEffects();
expect(xtreamStore.checkPortalStatus).toHaveBeenCalled();
expect(xtreamStore.setContentInitBlockReason).not.toHaveBeenCalledWith(
null
);
});
});
@@ -12,6 +12,7 @@ import { filter } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PortalRailSection } from '@iptvnator/portal/shared/util';
import {
PortalStatusType,
XtreamPlaylistData,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
@@ -44,6 +45,29 @@ function toXtreamPlaylistData(
};
}
function isImportDrivenSection(section: PortalRailSection | null): boolean {
return (
section === 'vod' ||
section === 'live' ||
section === 'series' ||
section === 'search' ||
section === 'recently-added'
);
}
function toContentInitBlockReason(
portalStatus: PortalStatusType
): 'expired' | 'inactive' | 'unavailable' | null {
switch (portalStatus) {
case 'expired':
case 'inactive':
case 'unavailable':
return portalStatus;
default:
return null;
}
}
@Injectable()
export class XtreamWorkspaceRouteSession {
private readonly destroyRef = inject(DestroyRef);
@@ -53,6 +77,8 @@ export class XtreamWorkspaceRouteSession {
private currentPlaylistId: string | null = null;
private currentPlaylistUpdateDate: number | null = null;
private syncInFlight = false;
private syncPending = false;
constructor() {
effect(() => {
@@ -68,7 +94,7 @@ export class XtreamWorkspaceRouteSession {
return;
}
void this.syncRouteContext();
this.scheduleSyncRouteContext();
});
this.router.events
@@ -80,10 +106,30 @@ export class XtreamWorkspaceRouteSession {
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
void this.syncRouteContext();
this.scheduleSyncRouteContext();
});
void this.syncRouteContext();
this.scheduleSyncRouteContext();
}
private scheduleSyncRouteContext(): void {
if (this.syncInFlight) {
this.syncPending = true;
return;
}
this.syncInFlight = true;
void (async () => {
try {
do {
this.syncPending = false;
await this.syncRouteContext();
} while (this.syncPending);
} finally {
this.syncInFlight = false;
}
})();
}
private async syncRouteContext(): Promise<void> {
@@ -92,6 +138,7 @@ export class XtreamWorkspaceRouteSession {
routeContext.provider === 'xtreams'
? routeContext.playlistId
: null;
const section = this.syncRouteState(routeContext.section);
const routePlaylist =
routeContext.provider === 'xtreams'
? toXtreamPlaylistData(this.playlistContext.activePlaylist())
@@ -104,6 +151,7 @@ export class XtreamWorkspaceRouteSession {
(currentPlaylist?.id !== playlistId ||
this.currentPlaylistUpdateDate !== routePlaylistUpdateDate)
);
let portalStatus = this.xtreamStore.portalStatus();
if (
playlistId &&
@@ -118,10 +166,23 @@ export class XtreamWorkspaceRouteSession {
this.xtreamStore.setCurrentPlaylist(routePlaylist);
await this.xtreamStore.fetchXtreamPlaylist();
await this.xtreamStore.checkPortalStatus();
portalStatus = await this.xtreamStore.checkPortalStatus();
const nextBlockReason = toContentInitBlockReason(portalStatus);
const currentBlockReason =
this.xtreamStore.contentInitBlockReason();
if (
nextBlockReason !== null ||
currentBlockReason !== 'cancelled'
) {
this.xtreamStore.setContentInitBlockReason(nextBlockReason);
}
}
if (isImportDrivenSection(section) && portalStatus !== 'active') {
return;
}
const section = this.syncRouteState(routeContext.section);
await this.initializeCurrentSectionContent(section);
}
@@ -150,11 +211,7 @@ export class XtreamWorkspaceRouteSession {
}
if (
section === 'vod' ||
section === 'live' ||
section === 'series' ||
section === 'search' ||
section === 'recently-added'
isImportDrivenSection(section)
) {
await this.xtreamStore.initializeContent();
}
+1
View File
@@ -2,6 +2,7 @@ export * from './lib/data.service';
export * from './lib/database-electron.service';
export * from './lib/downloads.service';
export * from './lib/playback-position.service';
export * from './lib/playlist-refresh.service';
export * from './lib/playlists.service';
export * from './lib/portal-status.service';
export * from './lib/settings-store.service';
@@ -99,6 +99,13 @@ export interface DbOperationOptions {
onEvent?: (event: DbOperationEvent) => void;
}
export type XtreamImportStatus =
| 'idle'
| 'importing'
| 'completed'
| 'cancelled'
| 'failed';
export function isDbAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
}
@@ -115,6 +122,13 @@ export interface GlobalRecentlyAddedItem extends XtreamContent {
providedIn: 'root',
})
export class DatabaseService {
private buildXtreamImportStateKey(
playlistId: string,
type: 'live' | 'movie' | 'series'
): string {
return `xtream-import-status:${playlistId}:${type}`;
}
createOperationId(prefix = 'db-op'): string {
return (
globalThis.crypto?.randomUUID?.() ??
@@ -459,6 +473,69 @@ export class DatabaseService {
}
}
async clearXtreamImportCache(
playlistId: string,
type: 'live' | 'movie' | 'series'
): Promise<boolean> {
try {
await window.electron.dbClearXtreamImportCache(playlistId, type);
return true;
} catch (error) {
console.error('Error clearing Xtream import cache:', error);
return false;
}
}
async getAppState(key: string): Promise<string | null> {
try {
return await window.electron.dbGetAppState(key);
} catch (error) {
console.error('Error getting app state:', error);
return null;
}
}
async setAppState(key: string, value: string): Promise<boolean> {
try {
await window.electron.dbSetAppState(key, value);
return true;
} catch (error) {
console.error('Error setting app state:', error);
return false;
}
}
async getXtreamImportStatus(
playlistId: string,
type: 'live' | 'movie' | 'series'
): Promise<XtreamImportStatus> {
const value = await this.getAppState(
this.buildXtreamImportStateKey(playlistId, type)
);
switch (value) {
case 'importing':
case 'completed':
case 'cancelled':
case 'failed':
case 'idle':
return value;
default:
return 'idle';
}
}
async setXtreamImportStatus(
playlistId: string,
type: 'live' | 'movie' | 'series',
status: XtreamImportStatus
): Promise<boolean> {
return this.setAppState(
this.buildXtreamImportStateKey(playlistId, type),
status
);
}
/**
* Search content within a playlist
*/
@@ -0,0 +1,52 @@
import { Injectable } from '@angular/core';
import {
Playlist,
PlaylistRefreshEvent,
PlaylistRefreshPayload,
} from 'shared-interfaces';
export interface PlaylistRefreshOptions {
onEvent?: (event: PlaylistRefreshEvent) => void;
}
@Injectable({
providedIn: 'root',
})
export class PlaylistRefreshService {
async refreshPlaylist(
payload: PlaylistRefreshPayload,
options?: PlaylistRefreshOptions
): Promise<Playlist> {
if (!window.electron?.refreshPlaylist) {
throw new Error('Playlist refresh is only available in Electron');
}
const unsubscribe = window.electron.onPlaylistRefreshEvent?.((event) => {
if (event.operationId !== payload.operationId) {
return;
}
options?.onEvent?.(event);
});
try {
return await window.electron.refreshPlaylist(payload);
} finally {
unsubscribe?.();
}
}
async cancelRefresh(operationId: string): Promise<boolean> {
if (!window.electron?.cancelPlaylistRefresh || !operationId) {
return false;
}
try {
const result = await window.electron.cancelPlaylistRefresh(operationId);
return result.success;
} catch (error) {
console.error('Failed to cancel playlist refresh:', error);
return false;
}
}
}
+1
View File
@@ -12,6 +12,7 @@ export * from './lib/language.enum';
export * from './lib/parsed-playlist.interface';
export * from './lib/playback-position.interface';
export * from './lib/playlist-meta.type';
export * from './lib/playlist-refresh.interface';
export * from './lib/playlist-recently-viewed.interface';
export * from './lib/playlist-recently-viewed.utils';
export * from './lib/playlist.interface';
@@ -12,6 +12,9 @@ export const EPG_FORCE_FETCH = 'EPG:EPG_FORCE_FETCH';
// Playlist related commands
export const PLAYLIST_PARSE_BY_URL = 'PLAYLIST:PARSE_PLAYLIST_BY_URL';
export const PLAYLIST_UPDATE = 'PLAYLIST:UPDATE';
export const PLAYLIST_REFRESH = 'PLAYLIST:REFRESH';
export const PLAYLIST_REFRESH_EVENT = 'PLAYLIST:REFRESH_EVENT';
export const PLAYLIST_CANCEL_REFRESH = 'PLAYLIST:CANCEL_REFRESH';
// General
export const ERROR = 'ERROR';
@@ -38,6 +41,7 @@ export const EXTERNAL_PLAYER_SESSION_UPDATE =
// Xtream
export const XTREAM_REQUEST = 'XTREAM_REQUEST';
export const XTREAM_RESPONSE = 'XTREAM_RESPONSE';
export const XTREAM_CANCEL_SESSION = 'XTREAM_CANCEL_SESSION';
// Stalker
export const STALKER_REQUEST = 'STALKER_REQUEST';
@@ -0,0 +1,28 @@
export type PlaylistRefreshPhase =
| 'fetching'
| 'reading-file'
| 'parsing'
| 'saving';
export type PlaylistRefreshStatus =
| 'started'
| 'progress'
| 'completed'
| 'cancelled'
| 'error';
export interface PlaylistRefreshEvent {
operationId: string;
playlistId: string;
phase?: PlaylistRefreshPhase;
status: PlaylistRefreshStatus;
error?: string;
}
export interface PlaylistRefreshPayload {
operationId: string;
playlistId: string;
title: string;
filePath?: string;
url?: string;
}