From eaa3e0e2bd6fee5957ec315c446364ea228911b9 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Apr 2026 10:10:52 +0200 Subject: [PATCH 01/15] feat: add loading state and spinner to source stats widget - Introduced a loading state in the source stats widget with a spinner. - Updated styles for the loading state in the SCSS file. - Created unit tests for the source stats widget to verify loading behavior. - Enhanced the source stats widget component to include a progress spinner. - Added a new service for managing workspace startup preferences. - Implemented logic to determine the initial workspace path based on user settings. - Updated workspace shell header to include a refresh playlist button. - Refactored workspace shell rail component to support dynamic brand link and tooltip. - Enhanced workspace shell facade to manage playlist refresh actions. - Improved loading overlay UI in the workspace shell component. Entire-Checkpoint: 271bd0162027 --- libs/workspace/dashboard/ui/jest.config.ts | 21 +++ libs/workspace/dashboard/ui/project.json | 8 + .../continue-watching-widget.component.html | 12 +- .../continue-watching-widget.component.scss | 7 + ...continue-watching-widget.component.spec.ts | 48 ++++++ .../continue-watching-widget.component.ts | 6 + .../global-favorites-widget.component.html | 35 ++-- .../global-favorites-widget.component.scss | 7 + .../global-favorites-widget.component.spec.ts | 88 ++++++++++ .../global-favorites-widget.component.ts | 8 +- .../recently-added-widget.component.html | 11 +- .../recently-added-widget.component.scss | 6 + .../recently-added-widget.component.spec.ts | 50 ++++++ .../recently-added-widget.component.ts | 2 + .../recently-watched-widget.component.html | 40 +++-- .../recently-watched-widget.component.scss | 7 + .../recently-watched-widget.component.spec.ts | 58 +++++++ .../recently-watched-widget.component.ts | 2 + .../source-stats-widget.component.html | 84 +++++----- .../source-stats-widget.component.scss | 7 + .../source-stats-widget.component.spec.ts | 44 +++++ .../widgets/source-stats-widget.component.ts | 8 +- libs/workspace/dashboard/ui/src/test-setup.ts | 6 + .../workspace/dashboard/ui/tsconfig.spec.json | 17 ++ .../workspace-shell-header.component.html | 17 ++ .../workspace-shell-header.component.ts | 8 +- .../workspace-shell-rail.component.html | 6 +- .../workspace-shell-rail.component.spec.ts | 15 ++ .../workspace-shell-rail.component.ts | 3 + .../services/workspace-shell.facade.spec.ts | 95 ++++++++++- .../services/workspace-shell.facade.ts | 127 +++++++++++++-- .../workspace-shell.component.html | 35 +++- .../workspace-shell.component.scss | 111 ++++++++++++- .../workspace-shell.component.spec.ts | 24 +++ libs/workspace/shell/util/src/index.ts | 1 + ...kspace-startup-preferences.service.spec.ts | 104 ++++++++++++ .../workspace-startup-preferences.service.ts | 153 ++++++++++++++++++ 37 files changed, 1178 insertions(+), 103 deletions(-) create mode 100644 libs/workspace/dashboard/ui/jest.config.ts create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.spec.ts create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.spec.ts create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.spec.ts create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.spec.ts create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.spec.ts create mode 100644 libs/workspace/dashboard/ui/src/test-setup.ts create mode 100644 libs/workspace/dashboard/ui/tsconfig.spec.json create mode 100644 libs/workspace/shell/util/src/lib/workspace-startup-preferences.service.spec.ts create mode 100644 libs/workspace/shell/util/src/lib/workspace-startup-preferences.service.ts diff --git a/libs/workspace/dashboard/ui/jest.config.ts b/libs/workspace/dashboard/ui/jest.config.ts new file mode 100644 index 000000000..d2fa01536 --- /dev/null +++ b/libs/workspace/dashboard/ui/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'workspace-dashboard-ui', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/workspace/dashboard/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/workspace/dashboard/ui/project.json b/libs/workspace/dashboard/ui/project.json index b61565f1f..51b734732 100644 --- a/libs/workspace/dashboard/ui/project.json +++ b/libs/workspace/dashboard/ui/project.json @@ -6,6 +6,14 @@ "projectType": "library", "tags": ["scope:workspace", "domain:workspace", "type:ui"], "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/workspace/dashboard/ui/jest.config.ts", + "tsConfig": "libs/workspace/dashboard/ui/tsconfig.spec.json" + } + }, "lint": { "executor": "@nx/eslint:lint" } diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.html index a960a4381..aa2fdb1a8 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.html +++ b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.html @@ -2,8 +2,16 @@ [eyebrow]="'WORKSPACE.DASHBOARD.FOCUS' | translate" [title]="'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate" > - @if (lastItem(); as item) { - + @if (data.globalRecentLoading()) { +
+ +
+ } @else if (lastItem(); as item) { +
@if (hasImage(item)) { diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.scss index f9132617e..588624475 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.scss +++ b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.scss @@ -13,6 +13,13 @@ --_series-accent: oklch(68% 0.16 55); } +.continue-watching-widget__loading { + min-height: 220px; + display: grid; + place-items: center; + padding: 16px 0; +} + // ── Hero card ───────────────────────────────────────────────────────────────── .hero { diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.spec.ts b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.spec.ts new file mode 100644 index 000000000..0a151b59b --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.spec.ts @@ -0,0 +1,48 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { DashboardDataService } from 'workspace-dashboard-data-access'; +import { ContinueWatchingWidgetComponent } from './continue-watching-widget.component'; + +describe('ContinueWatchingWidgetComponent', () => { + let fixture: ComponentFixture; + + const dataServiceMock = { + globalRecentLoading: signal(true), + globalRecentItems: signal([]), + quickRecent: signal([]), + getRecentItemLink: jest.fn(() => ['/workspace', 'global-recent']), + getRecentItemNavigationState: jest.fn(() => undefined), + getPlaylistLink: jest.fn(() => ['/workspace', 'sources']), + getPlaylistProvider: jest.fn(() => 'M3U'), + }; + + beforeEach(async () => { + dataServiceMock.globalRecentLoading.set(true); + dataServiceMock.globalRecentItems.set([]); + dataServiceMock.quickRecent.set([]); + + await TestBed.configureTestingModule({ + imports: [ContinueWatchingWidgetComponent, TranslateModule.forRoot()], + providers: [ + provideRouter([]), + { + provide: DashboardDataService, + useValue: dataServiceMock, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ContinueWatchingWidgetComponent); + }); + + it('shows a spinner while recent history is still loading', () => { + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('mat-progress-spinner') + ).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.focus-empty')).toBeNull(); + }); +}); diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts index 9f77c7702..7c893a4ab 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts +++ b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts @@ -2,6 +2,7 @@ import { Component, computed, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { RouterLink } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { startWith } from 'rxjs'; @@ -16,6 +17,7 @@ import { DashboardWidgetShellComponent } from './dashboard-widget-shell.componen imports: [ MatButtonModule, MatIcon, + MatProgressSpinnerModule, RouterLink, DashboardWidgetShellComponent, TranslatePipe, @@ -44,6 +46,10 @@ export class ContinueWatchingWidgetComponent { return this.data.getRecentItemLink(item); } + getState(item: GlobalRecentItem): Record | undefined { + return this.data.getRecentItemNavigationState(item); + } + typeIcon(type: GlobalRecentItem['type']): string { if (type === 'live') return 'live_tv'; if (type === 'movie') return 'movie'; diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html index 45b57a451..a6c7dfab0 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html +++ b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html @@ -80,15 +80,28 @@ - + @if (data.globalFavoritesLoading()) { +
+ +
+ } @else { + + } diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.scss index d8a1d6c94..b1620d2c7 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.scss +++ b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.scss @@ -38,3 +38,10 @@ font-weight: 600; color: var(--mat-sys-primary); } + +.global-favorites-widget__loading { + min-height: 220px; + display: grid; + place-items: center; + padding: 16px 0; +} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.spec.ts b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.spec.ts new file mode 100644 index 000000000..b71186306 --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.spec.ts @@ -0,0 +1,88 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { DashboardDataService } from 'workspace-dashboard-data-access'; +import { GlobalFavoritesWidgetComponent } from './global-favorites-widget.component'; + +describe('GlobalFavoritesWidgetComponent', () => { + let fixture: ComponentFixture; + const globalFavoritesLoading = signal(true); + const globalFavoritesLoaded = signal(false); + const globalFavoriteItems = signal([]); + + const dataServiceMock = { + globalFavoriteItems, + globalFavoritesLoading, + globalFavoritesLoaded, + reloadGlobalFavorites: jest.fn().mockResolvedValue(undefined), + isTypeInKind: jest.fn( + (type: 'live' | 'movie' | 'series', kind: string) => + kind === 'all' || + (kind === 'channels' && type === 'live') || + (kind === 'vod' && type === 'movie') || + (kind === 'series' && type === 'series') + ), + getFavoriteItemProviderLabel: jest.fn(() => 'Xtream'), + getFavoriteItemTypeLabel: jest.fn(() => 'Live TV'), + getGlobalFavoriteLink: jest.fn(() => ['/workspace', 'global-favorites']), + getGlobalFavoriteNavigationState: jest.fn(() => undefined), + removeGlobalFavorite: jest.fn(), + }; + + beforeEach(async () => { + globalFavoritesLoading.set(true); + globalFavoritesLoaded.set(false); + globalFavoriteItems.set([]); + jest.clearAllMocks(); + + await TestBed.configureTestingModule({ + imports: [ + GlobalFavoritesWidgetComponent, + TranslateModule.forRoot(), + ], + providers: [ + provideRouter([]), + { + provide: DashboardDataService, + useValue: dataServiceMock, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(GlobalFavoritesWidgetComponent); + fixture.componentRef.setInput('widget', { + id: 'global-favorites', + type: 'global-favorites', + enabled: true, + order: 0, + size: 'medium', + } as any); + }); + + it('shows a spinner while global favorites are still loading', () => { + fixture.detectChanges(); + + expect(dataServiceMock.reloadGlobalFavorites).toHaveBeenCalled(); + expect( + fixture.nativeElement.querySelector('mat-progress-spinner') + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('.empty-state') + ).toBeNull(); + }); + + it('shows the empty state only after the initial load completes', () => { + globalFavoritesLoading.set(false); + globalFavoritesLoaded.set(true); + + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('mat-progress-spinner') + ).toBeNull(); + expect( + fixture.nativeElement.querySelector('.empty-state') + ).not.toBeNull(); + }); +}); diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts index e543c35af..ce7bc7420 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts +++ b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts @@ -1,4 +1,5 @@ import { + ChangeDetectionStrategy, Component, OnInit, computed, @@ -11,6 +12,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { startWith } from 'rxjs'; @@ -35,11 +37,13 @@ const PAGE_SIZE = 20; @Component({ selector: 'app-global-favorites-widget', + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ MatButtonModule, MatButtonToggleModule, MatMenuModule, MatIcon, + MatProgressSpinnerModule, MatTooltipModule, DashboardActivityItemsComponent, DashboardWidgetShellComponent, @@ -97,10 +101,8 @@ export class GlobalFavoritesWidgetComponent implements OnInit { })) ); - constructor() {} - ngOnInit(): void { - this.data.reloadGlobalFavorites().then(() => { + void this.data.reloadGlobalFavorites().then(() => { this.visibleItemLimit.set(INITIAL_PAGE_SIZE); }); } diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.html index 535788fe1..88561673a 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.html +++ b/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.html @@ -61,11 +61,12 @@ @if (loading()) { -
+
+
+

{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}

+

+ {{ + 'SETTINGS.SHOW_DASHBOARD_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+ +
+
+

{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}

+

+ {{ + 'SETTINGS.STARTUP_BEHAVIOR_DESCRIPTION' + | translate + }} +

+
+
+ + {{ + 'SETTINGS.STARTUP_BEHAVIOR' | translate + }} + + @for ( + option of startupBehaviorOptions; + track option.value + ) { + + {{ option.labelKey | translate }} + + } + + +
+
+

{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}

diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 9c5f793c2..a504c8a54 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -28,6 +28,7 @@ import { DialogService } from 'components'; import { DataService, PlaylistsService } from 'services'; import { Language, + StartupBehavior, StreamFormat, Theme, VideoPlayer, @@ -58,6 +59,8 @@ const DEFAULT_SETTINGS = { streamFormat: StreamFormat.M3u8StreamFormat, language: Language.ENGLISH, showCaptions: false, + showDashboard: true, + startupBehavior: StartupBehavior.FirstView, showExternalPlaybackBar: true, theme: Theme.SystemTheme, mpvPlayerPath: '', @@ -405,6 +408,18 @@ describe('SettingsComponent', () => { expect(component.settingsForm.dirty).toBeTruthy(); }); + it('renders workspace startup controls with the expected defaults', () => { + const nativeElement = fixture.nativeElement as HTMLElement; + + expect( + nativeElement.querySelector('[data-test-id="toggle-show-dashboard"]') + ).not.toBeNull(); + expect(component.settingsForm.value.showDashboard).toBe(true); + expect(component.settingsForm.value.startupBehavior).toBe( + StartupBehavior.FirstView + ); + }); + it('should save settings on submit', async () => { const mockStore = settingsStore as unknown as MockSettingsStore; mockStore.updateSettings.mockResolvedValue(undefined); diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index b324a59ae..d532ead66 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -44,6 +44,7 @@ import { DataService, PlaylistsService } from 'services'; import { Language, Playlist, + StartupBehavior, StreamFormat, Theme, VideoPlayer, @@ -70,6 +71,11 @@ interface ThemeOption { labelKey: string; } +interface StartupBehaviorOption { + value: StartupBehavior; + labelKey: string; +} + @Component({ templateUrl: './settings.component.html', styleUrls: ['./settings.component.scss'], @@ -179,6 +185,17 @@ export class SettingsComponent implements OnInit, OnDestroy { }, ]; + readonly startupBehaviorOptions: StartupBehaviorOption[] = [ + { + value: StartupBehavior.FirstView, + labelKey: 'SETTINGS.STARTUP_BEHAVIOR_FIRST_VIEW', + }, + { + value: StartupBehavior.RestoreLastView, + labelKey: 'SETTINGS.STARTUP_BEHAVIOR_RESTORE_LAST_VIEW', + }, + ]; + /** Settings form object */ settingsForm = this.formBuilder.group({ player: [VideoPlayer.VideoJs], @@ -186,6 +203,8 @@ export class SettingsComponent implements OnInit, OnDestroy { streamFormat: StreamFormat.M3u8StreamFormat, language: Language.ENGLISH, showCaptions: false, + showDashboard: true, + startupBehavior: StartupBehavior.FirstView, showExternalPlaybackBar: true, theme: Theme.SystemTheme, mpvPlayerPath: '', diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index a9625b81e..c75b50f05 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -243,6 +243,8 @@ "SETTINGS_SAVED": "Einstellungen wurden erfolgreich gespeichert!", "THEME": "Farbdesign", "SHOW_CAPTIONS": "Untertitel anzeigen", + "SHOW_DASHBOARD": "Dashboard anzeigen", + "SHOW_DASHBOARD_DESCRIPTION": "Dashboard in der Workspace-Leiste verfügbar halten. Wenn deaktiviert, wird Quellen zur ersten Workspace-Seite.", "STREAM_FORMAT_DESCRIPTION": "Standard-Streamformat auswählen (Xtream)", "REMOVE_ALL": "Playlist entfernen", "REMOVE_ALL_BUTTON": "Entfernen", @@ -274,6 +276,10 @@ "VLC_PLAYER_PATH_LABEL": "VLC-Pfad", "VLC_PLAYER_PATH_DESCRIPTION": "Legen Sie den Pfad zum VLC-Player in Ihrem System fest. \nEs wird der bereitgestellte Pfad zu einer VLC-Binärdatei anstelle des Standardpfads für unser System verwendet.", "THEME_DESCRIPTION": "Wählen Sie das visuelle Thema der Anwendung aus", + "STARTUP_BEHAVIOR": "Startansicht", + "STARTUP_BEHAVIOR_DESCRIPTION": "Legen Sie fest, ob IPTVnator die erste verfügbare Workspace-Ansicht oder Ihre zuletzt geöffnete Abschnittsansicht startet. Erste Ansicht bedeutet Dashboard, wenn aktiviert, sonst Quellen.", + "STARTUP_BEHAVIOR_FIRST_VIEW": "Erste verfügbare Ansicht", + "STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Letzte Ansicht wiederherstellen", "EPG_NOTE": "Derzeit ist EPG nur in der Electron-basierten Version von IPTVnator verfügbar.", "VLC_PLAYER_PATH": "VLC-Playerpfad", "IMPORT_EXPORT_DATA_DESCRIPTION": "Exportieren oder importieren Sie Wiedergabelisten im JSON-Format, das in der Anwendung verwendet wird.", @@ -398,10 +404,18 @@ "COPY_STREAM_URL": "Stream-URL kopieren", "ERROR_VIEW": { "DELETE": "Löschen", + "IMPORT_CANCELLED": { + "TITLE": "Import abgebrochen", + "DESCRIPTION": "Der Playlist-Import wurde abgebrochen. Wiederholen Sie ihn, wenn Sie diese Quelle erneut laden möchten." + }, "ACCOUNT_EXPIRED": { "TITLE": "Konto abgelaufen", "DESCRIPTION": "Dieses Konto ist abgelaufen. \nBitte überprüfen Sie die Anmeldeinformationen in den Playlist-Einstellungen oder entfernen Sie sie." }, + "ACCOUNT_INACTIVE": { + "TITLE": "Konto inaktiv", + "DESCRIPTION": "Dieses Konto ist inaktiv. Bitte überprüfen Sie die Anmeldeinformationen in den Playlist-Einstellungen oder entfernen Sie es." + }, "HOME": "Startseite", "EMPTY_CATEGORY": { "TITLE": "Leere Kategorie", @@ -419,6 +433,10 @@ "TITLE": "Portal nicht gefunden", "DESCRIPTION": "Bitte überprüfen Sie die Anmeldeinformationen und die Portal-URL in den Playlist-Einstellungen oder entfernen Sie sie." }, + "PORTAL_UNAVAILABLE": { + "TITLE": "Portal nicht erreichbar", + "DESCRIPTION": "Das Portal konnte nicht erreicht werden. Überprüfen Sie die Server-URL oder versuchen Sie es erneut, wenn die Quelle wieder verfügbar ist." + }, "UNKNOWN_ERROR": { "TITLE": "Etwas ist schief gelaufen", "DESCRIPTION": "Unbekannter Fehler, bitte versuchen Sie es später noch einmal" @@ -590,6 +608,7 @@ "RAIL_DASHBOARD": "Dashboard", "OPEN_DASHBOARD": "Dashboard öffnen", "RAIL_SOURCES": "Quellen", + "OPEN_SOURCES": "Quellen öffnen", "RAIL_SETTINGS": "Einstellungen", "OPEN_SETTINGS": "Einstellungen öffnen", "RAIL_LIBRARY": "Bibliothek", @@ -620,6 +639,7 @@ "NO_SECTION_ACTIONS": "Keine Aktionen für diesen Abschnitt", "PLAYLIST_INFO": "Playlist-Info", "ACCOUNT_INFO": "Kontoinfo", + "REFRESH_PLAYLIST": "Playlist aktualisieren", "CLEAR_COMPLETED_DOWNLOADS_ALL": "Abgeschlossene Downloads löschen (alle Playlists)", "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Abgeschlossene Downloads für alle Playlists löschen", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Abgeschlossene Downloads löschen (diese Playlist)", @@ -632,12 +652,18 @@ "NO_SOURCES_AVAILABLE": "Keine Quellen verfügbar", "ONE_SOURCE_AVAILABLE": "1 Quelle verfügbar", "SOURCES_AVAILABLE": "{{count}} Quellen verfügbar", - "XTREAM_IMPORT_PREPARING": "Playlist-Daten werden vorbereitet...", - "XTREAM_IMPORT_SAVING": "Playlist-Inhalte werden gespeichert...", - "XTREAM_IMPORT_RESTORING_FAVORITES": "Favoriten werden wiederhergestellt...", - "XTREAM_IMPORT_RESTORING_RECENT": "Zuletzt angesehene Inhalte werden wiederhergestellt...", - "XTREAM_IMPORT_CANCEL": "Import abbrechen", - "XTREAM_IMPORT_CANCELLING": "Import wird abgebrochen...", + "XTREAM_IMPORT_TITLE": "Playlist wird synchronisiert", + "XTREAM_IMPORT_REMOTE_BADGE": "Remote-Quelle", + "XTREAM_IMPORT_LOCAL_BADGE": "Lokale Bibliothek", + "XTREAM_IMPORT_DETAIL_REMOTE": "Warte auf die Antwort des Providers.", + "XTREAM_IMPORT_DETAIL_LOCAL": "Bereitet schnellere Playlist-Wechsel für den nächsten Besuch vor.", + "XTREAM_IMPORT_PREPARING": "Lokale Bibliothek wird vorbereitet...", + "XTREAM_IMPORT_LOADING": "Playlist-Daten werden von der Quelle geladen...", + "XTREAM_IMPORT_SAVING": "Playlist-Daten werden lokal gespeichert...", + "XTREAM_IMPORT_RESTORING_FAVORITES": "Favoriten werden aus der lokalen Bibliothek wiederhergestellt...", + "XTREAM_IMPORT_RESTORING_RECENT": "Zuletzt angesehene Inhalte werden aus der lokalen Bibliothek wiederhergestellt...", + "XTREAM_IMPORT_CANCEL": "Synchronisierung stoppen", + "XTREAM_IMPORT_CANCELLING": "Synchronisierung wird gestoppt...", "COMMANDS": { "GLOBAL_SEARCH_LABEL": "Alle Xtream-Playlists durchsuchen", "GLOBAL_SEARCH_DESCRIPTION": "Globale Suchüberlagerung öffnen", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 197a73723..a15972c1e 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -200,6 +200,8 @@ "SETTINGS_SAVED": "Success! Configuration was saved.", "THEME": "Visual theme", "SHOW_CAPTIONS": "Show subtitles", + "SHOW_DASHBOARD": "Show dashboard", + "SHOW_DASHBOARD_DESCRIPTION": "Keep Dashboard available in the workspace rail. When disabled, Sources becomes the first workspace page.", "ADD_EPG_SOURCE": "Add EPG source", "CLEAR_EPG_DATA": "Clear EPG data", "CLEAR_EPG_DIALOG": { @@ -212,6 +214,10 @@ "STREAM_FORMAT_DESCRIPTION": "Select default stream format (xtream)", "LANGUAGE_DESCRIPTION": "Select language of the application", "THEME_DESCRIPTION": "Select visual theme of the application", + "STARTUP_BEHAVIOR": "Startup view", + "STARTUP_BEHAVIOR_DESCRIPTION": "Choose whether IPTVnator opens the first available workspace view or restores your last section-level view. First view means Dashboard when enabled, otherwise Sources.", + "STARTUP_BEHAVIOR_FIRST_VIEW": "First available view", + "STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Restore last view", "SHOW_CAPTIONS_DESCRIPTION": "Show or hide subtitles by default", "SHOW_EXTERNAL_PLAYBACK_BAR": "Show now playing footer", "SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Keep the external-player status bar visible at the bottom of the workspace on desktop.", @@ -469,10 +475,18 @@ "ACCOUNT_INFO": "Account info" }, "ERROR_VIEW": { + "IMPORT_CANCELLED": { + "TITLE": "Import cancelled", + "DESCRIPTION": "The playlist import was cancelled. Retry when you want to continue loading this source." + }, "ACCOUNT_EXPIRED": { "TITLE": "Account expired", "DESCRIPTION": "This account has been expired. Please check the credentials in playlist settings or remove it." }, + "ACCOUNT_INACTIVE": { + "TITLE": "Account inactive", + "DESCRIPTION": "This account is inactive. Please check the credentials in playlist settings or remove it." + }, "UNAUTHORIZED": { "TITLE": "Unauthorized", "DESCRIPTION": "Please check the credentials in playlist settings or remove it." @@ -481,6 +495,10 @@ "TITLE": "Portal not found", "DESCRIPTION": "Please check the credentials and portal url in playlist settings or remove it." }, + "PORTAL_UNAVAILABLE": { + "TITLE": "Portal unavailable", + "DESCRIPTION": "The portal could not be reached. Check the server URL or retry when the source is available again." + }, "EMPTY_CATEGORY": { "TITLE": "No content in this category", "DESCRIPTION": "Select another category or switch the content type." @@ -661,6 +679,7 @@ "RAIL_DASHBOARD": "Dashboard", "OPEN_DASHBOARD": "Open dashboard", "RAIL_SOURCES": "Sources", + "OPEN_SOURCES": "Open sources", "RAIL_SETTINGS": "Settings", "OPEN_SETTINGS": "Open settings", "RAIL_LIBRARY": "Library", @@ -691,6 +710,7 @@ "NO_SECTION_ACTIONS": "No actions for this section", "PLAYLIST_INFO": "Playlist info", "ACCOUNT_INFO": "Account info", + "REFRESH_PLAYLIST": "Refresh playlist", "CLEAR_COMPLETED_DOWNLOADS_ALL": "Clear completed downloads (all playlists)", "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Clear completed downloads for all playlists", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Clear completed downloads (this playlist)", @@ -703,12 +723,18 @@ "NO_SOURCES_AVAILABLE": "No sources available", "ONE_SOURCE_AVAILABLE": "1 source available", "SOURCES_AVAILABLE": "{{count}} sources available", - "XTREAM_IMPORT_PREPARING": "Preparing playlist data...", - "XTREAM_IMPORT_SAVING": "Saving playlist content...", - "XTREAM_IMPORT_RESTORING_FAVORITES": "Restoring favorites...", - "XTREAM_IMPORT_RESTORING_RECENT": "Restoring recently viewed...", - "XTREAM_IMPORT_CANCEL": "Cancel import", - "XTREAM_IMPORT_CANCELLING": "Cancelling import...", + "XTREAM_IMPORT_TITLE": "Syncing playlist", + "XTREAM_IMPORT_REMOTE_BADGE": "Remote source", + "XTREAM_IMPORT_LOCAL_BADGE": "Local library", + "XTREAM_IMPORT_DETAIL_REMOTE": "Waiting on the provider response.", + "XTREAM_IMPORT_DETAIL_LOCAL": "Preparing faster playlist switching for the next visit.", + "XTREAM_IMPORT_PREPARING": "Preparing local library...", + "XTREAM_IMPORT_LOADING": "Fetching playlist data from source...", + "XTREAM_IMPORT_SAVING": "Saving playlist data locally...", + "XTREAM_IMPORT_RESTORING_FAVORITES": "Restoring favorites from local library...", + "XTREAM_IMPORT_RESTORING_RECENT": "Restoring recently viewed from local library...", + "XTREAM_IMPORT_CANCEL": "Stop sync", + "XTREAM_IMPORT_CANCELLING": "Stopping sync...", "COMMANDS": { "GLOBAL_SEARCH_LABEL": "Search all Xtream playlists", "GLOBAL_SEARCH_DESCRIPTION": "Open global search overlay", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index fad1fccec..158ecc0a6 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -181,6 +181,8 @@ "SETTINGS_SAVED": "Новые настройки были успешно сохранены!", "THEME": "Тема оформления", "SHOW_CAPTIONS": "Отображать субтитры", + "SHOW_DASHBOARD": "Показывать панель", + "SHOW_DASHBOARD_DESCRIPTION": "Оставить Dashboard доступным в боковой панели рабочего пространства. Если отключить, первой страницей будет Источники.", "ADD_EPG_SOURCE": "Добавить источник EPG", "CLEAR_EPG_DATA": "Очистить данные EPG", "CLEAR_EPG_DIALOG": { @@ -193,6 +195,10 @@ "STREAM_FORMAT_DESCRIPTION": "Выбрать формат потока по умолчанию (Xtream)", "LANGUAGE_DESCRIPTION": "Выбрать язык приложения", "THEME_DESCRIPTION": "Выбрать визуальную тему оформления", + "STARTUP_BEHAVIOR": "Начальный экран", + "STARTUP_BEHAVIOR_DESCRIPTION": "Выберите, должен ли IPTVnator открывать первый доступный экран рабочего пространства или восстанавливать последний экран уровня раздела. Первый экран означает Dashboard, если он включён, иначе Источники.", + "STARTUP_BEHAVIOR_FIRST_VIEW": "Первый доступный экран", + "STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Восстановить последний экран", "SHOW_CAPTIONS_DESCRIPTION": "Показать или скрыть субтитры по умолчанию.", "VERSION_DESCRIPTION": "Текущая версия приложения", "EPG_NOTE": "На данный момент EPG доступен только в нативной версии IPTVnator.", @@ -393,10 +399,18 @@ "COPY_STREAM_URL": "Копировать URL-адрес потока", "ERROR_VIEW": { "DELETE": "Удалить", + "IMPORT_CANCELLED": { + "TITLE": "Импорт отменён", + "DESCRIPTION": "Импорт плейлиста был отменён. Повторите попытку, когда захотите снова загрузить этот источник." + }, "ACCOUNT_EXPIRED": { "TITLE": "Срок действия учетной записи истек", "DESCRIPTION": "Срок действия этой учетной записи истек. \nПожалуйста, проверьте учетные данные в настройках плейлиста или удалите его." }, + "ACCOUNT_INACTIVE": { + "TITLE": "Учётная запись неактивна", + "DESCRIPTION": "Эта учётная запись неактивна. Проверьте учётные данные в настройках плейлиста или удалите источник." + }, "HOME": "На главную", "EMPTY_CATEGORY": { "TITLE": "Пустая категория", @@ -410,6 +424,10 @@ "TITLE": "Портал не найден", "DESCRIPTION": "Пожалуйста, проверьте учетные данные и URL портала в настройках плейлиста или удалите его." }, + "PORTAL_UNAVAILABLE": { + "TITLE": "Портал недоступен", + "DESCRIPTION": "Не удалось подключиться к порталу. Проверьте URL сервера или повторите попытку позже." + }, "NO_CATEGORY_SELECTED": { "TITLE": "Категория не выбрана", "DESCRIPTION": "Пожалуйста, выберите категорию для просмотра содержимого" @@ -567,6 +585,7 @@ "RAIL_DASHBOARD": "Панель", "OPEN_DASHBOARD": "Открыть панель", "RAIL_SOURCES": "Источники", + "OPEN_SOURCES": "Открыть источники", "RAIL_SETTINGS": "Настройки", "OPEN_SETTINGS": "Открыть настройки", "RAIL_LIBRARY": "Библиотека", @@ -597,6 +616,7 @@ "NO_SECTION_ACTIONS": "Для этого раздела нет действий", "PLAYLIST_INFO": "Информация о плейлисте", "ACCOUNT_INFO": "Информация об аккаунте", + "REFRESH_PLAYLIST": "Обновить плейлист", "CLEAR_COMPLETED_DOWNLOADS_ALL": "Очистить завершенные загрузки (все плейлисты)", "CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Очистить завершенные загрузки для всех плейлистов", "CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Очистить завершенные загрузки (этот плейлист)", @@ -609,12 +629,18 @@ "NO_SOURCES_AVAILABLE": "Нет доступных источников", "ONE_SOURCE_AVAILABLE": "Доступен 1 источник", "SOURCES_AVAILABLE": "Доступно источников: {{count}}", - "XTREAM_IMPORT_PREPARING": "Подготовка данных плейлиста...", - "XTREAM_IMPORT_SAVING": "Сохранение контента плейлиста...", - "XTREAM_IMPORT_RESTORING_FAVORITES": "Восстановление избранного...", - "XTREAM_IMPORT_RESTORING_RECENT": "Восстановление недавно просмотренного...", - "XTREAM_IMPORT_CANCEL": "Отменить импорт", - "XTREAM_IMPORT_CANCELLING": "Отмена импорта...", + "XTREAM_IMPORT_TITLE": "Синхронизация плейлиста", + "XTREAM_IMPORT_REMOTE_BADGE": "Удалённый источник", + "XTREAM_IMPORT_LOCAL_BADGE": "Локальная библиотека", + "XTREAM_IMPORT_DETAIL_REMOTE": "Ожидание ответа от провайдера.", + "XTREAM_IMPORT_DETAIL_LOCAL": "Подготавливает более быстрые переключения плейлиста при следующем открытии.", + "XTREAM_IMPORT_PREPARING": "Подготовка локальной библиотеки...", + "XTREAM_IMPORT_LOADING": "Получение данных плейлиста из источника...", + "XTREAM_IMPORT_SAVING": "Сохранение данных плейлиста локально...", + "XTREAM_IMPORT_RESTORING_FAVORITES": "Восстановление избранного из локальной библиотеки...", + "XTREAM_IMPORT_RESTORING_RECENT": "Восстановление недавно просмотренного из локальной библиотеки...", + "XTREAM_IMPORT_CANCEL": "Остановить синхронизацию", + "XTREAM_IMPORT_CANCELLING": "Остановка синхронизации...", "COMMANDS": { "GLOBAL_SEARCH_LABEL": "Искать во всех плейлистах Xtream", "GLOBAL_SEARCH_DESCRIPTION": "Открыть глобальный поиск", diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index b2320aa59..08c718915 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -11,6 +11,7 @@ import { firstValueFrom } from 'rxjs'; import { Language, Settings, + StartupBehavior, STORE_KEY, StreamFormat, Theme, @@ -22,6 +23,8 @@ const DEFAULT_SETTINGS: Settings = { streamFormat: StreamFormat.M3u8StreamFormat, language: Language.ENGLISH, showCaptions: false, + showDashboard: true, + startupBehavior: StartupBehavior.FirstView, showExternalPlaybackBar: true, theme: Theme.SystemTheme, mpvPlayerPath: '', @@ -74,6 +77,8 @@ export const SettingsStore = signalStore( streamFormat: store.streamFormat(), language: store.language(), showCaptions: store.showCaptions(), + showDashboard: store.showDashboard(), + startupBehavior: store.startupBehavior(), showExternalPlaybackBar: store.showExternalPlaybackBar(), theme: store.theme(), mpvPlayerPath: store.mpvPlayerPath(), diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index 6078de6bd..78a74fcab 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -13,6 +13,11 @@ export enum VideoPlayer { ArtPlayer = 'artplayer', } +export enum StartupBehavior { + FirstView = 'first-view', + RestoreLastView = 'restore-last-view', +} + /** * Describes all available settings options of the application */ @@ -22,6 +27,8 @@ export interface Settings { streamFormat: StreamFormat; language: Language; showCaptions: boolean; + showDashboard: boolean; + startupBehavior: StartupBehavior; /** Show the desktop footer bar for external playback status */ showExternalPlaybackBar?: boolean; theme: Theme; From 4e0afe8b209f8abe2445c1d92a7979f411e4f991 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Apr 2026 13:39:45 +0200 Subject: [PATCH 05/15] feat: implement playlist refresh functionality and cache management --- apps/electron-backend/build-worker.js | 15 ++ .../src/app/api/main.preload.ts | 26 ++- .../database/operations/content.operations.ts | 45 +++++ .../operations/playlist.operations.spec.ts | 36 ++++ .../operations/playlist.operations.ts | 68 +++---- .../src/app/events/database/content.events.ts | 8 + .../src/app/events/playlist.events.ts | 155 ++++++++++++++- .../src/app/events/xtream.events.spec.ts | 168 ++++++++++++++++ .../src/app/events/xtream.events.ts | 58 +++++- .../src/app/workers/database-worker.types.ts | 1 + .../src/app/workers/database.worker.ts | 10 + .../app/workers/playlist-refresh.worker.ts | 188 ++++++++++++++++++ .../workers/playlist-refresh.worker.types.ts | 40 ++++ apps/web/src/app/services/electron.service.ts | 9 +- apps/web/src/app/services/pwa.service.ts | 12 +- apps/web/src/typings.d.ts | 19 ++ 16 files changed, 813 insertions(+), 45 deletions(-) create mode 100644 apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts create mode 100644 apps/electron-backend/src/app/events/xtream.events.spec.ts create mode 100644 apps/electron-backend/src/app/workers/playlist-refresh.worker.ts create mode 100644 apps/electron-backend/src/app/workers/playlist-refresh.worker.types.ts diff --git a/apps/electron-backend/build-worker.js b/apps/electron-backend/build-worker.js index c35b03aec..7d7d7c48f 100644 --- a/apps/electron-backend/build-worker.js +++ b/apps/electron-backend/build-worker.js @@ -44,6 +44,17 @@ async function buildWorker() { '../../dist/apps/electron-backend/workers/database.worker.js' ), }, + { + label: 'playlist refresh worker', + entry: path.join( + __dirname, + 'src/app/workers/playlist-refresh.worker.ts' + ), + outfile: path.join( + __dirname, + '../../dist/apps/electron-backend/workers/playlist-refresh.worker.js' + ), + }, ]; for (const worker of workers) { @@ -70,6 +81,10 @@ async function buildWorker() { __dirname, '../../libs/shared/interfaces/src/index.ts' ), + 'm3u-utils': path.join( + __dirname, + '../../libs/shared/m3u-utils/src/index.ts' + ), 'database': path.join( __dirname, '../../libs/shared/database/src/index.ts' diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index a552a2606..d99aed546 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -1,9 +1,14 @@ import { contextBridge, ipcRenderer } from 'electron'; -import type { ExternalPlayerSession } from 'shared-interfaces'; +import type { + ExternalPlayerSession, + PlaylistRefreshEvent, + PlaylistRefreshPayload, +} from 'shared-interfaces'; const PORTAL_DEBUG_EVENT = 'PORTAL_DEBUG_EVENT'; const EXTERNAL_PLAYER_SESSION_UPDATE = 'EXTERNAL_PLAYER_SESSION_UPDATE'; const DB_OPERATION_EVENT = 'DB_OPERATION_EVENT'; +const PLAYLIST_REFRESH_EVENT = 'PLAYLIST:REFRESH_EVENT'; type PortalDebugEvent = { requestId: string; @@ -112,6 +117,14 @@ contextBridge.exposeInMainWorld('electron', { ipcRenderer.on(DB_OPERATION_EVENT, handler); return () => ipcRenderer.off(DB_OPERATION_EVENT, handler); }, + onPlaylistRefreshEvent: ( + callback: (data: PlaylistRefreshEvent) => void + ) => { + const handler = (_event: Electron.IpcRendererEvent, data: any) => + callback(data as PlaylistRefreshEvent); + ipcRenderer.on(PLAYLIST_REFRESH_EVENT, handler); + return () => ipcRenderer.off(PLAYLIST_REFRESH_EVENT, handler); + }, // DB save content progress listener onDbSaveContentProgress: (callback: (count: number) => void) => { const handler = ( @@ -235,8 +248,15 @@ contextBridge.exposeInMainWorld('electron', { url: string; params: Record; requestId?: string; + sessionId?: string; suppressErrorLog?: boolean; }) => ipcRenderer.invoke('XTREAM_REQUEST', payload), + xtreamCancelSession: (sessionId: string) => + ipcRenderer.invoke('XTREAM_CANCEL_SESSION', sessionId), + refreshPlaylist: (payload: PlaylistRefreshPayload) => + ipcRenderer.invoke('PLAYLIST:REFRESH', payload), + cancelPlaylistRefresh: (operationId: string) => + ipcRenderer.invoke('PLAYLIST:CANCEL_REFRESH', operationId), // Database operations dbCreatePlaylist: (playlist: any) => ipcRenderer.invoke('DB_CREATE_PLAYLIST', playlist), @@ -314,6 +334,10 @@ contextBridge.exposeInMainWorld('electron', { type, operationId ), + dbClearXtreamImportCache: ( + playlistId: string, + type: 'live' | 'movie' | 'series' + ) => ipcRenderer.invoke('DB_CLEAR_XTREAM_IMPORT_CACHE', playlistId, type), dbSearchContent: ( playlistId: string, searchTerm: string, diff --git a/apps/electron-backend/src/app/database/operations/content.operations.ts b/apps/electron-backend/src/app/database/operations/content.operations.ts index e513f938f..efc8e59dc 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -3,6 +3,7 @@ import * as schema from 'database-schema'; import type { AppDatabase } from '../database.types'; import { checkpointOperation, + chunkValues, type OperationControl, reportOperationProgress, } from './operation-control'; @@ -292,6 +293,50 @@ export async function saveContent( return { success: true, count: totalInserted }; } +export async function clearXtreamImportCache( + db: AppDatabase, + playlistId: string, + type: 'live' | 'movie' | 'series' +): Promise<{ success: boolean }> { + const dbType = + type === 'series' ? 'series' : type === 'movie' ? 'movies' : 'live'; + + const categoryRows = await db + .select({ id: schema.categories.id }) + .from(schema.categories) + .where( + and( + eq(schema.categories.playlistId, playlistId), + eq(schema.categories.type, dbType) + ) + ); + + const categoryIds = categoryRows.map((category) => category.id); + if (categoryIds.length === 0) { + return { success: true }; + } + + const contentRows = await db + .select({ id: schema.content.id }) + .from(schema.content) + .where(inArray(schema.content.categoryId, categoryIds)); + + for (const chunk of chunkValues( + contentRows.map((row) => row.id), + 100 + )) { + await db.delete(schema.content).where(inArray(schema.content.id, chunk)); + } + + for (const chunk of chunkValues(categoryIds, 100)) { + await db + .delete(schema.categories) + .where(inArray(schema.categories.id, chunk)); + } + + return { success: true }; +} + export async function getContentByXtreamId( db: AppDatabase, xtreamId: number, diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts new file mode 100644 index 000000000..6b3416117 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts @@ -0,0 +1,36 @@ +import { parseAppPlaylist } from './playlist.operations'; + +describe('playlist.operations', () => { + it('hydrates updateDate from lastUpdated when payload is stale', async () => { + const parsed = parseAppPlaylist({ + id: 'playlist-1', + name: 'Refresh Xtream Source', + serverUrl: 'http://localhost:8080', + username: 'demo', + password: 'secret', + dateCreated: '2026-04-03T08:00:00.000Z', + lastUpdated: '2026-04-03T11:15:00.000Z', + type: 'xtream', + autoRefresh: false, + count: 0, + importDate: '2026-04-03T08:00:00.000Z', + payload: JSON.stringify({ + _id: 'playlist-1', + title: 'Refresh Xtream Source', + count: 0, + importDate: '2026-04-03T08:00:00.000Z', + autoRefresh: false, + serverUrl: 'http://localhost:8080', + username: 'demo', + password: 'secret', + }), + } as any); + + expect(parsed).toEqual( + expect.objectContaining({ + _id: 'playlist-1', + updateDate: new Date('2026-04-03T11:15:00.000Z').getTime(), + }) + ); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.ts index 96e66b627..bd9e4cdef 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.ts @@ -139,26 +139,12 @@ function buildPlaylistRow( }; } -function parseAppPlaylist(row: schema.Playlist): Record { +export function parseAppPlaylist(row: schema.Playlist): Record { const payload = parseJsonValue | null>( row.payload, null ); - - if (payload && typeof payload === 'object') { - return { - ...payload, - _id: - getStringValue(payload._id) ?? - getStringValue(payload.id) ?? - row.id, - title: - getStringValue(payload.title) ?? - getStringValue(payload.name) ?? - row.name, - }; - } - + const base = payload && typeof payload === 'object' ? payload : {}; const favorites = parseJsonValue(row.favorites, []); const recentlyViewed = parseJsonValue(row.recentlyViewed, []); const importDate = @@ -166,30 +152,42 @@ function parseAppPlaylist(row: schema.Playlist): Record { const portalUrl = row.portalUrl ?? (row.type === PLAYLIST_TYPES.STALKER ? row.url : null); + const updateDate = + row.updateDate ?? + (row.lastUpdated ? new Date(row.lastUpdated).getTime() : undefined); return { + ...base, _id: row.id, - title: row.name, - count: row.count ?? 0, - importDate, - lastUsage: row.lastUsage ?? importDate, + title: + getStringValue(base.title) ?? + getStringValue(base.name) ?? + row.name, + count: row.count ?? getNumericValue(base.count) ?? 0, + importDate: getStringValue(base.importDate) ?? importDate, + lastUsage: + row.lastUsage ?? + getStringValue(base.lastUsage) ?? + getStringValue(base.importDate) ?? + importDate, favorites, recentlyViewed, - autoRefresh: row.autoRefresh ?? false, - url: row.type === PLAYLIST_TYPES.M3U_URL ? row.url : undefined, - filePath: row.filePath ?? undefined, - userAgent: row.userAgent ?? undefined, - referrer: row.referrer ?? undefined, - origin: row.origin ?? undefined, - updateDate: - row.updateDate ?? - (row.lastUpdated ? new Date(row.lastUpdated).getTime() : undefined), - position: row.position ?? undefined, - serverUrl: row.serverUrl ?? undefined, - username: row.username ?? undefined, - password: row.password ?? undefined, - macAddress: row.macAddress ?? undefined, - portalUrl: portalUrl ?? undefined, + autoRefresh: row.autoRefresh ?? Boolean(base.autoRefresh), + url: + row.type === PLAYLIST_TYPES.M3U_URL + ? row.url ?? getStringValue(base.url) + : getStringValue(base.url), + filePath: row.filePath ?? getStringValue(base.filePath), + userAgent: row.userAgent ?? getStringValue(base.userAgent), + referrer: row.referrer ?? getStringValue(base.referrer), + origin: row.origin ?? getStringValue(base.origin), + updateDate, + position: row.position ?? getNumericValue(base.position), + serverUrl: row.serverUrl ?? getStringValue(base.serverUrl), + username: row.username ?? getStringValue(base.username), + password: row.password ?? getStringValue(base.password), + macAddress: row.macAddress ?? getStringValue(base.macAddress), + portalUrl: portalUrl ?? getStringValue(base.portalUrl), }; } diff --git a/apps/electron-backend/src/app/events/database/content.events.ts b/apps/electron-backend/src/app/events/database/content.events.ts index 7e0a2bc56..655c8b65d 100644 --- a/apps/electron-backend/src/app/events/database/content.events.ts +++ b/apps/electron-backend/src/app/events/database/content.events.ts @@ -60,6 +60,14 @@ ipcMain.handle( } ); +handleWorkerRequest( + 'DB_CLEAR_XTREAM_IMPORT_CACHE', + (playlistId: string, type: 'live' | 'movie' | 'series') => ({ + playlistId, + type, + }) +); + handleWorkerRequest( 'DB_GET_CONTENT_BY_XTREAM_ID', (xtreamId: number, playlistId: string) => ({ diff --git a/apps/electron-backend/src/app/events/playlist.events.ts b/apps/electron-backend/src/app/events/playlist.events.ts index 171ef5a84..c0035dfaf 100644 --- a/apps/electron-backend/src/app/events/playlist.events.ts +++ b/apps/electron-backend/src/app/events/playlist.events.ts @@ -4,12 +4,27 @@ */ import axios from 'axios'; -import { dialog, ipcMain } from 'electron'; +import { app, dialog, ipcMain, WebContents } from 'electron'; import { parse } from 'iptv-playlist-parser'; import { createPlaylistObject, getFilenameFromUrl } from 'm3u-utils'; import { readFile, writeFile } from 'node:fs/promises'; import { basename } from 'node:path'; -import { AUTO_UPDATE_PLAYLISTS } from 'shared-interfaces'; +import { pathToFileURL } from 'url'; +import { Worker } from 'worker_threads'; +import { + AUTO_UPDATE_PLAYLISTS, + PLAYLIST_CANCEL_REFRESH, + PLAYLIST_REFRESH, + PLAYLIST_REFRESH_EVENT, + Playlist, + PlaylistRefreshEvent, + PlaylistRefreshPayload, +} from 'shared-interfaces'; +import { resolveWorkerRuntimeBootstrap } from '../workers/worker-runtime-paths'; +import type { + PlaylistRefreshWorkerMessage, + PlaylistRefreshWorkerResponseMessage, +} from '../workers/playlist-refresh.worker.types'; export default class PlaylistEvents { static bootstrapPlaylistEvents(): Electron.IpcMain { @@ -19,6 +34,15 @@ export default class PlaylistEvents { const https = require('https'); +type ActivePlaylistRefresh = { + reject: (reason?: unknown) => void; + resolve: (value: Playlist) => void; + sender: WebContents; + worker: Worker; +}; + +const activePlaylistRefreshes = new Map(); + /** * Fetches and parses a playlist from a URL * @param url - The URL to fetch the playlist from @@ -73,6 +97,46 @@ async function fetchPlaylistFromFile( return playlistObject; } +function resolvePlaylistRefreshWorker(): Worker { + const bootstrap = resolveWorkerRuntimeBootstrap({ + isPackaged: app.isPackaged, + workerFilename: 'playlist-refresh.worker.js', + developmentWorkerDir: __dirname + '/workers', + resourcesPath: ( + process as NodeJS.Process & { resourcesPath?: string } + ).resourcesPath, + appPath: app.getAppPath(), + }); + + return new Worker(pathToFileURL(bootstrap.workerPath), { + workerData: { + nativeModuleSearchPaths: bootstrap.nativeModuleSearchPaths, + }, + }); +} + +function emitPlaylistRefreshEvent( + sender: WebContents, + event: PlaylistRefreshEvent +): void { + if (sender.isDestroyed()) { + return; + } + + sender.send(PLAYLIST_REFRESH_EVENT, event); +} + +function createPlaylistRefreshError(error: { + message: string; + name?: string; + stack?: string; +}): Error { + const workerError = new Error(error.message); + workerError.name = error.name || 'PlaylistRefreshWorkerError'; + workerError.stack = error.stack || workerError.stack; + return workerError; +} + ipcMain.handle('fetch-playlist-by-url', async (event, url, title?: string) => { try { return await fetchPlaylistFromUrl(url, title); @@ -179,6 +243,93 @@ ipcMain.handle(AUTO_UPDATE_PLAYLISTS, async (event, playlists) => { return updatedPlaylists; }); +ipcMain.handle(PLAYLIST_REFRESH, async (event, payload: PlaylistRefreshPayload) => { + const worker = resolvePlaylistRefreshWorker(); + + return await new Promise((resolve, reject) => { + const cleanup = async (): Promise => { + activePlaylistRefreshes.delete(payload.operationId); + worker.removeAllListeners(); + await worker.terminate().catch(() => undefined); + }; + + activePlaylistRefreshes.set(payload.operationId, { + worker, + sender: event.sender, + resolve, + reject, + }); + + worker.on('message', async (message: PlaylistRefreshWorkerMessage) => { + if (message.type === 'ready') { + worker.postMessage({ + type: 'request', + payload, + }); + return; + } + + if (message.type === 'event') { + emitPlaylistRefreshEvent(event.sender, message.event); + return; + } + + await cleanup(); + + const response = message as PlaylistRefreshWorkerResponseMessage; + if (response.success && response.result) { + resolve(response.result); + return; + } + + reject( + createPlaylistRefreshError( + response.error ?? { + message: 'Playlist refresh worker request failed', + } + ) + ); + }); + + worker.on('error', async (error) => { + await cleanup(); + reject(error); + }); + + worker.on('exit', async (code) => { + if (!activePlaylistRefreshes.has(payload.operationId)) { + return; + } + + await cleanup(); + reject( + new Error( + code === 0 + ? 'Playlist refresh worker exited unexpectedly' + : `Playlist refresh worker stopped with exit code ${code}` + ) + ); + }); + }); +}); + +ipcMain.handle( + PLAYLIST_CANCEL_REFRESH, + async (_event, operationId: string): Promise<{ success: boolean }> => { + const activeRefresh = activePlaylistRefreshes.get(operationId); + if (!activeRefresh) { + return { success: false }; + } + + activeRefresh.worker.postMessage({ + type: 'cancel', + operationId, + }); + + return { success: true }; + } +); + ipcMain.handle('save-file-dialog', async (event, defaultPath, filters) => { try { const { canceled, filePath } = await dialog.showSaveDialog({ diff --git a/apps/electron-backend/src/app/events/xtream.events.spec.ts b/apps/electron-backend/src/app/events/xtream.events.spec.ts new file mode 100644 index 000000000..743338f48 --- /dev/null +++ b/apps/electron-backend/src/app/events/xtream.events.spec.ts @@ -0,0 +1,168 @@ +import { XTREAM_CANCEL_SESSION } from 'shared-interfaces'; + +const registeredHandlers = new Map unknown>(); +const axiosMock = Object.assign(jest.fn(), { + isAxiosError: jest.fn(), +}); + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} + +jest.mock('electron', () => ({ + ipcMain: { + handle: jest.fn((channel: string, handler: (...args: unknown[]) => unknown) => { + registeredHandlers.set(channel, handler); + }), + }, +})); + +jest.mock('axios', () => ({ + __esModule: true, + default: axiosMock, +})); + +jest.mock('./portal-debug.events', () => ({ + emitPortalDebugEvent: jest.fn(), +})); + +describe('XtreamEvents session cancellation', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(async () => { + jest.resetModules(); + registeredHandlers.clear(); + axiosMock.mockReset(); + axiosMock.isAxiosError.mockReset(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + await import('./xtream.events'); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('aborts requests that were registered with only a session id', async () => { + const requestHandler = registeredHandlers.get('XTREAM_REQUEST'); + const cancelHandler = registeredHandlers.get(XTREAM_CANCEL_SESSION); + const pendingRequest = createDeferred<{ status: number; data: unknown }>(); + const cancelError = Object.assign(new Error('cancelled'), { + code: 'ERR_CANCELED', + }); + let abortSignal: AbortSignal | undefined; + + expect(requestHandler).toBeDefined(); + expect(cancelHandler).toBeDefined(); + + axiosMock.mockImplementation((config: { signal?: AbortSignal }) => { + abortSignal = config.signal; + return pendingRequest.promise; + }); + axiosMock.isAxiosError.mockImplementation( + (error: unknown) => error === cancelError + ); + + const requestPromise = requestHandler?.({}, { + url: 'http://localhost:3211', + params: { + action: 'get_live_categories', + password: 'secret', + username: 'user1', + }, + sessionId: 'session-1', + suppressErrorLog: true, + }) as Promise; + + expect(abortSignal?.aborted).toBe(false); + + const cancelResult = (await cancelHandler?.( + {}, + 'session-1' + )) as { success: boolean; cancelled: number }; + + expect(cancelResult).toEqual({ success: true, cancelled: 1 }); + expect(abortSignal?.aborted).toBe(true); + + pendingRequest.reject(cancelError); + + await expect(requestPromise).rejects.toMatchObject({ + name: 'AbortError', + status: 499, + }); + }); + + it('counts every matching in-flight request for the same session', async () => { + const requestHandler = registeredHandlers.get('XTREAM_REQUEST'); + const cancelHandler = registeredHandlers.get(XTREAM_CANCEL_SESSION); + const firstRequest = createDeferred<{ status: number; data: unknown }>(); + const secondRequest = createDeferred<{ status: number; data: unknown }>(); + const cancelError = Object.assign(new Error('cancelled'), { + code: 'ERR_CANCELED', + }); + const abortSignals: AbortSignal[] = []; + + expect(requestHandler).toBeDefined(); + expect(cancelHandler).toBeDefined(); + + axiosMock + .mockImplementationOnce((config: { signal?: AbortSignal }) => { + if (config.signal) { + abortSignals.push(config.signal); + } + return firstRequest.promise; + }) + .mockImplementationOnce((config: { signal?: AbortSignal }) => { + if (config.signal) { + abortSignals.push(config.signal); + } + return secondRequest.promise; + }); + axiosMock.isAxiosError.mockImplementation( + (error: unknown) => error === cancelError + ); + + const firstPromise = requestHandler?.({}, { + url: 'http://localhost:3211', + params: { + action: 'get_live_categories', + password: 'secret', + username: 'user1', + }, + sessionId: 'session-2', + suppressErrorLog: true, + }) as Promise; + const secondPromise = requestHandler?.({}, { + url: 'http://localhost:3211', + params: { + action: 'get_vod_streams', + password: 'secret', + username: 'user1', + }, + sessionId: 'session-2', + suppressErrorLog: true, + }) as Promise; + + const cancelResult = (await cancelHandler?.( + {}, + 'session-2' + )) as { success: boolean; cancelled: number }; + + expect(cancelResult).toEqual({ success: true, cancelled: 2 }); + expect(abortSignals).toHaveLength(2); + expect(abortSignals.every((signal) => signal.aborted)).toBe(true); + + firstRequest.reject(cancelError); + secondRequest.reject(cancelError); + + await expect(firstPromise).rejects.toMatchObject({ name: 'AbortError' }); + await expect(secondPromise).rejects.toMatchObject({ name: 'AbortError' }); + }); +}); diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index 9bf4026d5..73f21485f 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -5,7 +5,7 @@ import axios, { AxiosRequestConfig } from 'axios'; import { ipcMain } from 'electron'; -import { PortalDebugEvent } from 'shared-interfaces'; +import { PortalDebugEvent, XTREAM_CANCEL_SESSION } from 'shared-interfaces'; import { emitPortalDebugEvent } from './portal-debug.events'; export default class XtreamEvents { @@ -62,12 +62,14 @@ ipcMain.handle( url: string; params: Record; requestId?: string; + sessionId?: string; suppressErrorLog?: boolean; } ) => { const startedAt = Date.now(); + let activeRequestKey: string | null = null; try { - const { url, params, requestId } = payload; + const { url, params, requestId, sessionId } = payload; // Build URL with query parameters // Xtream API endpoint is always at /player_api.php @@ -76,6 +78,15 @@ ipcMain.handle( apiUrl.searchParams.append(key, value); }); + const controller = new AbortController(); + if (requestId || sessionId) { + activeRequestKey = requestId ?? crypto.randomUUID(); + activeXtreamRequests.set(activeRequestKey, { + controller, + sessionId, + }); + } + // Configure axios request const config: AxiosRequestConfig = { method: 'GET', @@ -87,6 +98,7 @@ ipcMain.handle( }, timeout: 30000, // 30 seconds timeout for Xtream API validateStatus: (status) => status < 500, // Don't throw on 4xx errors + signal: controller.signal, }; const response = await axios(config); @@ -166,6 +178,14 @@ ipcMain.handle( // Format error response if (axios.isAxiosError(error)) { + if (error.code === 'ERR_CANCELED') { + throw { + type: 'ERROR', + name: 'AbortError', + message: 'Xtream request cancelled', + status: 499, + }; + } const errorResponse = { type: 'ERROR', message: @@ -188,6 +208,40 @@ ipcMain.handle( status: 500, }; } + } finally { + if (activeRequestKey) { + activeXtreamRequests.delete(activeRequestKey); + } } } ); + +ipcMain.handle( + XTREAM_CANCEL_SESSION, + async (_event, sessionId: string): Promise<{ success: boolean; cancelled: number }> => { + if (!sessionId) { + return { success: false, cancelled: 0 }; + } + + let cancelled = 0; + for (const activeRequest of activeXtreamRequests.values()) { + if (activeRequest.sessionId !== sessionId) { + continue; + } + + activeRequest.controller.abort(); + cancelled += 1; + } + + return { + success: cancelled > 0, + cancelled, + }; + } +); +type ActiveXtreamRequest = { + controller: AbortController; + sessionId?: string; +}; + +const activeXtreamRequests = new Map(); diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index 797a4bd6c..5967e5495 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -8,6 +8,7 @@ export const DB_WORKER_OPERATIONS = [ 'DB_GET_CONTENT', 'DB_GET_GLOBAL_RECENTLY_ADDED', 'DB_SAVE_CONTENT', + 'DB_CLEAR_XTREAM_IMPORT_CACHE', 'DB_GET_CONTENT_BY_XTREAM_ID', 'DB_SEARCH_CONTENT', 'DB_GLOBAL_SEARCH', diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index e8e48a051..98240ea68 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -30,6 +30,7 @@ import { reorderGlobalFavorites, } from '../database/operations/favorites.operations'; import { + clearXtreamImportCache, getContent, getContentByXtreamId, getGlobalRecentlyAdded, @@ -387,6 +388,15 @@ async function executeRequest(message: DbWorkerRequestMessage) { ); } + case 'DB_CLEAR_XTREAM_IMPORT_CACHE': { + const payload = message.payload as { + playlistId: string; + type: 'live' | 'movie' | 'series'; + }; + + return clearXtreamImportCache(db, payload.playlistId, payload.type); + } + case 'DB_GET_CONTENT_BY_XTREAM_ID': { const payload = message.payload as { xtreamId: number; diff --git a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts new file mode 100644 index 000000000..959ce6bed --- /dev/null +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts @@ -0,0 +1,188 @@ +import axios from 'axios'; +import { parentPort } from 'worker_threads'; +import { parse } from 'iptv-playlist-parser'; +import { readFile } from 'node:fs/promises'; +import { createPlaylistObject, getFilenameFromUrl } from 'm3u-utils'; +import type { + Playlist, + PlaylistRefreshEvent, + PlaylistRefreshPayload, +} from 'shared-interfaces'; +import type { + PlaylistRefreshWorkerIncomingMessage, + PlaylistRefreshWorkerMessage, +} from './playlist-refresh.worker.types'; + +const https = require('https'); + +type ActiveRefreshState = { + cancelled: boolean; + controller: AbortController; +}; + +const activeRefreshes = new Map(); + +if (!parentPort) { + throw new Error('Playlist refresh worker must be started with a parent port'); +} + +function postMessage(message: PlaylistRefreshWorkerMessage): void { + parentPort?.postMessage(message); +} + +function serializeError(error: unknown) { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + return { + message: String(error), + }; +} + +function createAbortError(operationId: string): Error { + const error = new Error(`Playlist refresh "${operationId}" was cancelled`); + error.name = 'AbortError'; + return error; +} + +function emitEvent( + payload: PlaylistRefreshPayload, + partial: Omit +): void { + postMessage({ + type: 'event', + event: { + operationId: payload.operationId, + playlistId: payload.playlistId, + ...partial, + }, + }); +} + +function checkpoint(payload: PlaylistRefreshPayload): void { + const active = activeRefreshes.get(payload.operationId); + if (active?.cancelled) { + throw createAbortError(payload.operationId); + } +} + +async function fetchPlaylistFromUrl( + payload: PlaylistRefreshPayload, + controller: AbortController +): Promise { + emitEvent(payload, { status: 'started', phase: 'fetching' }); + checkpoint(payload); + + const agent = new https.Agent({ + rejectUnauthorized: false, + }); + const result = await axios.get(payload.url!, { + httpsAgent: agent, + signal: controller.signal, + timeout: 30000, + }); + + checkpoint(payload); + emitEvent(payload, { status: 'progress', phase: 'parsing' }); + const parsedPlaylist = parse(result.data); + checkpoint(payload); + + const extractedName = + payload.url && payload.url.length > 1 + ? getFilenameFromUrl(payload.url) + : ''; + const playlistName = + !extractedName || extractedName === 'Untitled playlist' + ? 'Imported from URL' + : extractedName; + + return createPlaylistObject( + payload.title || playlistName, + parsedPlaylist, + payload.url, + 'URL' + ); +} + +async function fetchPlaylistFromFile( + payload: PlaylistRefreshPayload +): Promise { + emitEvent(payload, { status: 'started', phase: 'reading-file' }); + checkpoint(payload); + const fileContent = await readFile(payload.filePath!, 'utf-8'); + checkpoint(payload); + + emitEvent(payload, { status: 'progress', phase: 'parsing' }); + const parsedPlaylist = parse(fileContent); + checkpoint(payload); + + return createPlaylistObject( + payload.title, + parsedPlaylist, + payload.filePath, + 'FILE' + ); +} + +async function executeRefresh(payload: PlaylistRefreshPayload): Promise { + const controller = new AbortController(); + activeRefreshes.set(payload.operationId, { + cancelled: false, + controller, + }); + + try { + const playlist = payload.url + ? await fetchPlaylistFromUrl(payload, controller) + : await fetchPlaylistFromFile(payload); + + checkpoint(payload); + return playlist; + } finally { + activeRefreshes.delete(payload.operationId); + } +} + +parentPort.on('message', async (message: PlaylistRefreshWorkerIncomingMessage) => { + if (message.type === 'cancel') { + const active = activeRefreshes.get(message.operationId); + if (active) { + active.cancelled = true; + active.controller.abort(); + } + return; + } + + try { + const result = await executeRefresh(message.payload); + postMessage({ + type: 'response', + success: true, + result, + }); + } catch (error) { + const payload = message.payload; + if (error instanceof Error && error.name === 'AbortError') { + emitEvent(payload, { status: 'cancelled', phase: 'parsing' }); + } else { + emitEvent(payload, { + status: 'error', + phase: payload.url ? 'fetching' : 'reading-file', + error: error instanceof Error ? error.message : String(error), + }); + } + + postMessage({ + type: 'response', + success: false, + error: serializeError(error), + }); + } +}); + +postMessage({ type: 'ready' }); diff --git a/apps/electron-backend/src/app/workers/playlist-refresh.worker.types.ts b/apps/electron-backend/src/app/workers/playlist-refresh.worker.types.ts new file mode 100644 index 000000000..ed6607311 --- /dev/null +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.types.ts @@ -0,0 +1,40 @@ +import type { PlaylistRefreshEvent, PlaylistRefreshPayload } from 'shared-interfaces'; + +export interface PlaylistRefreshWorkerRequestMessage { + type: 'request'; + payload: PlaylistRefreshPayload; +} + +export interface PlaylistRefreshWorkerCancelMessage { + type: 'cancel'; + operationId: string; +} + +export interface PlaylistRefreshWorkerReadyMessage { + type: 'ready'; +} + +export interface PlaylistRefreshWorkerEventMessage { + type: 'event'; + event: PlaylistRefreshEvent; +} + +export interface PlaylistRefreshWorkerResponseMessage { + type: 'response'; + success: boolean; + result?: TResult; + error?: { + name?: string; + message: string; + stack?: string; + }; +} + +export type PlaylistRefreshWorkerIncomingMessage = + | PlaylistRefreshWorkerRequestMessage + | PlaylistRefreshWorkerCancelMessage; + +export type PlaylistRefreshWorkerMessage = + | PlaylistRefreshWorkerReadyMessage + | PlaylistRefreshWorkerEventMessage + | PlaylistRefreshWorkerResponseMessage; diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index cadc61797..b5b23de6e 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -50,6 +50,7 @@ export class ElectronService extends DataService { XtreamCodeActions.GetLiveCategories, XtreamCodeActions.GetVodCategories, XtreamCodeActions.GetSeriesCategories, + XtreamCodeActions.GetShortEpg, ]); constructor() { @@ -432,6 +433,8 @@ export class ElectronService extends DataService { url: string; params: Record; requestId?: string; + sessionId?: string; + suppressErrorLog?: boolean; }) { const context = createPortalDebugRequestContext({ provider: 'xtream', @@ -456,9 +459,9 @@ export class ElectronService extends DataService { return result; } catch (error: unknown) { const action = payload.params?.action; - const isSilentAction = action - ? this.silentXtreamActions.has(action) - : false; + const isSilentAction = + payload.suppressErrorLog === true || + (action ? this.silentXtreamActions.has(action) : false); const normalizedMessage = this.getReadableXtreamErrorMessage(error); const errorInfo = this.getErrorDetails(error); diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 3d090343e..b3d20c234 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -62,6 +62,7 @@ export class PwaService extends DataService { XtreamCodeActions.GetLiveCategories, XtreamCodeActions.GetVodCategories, XtreamCodeActions.GetSeriesCategories, + XtreamCodeActions.GetShortEpg, ]); /** Proxy URL to avoid CORS issues */ @@ -244,6 +245,9 @@ export class PwaService extends DataService { url: string; params: Record; macAddress?: string; + requestId?: string; + sessionId?: string; + suppressErrorLog?: boolean; }) { const headers = payload.macAddress ? { @@ -292,7 +296,9 @@ export class PwaService extends DataService { if (!response.payload) { const action = payload.params.action; - const isSilentAction = this.silentXtreamActions.has(action); + const isSilentAction = + payload.suppressErrorLog === true || + this.silentXtreamActions.has(action); const normalizedMessage = this.getReadableXtreamErrorMessage(response); logPortalDebugEvent( @@ -332,7 +338,9 @@ export class PwaService extends DataService { } catch (error: unknown) { logPortalDebugEvent(createPortalDebugErrorEvent(context, error)); const action = payload.params.action; - const isSilentAction = this.silentXtreamActions.has(action); + const isSilentAction = + payload.suppressErrorLog === true || + this.silentXtreamActions.has(action); const normalizedMessage = this.getReadableXtreamErrorMessage(error); const errorInfo = this.getErrorDetails(error); diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index c0bd3b027..5bca10657 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -10,6 +10,8 @@ import { ExternalPlayerSession, PlaybackPositionData, Playlist, + PlaylistRefreshEvent, + PlaylistRefreshPayload, PortalDebugEvent, } from 'shared-interfaces'; import { @@ -31,6 +33,9 @@ declare global { onPortalDebugEvent?: ( callback: (data: PortalDebugEvent) => void ) => () => void; + onPlaylistRefreshEvent?: ( + callback: (data: PlaylistRefreshEvent) => void + ) => () => void; getAppVersion: () => Promise; platform: string; fetchPlaylistByUrl: ( @@ -119,8 +124,18 @@ declare global { url: string; params: Record; requestId?: string; + sessionId?: string; suppressErrorLog?: boolean; }) => Promise<{ payload: unknown; action: string }>; + xtreamCancelSession: ( + sessionId: string + ) => Promise<{ success: boolean; cancelled: number }>; + refreshPlaylist: ( + payload: PlaylistRefreshPayload + ) => Promise; + cancelPlaylistRefresh: ( + operationId: string + ) => Promise<{ success: boolean }>; // Database operations dbCreatePlaylist: ( playlist: Playlist @@ -202,6 +217,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, From 91cd650c1abb2a9443dc5ed66f7f4476e43c1c34 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Apr 2026 13:40:34 +0200 Subject: [PATCH 06/15] feat: enhance EPG and portal features with error suppression and status resolution - Added suppressErrorLog option to EPG API calls in withEpg feature. - Introduced resolvePortalStatus function to streamline portal status checks in withPortal feature. - Updated checkPortalStatus to return PortalStatusType instead of void. - Enhanced XtreamStore to preserve cancelled content initialization block. - Expanded XtreamState to include contentInitBlockReason for better state management. - Added XtreamContentGateComponent to manage content initialization states. - Implemented tests for XtreamContentGateComponent to ensure correct rendering based on contentInitBlockReason. - Created PlaylistRefreshService to handle playlist refresh operations and events. - Introduced new IPC commands for playlist refresh functionality. - Added interfaces for playlist refresh events and payloads to improve type safety. --- global.d.ts | 22 + .../lib/reducers/playlist.reducers.spec.ts | 44 ++ .../src/lib/reducers/playlist.reducers.ts | 3 + .../recent-playlists.component.spec.ts | 47 ++ .../recent-playlists.component.ts | 167 ++++- libs/playlist/shared/util/src/index.ts | 1 + .../lib/playlist-refresh-action.service.ts | 200 ++++++ .../category-content-view.component.ts | 22 + libs/portal/shared/ui/jest.config.ts | 21 + libs/portal/shared/ui/project.json | 8 + .../unified-collection-page.component.html | 2 + .../unified-collection-page.component.spec.ts | 187 ++++++ .../unified-collection-page.component.ts | 66 +- .../unified-grid-tab.component.ts | 11 +- .../unified-live-tab.component.ts | 102 ++- libs/portal/shared/ui/src/test-setup.ts | 6 + libs/portal/shared/ui/tsconfig.spec.json | 17 + .../stream-resolver.service.spec.ts | 85 +++ .../lib/collection/stream-resolver.service.ts | 101 ++- .../unified-favorites-data.service.spec.ts | 86 ++- .../workspace-portal-navigation.spec.ts | 186 +++++- .../navigation/workspace-portal-navigation.ts | 357 ++++++++-- .../src/lib/stalker-vod.utils.spec.ts | 27 + .../data-access/src/lib/stalker-vod.utils.ts | 7 +- .../with-stalker-favorites.feature.ts | 13 +- .../features/with-stalker-player.feature.ts | 9 +- .../features/with-stalker-recent.feature.ts | 13 +- .../stalker-catalog-detail.component.ts | 8 + .../stalker-series-view.component.ts | 8 + .../electron-xtream-data-source.ts | 67 +- .../data-sources/pwa-xtream-data-source.ts | 4 +- .../xtream-data-source.interface.ts | 5 +- .../lib/services/epg-queue.service.spec.ts | 85 +++ .../src/lib/services/epg-queue.service.ts | 51 +- .../src/lib/services/xtream-api.service.ts | 83 ++- .../features/with-content.feature.spec.ts | 286 ++++++-- .../stores/features/with-content.feature.ts | 628 ++++++++++++++---- .../lib/stores/features/with-epg.feature.ts | 10 +- .../stores/features/with-portal.feature.ts | 56 +- .../src/lib/stores/xtream.store.ts | 8 + .../data-access/src/lib/xtream-state.ts | 8 + libs/portal/xtream/feature/src/index.ts | 1 + .../portal-channels-list.component.ts | 16 +- .../lib/xtream-content-gate.component.spec.ts | 120 ++++ .../src/lib/xtream-content-gate.component.ts | 94 +++ .../src/lib/xtream-feature.routes.spec.ts | 28 + .../feature/src/lib/xtream-feature.routes.ts | 87 +-- ...am-workspace-route-session.service.spec.ts | 179 +++++ .../xtream-workspace-route-session.service.ts | 77 ++- libs/services/src/index.ts | 1 + .../src/lib/database-electron.service.ts | 77 +++ .../src/lib/playlist-refresh.service.ts | 52 ++ libs/shared/interfaces/src/index.ts | 1 + .../shared/interfaces/src/lib/ipc-commands.ts | 4 + .../src/lib/playlist-refresh.interface.ts | 28 + 55 files changed, 3485 insertions(+), 397 deletions(-) create mode 100644 libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts create mode 100644 libs/playlist/shared/util/src/lib/playlist-refresh-action.service.ts create mode 100644 libs/portal/shared/ui/jest.config.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts create mode 100644 libs/portal/shared/ui/src/test-setup.ts create mode 100644 libs/portal/shared/ui/tsconfig.spec.json create mode 100644 libs/portal/xtream/data-access/src/lib/services/epg-queue.service.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/xtream-content-gate.component.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/xtream-content-gate.component.ts create mode 100644 libs/portal/xtream/feature/src/lib/xtream-feature.routes.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts create mode 100644 libs/services/src/lib/playlist-refresh.service.ts create mode 100644 libs/shared/interfaces/src/lib/playlist-refresh.interface.ts 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; +} From c62e8859ea61304d9e0524b99506a022855ffb7c Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Apr 2026 20:19:14 +0200 Subject: [PATCH 07/15] feat: add copy URL functionality to downloads with user feedback Entire-Checkpoint: 55bde394786b --- .../feature/src/lib/downloads.component.html | 12 ++++++++++++ .../feature/src/lib/downloads.component.ts | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.html b/libs/portal/downloads/feature/src/lib/downloads.component.html index a6cecbeab..072f639f8 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.html +++ b/libs/portal/downloads/feature/src/lib/downloads.component.html @@ -167,6 +167,18 @@
+ @switch (item.status) { @case ('queued') { - } -
+

+ {{ getScopeTitleKey(group.scope) | translate }} +

+ @for (command of group.items; track command.id) { + + } } - } -
+ + } @else { +
+
+ {{ 'WORKSPACE.COMMAND_PALETTE.EMPTY' | translate }} +
+ } diff --git a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.scss b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.scss index bbf7a29af..002cfc395 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.scss @@ -5,8 +5,8 @@ app-region: no-drag; } +// ─── Shell ─────────────────────────────────────────────────────────────────── .palette-shell { - padding: 14px; background: var(--app-header-bg, var(--mat-sys-surface-container-low)); color: var(--mat-sys-on-surface); border: 1px solid var(--mat-sys-outline-variant); @@ -14,205 +14,207 @@ box-shadow: 0 24px 48px -28px rgba(0, 0, 0, 0.5), 0 10px 24px -18px rgba(0, 0, 0, 0.35); + overflow: hidden; } -.palette-header { - display: grid; - gap: 2px; - margin-bottom: 10px; - - h2 { - margin: 0; - font-size: 1rem; - letter-spacing: -0.01em; - font-weight: 640; - } - - p { - margin: 0; - font-size: 0.78rem; - color: var(--mat-sys-on-surface-variant); - } -} - -.palette-search-panel { - margin: 0 -2px; - padding: 10px 12px 12px; - border: 1px solid var(--app-separator, var(--mat-sys-outline-variant)); - border-radius: 12px; - background: var( - --app-widget-header-bg, - var(--mat-sys-surface-container-high) - ); -} - +// ─── Search ────────────────────────────────────────────────────────────────── .palette-search { - width: 100%; display: flex; align-items: center; - gap: 8px; - border-radius: 10px; - border: 1px solid var(--app-search-border, var(--mat-sys-outline-variant)); - background: var(--app-search-bg, var(--mat-sys-surface-container)); - min-height: 40px; - padding: 0 12px; - box-sizing: border-box; - transition: - border-color 0.15s ease, - background-color 0.15s ease, - box-shadow 0.15s ease; + gap: 10px; + padding: 0 14px; + min-height: 44px; + cursor: text; +} - &:focus-within { - border-color: var(--mat-sys-primary); - box-shadow: 0 0 0 3px - color-mix(in srgb, var(--mat-sys-primary) 12%, transparent); - } +.palette-search__icon { + font-size: 20px; + width: 20px; + height: 20px; + color: var(--mat-sys-on-surface-variant); + flex-shrink: 0; + opacity: 0.7; +} - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; +.palette-search input { + flex: 1; + border: none; + outline: none; + background: transparent; + color: var(--mat-sys-on-surface); + font: inherit; + font-size: 0.94rem; + font-weight: 450; + width: 100%; + min-width: 0; + + &::placeholder { color: var(--mat-sys-on-surface-variant); - flex-shrink: 0; + opacity: 0.55; } - input { - flex: 1; - border: none; - outline: none; - background: transparent; - color: var(--mat-sys-on-surface); - font: inherit; - font-size: 0.875rem; - width: 100%; - - &::placeholder { - color: var(--mat-sys-on-surface-variant); - opacity: 0.7; - } + &::-webkit-search-cancel-button { + display: none; } } -.palette-search__hint { - font-size: 0.72rem; +.palette-search__kbd { + font-family: inherit; + font-size: 0.68rem; + font-weight: 500; color: var(--mat-sys-on-surface-variant); - border: 1px solid var(--app-separator, var(--mat-sys-outline-variant)); - border-radius: 6px; - padding: 2px 6px; + border: 1px solid + color-mix( + in srgb, + var(--mat-sys-outline-variant) 80%, + transparent + ); + border-radius: 5px; + padding: 2px 5px; line-height: 1; flex-shrink: 0; - background: var( - --app-widget-header-bg, - var(--mat-sys-surface-container-high) - ); + opacity: 0.7; } +// ─── Divider ───────────────────────────────────────────────────────────────── +.palette-divider { + height: 1px; + background: var(--mat-sys-outline-variant); + opacity: 0.6; +} + +// ─── Results ───────────────────────────────────────────────────────────────── .palette-results { - max-height: min(56vh, 520px); - overflow: auto; - margin-top: 12px; - display: grid; - gap: 10px; - padding-right: 2px; + max-height: min(56vh, 480px); + overflow-y: auto; + overflow-x: hidden; + padding: 6px; + scrollbar-width: thin; + scrollbar-color: var(--mat-sys-outline-variant) transparent; } +// ─── Group ─────────────────────────────────────────────────────────────────── .palette-group { - display: grid; - gap: 6px; - - h3 { - margin: 0; - font-size: 0.74rem; - color: var(--mat-sys-on-surface-variant); - text-transform: uppercase; - letter-spacing: 0.05em; - font-weight: 620; + &:not(:first-child) { + margin-top: 4px; } } -.palette-group__items { - display: grid; - gap: 4px; +.palette-group__title { + margin: 0; + padding: 6px 8px 4px; + font-size: 0.68rem; + font-weight: 600; + color: var(--mat-sys-on-surface-variant); + text-transform: uppercase; + letter-spacing: 0.06em; + opacity: 0.7; } +// ─── Command Row ───────────────────────────────────────────────────────────── .palette-command { - display: grid; - gap: 1px; + display: flex; + align-items: center; + gap: 10px; width: 100%; - text-align: left; + padding: 7px 8px; border: 1px solid transparent; - border-radius: 10px; + border-radius: 9px; background: transparent; color: inherit; - padding: 8px 10px; + text-align: left; cursor: pointer; + transition: + background 0.12s ease, + border-color 0.12s ease; &:hover:not(:disabled), &.is-selected:not(:disabled) { background: linear-gradient( - 145deg, + 135deg, var(--app-selection-surface-strong), var(--app-selection-surface) ); border-color: var(--app-selection-border); } + &.is-selected:not(:disabled) { + box-shadow: 0 1px 4px -2px var(--app-selection-glow); + + .palette-command__icon { + background: color-mix( + in srgb, + var(--app-selection-color) 22%, + transparent + ); + color: var(--app-selection-color); + } + + .palette-command__label { + color: var(--app-selection-color); + } + } + &:disabled { cursor: not-allowed; - opacity: 0.45; + opacity: 0.4; } } -.palette-command--with-icon { - grid-template-columns: auto minmax(0, 1fr); - align-items: center; - gap: 10px; -} - .palette-command__icon { - width: 30px; - height: 30px; + width: 28px; + height: 28px; display: inline-flex; align-items: center; justify-content: center; - border-radius: 9px; + border-radius: 7px; background: color-mix( in srgb, - var(--app-selection-surface-strong) 72%, + var(--mat-sys-on-surface-variant) 10%, transparent ); - color: var(--app-selection-color); + color: var(--mat-sys-on-surface-variant); flex-shrink: 0; + transition: + background 0.12s ease, + color 0.12s ease; mat-icon { - font-size: 18px; - width: 18px; - height: 18px; + font-size: 17px; + width: 17px; + height: 17px; } } -.palette-command__content { - min-width: 0; - display: grid; - gap: 1px; -} - .palette-command__label { - font-size: 0.9rem; - font-weight: 560; + font-size: 0.84rem; + font-weight: 530; letter-spacing: -0.006em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + transition: color 0.12s ease; } -.palette-command__description { - font-size: 0.76rem; +.palette-command__desc { + margin-left: auto; + font-size: 0.74rem; color: var(--mat-sys-on-surface-variant); + opacity: 0.65; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 1; + min-width: 0; + padding-left: 12px; } +// ─── Empty State ───────────────────────────────────────────────────────────── .palette-empty { - border: 1px dashed var(--mat-sys-outline-variant); - border-radius: 10px; - padding: 18px; + padding: 20px 14px; text-align: center; color: var(--mat-sys-on-surface-variant); - font-size: 0.88rem; + font-size: 0.82rem; + opacity: 0.7; } diff --git a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts index 1520e37b6..21234e462 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts @@ -23,7 +23,17 @@ export type WorkspaceCommandId = | 'playlist-search' | 'open-global-favorites' | 'open-downloads' - | 'open-global-recent'; + | 'open-global-recent' + | 'open-settings' + | 'open-sources' + | 'open-dashboard' + | 'add-playlist' + | 'refresh-playlist' + | 'playlist-info' + | 'account-info' + | 'go-to-vod' + | 'go-to-live' + | 'go-to-series'; export interface WorkspaceCommandItem { id: WorkspaceCommandId; @@ -229,20 +239,38 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit { return 'WORKSPACE.COMMAND_PALETTE.GROUP_SECTION'; } - getCommandIcon(command: WorkspaceCommandItem): string | null { - if (command.scope !== 'global') { - return null; - } - + getCommandIcon(command: WorkspaceCommandItem): string { switch (command.id) { case 'global-search': return 'search'; + case 'playlist-search': + return 'playlist_play'; case 'open-global-favorites': return 'star'; case 'open-downloads': return 'download'; case 'open-global-recent': return 'history'; + case 'open-settings': + return 'settings'; + case 'open-sources': + return 'library_books'; + case 'open-dashboard': + return 'dashboard'; + case 'add-playlist': + return 'add_circle_outline'; + case 'refresh-playlist': + return 'sync'; + case 'playlist-info': + return 'info'; + case 'account-info': + return 'account_circle'; + case 'go-to-vod': + return 'movie'; + case 'go-to-live': + return 'live_tv'; + case 'go-to-series': + return 'video_library'; default: return 'bolt'; } diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html index 24dfde787..f005049a8 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html @@ -1,15 +1,24 @@ @if (items()?.length > 0) {
- - search + +
@for (category of filteredCategories(); track category.id) { diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss index f617e264d..2f1944edf 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss @@ -43,17 +43,74 @@ } } -.search-field { - width: 100%; +.search-inline { + display: flex; + align-items: center; + gap: 8px; + padding: 0 10px; + min-height: 36px; + border-radius: 10px; + border: 1px solid var(--app-search-border, var(--mat-sys-outline-variant)); + background: var(--app-search-bg, var(--mat-sys-surface-container)); + cursor: text; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; - mat-icon[matPrefix] { - margin-right: 8px; + &:focus-within { + border-color: var(--mat-sys-primary); + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--mat-sys-primary) 12%, transparent); + } +} + +.search-inline__icon { + font-size: 18px; + width: 18px; + height: 18px; + color: var(--mat-sys-on-surface-variant); + flex-shrink: 0; +} + +.search-inline input { + flex: 1; + border: none; + outline: none; + background: transparent; + color: var(--mat-sys-on-surface); + font: inherit; + font-size: 0.84rem; + width: 100%; + min-width: 0; + + &::placeholder { color: var(--mat-sys-on-surface-variant); + opacity: 0.6; + } + + &::-webkit-search-cancel-button { + display: none; + } +} + +.search-inline__clear { + width: 24px; + height: 24px; + min-width: 24px; + line-height: 24px; + flex-shrink: 0; + padding: 0; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; } } .categories-list { - height: 450px; + flex: 1; + min-height: 200px; overflow-y: auto; border: 1px solid var(--mat-sys-outline-variant); border-radius: 8px; @@ -62,9 +119,10 @@ .category-item { display: flex; align-items: center; - padding: 2px 10px; + padding: 0 10px; + min-height: 30px; cursor: pointer; - gap: 8px; + gap: 6px; transition: background-color 0.12s ease; &:hover { @@ -76,7 +134,7 @@ } mat-checkbox { - --mdc-checkbox-state-layer-size: 32px; + --mdc-checkbox-state-layer-size: 28px; } } @@ -99,7 +157,7 @@ .mat-mdc-dialog-content { min-width: 480px; max-width: 600px; - max-height: 75vh !important; + max-height: 85vh !important; } } diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts index e00a7901b..44e1cac81 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts @@ -1,5 +1,4 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { @@ -7,9 +6,7 @@ import { MatDialogModule, MatDialogRef, } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslatePipe } from '@ngx-translate/core'; @@ -32,11 +29,8 @@ interface CategoryWithSelection extends XCategoryFromDb { MatDialogModule, MatButtonModule, MatCheckboxModule, - MatFormFieldModule, MatIconModule, - MatInputModule, MatProgressSpinnerModule, - FormsModule, TranslatePipe, ], templateUrl: './category-management-dialog.component.html', From 02c773170e2851360987e0d1af5f0f3494f10e23 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Apr 2026 20:41:07 +0200 Subject: [PATCH 11/15] feat: add Jest configuration and tests for playlist shared utility --- libs/playlist/shared/util/jest.config.ts | 21 ++ libs/playlist/shared/util/project.json | 8 + .../playlist-refresh-action.service.spec.ts | 182 ++++++++++++++++++ .../lib/playlist-refresh-action.service.ts | 21 +- libs/playlist/shared/util/src/test-setup.ts | 6 + libs/playlist/shared/util/tsconfig.spec.json | 17 ++ 6 files changed, 245 insertions(+), 10 deletions(-) create mode 100644 libs/playlist/shared/util/jest.config.ts create mode 100644 libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts create mode 100644 libs/playlist/shared/util/src/test-setup.ts create mode 100644 libs/playlist/shared/util/tsconfig.spec.json diff --git a/libs/playlist/shared/util/jest.config.ts b/libs/playlist/shared/util/jest.config.ts new file mode 100644 index 000000000..264b8c141 --- /dev/null +++ b/libs/playlist/shared/util/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'playlist-shared-util', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/playlist/shared/util', + 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/playlist/shared/util/project.json b/libs/playlist/shared/util/project.json index 9b55122b0..5c3b6a5c1 100644 --- a/libs/playlist/shared/util/project.json +++ b/libs/playlist/shared/util/project.json @@ -6,6 +6,14 @@ "projectType": "library", "tags": ["scope:playlist", "domain:m3u", "type:util"], "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/playlist/shared/util/jest.config.ts", + "tsConfig": "libs/playlist/shared/util/tsconfig.spec.json" + } + }, "lint": { "executor": "@nx/eslint:lint" } diff --git a/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts new file mode 100644 index 000000000..2678e3547 --- /dev/null +++ b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts @@ -0,0 +1,182 @@ +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { DialogService } from 'components'; +import { DatabaseService, PlaylistRefreshService } from 'services'; +import { PlaylistActions } from 'm3u-state'; +import { PlaylistMeta } from 'shared-interfaces'; +import { PlaylistRefreshActionService } from './playlist-refresh-action.service'; + +function createPlaylistMeta( + overrides: Partial = {} +): PlaylistMeta { + return { + _id: 'playlist-1', + title: 'Xtream Playlist', + serverUrl: 'http://localhost:8080', + username: 'demo', + password: 'secret', + ...overrides, + } as PlaylistMeta; +} + +describe('PlaylistRefreshActionService', () => { + let service: PlaylistRefreshActionService; + let databaseService: { + createOperationId: jest.Mock; + deleteXtreamPlaylistContent: jest.Mock; + updateXtreamPlaylistDetails: jest.Mock; + }; + let dialogService: { + openConfirmDialog: jest.Mock; + }; + let router: { + navigate: jest.Mock; + }; + let snackBar: { + open: jest.Mock; + }; + let store: { + dispatch: jest.Mock; + }; + + beforeEach(() => { + localStorage.clear(); + + databaseService = { + createOperationId: jest.fn((prefix: string) => `${prefix}-op`), + deleteXtreamPlaylistContent: jest.fn().mockResolvedValue({ + success: true, + favoritedXtreamIds: [101, 202], + recentlyViewedXtreamIds: [ + { + xtreamId: 303, + viewedAt: '2026-04-04T08:00:00.000Z', + }, + ], + hiddenCategories: [{ xtreamId: 404, type: 'live' }], + }), + updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(true), + }; + dialogService = { + openConfirmDialog: jest.fn(), + }; + router = { + navigate: jest.fn().mockResolvedValue(true), + }; + snackBar = { + open: jest.fn(), + }; + store = { + dispatch: jest.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + PlaylistRefreshActionService, + { + provide: Router, + useValue: router, + }, + { + provide: Store, + useValue: store, + }, + { + provide: TranslateService, + useValue: { + instant: jest.fn((key: string) => key), + }, + }, + { + provide: MatSnackBar, + useValue: snackBar, + }, + { + provide: DialogService, + useValue: dialogService, + }, + { + provide: DatabaseService, + useValue: databaseService, + }, + { + provide: PlaylistRefreshService, + useValue: { + refreshPlaylist: jest.fn(), + }, + }, + ], + }); + + service = TestBed.inject(PlaylistRefreshActionService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + localStorage.clear(); + }); + + it('stores Xtream restore data before updating playlist meta and navigating', async () => { + const item = createPlaylistMeta(); + const executionOrder: string[] = []; + let confirmPromise: Promise | undefined; + const dateNowSpy = jest + .spyOn(Date, 'now') + .mockReturnValue(1712217600000); + const originalSetItem = Storage.prototype.setItem; + const setItemSpy = jest + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(function (key: string, value: string) { + executionOrder.push('setItem'); + return originalSetItem.call(this, key, value); + }); + + store.dispatch.mockImplementation((action: unknown) => { + executionOrder.push('dispatch'); + return action; + }); + router.navigate.mockImplementation((commands: unknown[]) => { + executionOrder.push('navigate'); + return Promise.resolve(Boolean(commands)); + }); + dialogService.openConfirmDialog.mockImplementation( + ({ onConfirm }: { onConfirm?: () => Promise }) => { + confirmPromise = onConfirm?.(); + } + ); + + service.refresh(item); + await confirmPromise; + + expect(setItemSpy).toHaveBeenCalledWith( + `xtream-restore-${item._id}`, + JSON.stringify({ + favoritedXtreamIds: [101, 202], + recentlyViewedXtreamIds: [ + { + xtreamId: 303, + viewedAt: '2026-04-04T08:00:00.000Z', + }, + ], + hiddenCategories: [{ xtreamId: 404, type: 'live' }], + }) + ); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylistMeta({ + playlist: { ...item, updateDate: 1712217600000 }, + }) + ); + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'xtreams', + item._id, + ]); + expect(executionOrder).toEqual(['setItem', 'dispatch', 'navigate']); + + setItemSpy.mockRestore(); + dateNowSpy.mockRestore(); + }); +}); 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 index d47b42b31..6e895c4a0 100644 --- a/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.ts +++ b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.ts @@ -90,22 +90,23 @@ export class PlaylistRefreshActionService { }), ]); + const restoreKey = `xtream-restore-${item._id}`; + const restorePayload = { + favoritedXtreamIds, + recentlyViewedXtreamIds, + hiddenCategories, + }; + localStorage.setItem( + restoreKey, + JSON.stringify(restorePayload) + ); + 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', diff --git a/libs/playlist/shared/util/src/test-setup.ts b/libs/playlist/shared/util/src/test-setup.ts new file mode 100644 index 000000000..0d88648e3 --- /dev/null +++ b/libs/playlist/shared/util/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/playlist/shared/util/tsconfig.spec.json b/libs/playlist/shared/util/tsconfig.spec.json new file mode 100644 index 000000000..c0bb248be --- /dev/null +++ b/libs/playlist/shared/util/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" + ] +} From eef18dea689ea3a648021b463d638cb179542fec Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Apr 2026 20:41:59 +0200 Subject: [PATCH 12/15] feat: add Jest configuration and tests for category content view component --- libs/portal/catalog/feature/jest.config.ts | 21 +++ libs/portal/catalog/feature/project.json | 8 + .../category-content-view.component.spec.ts | 175 ++++++++++++++++++ .../category-content-view.component.ts | 14 +- libs/portal/catalog/feature/src/test-setup.ts | 6 + libs/portal/catalog/feature/tsconfig.json | 28 +++ libs/portal/catalog/feature/tsconfig.lib.json | 12 ++ .../portal/catalog/feature/tsconfig.spec.json | 17 ++ 8 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 libs/portal/catalog/feature/jest.config.ts create mode 100644 libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts create mode 100644 libs/portal/catalog/feature/src/test-setup.ts create mode 100644 libs/portal/catalog/feature/tsconfig.json create mode 100644 libs/portal/catalog/feature/tsconfig.lib.json create mode 100644 libs/portal/catalog/feature/tsconfig.spec.json diff --git a/libs/portal/catalog/feature/jest.config.ts b/libs/portal/catalog/feature/jest.config.ts new file mode 100644 index 000000000..7f2920c30 --- /dev/null +++ b/libs/portal/catalog/feature/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'portal-catalog-feature', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/portal/catalog/feature', + 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/catalog/feature/project.json b/libs/portal/catalog/feature/project.json index b5eec6cb8..58afa7f34 100644 --- a/libs/portal/catalog/feature/project.json +++ b/libs/portal/catalog/feature/project.json @@ -6,6 +6,14 @@ "projectType": "library", "tags": ["scope:portal", "domain:portal-shared", "type:feature"], "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/portal/catalog/feature/jest.config.ts", + "tsConfig": "libs/portal/catalog/feature/tsconfig.spec.json" + } + }, "lint": { "executor": "@nx/eslint:lint" } diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts new file mode 100644 index 000000000..3efd82a4a --- /dev/null +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts @@ -0,0 +1,175 @@ +import { Component, input, output, signal } from '@angular/core'; +import { NgComponentOutlet } from '@angular/common'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { MatIconButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { MatTooltip } from '@angular/material/tooltip'; +import { ActivatedRoute, Router } from '@angular/router'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { + PORTAL_CATALOG_DETAIL_COMPONENT, + PORTAL_CATALOG_FACADE, + PortalCatalogSortMode, +} from '@iptvnator/portal/shared/util'; +import { CategoryContentViewComponent } from './category-content-view.component'; + +@Component({ + selector: 'app-grid-list', + standalone: true, + template: '', +}) +class MockGridListComponent { + readonly isLoading = input(); + readonly items = input(); + readonly pageIndex = input(); + readonly totalPages = input(); + readonly limit = input(); + readonly pageSizeOptions = input(); + readonly showPaginator = input(true); + readonly itemClicked = output(); + readonly pageChange = output(); +} + +@Component({ + selector: 'app-playlist-error-view', + standalone: true, + template: '', +}) +class MockPlaylistErrorViewComponent { + readonly title = input(''); + readonly description = input(''); + readonly showActionButtons = input(true); + readonly viewType = input(''); +} + +@Component({ + standalone: true, + template: '', +}) +class MockDetailComponent {} + +describe('CategoryContentViewComponent', () => { + let fixture: ComponentFixture; + const isPaginatedContentLoading = signal(true); + const categoryItemCount = signal(0); + const contentSortMode = signal(null); + const catalog = { + provider: 'xtream' as const, + pageSizeOptions: [10, 25, 50], + contentType: signal('vod'), + limit: signal(25), + pageIndex: signal(0), + selectedCategory: signal({ id: 1 }), + paginatedContent: signal([]), + selectedCategoryTitle: signal('Movies'), + categoryItemCount, + selectedItem: signal(null), + totalPages: signal(0), + contentSortMode, + playlist: signal(null), + isPaginatedContentLoading, + initialize: jest.fn(), + clearSelectedItem: jest.fn(), + setPage: jest.fn(), + setLimit: jest.fn(), + setContentSortMode: jest.fn(), + selectItem: jest.fn().mockReturnValue(null), + getItemProgress: jest.fn().mockReturnValue({}), + }; + + beforeEach(async () => { + isPaginatedContentLoading.set(true); + categoryItemCount.set(0); + contentSortMode.set(null); + catalog.initialize.mockClear(); + + await TestBed.configureTestingModule({ + imports: [CategoryContentViewComponent, NoopAnimationsModule], + providers: [ + { + provide: TranslateService, + useValue: { + instant: (key: string) => + key === 'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING' + ? 'Fetching playlist data from source...' + : key, + get: (key: string) => + of( + key === 'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING' + ? 'Fetching playlist data from source...' + : key + ), + stream: (key: string) => + of( + key === 'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING' + ? 'Fetching playlist data from source...' + : key + ), + onLangChange: of(null), + onTranslationChange: of(null), + onDefaultLangChange: of(null), + currentLang: 'en', + defaultLang: 'en', + }, + }, + { + provide: PORTAL_CATALOG_FACADE, + useValue: catalog, + }, + { + provide: PORTAL_CATALOG_DETAIL_COMPONENT, + useValue: MockDetailComponent, + }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + params: {}, + }, + }, + }, + { + provide: Router, + useValue: { + navigate: jest.fn(), + }, + }, + ], + }) + .overrideComponent(CategoryContentViewComponent, { + set: { + imports: [ + NgComponentOutlet, + MockGridListComponent, + MockPlaylistErrorViewComponent, + MatIcon, + MatIconButton, + MatMenuModule, + MatPaginatorModule, + MatTooltip, + TranslatePipe, + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(CategoryContentViewComponent); + }); + + it('shows loading copy in the subtitle instead of 0 items while xtream content is still warming up', () => { + fixture.detectChanges(); + + const subtitle = fixture.nativeElement.querySelector( + '.category-subtitle' + ) as HTMLElement | null; + + expect(catalog.initialize).toHaveBeenCalledWith(null); + expect(subtitle?.textContent?.trim()).toBe( + 'Fetching playlist data from source...' + ); + }); +}); 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 3b27f860b..7d17b2822 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 @@ -6,7 +6,7 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { MatTooltip } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; -import { TranslatePipe } from '@ngx-translate/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { GridListComponent, PlaylistErrorViewComponent, @@ -50,6 +50,7 @@ interface CategoryContentItem { export class CategoryContentViewComponent implements OnInit { private readonly activatedRoute = inject(ActivatedRoute); private readonly router = inject(Router); + private readonly translate = inject(TranslateService); private readonly catalog = inject(PORTAL_CATALOG_FACADE) as PortalCatalogFacade< CategoryContentItem, CategoryContentItem, @@ -70,7 +71,18 @@ export class CategoryContentViewComponent implements OnInit { readonly contentSortMode = this.catalog.contentSortMode; readonly isPaginatedContentLoading = this.catalog.isPaginatedContentLoading; + readonly isXtreamLoadingSubtitle = computed( + () => + this.catalog.provider === 'xtream' && + this.isPaginatedContentLoading() + ); readonly categoryItemSubtitle = computed(() => { + if (this.isXtreamLoadingSubtitle()) { + return this.translate.instant( + 'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING' + ); + } + const itemCount = this.categoryItemCount(); return `${itemCount} ${itemCount === 1 ? 'item' : 'items'}`; }); diff --git a/libs/portal/catalog/feature/src/test-setup.ts b/libs/portal/catalog/feature/src/test-setup.ts new file mode 100644 index 000000000..0d88648e3 --- /dev/null +++ b/libs/portal/catalog/feature/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/catalog/feature/tsconfig.json b/libs/portal/catalog/feature/tsconfig.json new file mode 100644 index 000000000..7d33479d8 --- /dev/null +++ b/libs/portal/catalog/feature/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "isolatedModules": true, + "target": "es2022", + "moduleResolution": "bundler", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "emitDecoratorMetadata": false, + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/libs/portal/catalog/feature/tsconfig.lib.json b/libs/portal/catalog/feature/tsconfig.lib.json new file mode 100644 index 000000000..7fba372dc --- /dev/null +++ b/libs/portal/catalog/feature/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/portal/catalog/feature/tsconfig.spec.json b/libs/portal/catalog/feature/tsconfig.spec.json new file mode 100644 index 000000000..c0bb248be --- /dev/null +++ b/libs/portal/catalog/feature/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" + ] +} From a0a084fef7f7114cd0f95afdb39e98379ddc56b0 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Apr 2026 20:42:22 +0200 Subject: [PATCH 13/15] feat: enhance content loading state management and improve recent playlists component tests --- .../recent-playlists.component.spec.ts | 36 ++++++ .../recent-playlists.component.ts | 22 ++-- .../features/with-content.feature.spec.ts | 115 ++++++++++++++++++ .../stores/features/with-content.feature.ts | 67 +++++++++- .../stores/features/with-selection.feature.ts | 36 +++++- .../data-access/src/lib/xtream-state.ts | 6 + 6 files changed, 262 insertions(+), 20 deletions(-) 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 aa35f1b04..b62643b34 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 @@ -176,6 +176,11 @@ describe('RecentPlaylistsComponent busy state', () => { jest.spyOn(store, 'dispatch'); }); + afterEach(() => { + jest.restoreAllMocks(); + localStorage.clear(); + }); + it('tracks delete progress and clears the busy row after completion', async () => { const item = createPlaylistMeta({ _id: 'playlist-delete-1' }); const deletion = createDeferred(); @@ -291,15 +296,31 @@ describe('RecentPlaylistsComponent busy state', () => { 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 executionOrder: string[] = []; const dateNowSpy = jest .spyOn(Date, 'now') .mockReturnValue(1712145600000); + const originalSetItem = Storage.prototype.setItem; + const setItemSpy = jest + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(function (key: string, value: string) { + executionOrder.push('setItem'); + return originalSetItem.call(this, key, value); + }); dialogService.openConfirmDialog.mockImplementation( ({ onConfirm }: { onConfirm?: () => Promise }) => { confirmPromise = onConfirm?.(); } ); + (store.dispatch as jest.Mock).mockImplementation((action: unknown) => { + executionOrder.push('dispatch'); + return action; + }); + router.navigate.mockImplementation((commands: unknown[]) => { + executionOrder.push('navigate'); + return Promise.resolve(Boolean(commands)); + }); databaseService.deleteXtreamPlaylistContent.mockResolvedValue({ success: true, favoritedXtreamIds: [101, 202], @@ -326,12 +347,27 @@ describe('RecentPlaylistsComponent busy state', () => { playlist: { ...item, updateDate: 1712145600000 }, }) ); + expect(setItemSpy).toHaveBeenCalledWith( + `xtream-restore-${item._id}`, + JSON.stringify({ + favoritedXtreamIds: [101, 202], + recentlyViewedXtreamIds: [ + { + xtreamId: 303, + viewedAt: '2026-04-03T11:15:00.000Z', + }, + ], + hiddenCategories: [{ xtreamId: 404, type: 'live' }], + }) + ); expect(router.navigate).toHaveBeenCalledWith([ '/workspace', 'xtreams', item._id, ]); + expect(executionOrder).toEqual(['setItem', 'dispatch', 'navigate']); + setItemSpy.mockRestore(); dateNowSpy.mockRestore(); }); 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 a5d914213..bd13efd1c 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 @@ -368,6 +368,17 @@ export class RecentPlaylistsComponent { }), ]); + const restoreKey = `xtream-restore-${item._id}`; + const restorePayload = { + favoritedXtreamIds, + recentlyViewedXtreamIds, + hiddenCategories, + }; + localStorage.setItem( + restoreKey, + JSON.stringify(restorePayload) + ); + // Update the timestamp in NgRx / IndexedDB this.store.dispatch( PlaylistActions.updatePlaylistMeta({ @@ -375,17 +386,6 @@ export class RecentPlaylistsComponent { }) ); - // Persist user data so it can be restored after re-import - const restoreKey = `xtream-restore-${item._id}`; - localStorage.setItem( - restoreKey, - JSON.stringify({ - favoritedXtreamIds, - recentlyViewedXtreamIds, - hiddenCategories, - }) - ); - // Navigate to the playlist to trigger re-import this.router.navigate(['/workspace', 'xtreams', item._id]); } catch (error) { 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 305aa20e7..56e5d29bc 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 @@ -274,12 +274,117 @@ describe('withContent import state', () => { expect(store.isImporting()).toBe(false); expect(store.isContentInitialized()).toBe(true); expect(store.contentInitBlockReason()).toBeNull(); + expect(store.contentLoadStateByType()).toEqual({ + live: 'ready', + vod: 'ready', + series: 'ready', + }); expect(store.activeImportOperationIds()).toEqual([]); expect(store.importPhase()).toBeNull(); expect(store.importCount()).toBe(0); expect(store.itemsToImport()).toBe(0); }); + it('marks content types ready and patches their streams as each import completes', async () => { + const pendingCategories = { + live: createDeferred(), + vod: createDeferred(), + series: createDeferred(), + }; + const pending = { + live: createDeferred(), + movie: createDeferred(), + series: createDeferred(), + }; + + const liveItems = [ + { + xtream_id: 101, + category_id: 11, + title: 'Live One', + }, + ]; + const vodItems = [ + { + xtream_id: 202, + category_id: 22, + title: 'Movie One', + }, + ]; + const seriesItems = [ + { + xtream_id: 303, + category_id: 33, + title: 'Series One', + }, + ]; + + dataSource.getCategories.mockImplementation( + ( + _playlistId: string, + _credentials: unknown, + type: 'live' | 'vod' | 'series' + ) => pendingCategories[type].promise + ); + dataSource.getContent.mockImplementation( + ( + _playlistId: string, + _credentials: unknown, + type: ContentType + ) => pending[type].promise + ); + + const initialization = store.initializeContent(); + await Promise.resolve(); + + expect(store.contentLoadStateByType()).toEqual({ + live: 'loading', + vod: 'loading', + series: 'loading', + }); + + pendingCategories.live.resolve([]); + pendingCategories.vod.resolve([]); + pendingCategories.series.resolve([]); + + pending.live.resolve(liveItems); + await waitForCondition( + () => store.contentLoadStateByType().live === 'ready' + ); + + expect(store.liveStreams()).toEqual(liveItems); + expect(store.contentLoadStateByType()).toEqual({ + live: 'ready', + vod: 'loading', + series: 'loading', + }); + expect(store.isContentInitialized()).toBe(false); + + pending.movie.resolve(vodItems); + await waitForCondition( + () => store.contentLoadStateByType().vod === 'ready' + ); + + expect(store.vodStreams()).toEqual(vodItems); + expect(store.contentLoadStateByType()).toEqual({ + live: 'ready', + vod: 'ready', + series: 'loading', + }); + expect(store.isContentInitialized()).toBe(false); + + pending.series.resolve(seriesItems); + await initialization; + + expect(store.serialStreams()).toEqual(seriesItems); + expect(store.contentLoadStateByType()).toEqual({ + live: 'ready', + vod: 'ready', + series: 'ready', + }); + expect(store.isContentInitialized()).toBe(true); + }); + it('loads categories before starting content import', async () => { const pendingCategories = { live: createDeferred(), @@ -405,6 +510,11 @@ describe('withContent import state', () => { expect(store.isCancellingImport()).toBe(false); expect(store.isContentInitialized()).toBe(false); expect(store.contentInitBlockReason()).toBe('cancelled'); + expect(store.contentLoadStateByType()).toEqual({ + live: 'idle', + vod: 'idle', + series: 'idle', + }); await store.initializeContent(); @@ -575,6 +685,11 @@ describe('withContent import state', () => { expect(store.isCancellingImport()).toBe(false); expect(store.isContentInitialized()).toBe(false); expect(store.contentInitBlockReason()).toBe('cancelled'); + expect(store.contentLoadStateByType()).toEqual({ + live: 'idle', + vod: 'idle', + series: 'idle', + }); 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 091988b1a..e67448ae1 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 @@ -31,6 +31,8 @@ import { ContentType, PortalStatusType, XtreamContentInitBlockReason, + XtreamContentLoadState, + XtreamContentLoadStateByType, } from '../../xtream-state'; const cancelledPlaylistInitializationLockKey = ( @@ -89,6 +91,7 @@ export interface ContentState { isLoadingCategories: boolean; isLoadingContent: boolean; isImporting: boolean; + contentLoadStateByType: XtreamContentLoadStateByType; isCancellingImport: boolean; importCount: number; importPhase: string | null; @@ -102,6 +105,12 @@ export interface ContentState { /** * Initial content state */ +const initialContentLoadStateByType: XtreamContentLoadStateByType = { + live: 'idle', + vod: 'idle', + series: 'idle', +}; + const initialContentState: ContentState = { liveCategories: [], vodCategories: [], @@ -112,6 +121,7 @@ const initialContentState: ContentState = { isLoadingCategories: false, isLoadingContent: false, isImporting: false, + contentLoadStateByType: { ...initialContentLoadStateByType }, isCancellingImport: false, importCount: 0, importPhase: null, @@ -201,6 +211,18 @@ export function withContent() { const importTypes: ContentType[] = ['live', 'vod', 'series']; let activeInitializationPromise: Promise | null = null; + const updateContentTypeLoadState = ( + type: ContentType, + loadState: XtreamContentLoadState + ): void => { + patchState(store, (state) => ({ + contentLoadStateByType: { + ...state.contentLoadStateByType, + [type]: loadState, + }, + })); + }; + const resolveInitBlockReason = ( portalStatus: PortalStatusType | null | undefined ): XtreamContentInitBlockReason | null => { @@ -356,6 +378,29 @@ export function withContent() { } }; + const finalizePendingContentLoadStates = ( + completedTypes: Set, + loadState: XtreamContentLoadState + ): void => { + patchState(store, (state) => { + const nextLoadStates = { + ...state.contentLoadStateByType, + }; + + for (const type of importTypes) { + if (completedTypes.has(type)) { + continue; + } + + nextLoadStates[type] = loadState; + } + + return { + contentLoadStateByType: nextLoadStates, + }; + }); + }; + const executeContentInitialization = async ( ignoreBlockedState = false ): Promise => { @@ -397,6 +442,11 @@ export function withContent() { itemsToImport: 0, activeImportSessionId: importSessionId, activeImportOperationIds: [], + contentLoadStateByType: { + live: 'loading', + vod: 'loading', + series: 'loading', + }, }); const completedTypes = new Set(); @@ -472,6 +522,11 @@ export function withContent() { ); } + finalizePendingContentLoadStates( + completedTypes, + isDbAbortError(error) ? 'idle' : 'error' + ); + if (isDbAbortError(error)) { patchState(store, (state) => ({ contentInitBlockReason: @@ -656,6 +711,10 @@ export function withContent() { throwIfImportCancelled(options?.importSessionId); await setImportStatus(ctx.playlistId, 'live', 'completed'); options?.completedTypes?.add('live'); + patchState(store, { + liveStreams: live, + }); + updateContentTypeLoadState('live', 'ready'); throwIfImportCancelled(options?.importSessionId); const vodOperationId = databaseService.createOperationId( @@ -682,6 +741,10 @@ export function withContent() { throwIfImportCancelled(options?.importSessionId); await setImportStatus(ctx.playlistId, 'vod', 'completed'); options?.completedTypes?.add('vod'); + patchState(store, { + vodStreams: vod, + }); + updateContentTypeLoadState('vod', 'ready'); throwIfImportCancelled(options?.importSessionId); const seriesOperationId = @@ -709,13 +772,11 @@ export function withContent() { throwIfImportCancelled(options?.importSessionId); await setImportStatus(ctx.playlistId, 'series', 'completed'); options?.completedTypes?.add('series'); - patchState(store, { - liveStreams: live, - vodStreams: vod, serialStreams: series, isLoadingContent: false, }); + updateContentTypeLoadState('series', 'ready'); } catch (error) { if (!isDbAbortError(error)) { logger.error('Error fetching content', error); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts index 8f9e61c59..f62362be5 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts @@ -6,7 +6,7 @@ import { withMethods, withState, } from '@ngrx/signals'; -import { ContentType } from '../../xtream-state'; +import { ContentType, XtreamContentLoadState } from '../../xtream-state'; /** * Module-level collator — allocating Intl.Collator is expensive; @@ -101,6 +101,7 @@ interface XtreamSelectionItem { } type ParentSelectionStoreLike = { + contentLoadStateByType?: () => Record; isLoadingContent?: () => boolean; liveCategories?: () => XtreamSelectionCategory[]; liveStreams?: () => XtreamSelectionItem[]; @@ -222,6 +223,23 @@ export function withSelection() { (store as ParentSelectionStoreLike).serialStreams?.() || [] ) ); + const selectedTypeContentState = computed(() => { + const storeAny = store as ParentSelectionStoreLike; + const selectedType = store.selectedContentType(); + return ( + storeAny.contentLoadStateByType?.()?.[selectedType] ?? + 'idle' + ); + }); + const selectedTypeContentLoading = computed( + () => selectedTypeContentState() === 'loading' + ); + const selectedTypeContentReady = computed( + () => selectedTypeContentState() === 'ready' + ); + const selectedTypeCountsReady = computed( + () => selectedTypeContentReady() + ); // --------------------------------------------------------------------------- // Stable filter + sort intermediate. @@ -344,11 +362,17 @@ export function withSelection() { /** * Check if paginated content is loading */ - isPaginatedContentLoading: computed(() => { - // Access parent store loading state (from withContent) - const storeAny = store as ParentSelectionStoreLike; - return storeAny.isLoadingContent?.() || false; - }), + isPaginatedContentLoading: computed(() => + selectedTypeContentLoading() + ), + + selectedTypeContentState, + + selectedTypeContentLoading, + + selectedTypeContentReady, + + selectedTypeCountsReady, /** * Memoized category item counts map. 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 1f1ee5af0..0f79c9b31 100644 --- a/libs/portal/xtream/data-access/src/lib/xtream-state.ts +++ b/libs/portal/xtream/data-access/src/lib/xtream-state.ts @@ -12,6 +12,11 @@ import { } from './data-sources/xtream-data-source.interface'; export type ContentType = 'live' | 'vod' | 'series'; +export type XtreamContentLoadState = 'idle' | 'loading' | 'ready' | 'error'; +export type XtreamContentLoadStateByType = Record< + ContentType, + XtreamContentLoadState +>; export type PortalStatusType = | 'active' @@ -35,6 +40,7 @@ export interface XtreamState { isLoadingCategories: boolean; isLoadingContent: boolean; isImporting: boolean; + contentLoadStateByType: XtreamContentLoadStateByType; liveCategories: XtreamCategory[]; vodCategories: XtreamCategory[]; serialCategories: XtreamCategory[]; From 1d0b4ca1fed43d9ce74f405211279160c81f4a7b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Apr 2026 21:16:32 +0200 Subject: [PATCH 14/15] feat: implement category ID normalization and enhance category operations tests --- .../src/category-management.e2e.ts | 105 ++++++++++++++++++ .../operations/category.operations.spec.ts | 98 ++++++++++++++++ .../operations/category.operations.ts | 44 ++++++-- .../app/events/database/category.events.ts | 2 +- .../src/app/workers/database.worker.ts | 2 +- 5 files changed, 238 insertions(+), 13 deletions(-) create mode 100644 apps/electron-backend/src/app/database/operations/category.operations.spec.ts diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index 8ab3a5b38..a7696d3a9 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -116,6 +116,77 @@ test.describe('Electron Xtream Category Management', () => { await closeElectronApp(app); } }); + + test('persists hidden selections after refreshing from the workspace header', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const portalName = 'Header Refresh Xtream'; + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + name: portalName, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await app.mainWindow.waitForURL( + /\/workspace\/xtreams\/[^/]+\/live/ + ); + + const targetCategory = await pickSidebarCategory(app.mainWindow); + const dialog = await openManageCategoriesDialog(app.mainWindow); + + await dialog + .getByRole('button', { + name: 'Deselect All', + exact: true, + }) + .click(); + await expect( + dialog.locator('.category-item mat-checkbox input:checked') + ).toHaveCount(0); + + await dialog.locator('.search-field input').fill(targetCategory.name); + await toggleManagedCategory(dialog, targetCategory, true); + await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + await app.mainWindow.waitForSelector('mat-dialog-container', { + state: 'detached', + }); + + await expect( + sidebarCategoryById(app.mainWindow, targetCategory.id) + ).toBeVisible(); + + await refreshFromWorkspaceHeader(app.mainWindow); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await app.mainWindow.waitForURL( + /\/workspace\/xtreams\/[^/]+\/live/ + ); + await expectVisibleSidebarCategoryIds(app.mainWindow, [ + targetCategory.id, + ]); + + const restarted = await restartElectronApp(app, dataDir); + app.electronApp = restarted.electronApp; + app.mainWindow = restarted.mainWindow; + + await openSources(app.mainWindow); + await sourceRowByTitle(app.mainWindow, portalName).first().click(); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await app.mainWindow.waitForURL( + /\/workspace\/xtreams\/[^/]+\/live/ + ); + await expectVisibleSidebarCategoryIds(app.mainWindow, [ + targetCategory.id, + ]); + } finally { + await closeElectronApp(app); + } + }); }); async function openManageCategoriesDialog(page: Page) { @@ -134,12 +205,46 @@ async function openManageCategoriesDialog(page: Page) { return dialog; } +async function refreshFromWorkspaceHeader(page: Page): Promise { + await page + .getByRole('button', { name: 'Refresh playlist', exact: true }) + .click(); + + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: 'Yes', exact: true }).click(); + await page.waitForSelector('mat-dialog-container', { state: 'detached' }); +} + function sidebarCategoryById(page: Page, categoryId: string): Locator { return page.locator( `app-workspace-context-panel .category-item[data-category-id="${categoryId}"]` ); } +async function expectVisibleSidebarCategoryIds( + page: Page, + expectedIds: string[] +): Promise { + const categories = page.locator( + 'app-workspace-context-panel .category-item:visible' + ); + const actualIds: string[] = []; + const count = await categories.count(); + + for (let index = 0; index < count; index += 1) { + const categoryId = await categories + .nth(index) + .getAttribute('data-category-id'); + + if (categoryId) { + actualIds.push(categoryId.trim()); + } + } + + expect(actualIds).toEqual(expectedIds); +} + async function pickSidebarCategory( page: Page ): Promise<{ id: string; itemCount: number; name: string }> { diff --git a/apps/electron-backend/src/app/database/operations/category.operations.spec.ts b/apps/electron-backend/src/app/database/operations/category.operations.spec.ts new file mode 100644 index 000000000..6992f28f2 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/category.operations.spec.ts @@ -0,0 +1,98 @@ +import type { AppDatabase } from '../database.types'; +import { saveCategories } from './category.operations'; + +function createDbMock(existingCount = 0) { + const where = jest.fn().mockResolvedValue([{ count: existingCount }]); + const from = jest.fn().mockReturnValue({ where }); + const select = jest.fn().mockReturnValue({ from }); + const onConflictDoNothing = jest.fn().mockResolvedValue(undefined); + const values = jest.fn().mockReturnValue({ onConflictDoNothing }); + const insert = jest.fn().mockReturnValue({ values }); + + return { + db: { + select, + insert, + } as unknown as AppDatabase, + insert, + values, + onConflictDoNothing, + select, + from, + where, + }; +} + +describe('category.operations', () => { + it('restores hidden categories when Xtream API category IDs are strings', async () => { + const { db, values, insert } = createDbMock(); + + await saveCategories( + db, + 'playlist-1', + [ + { category_name: 'News', category_id: '101' }, + { category_name: 'Sports', category_id: '102' }, + ], + 'live', + [102] + ); + + expect(insert).toHaveBeenCalled(); + expect(values).toHaveBeenCalledWith([ + { + playlistId: 'playlist-1', + name: 'News', + type: 'live', + xtreamId: 101, + hidden: false, + }, + { + playlistId: 'playlist-1', + name: 'Sports', + type: 'live', + xtreamId: 102, + hidden: true, + }, + ]); + }); + + it('skips categories whose Xtream IDs are not numeric', async () => { + const { db, values } = createDbMock(); + + await saveCategories( + db, + 'playlist-1', + [ + { category_name: 'Valid', category_id: '201' }, + { category_name: 'Broken', category_id: 'not-a-number' }, + ], + 'movies', + [201] + ); + + expect(values).toHaveBeenCalledWith([ + { + playlistId: 'playlist-1', + name: 'Valid', + type: 'movies', + xtreamId: 201, + hidden: true, + }, + ]); + }); + + it('does not insert categories when all Xtream IDs are invalid', async () => { + const { db, insert } = createDbMock(); + + await saveCategories( + db, + 'playlist-1', + [{ category_name: 'Broken', category_id: 'not-a-number' }], + 'series', + [301] + ); + + expect(insert).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/category.operations.ts b/apps/electron-backend/src/app/database/operations/category.operations.ts index e20c8add6..bc9d1828f 100644 --- a/apps/electron-backend/src/app/database/operations/category.operations.ts +++ b/apps/electron-backend/src/app/database/operations/category.operations.ts @@ -2,6 +2,17 @@ import { and, eq, inArray, sql } from 'drizzle-orm'; import * as schema from 'database-schema'; import type { AppDatabase } from '../database.types'; +type XtreamCategoryInput = { + category_name: string; + category_id: string | number; +}; + +function normalizeXtreamCategoryId(rawCategoryId: string | number): number | null { + const xtreamId = Number.parseInt(String(rawCategoryId), 10); + + return Number.isNaN(xtreamId) ? null : xtreamId; +} + export async function hasCategories( db: AppDatabase, playlistId: string, @@ -41,10 +52,7 @@ export async function getCategories( export async function saveCategories( db: AppDatabase, playlistId: string, - categories: Array<{ - category_name: string; - category_id: number; - }>, + categories: XtreamCategoryInput[], type: 'live' | 'movies' | 'series', hiddenCategoryXtreamIds?: number[] ): Promise<{ success: boolean }> { @@ -67,13 +75,27 @@ export async function saveCategories( } const hiddenSet = new Set(hiddenCategoryXtreamIds || []); - const values = categories.map((category) => ({ - playlistId, - name: category.category_name, - type, - xtreamId: category.category_id, - hidden: hiddenSet.has(category.category_id), - })); + const values = categories.flatMap((category) => { + const xtreamId = normalizeXtreamCategoryId(category.category_id); + + if (xtreamId === null) { + return []; + } + + return [ + { + playlistId, + name: category.category_name, + type, + xtreamId, + hidden: hiddenSet.has(xtreamId), + }, + ]; + }); + + if (values.length === 0) { + return { success: true }; + } await db .insert(schema.categories) diff --git a/apps/electron-backend/src/app/events/database/category.events.ts b/apps/electron-backend/src/app/events/database/category.events.ts index 8c9dcab11..e25d97bce 100644 --- a/apps/electron-backend/src/app/events/database/category.events.ts +++ b/apps/electron-backend/src/app/events/database/category.events.ts @@ -27,7 +27,7 @@ handleWorkerRequest( playlistId: string, categories: Array<{ category_name: string; - category_id: number; + category_id: string | number; }>, type: 'live' | 'movies' | 'series', hiddenCategoryXtreamIds?: number[] diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 98240ea68..825b28b24 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -289,7 +289,7 @@ async function executeRequest(message: DbWorkerRequestMessage) { playlistId: string; categories: Array<{ category_name: string; - category_id: number; + category_id: string | number; }>; type: 'live' | 'movies' | 'series'; hiddenCategoryXtreamIds?: number[]; From 71befe0d4776c70ca2514867731046fe6ab9bdc0 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Apr 2026 21:17:13 +0200 Subject: [PATCH 15/15] feat: enhance typings and localization for Xtream categories and URL handling --- .../src/app/api/main.preload.ts | 3 +- apps/web/src/assets/i18n/de.json | 32 +++++++++++++++-- apps/web/src/assets/i18n/en.json | 32 +++++++++++++++-- apps/web/src/assets/i18n/ru.json | 34 +++++++++++++++++-- apps/web/src/typings.d.ts | 3 +- 5 files changed, 95 insertions(+), 9 deletions(-) diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index d99aed546..1d20e000b 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -3,6 +3,7 @@ import type { ExternalPlayerSession, PlaylistRefreshEvent, PlaylistRefreshPayload, + XtreamCategory, } from 'shared-interfaces'; const PORTAL_DEBUG_EVENT = 'PORTAL_DEBUG_EVENT'; @@ -298,7 +299,7 @@ contextBridge.exposeInMainWorld('electron', { ipcRenderer.invoke('DB_GET_CATEGORIES', playlistId, type), dbSaveCategories: ( playlistId: string, - categories: any[], + categories: XtreamCategory[], type: string, hiddenCategoryXtreamIds?: number[] ) => diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index c75b50f05..f1cda9069 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -491,6 +491,9 @@ "REMOVE": "Entfernen", "PLAY": "Abspielen", "REVEAL": "Im Ordner anzeigen", + "COPY_URL": "Download-URL kopieren", + "URL_COPIED": "Download-URL in die Zwischenablage kopiert", + "URL_COPY_FAILED": "URL konnte nicht kopiert werden", "STATUS": { "QUEUED": "In Warteschlange", "DOWNLOADING": "Wird heruntergeladen", @@ -674,7 +677,27 @@ "OPEN_DOWNLOADS_LABEL": "Downloads öffnen", "OPEN_DOWNLOADS_DESCRIPTION": "Zur Download-Ansicht wechseln", "OPEN_GLOBAL_RECENT_LABEL": "Zuletzt angesehen öffnen", - "OPEN_GLOBAL_RECENT_DESCRIPTION": "Globale Zuletzt-angesehen-Seite öffnen" + "OPEN_GLOBAL_RECENT_DESCRIPTION": "Globale Zuletzt-angesehen-Seite öffnen", + "OPEN_SETTINGS_LABEL": "Einstellungen öffnen", + "OPEN_SETTINGS_DESCRIPTION": "Zu den Anwendungseinstellungen", + "OPEN_SOURCES_LABEL": "Quellen verwalten", + "OPEN_SOURCES_DESCRIPTION": "Zur Quellenverwaltung", + "OPEN_DASHBOARD_LABEL": "Dashboard öffnen", + "OPEN_DASHBOARD_DESCRIPTION": "Zum Dashboard navigieren", + "ADD_PLAYLIST_LABEL": "Neue Quelle hinzufügen", + "ADD_PLAYLIST_DESCRIPTION": "Dialog zum Hinzufügen öffnen", + "REFRESH_PLAYLIST_LABEL": "Playlist aktualisieren", + "REFRESH_PLAYLIST_DESCRIPTION": "Daten von der Quelle neu laden", + "PLAYLIST_INFO_LABEL": "Playlist-Details", + "PLAYLIST_INFO_DESCRIPTION": "Aktuelle Playlist-Informationen anzeigen", + "ACCOUNT_INFO_LABEL": "Kontoinformationen", + "ACCOUNT_INFO_DESCRIPTION": "Xtream-Server-Kontodaten anzeigen", + "GO_TO_VOD_LABEL": "Filme", + "GO_TO_VOD_DESCRIPTION": "Zum Filmbereich wechseln", + "GO_TO_LIVE_LABEL": "Live-TV", + "GO_TO_LIVE_DESCRIPTION": "Zum Live-TV-Bereich wechseln", + "GO_TO_SERIES_LABEL": "Serien", + "GO_TO_SERIES_DESCRIPTION": "Zum Serienbereich wechseln" } }, "SOURCES": { @@ -694,6 +717,9 @@ "MOVIE_CATEGORIES": "Filmkategorien", "SERIES_CATEGORIES": "Serienkategorien", "LIVE_CATEGORIES": "Live-Kategorien", + "XTREAM_SYNCING_MOVIES": "Filmkategorien werden synchronisiert...", + "XTREAM_SYNCING_SERIES": "Serienkategorien werden synchronisiert...", + "XTREAM_SYNCING_LIVE": "Live-Kategorien werden synchronisiert...", "XTREAM_SOURCE": "Xtream-Quelle", "STALKER_PORTAL": "Stalker-Portal", "MANAGE_CATEGORIES": "Kategorien verwalten", @@ -701,7 +727,9 @@ "LOAD_CATEGORIES_ERROR_HINT": "Bitte erneut versuchen oder die Portal-Einstellungen prüfen.", "FILTER_BY_TYPE": "Nach Typ filtern", "ITEM_COUNT_ONE": "1 Element", - "ITEM_COUNT_OTHER": "{{count}} Elemente" + "ITEM_COUNT_OTHER": "{{count}} Elemente", + "SEARCH": "Suchen", + "SEARCH_CATEGORIES": "Kategorien suchen" }, "COMMAND_PALETTE": { "TITLE": "Befehlspalette", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index a15972c1e..81f628aae 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -561,6 +561,9 @@ "REMOVE": "Remove", "PLAY": "Play", "REVEAL": "Show in Folder", + "COPY_URL": "Copy download URL", + "URL_COPIED": "Download URL copied to clipboard", + "URL_COPY_FAILED": "Failed to copy URL to clipboard", "STATUS": { "QUEUED": "Queued", "DOWNLOADING": "Downloading", @@ -745,7 +748,27 @@ "OPEN_DOWNLOADS_LABEL": "Open downloads", "OPEN_DOWNLOADS_DESCRIPTION": "Navigate to downloads view", "OPEN_GLOBAL_RECENT_LABEL": "Open recently viewed", - "OPEN_GLOBAL_RECENT_DESCRIPTION": "Open global recently viewed page" + "OPEN_GLOBAL_RECENT_DESCRIPTION": "Open global recently viewed page", + "OPEN_SETTINGS_LABEL": "Open settings", + "OPEN_SETTINGS_DESCRIPTION": "Navigate to application settings", + "OPEN_SOURCES_LABEL": "Manage sources", + "OPEN_SOURCES_DESCRIPTION": "Navigate to source management", + "OPEN_DASHBOARD_LABEL": "Open dashboard", + "OPEN_DASHBOARD_DESCRIPTION": "Navigate to the dashboard", + "ADD_PLAYLIST_LABEL": "Add new source", + "ADD_PLAYLIST_DESCRIPTION": "Open add playlist dialog", + "REFRESH_PLAYLIST_LABEL": "Refresh playlist", + "REFRESH_PLAYLIST_DESCRIPTION": "Re-sync data from remote source", + "PLAYLIST_INFO_LABEL": "Playlist details", + "PLAYLIST_INFO_DESCRIPTION": "Show current playlist information", + "ACCOUNT_INFO_LABEL": "Account info", + "ACCOUNT_INFO_DESCRIPTION": "Show Xtream server account details", + "GO_TO_VOD_LABEL": "Movies", + "GO_TO_VOD_DESCRIPTION": "Jump to movies section", + "GO_TO_LIVE_LABEL": "Live TV", + "GO_TO_LIVE_DESCRIPTION": "Jump to live TV section", + "GO_TO_SERIES_LABEL": "Series", + "GO_TO_SERIES_DESCRIPTION": "Jump to series section" } }, "SOURCES": { @@ -765,6 +788,9 @@ "MOVIE_CATEGORIES": "Movie Categories", "SERIES_CATEGORIES": "Series Categories", "LIVE_CATEGORIES": "Live Categories", + "XTREAM_SYNCING_MOVIES": "Syncing movie categories...", + "XTREAM_SYNCING_SERIES": "Syncing series categories...", + "XTREAM_SYNCING_LIVE": "Syncing live categories...", "XTREAM_SOURCE": "Xtream source", "STALKER_PORTAL": "Stalker portal", "MANAGE_CATEGORIES": "Manage categories", @@ -772,7 +798,9 @@ "LOAD_CATEGORIES_ERROR_HINT": "Please retry or verify portal settings.", "FILTER_BY_TYPE": "Filter by type", "ITEM_COUNT_ONE": "1 item", - "ITEM_COUNT_OTHER": "{{count}} items" + "ITEM_COUNT_OTHER": "{{count}} items", + "SEARCH": "Search", + "SEARCH_CATEGORIES": "Search categories" }, "COMMAND_PALETTE": { "TITLE": "Command palette", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 158ecc0a6..505f422ff 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -474,7 +474,10 @@ }, "SOURCE_PLAYLIST_MISSING": "Эту загрузку больше нельзя открыть в библиотеке, потому что исходный плейлист был удален.", "FILE_NOT_FOUND": "Этот загруженный файл больше недоступен на диске.", - "FILE_ACTION_ERROR": "Не удалось выполнить действие с файлом." + "FILE_ACTION_ERROR": "Не удалось выполнить действие с файлом.", + "COPY_URL": "Копировать URL загрузки", + "URL_COPIED": "URL загрузки скопирован в буфер обмена", + "URL_COPY_FAILED": "Не удалось скопировать URL" }, "REMOTE_CONTROL": { "TITLE": "Дистанционное управление", @@ -651,7 +654,27 @@ "OPEN_DOWNLOADS_LABEL": "Открыть загрузки", "OPEN_DOWNLOADS_DESCRIPTION": "Перейти к представлению загрузок", "OPEN_GLOBAL_RECENT_LABEL": "Открыть недавно просмотренные", - "OPEN_GLOBAL_RECENT_DESCRIPTION": "Открыть глобальную страницу недавно просмотренного" + "OPEN_GLOBAL_RECENT_DESCRIPTION": "Открыть глобальную страницу недавно просмотренного", + "OPEN_SETTINGS_LABEL": "Открыть настройки", + "OPEN_SETTINGS_DESCRIPTION": "Перейти к настройкам приложения", + "OPEN_SOURCES_LABEL": "Управление источниками", + "OPEN_SOURCES_DESCRIPTION": "Перейти к управлению источниками", + "OPEN_DASHBOARD_LABEL": "Открыть панель", + "OPEN_DASHBOARD_DESCRIPTION": "Перейти к панели управления", + "ADD_PLAYLIST_LABEL": "Добавить источник", + "ADD_PLAYLIST_DESCRIPTION": "Открыть диалог добавления плейлиста", + "REFRESH_PLAYLIST_LABEL": "Обновить плейлист", + "REFRESH_PLAYLIST_DESCRIPTION": "Синхронизировать данные с источника", + "PLAYLIST_INFO_LABEL": "Сведения о плейлисте", + "PLAYLIST_INFO_DESCRIPTION": "Показать информацию о текущем плейлисте", + "ACCOUNT_INFO_LABEL": "Информация об аккаунте", + "ACCOUNT_INFO_DESCRIPTION": "Показать данные аккаунта Xtream", + "GO_TO_VOD_LABEL": "Фильмы", + "GO_TO_VOD_DESCRIPTION": "Перейти к разделу фильмов", + "GO_TO_LIVE_LABEL": "Прямой эфир", + "GO_TO_LIVE_DESCRIPTION": "Перейти к разделу ТВ", + "GO_TO_SERIES_LABEL": "Сериалы", + "GO_TO_SERIES_DESCRIPTION": "Перейти к разделу сериалов" } }, "SOURCES": { @@ -671,6 +694,9 @@ "MOVIE_CATEGORIES": "Категории фильмов", "SERIES_CATEGORIES": "Категории сериалов", "LIVE_CATEGORIES": "Категории ТВ", + "XTREAM_SYNCING_MOVIES": "Синхронизация категорий фильмов...", + "XTREAM_SYNCING_SERIES": "Синхронизация категорий сериалов...", + "XTREAM_SYNCING_LIVE": "Синхронизация категорий ТВ...", "XTREAM_SOURCE": "Xtream источник", "STALKER_PORTAL": "Портал Stalker", "MANAGE_CATEGORIES": "Управлять категориями", @@ -678,7 +704,9 @@ "LOAD_CATEGORIES_ERROR_HINT": "Повторите попытку или проверьте настройки портала.", "FILTER_BY_TYPE": "Фильтр по типу", "ITEM_COUNT_ONE": "1 элемент", - "ITEM_COUNT_OTHER": "{{count}} элементов" + "ITEM_COUNT_OTHER": "{{count}} элементов", + "SEARCH": "Поиск", + "SEARCH_CATEGORIES": "Поиск категорий" }, "COMMAND_PALETTE": { "TITLE": "Палитра команд", diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index 5bca10657..a17d42fab 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -13,6 +13,7 @@ import { PlaylistRefreshEvent, PlaylistRefreshPayload, PortalDebugEvent, + XtreamCategory, } from 'shared-interfaces'; import { DbOperationEvent, @@ -191,7 +192,7 @@ declare global { ) => Promise; dbSaveCategories: ( playlistId: string, - categories: XCategoryFromDb[], + categories: XtreamCategory[], type: string, hiddenCategoryXtreamIds?: number[] ) => Promise<{ success: boolean }>;