diff --git a/global.d.ts b/global.d.ts index 4b89b4696..b47580a28 100644 --- a/global.d.ts +++ b/global.d.ts @@ -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; platform: string; fetchPlaylistByUrl: ( @@ -113,7 +120,18 @@ declare global { url: string; params: Record; requestId?: string; + sessionId?: string; + suppressErrorLog?: boolean; }) => Promise<{ payload: any; action: string }>; + xtreamCancelSession: ( + sessionId: string + ) => Promise<{ success: boolean; cancelled: number }>; + refreshPlaylist: ( + payload: PlaylistRefreshPayload + ) => Promise; + cancelPlaylistRefresh: ( + operationId: string + ) => Promise<{ success: boolean }>; // Database operations dbCreatePlaylist: (playlist: any) => Promise<{ success: boolean }>; dbGetPlaylist: (playlistId: string) => Promise; @@ -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, diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts new file mode 100644 index 000000000..bd2f67b1a --- /dev/null +++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts @@ -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 + ); + }); +}); diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts index 4703ca092..2f413c808 100644 --- a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts +++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts @@ -122,6 +122,9 @@ export const playlistReducers = [ ...(p.recentlyViewed != null ? { recentlyViewed: p.recentlyViewed } : {}), + ...(p.updateDate !== undefined + ? { updateDate: p.updateDate } + : {}), }, }, state.playlists diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts index f5c8dcb59..aa35f1b04 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts @@ -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 | undefined; + const dateNowSpy = jest + .spyOn(Date, 'now') + .mockReturnValue(1712145600000); + + dialogService.openConfirmDialog.mockImplementation( + ({ onConfirm }: { onConfirm?: () => Promise }) => { + 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', diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index 46dbb5279..a5d914213 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -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 { + 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 { @@ -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; + } } diff --git a/libs/playlist/shared/util/src/index.ts b/libs/playlist/shared/util/src/index.ts index 68b5f7871..3cc1fb482 100644 --- a/libs/playlist/shared/util/src/index.ts +++ b/libs/playlist/shared/util/src/index.ts @@ -1,2 +1,3 @@ export * from './lib/playlist-player-actions'; export * from './lib/playlist-context.facade'; +export * from './lib/playlist-refresh-action.service'; diff --git a/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.ts b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.ts new file mode 100644 index 000000000..d47b42b31 --- /dev/null +++ b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.ts @@ -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 { + 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' + ); + } +} diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts index 1fc58951e..3b27f860b 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts @@ -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', + ]); + } } diff --git a/libs/portal/shared/ui/jest.config.ts b/libs/portal/shared/ui/jest.config.ts new file mode 100644 index 000000000..250fe96e1 --- /dev/null +++ b/libs/portal/shared/ui/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'portal-shared-ui', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/portal/shared/ui', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/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', + ], +}; diff --git a/libs/portal/shared/ui/project.json b/libs/portal/shared/ui/project.json index 758ca8fd4..26214a466 100644 --- a/libs/portal/shared/ui/project.json +++ b/libs/portal/shared/ui/project.json @@ -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" } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html index 6a6f05bbd..8905643ae 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html @@ -118,9 +118,11 @@ [items]="liveItems()" [mode]="mode()" [searchTerm]="workspaceSearchTerm()" + [autoOpenItem]="pendingAutoOpenLiveItem()" (removeItem)="onRemoveItem($event)" (reorderItems)="onReorder($event)" (itemPlayed)="onItemPlayed($event)" + (autoOpenHandled)="onLiveAutoOpenHandled()" /> } } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts new file mode 100644 index 000000000..ce0c806fd --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -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(); + readonly mode = input<'favorites' | 'recent'>('favorites'); + readonly searchTerm = input(''); + readonly autoOpenItem = input(null); + + readonly removeItem = output(); + readonly reorderItems = output(); + readonly itemPlayed = output(); + readonly autoOpenHandled = output(); +} + +@Component({ + selector: 'app-unified-grid-tab', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class StubUnifiedGridTabComponent { + readonly items = input.required(); + readonly mode = input<'favorites' | 'recent'>('favorites'); + readonly contentType = input<'movie' | 'series'>('movie'); + readonly searchTerm = input(''); + + readonly removeItem = output(); +} + +describe('UnifiedCollectionPageComponent', () => { + let fixture: ComponentFixture; + const playlistsLoaded = signal(false); + const playlists = signal([]); + 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('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 + ); + }); +}); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts index d80c678af..e3f8b3cea 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts @@ -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(); 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([]); readonly selectedContentType = signal('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 { - 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]); + } } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts index 8af63ad20..7983e11f5 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts @@ -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, }); } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index 30183e31d..ddfd3136c 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -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(); readonly mode = input<'favorites' | 'recent'>('favorites'); readonly searchTerm = input(''); + readonly autoOpenItem = input(null); readonly removeItem = output(); readonly reorderItems = output(); readonly itemPlayed = output(); + readonly autoOpenHandled = output(); 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 { + const epgMap = await this.streamResolver.loadEpgForItems(items); + this.epgMap.set(epgMap); + } + + private async activateItem( + item: UnifiedCollectionItem, + isAutoOpen = false + ): Promise { + 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 { - const epgMap = await this.streamResolver.loadEpgForItems(items); - this.epgMap.set(epgMap); - } - private async hydrateSelectedM3uPrograms( item: UnifiedCollectionItem, detail: ResolvedLiveCollectionDetail, diff --git a/libs/portal/shared/ui/src/test-setup.ts b/libs/portal/shared/ui/src/test-setup.ts new file mode 100644 index 000000000..0d88648e3 --- /dev/null +++ b/libs/portal/shared/ui/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/libs/portal/shared/ui/tsconfig.spec.json b/libs/portal/shared/ui/tsconfig.spec.json new file mode 100644 index 000000000..c0bb248be --- /dev/null +++ b/libs/portal/shared/ui/tsconfig.spec.json @@ -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" + ] +} diff --git a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.spec.ts index 4436558a9..dde8c52e9 100644 --- a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.spec.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) + ); + 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) + ); + 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 () => { diff --git a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts index 71c17f6b3..d0b3d37a3 100644 --- a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts @@ -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(); + private readonly xtreamEpgFailureTimestamps = new Map(); + 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 { + 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[], diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts index 2fea3761a..a4f2e329c 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts @@ -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({ diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts index 4e5c8cecc..b2673ce0e 100644 --- a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts @@ -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({ diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts index 3cebf5b28..931068191 100644 --- a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts @@ -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; } +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)['sourceType'] + ) as CollectionSourceType; + const playlistId = toPathSegment( + (candidate as Record)['playlistId'] + ); + const itemId = toPathSegment((candidate as Record)['itemId']); + + if ( + !playlistId || + !itemId || + (sourceType !== 'm3u' && + sourceType !== 'xtream' && + sourceType !== 'stalker') + ) { + return null; + } + + return { + contentType: 'live', + sourceType, + playlistId, + itemId, + title: toOptionalPathSegment( + (candidate as Record)['title'] + ), + imageUrl: toOptionalPathSegment( + (candidate as Record)['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; + categoryId?: string | number | null; + item: Record; + 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 = { + [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 | 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) + : 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 { - 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), @@ -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 | null { + return state && typeof state === 'object' + ? (state as Record) + : null; +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts index 8f6838f0b..f1f5db1fb 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts @@ -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[0]) + ).toEqual( + expect.objectContaining({ + id: '42', + category_id: '17', + }) + ); + }); + it('normalizes nested favorite details for the shared inline detail shell', () => { const detailState = createStalkerInlineDetailState( normalizeStalkerFavoriteItem({ diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts index b9e922b54..83e879f67 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts @@ -73,9 +73,14 @@ export function isStalkerSeriesItem(item: { export function buildStalkerFavoritePayload( data: StalkerVodDetails ): Record { + 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(), diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.ts index 7e4080300..41a4d7d48 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.ts @@ -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, }) diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts index c4dcfdf17..63664f02c 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts @@ -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 diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-recent.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-recent.feature.ts index 8db78d0b7..e4dbaef46 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-recent.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-recent.feature.ts @@ -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: diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index f7100be76..403769572 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -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: { diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 1b04c63d3..4fd484c66 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -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 { diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts index a4691a6ba..84bb4dbf5 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts @@ -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 >(); + 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 { + 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 { 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 { - + 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 { + 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 diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index 73a482756..fb9c8d212 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -136,8 +136,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource { async getCategories( playlistId: string, credentials: XtreamCredentials, - type: CategoryType + type: CategoryType, + options?: XtreamOperationOptions ): Promise { + void options; const cacheKey = `${playlistId}-${type}-categories`; // Check in-memory cache first diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index 0e87e9cb8..66652c060 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -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; /** diff --git a/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.spec.ts new file mode 100644 index 000000000..b63682ea3 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.spec.ts @@ -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 }).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 }).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); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.ts b/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.ts index 2a1f58010..52b9bf1dc 100644 --- a/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.ts @@ -26,10 +26,12 @@ export class EpgQueueService implements OnDestroy { private readonly inFlight = new Set(); private visibleSet = new Set(); private processing = false; + private readonly failureTimestamps = new Map(); 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 diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts index cb5d1a1f6..c62225b0f 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts @@ -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 { + 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 { 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 { const actionMap: Record = { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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( url: string, - params: Record + params: Record, + options?: XtreamRequestOptions ): Promise { const serializedParams: Record = {}; 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; diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts index cdc9a2d22..305aa20e7 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts @@ -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, []>; + const TestContentStore = signalStore( withState({ playlistId: PLAYLIST.id, currentPlaylist: PLAYLIST, + portalStatus: 'active' as PortalStatusType, }), + withMethods((store) => ({ + async checkPortalStatus(): Promise { + 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 = { 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(), @@ -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(), + vod: createDeferred(), + series: createDeferred(), + }; + + 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(), + vod: createDeferred(), + series: createDeferred(), + }; + + 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(), @@ -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); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts index da972a87f..091988b1a 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts @@ -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; }; 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 | 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 => { + const importType = type === 'vod' ? 'movie' : type; + await databaseService.setXtreamImportStatus( + playlistId, + importType, + status + ); + }; + + const clearImportCache = async ( + playlistId: string, + type: ContentType + ): Promise => { + const importType = type === 'vod' ? 'movie' : type; + await databaseService.clearXtreamImportCache( + playlistId, + importType + ); + }; + + const finalizePendingImportTypes = async ( + playlistId: string, + completedTypes: Set, + status: XtreamImportStatus + ): Promise => { + 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 => { + 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(); + + 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 => { + 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 { + async fetchAllCategories( + options?: { sessionId?: string } + ): Promise { 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 { + async fetchAllContent( + options?: { + importSessionId?: string; + sessionId?: string; + completedTypes?: Set; + } + ): Promise { 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 { - 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 { + 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 { - 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; }) ); } diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts index b86d0534b..f7afcf6c3 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts @@ -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); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts index a315953f6..c4a63173c 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts @@ -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(initialPortalState), @@ -98,11 +123,11 @@ export function withPortal() { /** * Check portal status via API */ - async checkPortalStatus(): Promise { + async checkPortalStatus(): Promise { 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'; } }, diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts index 0e8566a50..c77c0ff81 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts @@ -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'); + } }, /** diff --git a/libs/portal/xtream/data-access/src/lib/xtream-state.ts b/libs/portal/xtream/data-access/src/lib/xtream-state.ts index 4f269cffd..1f1ee5af0 100644 --- a/libs/portal/xtream/data-access/src/lib/xtream-state.ts +++ b/libs/portal/xtream/data-access/src/lib/xtream-state.ts @@ -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; diff --git a/libs/portal/xtream/feature/src/index.ts b/libs/portal/xtream/feature/src/index.ts index eb7179979..a207275a1 100644 --- a/libs/portal/xtream/feature/src/index.ts +++ b/libs/portal/xtream/feature/src/index.ts @@ -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'; diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index 6549e0849..570d367c5 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -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); } } diff --git a/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.spec.ts new file mode 100644 index 000000000..ffe4cc547 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.spec.ts @@ -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: ` +
+ {{ title() }} + {{ description() }} +
+ `, +}) +class MockPlaylistErrorViewComponent { + readonly title = input(''); + readonly description = input(undefined); +} + +describe('XtreamContentGateComponent', () => { + let fixture: ComponentFixture; + const contentInitBlockReason = + signal(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); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.ts b/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.ts new file mode 100644 index 000000000..dd02b0509 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.ts @@ -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) { +
+ + +
+ +
+
+ } @else { + + } + `, + 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(); + } +} diff --git a/libs/portal/xtream/feature/src/lib/xtream-feature.routes.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.spec.ts new file mode 100644 index 000000000..4af1c064f --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.spec.ts @@ -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' }); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts index ce1c58321..c8942e037 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts @@ -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, diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts new file mode 100644 index 000000000..6ca8ca9a8 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts @@ -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 { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('XtreamWorkspaceRouteSession', () => { + const routerEvents = new Subject(); + const routeProvider = signal<'xtreams' | null>('xtreams'); + const routePlaylistId = signal(PLAYLIST_ID); + const activePlaylist = signal(ACTIVE_PLAYLIST); + const currentPlaylist = signal(XTREAM_PLAYLIST); + const playlistId = signal(PLAYLIST_ID); + const portalStatus = signal('active'); + const contentInitBlockReason = + signal(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 + ); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts index 3266fb9a4..6efb3f4d0 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts @@ -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 { @@ -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(); } diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index 65faf83a6..887f0a323 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -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'; diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index 09638abed..bf62ce1d2 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + return this.setAppState( + this.buildXtreamImportStateKey(playlistId, type), + status + ); + } + /** * Search content within a playlist */ diff --git a/libs/services/src/lib/playlist-refresh.service.ts b/libs/services/src/lib/playlist-refresh.service.ts new file mode 100644 index 000000000..a38f10218 --- /dev/null +++ b/libs/services/src/lib/playlist-refresh.service.ts @@ -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 { + 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 { + 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; + } + } +} diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 61899cf1a..9081c7df9 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -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'; diff --git a/libs/shared/interfaces/src/lib/ipc-commands.ts b/libs/shared/interfaces/src/lib/ipc-commands.ts index 38779a707..c26b14708 100644 --- a/libs/shared/interfaces/src/lib/ipc-commands.ts +++ b/libs/shared/interfaces/src/lib/ipc-commands.ts @@ -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'; diff --git a/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts b/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts new file mode 100644 index 000000000..87ad10681 --- /dev/null +++ b/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts @@ -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; +}