mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
1 parent
4e0afe8b20
commit
91cd650c1a
55 files changed
+3485
-397
No files matched your search
Vendored
+22
@@ -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,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'
|
||||
);
|
||||
}
|
||||
}
|
||||
+22
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+2
@@ -118,9 +118,11 @@
|
||||
[items]="liveItems()"
|
||||
[mode]="mode()"
|
||||
[searchTerm]="workspaceSearchTerm()"
|
||||
[autoOpenItem]="pendingAutoOpenLiveItem()"
|
||||
(removeItem)="onRemoveItem($event)"
|
||||
(reorderItems)="onReorder($event)"
|
||||
(itemPlayed)="onItemPlayed($event)"
|
||||
(autoOpenHandled)="onLiveAutoOpenHandled()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
+187
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
+64
-2
@@ -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]);
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+70
-32
@@ -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,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
+12
-1
@@ -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,
|
||||
})
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
+12
-1
@@ -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:
|
||||
|
||||
+8
@@ -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: {
|
||||
|
||||
+8
@@ -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 {
|
||||
|
||||
+57
-10
@@ -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;
|
||||
|
||||
+246
-40
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
+11
-5
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user