From 55efc24608c6aa69f7f4496b28a9a4f0febcf69b Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 02:09:57 +0300 Subject: [PATCH] fix(pwa): harden self-hosted runtime boundaries --- README.md | 8 +- apps/web/src/app/app.config.ts | 8 +- ...edded-mpv-player-recording-message.spec.ts | 8 +- apps/web/src/assets/i18n/ar.json | 8 +- apps/web/src/assets/i18n/ary.json | 8 +- apps/web/src/assets/i18n/by.json | 8 +- apps/web/src/assets/i18n/de.json | 8 +- apps/web/src/assets/i18n/el.json | 8 +- apps/web/src/assets/i18n/en.json | 8 +- apps/web/src/assets/i18n/es.json | 8 +- apps/web/src/assets/i18n/fr.json | 8 +- apps/web/src/assets/i18n/it.json | 8 +- apps/web/src/assets/i18n/ja.json | 8 +- apps/web/src/assets/i18n/ko.json | 8 +- apps/web/src/assets/i18n/nl.json | 8 +- apps/web/src/assets/i18n/pl.json | 8 +- apps/web/src/assets/i18n/pt.json | 8 +- apps/web/src/assets/i18n/ru.json | 10 +-- apps/web/src/assets/i18n/tr.json | 8 +- apps/web/src/assets/i18n/zh.json | 8 +- apps/web/src/assets/i18n/zhtw.json | 8 +- docker/README.md | 15 ++++ docs/architecture/pwa-self-hosted.md | 38 +++++++-- .../video-player/video-player.component.html | 2 +- .../video-player.component.spec.ts | 39 ++++++++- .../video-player/video-player.component.ts | 18 +++- .../playlist-switcher.component.spec.ts | 81 ++++++++++++++---- .../playlist-switcher.component.ts | 37 ++++++-- .../empty-state/empty-state.component.ts | 8 +- .../recent-playlists.component.spec.ts | 53 ++++++++++++ .../recent-playlists.component.ts | 51 +++++++---- .../stream-resolver.service.spec.ts | 33 +++++++ .../lib/collection/stream-resolver.service.ts | 43 ++++++++-- .../unified-live-tab.component.html | 2 +- .../unified-live-tab.component.spec.ts | 76 ++++++++++++++++- .../unified-live-tab.component.ts | 13 ++- .../features/with-stalker-epg.feature.spec.ts | 35 +++++++- .../features/with-stalker-epg.feature.ts | 24 +++++- .../utils/stalker-player-request.utils.ts | 2 +- .../stalker-live-stream-layout.component.html | 18 ++-- ...alker-live-stream-layout.component.spec.ts | 29 +++++++ .../stalker-live-stream-layout.component.ts | 15 +++- .../data-access/src/lib/data-sources/index.ts | 21 ++++- .../stores/features/with-epg.feature.spec.ts | 29 ++++++- .../lib/stores/features/with-epg.feature.ts | 26 ++++-- .../live-stream-layout.component.html | 58 +++++++------ .../live-stream-layout.component.spec.ts | 21 +++++ .../live-stream-layout.component.ts | 1 + .../portal-channels-list.component.html | 12 ++- .../portal-channels-list.component.spec.ts | 85 ++++++++++++++++--- .../portal-channels-list.component.ts | 41 ++++++--- libs/services/src/index.ts | 1 + .../src/lib/playlist-delete-cleanup.token.ts | 9 ++ .../src/lib/playlists.service.spec.ts | 33 ++++++- libs/services/src/lib/playlists.service.ts | 54 +++++++++--- libs/ui/playback/src/index.ts | 2 + .../workspace-dashboard-rails.component.ts | 46 +++++++--- 57 files changed, 984 insertions(+), 257 deletions(-) create mode 100644 libs/services/src/lib/playlist-delete-cleanup.token.ts diff --git a/README.md b/README.md index d56cbbc45..2c1d2f2df 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,13 @@ docker compose -f docker/docker-compose.yml up --build -d The application is available at . See [`docker/docker-compose.yml`](./docker/docker-compose.yml) for the ready-to-run compose file and [`docker/README.md`](./docker/README.md) for environment -variables, reverse proxy notes, and build details. +variables, reverse proxy notes, PWA limitations, and build details. + +The self-hosted image runs the browser PWA rather than the Electron desktop app: +EPG/XMLTV panels, Embedded MPV, managed MPV/VLC launching, the download manager, +and Electron remote-control features are not available there. If browser +playback fails, copy the stream URL and open it manually in an external player +such as MPV, VLC, or IINA. ## Download diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 70bb84b3f..9acd7cf34 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -5,6 +5,7 @@ import { } from '@angular/common/http'; import { ApplicationConfig, + inject, importProvidersFrom, provideZoneChangeDetection, } from '@angular/core'; @@ -19,7 +20,7 @@ import { provideStoreDevtools } from '@ngrx/store-devtools'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateHttpLoader } from '@ngx-translate/http-loader'; import { PlaylistEffects, playlistReducer } from '@iptvnator/m3u-state'; -import { NgxIndexedDBModule, NgxIndexedDBService } from 'ngx-indexed-db'; +import { NgxIndexedDBModule } from 'ngx-indexed-db'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { PORTAL_EXTERNAL_PLAYBACK, @@ -95,9 +96,9 @@ export function getInitialLanguage(): string { */ export function DataFactory() { if (window.electron) { - return new ElectronService(); + return inject(ElectronService); } - return new PwaService(); + return inject(PwaService); } export const appConfig: ApplicationConfig = { @@ -135,7 +136,6 @@ export const appConfig: ApplicationConfig = { { provide: DataService, useFactory: DataFactory, - deps: [NgxIndexedDBService, HttpClient], }, { provide: PORTAL_PLAYER, diff --git a/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts b/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts index d8932fac6..24f0d42d6 100644 --- a/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts +++ b/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts @@ -2,9 +2,11 @@ import { Component, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; -import { EmbeddedMpvOverlayVisibilityService } from '../../../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-overlay-visibility.service'; -import { EmbeddedMpvPlayerComponent } from '../../../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component'; -import { EmbeddedMpvSessionController } from '../../../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller'; +import { + EmbeddedMpvOverlayVisibilityService, + EmbeddedMpvPlayerComponent, + EmbeddedMpvSessionController, +} from '@iptvnator/ui/playback'; @Component({ imports: [EmbeddedMpvPlayerComponent], diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 8793c3da9..1b233a9fa 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -391,11 +391,11 @@ "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", - "DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support." + "DESCRIPTION": "The browser player may not decode this container inline. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA for broader codec support." }, "UNSUPPORTED_CODEC": { "TITLE": "This stream codec is likely unsupported by the browser player", - "DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it." + "DESCRIPTION": "The stream advertises codecs that the browser player may not decode. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA." }, "MEDIA_DECODE_ERROR": { "TITLE": "The browser player could not decode this stream", @@ -407,7 +407,7 @@ }, "BROWSER_ACCESS_ERROR": { "TITLE": "The browser player could not access this stream", - "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits." + "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits." }, "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", @@ -418,7 +418,7 @@ "DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause." }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", - "INLINE_FAILURE_TITLE": "Check the stream or try again", + "INLINE_FAILURE_TITLE": "Copy the URL or try again", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index e3818bebd..0cdbbfbea 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -391,11 +391,11 @@ "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", - "DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support." + "DESCRIPTION": "The browser player may not decode this container inline. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA for broader codec support." }, "UNSUPPORTED_CODEC": { "TITLE": "This stream codec is likely unsupported by the browser player", - "DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it." + "DESCRIPTION": "The stream advertises codecs that the browser player may not decode. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA." }, "MEDIA_DECODE_ERROR": { "TITLE": "The browser player could not decode this stream", @@ -407,7 +407,7 @@ }, "BROWSER_ACCESS_ERROR": { "TITLE": "The browser player could not access this stream", - "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits." + "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits." }, "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", @@ -418,7 +418,7 @@ "DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause." }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", - "INLINE_FAILURE_TITLE": "Check the stream or try again", + "INLINE_FAILURE_TITLE": "Copy the URL or try again", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 28166c96e..c7d6c9994 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -391,11 +391,11 @@ "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", - "DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support." + "DESCRIPTION": "The browser player may not decode this container inline. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA for broader codec support." }, "UNSUPPORTED_CODEC": { "TITLE": "This stream codec is likely unsupported by the browser player", - "DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it." + "DESCRIPTION": "The stream advertises codecs that the browser player may not decode. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA." }, "MEDIA_DECODE_ERROR": { "TITLE": "The browser player could not decode this stream", @@ -407,7 +407,7 @@ }, "BROWSER_ACCESS_ERROR": { "TITLE": "The browser player could not access this stream", - "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits." + "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits." }, "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", @@ -418,7 +418,7 @@ "DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause." }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", - "INLINE_FAILURE_TITLE": "Check the stream or try again", + "INLINE_FAILURE_TITLE": "Copy the URL or try again", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index c03f42e6b..96c4cba3e 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -391,11 +391,11 @@ "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", - "DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support." + "DESCRIPTION": "The browser player may not decode this container inline. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA for broader codec support." }, "UNSUPPORTED_CODEC": { "TITLE": "This stream codec is likely unsupported by the browser player", - "DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it." + "DESCRIPTION": "The stream advertises codecs that the browser player may not decode. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA." }, "MEDIA_DECODE_ERROR": { "TITLE": "The browser player could not decode this stream", @@ -407,7 +407,7 @@ }, "BROWSER_ACCESS_ERROR": { "TITLE": "The browser player could not access this stream", - "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits." + "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits." }, "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", @@ -418,7 +418,7 @@ "DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause." }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", - "INLINE_FAILURE_TITLE": "Check the stream or try again", + "INLINE_FAILURE_TITLE": "Copy the URL or try again", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 026ee5d9d..3e906a4e7 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -391,11 +391,11 @@ "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", - "DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support." + "DESCRIPTION": "The browser player may not decode this container inline. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA for broader codec support." }, "UNSUPPORTED_CODEC": { "TITLE": "This stream codec is likely unsupported by the browser player", - "DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it." + "DESCRIPTION": "The stream advertises codecs that the browser player may not decode. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA." }, "MEDIA_DECODE_ERROR": { "TITLE": "The browser player could not decode this stream", @@ -407,7 +407,7 @@ }, "BROWSER_ACCESS_ERROR": { "TITLE": "The browser player could not access this stream", - "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits." + "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits." }, "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", @@ -418,7 +418,7 @@ "DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause." }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", - "INLINE_FAILURE_TITLE": "Check the stream or try again", + "INLINE_FAILURE_TITLE": "Copy the URL or try again", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 30aa8e0b0..1eb34700d 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -391,11 +391,11 @@ "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", - "DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support." + "DESCRIPTION": "The browser player may not decode this container inline. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA for broader codec support." }, "UNSUPPORTED_CODEC": { "TITLE": "This stream codec is likely unsupported by the browser player", - "DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it." + "DESCRIPTION": "The stream advertises codecs that the browser player may not decode. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA." }, "MEDIA_DECODE_ERROR": { "TITLE": "The browser player could not decode this stream", @@ -407,7 +407,7 @@ }, "BROWSER_ACCESS_ERROR": { "TITLE": "The browser player could not access this stream", - "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits." + "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits." }, "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", @@ -418,7 +418,7 @@ "DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause." }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", - "INLINE_FAILURE_TITLE": "Check the stream or try again", + "INLINE_FAILURE_TITLE": "Copy the URL or try again", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 2a46d3a5c..5132150c6 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -391,11 +391,11 @@ "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", - "DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support." + "DESCRIPTION": "The browser player may not decode this container inline. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA for broader codec support." }, "UNSUPPORTED_CODEC": { "TITLE": "This stream codec is likely unsupported by the browser player", - "DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it." + "DESCRIPTION": "The stream advertises codecs that the browser player may not decode. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA." }, "MEDIA_DECODE_ERROR": { "TITLE": "The browser player could not decode this stream", @@ -407,7 +407,7 @@ }, "BROWSER_ACCESS_ERROR": { "TITLE": "The browser player could not access this stream", - "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits." + "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits." }, "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", @@ -418,7 +418,7 @@ "DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause." }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", - "INLINE_FAILURE_TITLE": "Check the stream or try again", + "INLINE_FAILURE_TITLE": "Copy the URL or try again", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index bf5fb1041..91443af95 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -391,11 +391,11 @@ "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", - "DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support." + "DESCRIPTION": "The browser player may not decode this container inline. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA for broader codec support." }, "UNSUPPORTED_CODEC": { "TITLE": "This stream codec is likely unsupported by the browser player", - "DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it." + "DESCRIPTION": "The stream advertises codecs that the browser player may not decode. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA." }, "MEDIA_DECODE_ERROR": { "TITLE": "The browser player could not decode this stream", @@ -407,7 +407,7 @@ }, "BROWSER_ACCESS_ERROR": { "TITLE": "The browser player could not access this stream", - "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits." + "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits." }, "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", @@ -418,7 +418,7 @@ "DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause." }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", - "INLINE_FAILURE_TITLE": "Check the stream or try again", + "INLINE_FAILURE_TITLE": "Copy the URL or try again", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index e3827b296..613e3b622 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -391,11 +391,11 @@ "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", - "DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support." + "DESCRIPTION": "The browser player may not decode this container inline. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA for broader codec support." }, "UNSUPPORTED_CODEC": { "TITLE": "This stream codec is likely unsupported by the browser player", - "DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it." + "DESCRIPTION": "The stream advertises codecs that the browser player may not decode. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA." }, "MEDIA_DECODE_ERROR": { "TITLE": "The browser player could not decode this stream", @@ -407,7 +407,7 @@ }, "BROWSER_ACCESS_ERROR": { "TITLE": "The browser player could not access this stream", - "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits." + "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits." }, "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", @@ -418,7 +418,7 @@ "DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause." }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", - "INLINE_FAILURE_TITLE": "Check the stream or try again", + "INLINE_FAILURE_TITLE": "Copy the URL or try again", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 1bb34bd1a..c1047ff9d 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -391,11 +391,11 @@ "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", - "DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support." + "DESCRIPTION": "The browser player may not decode this container inline. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA for broader codec support." }, "UNSUPPORTED_CODEC": { "TITLE": "This stream codec is likely unsupported by the browser player", - "DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it." + "DESCRIPTION": "The stream advertises codecs that the browser player may not decode. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA." }, "MEDIA_DECODE_ERROR": { "TITLE": "The browser player could not decode this stream", @@ -407,7 +407,7 @@ }, "BROWSER_ACCESS_ERROR": { "TITLE": "The browser player could not access this stream", - "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits." + "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits." }, "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", @@ -418,7 +418,7 @@ "DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause." }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", - "INLINE_FAILURE_TITLE": "Check the stream or try again", + "INLINE_FAILURE_TITLE": "Copy the URL or try again", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 609035586..eca2c9ec0 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -391,11 +391,11 @@ "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", - "DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support." + "DESCRIPTION": "The browser player may not decode this container inline. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA for broader codec support." }, "UNSUPPORTED_CODEC": { "TITLE": "This stream codec is likely unsupported by the browser player", - "DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it." + "DESCRIPTION": "The stream advertises codecs that the browser player may not decode. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA." }, "MEDIA_DECODE_ERROR": { "TITLE": "The browser player could not decode this stream", @@ -407,7 +407,7 @@ }, "BROWSER_ACCESS_ERROR": { "TITLE": "The browser player could not access this stream", - "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits." + "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits." }, "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", @@ -418,7 +418,7 @@ "DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause." }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", - "INLINE_FAILURE_TITLE": "Check the stream or try again", + "INLINE_FAILURE_TITLE": "Copy the URL or try again", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 315933711..915690590 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -391,11 +391,11 @@ "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", - "DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support." + "DESCRIPTION": "The browser player may not decode this container inline. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA for broader codec support." }, "UNSUPPORTED_CODEC": { "TITLE": "This stream codec is likely unsupported by the browser player", - "DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it." + "DESCRIPTION": "The stream advertises codecs that the browser player may not decode. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA." }, "MEDIA_DECODE_ERROR": { "TITLE": "The browser player could not decode this stream", @@ -407,7 +407,7 @@ }, "BROWSER_ACCESS_ERROR": { "TITLE": "The browser player could not access this stream", - "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits." + "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits." }, "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", @@ -418,7 +418,7 @@ "DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause." }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", - "INLINE_FAILURE_TITLE": "Check the stream or try again", + "INLINE_FAILURE_TITLE": "Copy the URL or try again", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 8e1b107e4..fcdc25b40 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -391,11 +391,11 @@ "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", - "DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support." + "DESCRIPTION": "The browser player may not decode this container inline. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA for broader codec support." }, "UNSUPPORTED_CODEC": { "TITLE": "This stream codec is likely unsupported by the browser player", - "DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it." + "DESCRIPTION": "The stream advertises codecs that the browser player may not decode. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA." }, "MEDIA_DECODE_ERROR": { "TITLE": "The browser player could not decode this stream", @@ -407,7 +407,7 @@ }, "BROWSER_ACCESS_ERROR": { "TITLE": "The browser player could not access this stream", - "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits." + "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits." }, "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", @@ -418,7 +418,7 @@ "DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause." }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", - "INLINE_FAILURE_TITLE": "Check the stream or try again", + "INLINE_FAILURE_TITLE": "Copy the URL or try again", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index c1617c139..bff895f08 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -391,11 +391,11 @@ "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", - "DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support." + "DESCRIPTION": "The browser player may not decode this container inline. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA for broader codec support." }, "UNSUPPORTED_CODEC": { "TITLE": "This stream codec is likely unsupported by the browser player", - "DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it." + "DESCRIPTION": "The stream advertises codecs that the browser player may not decode. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA." }, "MEDIA_DECODE_ERROR": { "TITLE": "The browser player could not decode this stream", @@ -407,7 +407,7 @@ }, "BROWSER_ACCESS_ERROR": { "TITLE": "The browser player could not access this stream", - "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits." + "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits." }, "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", @@ -418,7 +418,7 @@ "DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause." }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", - "INLINE_FAILURE_TITLE": "Check the stream or try again", + "INLINE_FAILURE_TITLE": "Copy the URL or try again", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index e2d94ce57..ba14d6d36 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -391,11 +391,11 @@ "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "Контейнер потока, вероятно, не поддерживается встроенным браузерным плеером", - "DESCRIPTION": "Chromium/Electron может не воспроизводить этот контейнер внутри приложения. Попробуйте MPV или VLC с более широкой поддержкой кодеков." + "DESCRIPTION": "Браузерный плеер может не декодировать этот контейнер внутри приложения. Скопируйте URL потока и откройте его во внешнем плеере, например MPV, VLC или IINA." }, "UNSUPPORTED_CODEC": { "TITLE": "Кодек потока, вероятно, не поддерживается встроенным браузерным плеером", - "DESCRIPTION": "Поток сообщает кодеки, которые встроенный Chromium-плеер может не декодировать. MPV или VLC часто справляются с такими потоками." + "DESCRIPTION": "Поток сообщает кодеки, которые браузерный плеер может не декодировать. Скопируйте URL потока и откройте его во внешнем плеере, например MPV, VLC или IINA." }, "MEDIA_DECODE_ERROR": { "TITLE": "Браузерный плеер не смог декодировать этот поток", @@ -407,7 +407,7 @@ }, "BROWSER_ACCESS_ERROR": { "TITLE": "Браузерный плеер не получил доступ к потоку", - "DESCRIPTION": "Запрос может быть заблокирован CORS, mixed content или требованиями провайдера/прокси к заголовкам. MPV или VLC часто обходят такие ограничения браузерного плеера." + "DESCRIPTION": "Запрос может быть заблокирован CORS, mixed content или требованиями провайдера/прокси к заголовкам. Скопируйте URL потока и откройте его во внешнем плеере, например MPV, VLC или IINA." }, "DRM_OR_ENCRYPTION": { "TITLE": "Поток может быть зашифрован или защищен DRM", @@ -418,12 +418,12 @@ "DESCRIPTION": "Поток не воспроизвелся во встроенном плеере, но ошибка не указала конкретную причину в кодеке, контейнере или сети." }, "NATIVE_FALLBACK_TITLE": "Откройте поток во внешнем плеере", - "INLINE_FAILURE_TITLE": "Проверьте поток или повторите попытку", + "INLINE_FAILURE_TITLE": "Скопируйте URL или повторите попытку", "ACTION_OPEN_MPV": "Открыть в MPV", "ACTION_OPEN_VLC": "Открыть в VLC", "ACTION_OPEN_MPV_HINT": "настроенный плеер", "ACTION_OPEN_VLC_HINT": "настроенный плеер", - "ACTION_COPY_URL": "Скопировать URL", + "ACTION_COPY_URL": "Скопировать URL потока", "ACTION_RETRY": "Повторить", "DETAILS_SUMMARY": "Технические сведения", "CODEC_HINT": "Обнаружены {{codecs}}. Браузерные плееры Chromium/Electron часто имеют ограниченную поддержку этих кодеков.", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index a7e6cf1c3..a477ec002 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -391,11 +391,11 @@ "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", - "DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support." + "DESCRIPTION": "The browser player may not decode this container inline. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA for broader codec support." }, "UNSUPPORTED_CODEC": { "TITLE": "This stream codec is likely unsupported by the browser player", - "DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it." + "DESCRIPTION": "The stream advertises codecs that the browser player may not decode. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA." }, "MEDIA_DECODE_ERROR": { "TITLE": "The browser player could not decode this stream", @@ -407,7 +407,7 @@ }, "BROWSER_ACCESS_ERROR": { "TITLE": "The browser player could not access this stream", - "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits." + "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits." }, "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", @@ -418,7 +418,7 @@ "DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause." }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", - "INLINE_FAILURE_TITLE": "Check the stream or try again", + "INLINE_FAILURE_TITLE": "Copy the URL or try again", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 07ad2e46a..501fbfbe9 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -391,11 +391,11 @@ "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", - "DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support." + "DESCRIPTION": "The browser player may not decode this container inline. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA for broader codec support." }, "UNSUPPORTED_CODEC": { "TITLE": "This stream codec is likely unsupported by the browser player", - "DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it." + "DESCRIPTION": "The stream advertises codecs that the browser player may not decode. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA." }, "MEDIA_DECODE_ERROR": { "TITLE": "The browser player could not decode this stream", @@ -407,7 +407,7 @@ }, "BROWSER_ACCESS_ERROR": { "TITLE": "The browser player could not access this stream", - "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits." + "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits." }, "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", @@ -418,7 +418,7 @@ "DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause." }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", - "INLINE_FAILURE_TITLE": "Check the stream or try again", + "INLINE_FAILURE_TITLE": "Copy the URL or try again", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 0f4e9830a..55cb9b798 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -391,11 +391,11 @@ "PLAYBACK_DIAGNOSTICS": { "UNSUPPORTED_CONTAINER": { "TITLE": "This stream container is likely unsupported by the browser player", - "DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support." + "DESCRIPTION": "The browser player may not decode this container inline. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA for broader codec support." }, "UNSUPPORTED_CODEC": { "TITLE": "This stream codec is likely unsupported by the browser player", - "DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it." + "DESCRIPTION": "The stream advertises codecs that the browser player may not decode. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA." }, "MEDIA_DECODE_ERROR": { "TITLE": "The browser player could not decode this stream", @@ -407,7 +407,7 @@ }, "BROWSER_ACCESS_ERROR": { "TITLE": "The browser player could not access this stream", - "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits." + "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits." }, "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", @@ -418,7 +418,7 @@ "DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause." }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", - "INLINE_FAILURE_TITLE": "Check the stream or try again", + "INLINE_FAILURE_TITLE": "Copy the URL or try again", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/docker/README.md b/docker/README.md index 7906c6fa8..30e49b150 100644 --- a/docker/README.md +++ b/docker/README.md @@ -8,6 +8,21 @@ The self-hosted image contains both pieces required for the browser PWA: The historical standalone `4gray/iptvnator-backend` image is no longer needed for the default Docker deployment. +## PWA Limitations And Playback Troubleshooting + +The Docker image runs the browser PWA, not the Electron desktop application. +That means: + +- EPG/XMLTV panels and multi-EPG views are not available in the self-hosted PWA. +- Playlist metadata is stored in the browser through IndexedDB, and Xtream user + data is stored by the PWA data source in localStorage. The PWA does not use + the Electron SQLite database or DB worker. +- Docker cannot launch local desktop players or Embedded MPV. If a stream does + not play inline in the browser, use the in-app copy URL action and open the + stream manually in an external player such as MPV, VLC, or IINA. +- Electron-only features such as the download manager, external-player process + control, and remote-control integrations are outside this runtime. + ## Run With Docker Compose From the repository root: diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 6920af21b..8f9abc584 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -64,8 +64,26 @@ The PWA continues to use `PwaService`; only the backend base URL is resolved at runtime. Electron routes remain owned by the Electron backend and preload bridge. -EPG/XMLTV is not a supported self-hosted PWA capability yet. Do not document it -as part of the Docker user flow or use it as the readiness signal for this path. +## Runtime Limitations + +The self-hosted build is the browser PWA, not the Electron desktop app. Keep +these limitations explicit in UI, troubleshooting, and release notes: + +- EPG/XMLTV is not supported in the PWA yet. Do not render live EPG panels, + multi-EPG shortcuts, or EPG-fetching flows in browser/PWA mode, and do not use + EPG as the readiness signal for Docker. +- The PWA does not use the Electron SQLite database or DB worker. Playlist + metadata uses `PlaylistsService` with IndexedDB; Xtream favorites, recently + viewed items, playback positions, and cached collection snapshots use + `PwaXtreamDataSource` browser storage. +- Browser playlist deletion must go through `PlaylistsService`, not + `DatabaseService`. `PlaylistsService.deletePlaylist()` runs registered + cleanup hooks such as the PWA Xtream cleanup so localStorage sidecar data does + not survive after the source is removed. +- The Docker/PWA runtime cannot launch MPV, VLC, IINA, Embedded MPV, download + manager flows, or Electron remote-control features. If inline browser + playback fails, the supported browser fallback is copying the stream URL and + opening it manually in an external player. Provider URLs are registered before proxy calls so the proxy endpoints do not accept raw target URLs in query strings. Registration validates the target URL @@ -85,21 +103,25 @@ certificate authorities, configure Node with `NODE_EXTRA_CA_CERTS`. Xtream favorites and recently viewed items use the browser-side `PwaXtreamDataSource` when Electron DB preload APIs are unavailable. The PWA -stores this user activity in localStorage: +stores this user activity and sidecar state in localStorage: +- `xtream-collection-items` - `xtream-favorites` - `xtream-recent-items` +- `xtream-playlists` +- `xtream-playback-positions` Entries should include a content snapshot when the item is added. Global collection routes and the dashboard can then restore titles, posters, content type, and category IDs after navigation or a page reload without relying on the Electron SQLite content table. -Shared collection services must not import `@iptvnator/portal/xtream/data-access` -directly. Use `XTREAM_COLLECTION_DATA_SOURCE` from -`@iptvnator/portal/shared/util` and bind it to `XTREAM_DATA_SOURCE` at the app -provider boundary (`apps/web/src/app/app.config.ts`). This keeps -`portal-shared-util` provider-neutral and avoids adding new Nx boundary cycles. +Shared collection services that need Xtream favorites or recent data should use +`XTREAM_DATA_SOURCE` from `@iptvnator/portal/xtream/data-access` from a +`type:data-access` or `type:feature` boundary. UI libraries in the M3U domain +must not import Xtream data-access directly; use `PlaylistsService` for source +metadata changes and let app-level cleanup providers handle portal-specific +browser sidecar data. ## Docker Runtime diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index 82a8ed4d1..9f28374bf 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -87,7 +87,7 @@ } - @if (activeChannel.radio !== 'true') { + @if (supportsEpg && activeChannel.radio !== 'true') { @let canCollapseEpgPanel = shouldShowInlinePlayer(activeChannel);
{ const player = signal(VideoPlayer.VideoJs); const showCaptions = signal(false); + const originalElectron = window.electron; const overlayRef = { attach: jest.fn().mockReturnValue({ instance: {} }), @@ -273,6 +283,10 @@ describe('VideoPlayerComponent', () => { } beforeEach(async () => { + window.electron = { + platform: 'darwin', + } as typeof window.electron; + syncStoreState(null); localStorage.removeItem('m3u-sidebar-width'); localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); @@ -384,6 +398,7 @@ describe('VideoPlayerComponent', () => { afterEach(() => { fixture?.destroy(); localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); + window.electron = originalElectron; }); it('registers and clears the workspace multi-EPG header shortcut', () => { @@ -417,6 +432,26 @@ describe('VideoPlayerComponent', () => { ).not.toBeNull(); }); + it('hides EPG controls and the multi-EPG header action in browser/PWA playback', () => { + fixture.destroy(); + window.electron = undefined as unknown as typeof window.electron; + + fixture = TestBed.createComponent(VideoPlayerComponent); + component = fixture.componentInstance; + headerContext = TestBed.inject(WorkspaceHeaderContextService); + syncStoreState(sampleChannel); + player.set(VideoPlayer.VideoJs); + + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('app-web-player-view') + ).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.epg')).toBeNull(); + expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull(); + expect(headerContext.action()).toBeNull(); + }); + it('opens MPV fallback with the active channel headers preserved', () => { syncStoreState({ ...sampleChannel, diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 90e8e3512..c5f318037 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -72,9 +72,16 @@ import { SidebarComponent, WebPlayerViewComponent, } from '@iptvnator/ui/playback'; -import { LiveEpgPanelComponent, LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals'; +import { + LiveEpgPanelComponent, + LiveEpgPanelSummary, +} from '@iptvnator/ui/shared-portals'; import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components'; -import { DataService, PlaylistsService, SettingsStore } from '@iptvnator/services'; +import { + DataService, + PlaylistsService, + SettingsStore, +} from '@iptvnator/services'; import { Channel, EpgProgram, @@ -244,6 +251,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { }; readonly isDesktop = !!window['electron']; + readonly supportsEpg = this.isDesktop; readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute); /** EPG overlay reference */ @@ -635,6 +643,10 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { * Opens the overlay with multi EPG view */ openMultiEpgView(): void { + if (!this.supportsEpg) { + return; + } + const positionStrategy = this.overlay .position() .global() @@ -862,7 +874,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { } private registerHeaderShortcut(): void { - if (!this.isWorkspaceLayout) { + if (!this.isWorkspaceLayout || !this.supportsEpg) { return; } diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts index dfe6e277f..27e452eef 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts @@ -11,12 +11,18 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { PlaylistActions } from '@iptvnator/m3u-state'; import { PlaylistContextFacade, PlaylistRefreshActionService, } from '@iptvnator/playlist/shared/util'; import { DialogService } from '@iptvnator/ui/components'; -import { DatabaseService, PortalStatusService } from '@iptvnator/services'; +import { + DatabaseService, + PlaylistsService, + PortalStatusService, +} from '@iptvnator/services'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; import { PlaylistSwitcherComponent } from './playlist-switcher.component'; @@ -32,13 +38,16 @@ function createPlaylist( filename: overrides.filename, count: overrides.count ?? 0, importDate: - overrides.importDate ?? new Date('2026-04-05T10:00:00.000Z').toISOString(), + overrides.importDate ?? + new Date('2026-04-05T10:00:00.000Z').toISOString(), autoRefresh: overrides.autoRefresh ?? false, ...overrides, } as PlaylistMeta; } describe('PlaylistSwitcherComponent', () => { + const testWindow = window as unknown as { electron?: unknown }; + const originalElectron = testWindow.electron; let fixture: ComponentFixture; let component: PlaylistSwitcherComponent; let playlistsSignal: ReturnType>; @@ -70,6 +79,9 @@ describe('PlaylistSwitcherComponent', () => { createOperationId: jest.Mock; deletePlaylist: jest.Mock; }; + let playlistsService: { + deletePlaylist: jest.Mock; + }; let snackBar: { open: jest.Mock; }; @@ -133,6 +145,10 @@ describe('PlaylistSwitcherComponent', () => { provide: DatabaseService, useValue: databaseService, }, + { + provide: PlaylistsService, + useValue: playlistsService, + }, { provide: MatSnackBar, useValue: snackBar, @@ -154,7 +170,11 @@ describe('PlaylistSwitcherComponent', () => { beforeEach(() => { localStorage.clear(); - playlistsSignal = signal([m3uPlaylist, stalkerPlaylist, xtreamPlaylist]); + playlistsSignal = signal([ + m3uPlaylist, + stalkerPlaylist, + xtreamPlaylist, + ]); resolvedPlaylistIdSignal = signal(xtreamPlaylist._id); activePlaylistSignal = signal(xtreamPlaylist); @@ -177,11 +197,8 @@ describe('PlaylistSwitcherComponent', () => { getStatusClass: jest.fn((status: string) => `status-${status}`), }; refreshActionService = { - canRefresh: jest.fn( - (playlist: PlaylistMeta) => - Boolean( - playlist.serverUrl || playlist.url || playlist.filePath - ) + canRefresh: jest.fn((playlist: PlaylistMeta) => + Boolean(playlist.serverUrl || playlist.url || playlist.filePath) ), refresh: jest.fn(), }; @@ -195,6 +212,9 @@ describe('PlaylistSwitcherComponent', () => { createOperationId: jest.fn(), deletePlaylist: jest.fn(), }; + playlistsService = { + deletePlaylist: jest.fn(() => of({ success: true })), + }; snackBar = { open: jest.fn(), }; @@ -205,6 +225,11 @@ describe('PlaylistSwitcherComponent', () => { afterEach(() => { localStorage.clear(); + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: originalElectron, + }); jest.restoreAllMocks(); }); @@ -229,11 +254,9 @@ describe('PlaylistSwitcherComponent', () => { it('sorts playlists by import date descending and prevents clearing the last enabled type filter', async () => { await createComponent(); - expect(component.filteredPlaylists().map((playlist) => playlist._id)).toEqual([ - xtreamPlaylist._id, - stalkerPlaylist._id, - m3uPlaylist._id, - ]); + expect( + component.filteredPlaylists().map((playlist) => playlist._id) + ).toEqual([xtreamPlaylist._id, stalkerPlaylist._id, m3uPlaylist._id]); component.togglePlaylistTypeFilter('m3u'); component.togglePlaylistTypeFilter('stalker'); @@ -320,8 +343,12 @@ describe('PlaylistSwitcherComponent', () => { xtreamPlaylist.username, xtreamPlaylist.password ); - expect(component.portalStatuses().get(xtreamPlaylist._id)).toBe('active'); - expect(component.getStatusClass(xtreamPlaylist._id)).toBe('status-active'); + expect(component.portalStatuses().get(xtreamPlaylist._id)).toBe( + 'active' + ); + expect(component.getStatusClass(xtreamPlaylist._id)).toBe( + 'status-active' + ); component.onMenuClosed(); expect(component.isMenuOpen()).toBe(false); @@ -343,4 +370,28 @@ describe('PlaylistSwitcherComponent', () => { expect(component.displayTitle()).toBe('Select playlist'); }); + + it('deletes browser/PWA playlists through PlaylistsService instead of the Electron database service', async () => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: undefined, + }); + await createComponent(); + + component.removePlaylistFor(xtreamPlaylist); + const confirm = dialogService.openConfirmDialog.mock.calls[0][0] + .onConfirm as () => Promise; + await confirm(); + + expect(playlistsService.deletePlaylist).toHaveBeenCalledWith( + xtreamPlaylist._id + ); + expect(databaseService.deletePlaylist).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.removePlaylist({ + playlistId: xtreamPlaylist._id, + }) + ); + }); }); diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts index 44775c432..641bea54a 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts @@ -33,11 +33,12 @@ import { } from '@iptvnator/playlist/shared/util'; import { DatabaseService, + PlaylistsService, PortalStatus, PortalStatusService, } from '@iptvnator/services'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; -import { startWith } from 'rxjs'; +import { firstValueFrom, startWith } from 'rxjs'; import { PlaylistInfoComponent } from '../recent-playlists/playlist-info/playlist-info.component'; type PlaylistFilterType = 'm3u' | 'stalker' | 'xtream'; @@ -78,6 +79,7 @@ export class PlaylistSwitcherComponent { private readonly dialog = inject(MatDialog); private readonly dialogService = inject(DialogService); private readonly databaseService = inject(DatabaseService); + private readonly playlistsService = inject(PlaylistsService); private readonly snackBar = inject(MatSnackBar); private readonly store = inject(Store); private focusSearchTimeoutId: ReturnType | null = null; @@ -311,14 +313,9 @@ export class PlaylistSwitcherComponent { private async removePlaylistConfirmed( playlist: PlaylistMeta ): Promise { - const operationId = playlist.serverUrl - ? this.databaseService.createOperationId('playlist-delete') - : undefined; - - const deleted = await this.databaseService.deletePlaylist( - playlist._id, - operationId ? { operationId } : undefined - ); + const deleted = window.electron + ? await this.deletePlaylistInElectron(playlist) + : await this.deletePlaylistInBrowser(playlist); if (!deleted) { return; @@ -334,6 +331,28 @@ export class PlaylistSwitcherComponent { ); } + private async deletePlaylistInElectron( + playlist: PlaylistMeta + ): Promise { + const operationId = playlist.serverUrl + ? this.databaseService.createOperationId('playlist-delete') + : undefined; + + return this.databaseService.deletePlaylist( + playlist._id, + operationId ? { operationId } : undefined + ); + } + + private async deletePlaylistInBrowser( + playlist: PlaylistMeta + ): Promise { + const result = await firstValueFrom( + this.playlistsService.deletePlaylist(playlist._id) + ); + return result.success; + } + getPlaylistIcon(playlist: PlaylistMeta): string { if (playlist.macAddress) { return 'dashboard'; diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts index d4c3f9704..ba2a4e9a9 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts @@ -2,7 +2,7 @@ import { Component, input, output } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { TranslatePipe } from '@ngx-translate/core'; -import type { WorkspacePlaylistType } from '@iptvnator/workspace/shell/util'; +import type { PlaylistType } from '../../add-playlist-menu/playlist-type'; export type EmptyStateType = | 'welcome-dashboard' @@ -18,7 +18,7 @@ interface FeatureCard { } interface SourceCard { - type: WorkspacePlaylistType; + type: PlaylistType; icon: string; nameKey: string; needsKey: string; @@ -113,14 +113,14 @@ export class EmptyStateComponent { readonly showElectronOnlyValueProps = input(false); - readonly addPlaylistClicked = output(); + readonly addPlaylistClicked = output(); readonly primaryActionClicked = output(); readonly secondaryActionClicked = output(); readonly featureCards = FEATURE_CARDS; readonly sourceCards = SOURCE_CARDS; - onAddPlaylist(type?: WorkspacePlaylistType): void { + onAddPlaylist(type?: PlaylistType): void { this.addPlaylistClicked.emit(type); } 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 1705094f7..75b6b521c 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 @@ -18,6 +18,7 @@ import { DatabaseService, DataService, PlaybackPositionService, + PlaylistsService, SortBy, SortOrder, SortService, @@ -76,6 +77,9 @@ describe('RecentPlaylistsComponent busy state', () => { let playbackPositionService: { getAllPlaybackPositions: jest.Mock; }; + let playlistsService: { + deletePlaylist: jest.Mock; + }; let router: { navigate: jest.Mock; }; @@ -83,8 +87,13 @@ describe('RecentPlaylistsComponent busy state', () => { open: jest.Mock; }; let store: MockStore; + const originalElectron = window.electron; beforeEach(async () => { + window.electron = { + platform: 'darwin', + } as typeof window.electron; + databaseService = { cancelOperation: jest.fn().mockResolvedValue(true), createOperationId: jest.fn((prefix: string) => `${prefix}-op`), @@ -101,6 +110,9 @@ describe('RecentPlaylistsComponent busy state', () => { playbackPositionService = { getAllPlaybackPositions: jest.fn().mockResolvedValue([]), }; + playlistsService = { + deletePlaylist: jest.fn(), + }; router = { navigate: jest.fn(), }; @@ -139,6 +151,10 @@ describe('RecentPlaylistsComponent busy state', () => { provide: PlaybackPositionService, useValue: playbackPositionService, }, + { + provide: PlaylistsService, + useValue: playlistsService, + }, { provide: PlaylistContextFacade, useValue: { @@ -192,6 +208,7 @@ describe('RecentPlaylistsComponent busy state', () => { afterEach(() => { jest.restoreAllMocks(); localStorage.clear(); + window.electron = originalElectron; }); it('tracks delete progress and clears the busy row after completion', async () => { @@ -247,6 +264,36 @@ describe('RecentPlaylistsComponent busy state', () => { ); }); + it('deletes browser/PWA playlists through PlaylistsService instead of the Electron database service', async () => { + const item = createPlaylistMeta({ + _id: 'pwa-playlist-1', + serverUrl: undefined, + username: undefined, + password: undefined, + url: 'https://example.com/playlist.m3u', + }); + window.electron = undefined as unknown as typeof window.electron; + ( + component as unknown as { + isElectron: boolean; + } + ).isElectron = false; + playlistsService.deletePlaylist.mockReturnValue(of({ success: true })); + + await component.removePlaylist(item); + + expect(playlistsService.deletePlaylist).toHaveBeenCalledWith(item._id); + expect(databaseService.deletePlaylist).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.removePlaylist({ playlistId: item._id }) + ); + expect(snackBar.open).toHaveBeenCalledWith( + 'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS', + undefined, + { duration: 2000 } + ); + }); + it('tracks Xtream refresh progress and clears the busy row after abort', async () => { const item = createPlaylistMeta({ _id: 'playlist-refresh-1' }); const refresh = createDeferred<{ @@ -406,6 +453,12 @@ describe('RecentPlaylistsComponent busy state', () => { }); it('uses the legacy IPC refresh flow for non-Xtream playlists', () => { + window.electron = undefined as unknown as typeof window.electron; + ( + component as unknown as { + isElectron: boolean; + } + ).isElectron = false; const item = createPlaylistMeta({ _id: 'playlist-m3u-1', serverUrl: undefined, 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 7743a3277..830032ea7 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 @@ -20,14 +20,13 @@ import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; -import type { WorkspacePlaylistType } from '@iptvnator/workspace/shell/util'; import { PlaylistActions, selectActiveTypeFilters, selectAllPlaylistsMeta, selectPlaylistsLoadingFlag, } from '@iptvnator/m3u-state'; -import { BehaviorSubject, combineLatest, map } from 'rxjs'; +import { BehaviorSubject, combineLatest, firstValueFrom, map } from 'rxjs'; import { DialogService } from '@iptvnator/ui/components'; import { DatabaseService, @@ -36,6 +35,7 @@ import { isDbAbortError, PlaybackPositionService, PlaylistRefreshService, + PlaylistsService, SortBy, SortService, XtreamPendingRestoreService, @@ -47,6 +47,7 @@ import { } from '@iptvnator/shared/interfaces'; import { EmptyStateComponent } from './empty-state/empty-state.component'; +import type { PlaylistType } from '../add-playlist-menu/playlist-type'; import { PlaylistInfoComponent } from './playlist-info/playlist-info.component'; import { PlaylistItemComponent } from './playlist-item/playlist-item.component'; @@ -84,6 +85,7 @@ export class RecentPlaylistsComponent { private readonly store = inject(Store); private readonly translate = inject(TranslateService); private readonly playlistContext = inject(PlaylistContextFacade); + private readonly playlistsService = inject(PlaylistsService); private readonly pendingRestoreService = inject( XtreamPendingRestoreService ); @@ -91,7 +93,7 @@ export class RecentPlaylistsComponent { readonly sidebarMode = input(false); readonly searchQueryInput = input(''); readonly playlistClicked = output(); - readonly addPlaylistClicked = output(); + readonly addPlaylistClicked = output(); readonly isElectron = !!window.electron; @@ -203,7 +205,7 @@ export class RecentPlaylistsComponent { ); } - onAddPlaylist(type?: WorkspacePlaylistType) { + onAddPlaylist(type?: PlaylistType) { this.addPlaylistClicked.emit(type); } @@ -248,21 +250,10 @@ export class RecentPlaylistsComponent { } this.setPendingDeletion(item._id, true); - const operationId = item.serverUrl - ? this.databaseService.createOperationId('playlist-delete') - : undefined; - try { - const deleted = await this.databaseService.deletePlaylist( - item._id, - operationId - ? { - operationId, - onEvent: (event) => - this.updateBusyOperation(item._id, event), - } - : undefined - ); + const deleted = this.isElectron + ? await this.deletePlaylistInElectron(item) + : await this.deletePlaylistInBrowser(item); if (deleted) { this.store.dispatch( PlaylistActions.removePlaylist({ playlistId: item._id }) @@ -283,6 +274,30 @@ export class RecentPlaylistsComponent { } } + private async deletePlaylistInElectron(item: PlaylistMeta) { + const operationId = item.serverUrl + ? this.databaseService.createOperationId('playlist-delete') + : undefined; + + return this.databaseService.deletePlaylist( + item._id, + operationId + ? { + operationId, + onEvent: (event) => + this.updateBusyOperation(item._id, event), + } + : undefined + ); + } + + private async deletePlaylistInBrowser(item: PlaylistMeta) { + const result = await firstValueFrom( + this.playlistsService.deletePlaylist(item._id) + ); + return result.success; + } + /** * Sends an IPC event with the playlist details to the main process to trigger the refresh operation * @param item playlist to update diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index 517492b7e..e79eee0ae 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -265,6 +265,39 @@ describe('StreamResolverService', () => { ); }); + it('skips portal EPG lookups in browser/PWA mode', async () => { + window.electron = undefined as unknown as typeof window.electron; + 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' + ); + const item = { + uid: 'xtream::xtream-1::1', + name: 'Xtream Live', + contentType: 'live', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream', + xtreamId: 1, + logo: 'xtream.png', + } satisfies UnifiedCollectionItem; + + const detail = await service.resolveLiveDetail(item); + const epgMap = await service.loadEpgForItems([item]); + + expect(detail.epgItems).toEqual([]); + expect(epgMap.size).toBe(0); + expect(xtreamApi.getShortEpg).not.toHaveBeenCalled(); + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + }); + it('reuses cached empty Xtream preview EPG results instead of refetching immediately', async () => { playlistsService.getPlaylistById.mockReturnValue( of({ diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 0b67ea525..4c6d94cc9 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -67,6 +67,10 @@ export class StreamResolverService { private readonly xtreamEpgCacheTtlMs = 60 * 1000; private readonly xtreamEpgFailureCooldownMs = 60 * 1000; + private get supportsEpg(): boolean { + return typeof window !== 'undefined' && !!window.electron; + } + private async getElectronPlaylist( playlistId: string ): Promise { @@ -137,6 +141,10 @@ export class StreamResolverService { items: UnifiedCollectionItem[] ): Promise> { const epgMap = new Map(); + if (!this.supportsEpg) { + return epgMap; + } + const now = Date.now(); const xtreamByPlaylist = new Map(); const stalkerByPlaylist = new Map(); @@ -199,6 +207,14 @@ export class StreamResolverService { item: UnifiedCollectionItem ): Promise { const playback = await this.resolveXtream(item); + if (!this.supportsEpg) { + return { + playback, + epgMode: 'portal', + epgItems: [], + }; + } + const epgItems = await this.withFallbackTimeout( this.loadXtreamEpgItems(item), this.portalEpgTimeoutMs, @@ -225,6 +241,14 @@ export class StreamResolverService { }; } + if (!this.supportsEpg) { + return { + playback, + epgMode: 'portal', + epgItems: [], + }; + } + const epgItems = await this.withFallbackTimeout( this.loadStalkerEpgItems(item, 10), this.portalEpgTimeoutMs, @@ -459,11 +483,12 @@ export class StreamResolverService { const channel = this.findM3uChannel(playlist?.playlist?.items ?? [], item) ?? this.buildFallbackM3uChannel(item); - const epgPrograms = includePrograms - ? await this.fetchM3uPrograms( - this.getM3uEpgLookupKey(channel, item) - ) - : []; + const epgPrograms = + includePrograms && this.supportsEpg + ? await this.fetchM3uPrograms( + this.getM3uEpgLookupKey(channel, item) + ) + : []; return { playback: this.buildM3uPlayback(channel, playlist), @@ -632,6 +657,10 @@ export class StreamResolverService { streamId: number, limit: number ): Promise { + if (!this.supportsEpg) { + return []; + } + const cacheKey = this.getXtreamEpgCacheKey(playlistId, streamId, limit); const cached = this.getCachedXtreamEpgItems(cacheKey); if (cached !== null) { @@ -712,6 +741,10 @@ export class StreamResolverService { channelId: string, size: number ): Promise { + if (!this.supportsEpg) { + return []; + } + const params = { action: StalkerPortalActions.GetShortEpg, type: 'itv', diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index ea23637ed..1f2e5e557 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -69,7 +69,7 @@
} - @if (!isRadioSelection()) { + @if (!isRadioSelection() && supportsEpg) {
} - @if (!isRadioMode()) { + @if (!isRadioMode() && supportsEpg) { @let canCollapseEpgPanel = !!streamUrl() && isEmbeddedPlayer;
{ const settingsStore = { openStreamOnDoubleClick: signal(false), }; + const originalElectron = window.electron; beforeEach(async () => { + window.electron = { + platform: 'darwin', + } as typeof window.electron; + fetchChannelEpg = stalkerStore.fetchChannelEpg; ensureBulkItvEpg = stalkerStore.ensureBulkItvEpg; resolveItvPlayback = stalkerStore.resolveItvPlayback; @@ -358,6 +363,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { afterEach(() => { fixture?.destroy(); localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); + window.electron = originalElectron; }); it('renders the controlled epg list and removes the load-more button', () => { @@ -371,6 +377,29 @@ describe('StalkerLiveStreamLayoutComponent', () => { ).toBeNull(); }); + it('does not request or render EPG in browser/PWA playback', async () => { + fixture.destroy(); + window.electron = undefined as unknown as typeof window.electron; + + fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent); + component = fixture.componentInstance; + + await component.playChannel(itvChannels()[0]); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(ensureBulkItvEpg).not.toHaveBeenCalled(); + expect(fetchChannelEpg).not.toHaveBeenCalled(); + expect( + fixture.nativeElement.querySelector('app-web-player-view') + ).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.epg')).toBeNull(); + expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull(); + expect( + fixture.nativeElement.querySelector('app-live-epg-panel') + ).toBeNull(); + }); + it('restores the collapsed live EPG panel state after embedded playback starts', async () => { fixture.destroy(); localStorage.setItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY, 'collapsed'); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 42536422f..c4bd39ee0 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -138,6 +138,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { readonly selectedChannelId = this.stalkerStore.selectedItvId; protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId; + readonly isElectron = Boolean(window.electron); + readonly supportsEpg = this.isElectron; readonly openStreamOnDoubleClick = computed(() => this.settingsStore.openStreamOnDoubleClick() ); @@ -288,7 +290,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } } - if (this.isRadioMode()) { + if (this.isRadioMode() || !this.supportsEpg) { this.clearEpgPreviewMaps(); this.cdr.markForCheck(); return; @@ -413,7 +415,9 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { return; } - void this.loadEpgForChannel(item); + if (this.supportsEpg) { + void this.loadEpgForChannel(item); + } if (this.usesEmbeddedPlayer()) { this.activePlayback.set(playback); @@ -523,6 +527,13 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } private async loadEpgForChannel(item: StalkerItvChannel) { + if (!this.supportsEpg) { + this.fallbackEpgPrograms.set([]); + this.isLoadingFallbackEpg.set(false); + this.clearEpgPreviewMaps(); + return; + } + const requestId = ++this.epgLoadRequestId; const normalizedChannelId = normalizeStalkerEntityId(item.id); const playlistId = this.stalkerStore.currentPlaylist()?._id ?? null; diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/index.ts b/libs/portal/xtream/data-access/src/lib/data-sources/index.ts index 7f20fe0af..77b09ffb4 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/index.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/index.ts @@ -1,7 +1,11 @@ import { inject, Provider } from '@angular/core'; +import { PLAYLIST_DELETE_CLEANUP } from '@iptvnator/services'; import { ElectronXtreamDataSource } from './electron-xtream-data-source'; import { PwaXtreamDataSource } from './pwa-xtream-data-source'; -import { IXtreamDataSource, XTREAM_DATA_SOURCE } from './xtream-data-source.interface'; +import { + IXtreamDataSource, + XTREAM_DATA_SOURCE, +} from './xtream-data-source.interface'; // Re-export all types and interfaces export * from './xtream-data-source.interface'; @@ -35,5 +39,20 @@ export function provideXtreamDataSource(): Provider[] { provide: XTREAM_DATA_SOURCE, useFactory: xtreamDataSourceFactory, }, + { + provide: PLAYLIST_DELETE_CLEANUP, + multi: true, + useFactory: () => { + const dataSource = inject(XTREAM_DATA_SOURCE); + + return (playlistId: string) => { + if (typeof window !== 'undefined' && window.electron) { + return Promise.resolve(); + } + + return dataSource.deletePlaylist(playlistId); + }; + }, + }, ]; } diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts index 48c6a432b..ebea2de06 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts @@ -43,6 +43,8 @@ function buildProgram( } interface TestStoreSetup { + appEnvironment?: 'electron' | 'pwa'; + isElectron?: boolean; selectedItem: { xtream_id: number; epg_channel_id?: string | null }; preferUploaded?: boolean; } @@ -76,7 +78,15 @@ function configureStore(setup: TestStoreSetup) { TestBed.configureTestingModule({ providers: [ TestEpgStore, - { provide: DataService, useValue: { isElectron: true } }, + { + provide: DataService, + useValue: { + getAppEnvironment: jest.fn( + () => setup.appEnvironment ?? 'electron' + ), + isElectron: setup.isElectron ?? true, + }, + }, { provide: XtreamApiService, useValue: xtreamApiService }, { provide: XtreamXmltvFallbackService, @@ -155,6 +165,23 @@ describe('withEpg', () => { expect(fallbackService.getProgramsForChannel).not.toHaveBeenCalled(); }); + it('does not load Xtream or XMLTV EPG in browser/PWA mode', async () => { + const { store, xtreamApiService, fallbackService } = configureStore({ + appEnvironment: 'pwa', + isElectron: false, + selectedItem: { xtream_id: 101, epg_channel_id: 'rtl.de' }, + }); + + const result = await store.loadEpg(); + + expect(result).toEqual([]); + expect(store.epgItems()).toEqual([]); + expect(store.isLoadingEpg()).toBe(false); + expect(xtreamApiService.getFullEpg).not.toHaveBeenCalled(); + expect(xtreamApiService.getShortEpg).not.toHaveBeenCalled(); + expect(fallbackService.getProgramsForChannel).not.toHaveBeenCalled(); + }); + it('queries XMLTV first when preferUploadedEpgOverXtream is on', async () => { const { store, xtreamApiService, fallbackService } = configureStore({ selectedItem: { xtream_id: 101, epg_channel_id: 'rtl.de' }, 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 ed00cd453..b2cd628ea 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 @@ -58,14 +58,8 @@ export function withEpg() { const now = Date.now(); const items = [...store.epgItems()].sort( (left, right) => - getEpgTimestampMs( - left.start, - left.start_timestamp - ) - - getEpgTimestampMs( - right.start, - right.start_timestamp - ) + getEpgTimestampMs(left.start, left.start_timestamp) - + getEpgTimestampMs(right.start, right.start_timestamp) ); return ( @@ -90,6 +84,10 @@ export function withEpg() { const fallbackService = inject(XtreamXmltvFallbackService); const settingsStore = inject(SettingsStore); + const supportsEpg = (): boolean => + typeof dataService.getAppEnvironment !== 'function' || + dataService.getAppEnvironment() !== 'pwa'; + /** * Helper to get credentials from parent store */ @@ -132,6 +130,14 @@ export function withEpg() { * sets `preferUploadedEpgOverXtream`. */ async loadEpg(): Promise { + if (!supportsEpg()) { + patchState(store, { + epgItems: [], + isLoadingEpg: false, + }); + return []; + } + const credentials = getCredentialsFromStore(); if (!credentials) { patchState(store, { epgItems: [] }); @@ -180,6 +186,10 @@ export function withEpg() { streamId: number, epgChannelId?: string | null ): Promise { + if (!supportsEpg()) { + return []; + } + const credentials = getCredentialsFromStore(); if (!credentials) return []; diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index ed8b3202a..1c1e96c1b 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -105,38 +105,42 @@ />
} -
- @if (isEmbeddedPlayer) { - + @if (supportsEpg) { +
+ @if (isEmbeddedPlayer) { + + + + } @else { - - } @else { - - } -
+ } +
+ } } @else if (!showLiveChannelSidebar()) { @if (isSelectedTypeContentLoading()) {
diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index 93ba11174..387b02f11 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -292,6 +292,27 @@ describe('LiveStreamLayoutComponent', () => { expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull(); }); + it('hides the EPG panel in browser/PWA playback', () => { + fixture.destroy(); + window.electron = undefined as unknown as typeof window.electron; + + fixture = TestBed.createComponent(LiveStreamLayoutComponent); + component = fixture.componentInstance; + + component.playLive(sampleChannel); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('app-web-player-view') + ).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.epg')).toBeNull(); + expect( + fixture.nativeElement.querySelector('app-live-epg-panel') + ).toBeNull(); + expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull(); + expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull(); + }); + it('restores the collapsed live EPG panel state for embedded playback', () => { fixture.destroy(); localStorage.setItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY, 'collapsed'); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index d7b6cf7a9..5777e7f4e 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -120,6 +120,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { readonly selectedCategoryId = this.xtreamStore.selectedCategoryId; readonly liveChannelSortMode = signal('server'); readonly isElectron = Boolean(window.electron); + readonly supportsEpg = this.isElectron; readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route); private readonly routeSearchTerm = queryParamSignal( this.route, diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html index f25a16c8b..eac337946 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html @@ -10,7 +10,7 @@
@@ -31,9 +31,15 @@ [selected]=" xtreamStore.selectedItem()?.xtream_id === item.xtream_id " - [epgProgram]="epgPrograms.get(item.xtream_id) ?? null" + [epgProgram]=" + supportsEpg + ? (epgPrograms.get(item.xtream_id) ?? null) + : null + " [progressPercentage]=" - currentProgramsProgress.get(item.xtream_id) ?? 0 + supportsEpg + ? (currentProgramsProgress.get(item.xtream_id) ?? 0) + : 0 " [showFavoriteButton]="true" [showProgramInfoButton]="false" diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts index 2003c3390..c0b8b0003 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts @@ -37,6 +37,8 @@ function buildEpgItem(params: { } describe('PortalChannelsListComponent', () => { + const testWindow = window as unknown as { electron?: unknown }; + const originalElectron = testWindow.electron; let fixture: ComponentFixture; const selectedChannels = signal([]); const selectedItem = signal(null); @@ -60,8 +62,18 @@ describe('PortalChannelsListComponent', () => { const favoritesService = { getFavorites: jest.fn().mockReturnValue(of([] as FavoriteItem[])), }; + const epgQueueService = { + epgResult$: epgResults$, + getCached: jest.fn().mockReturnValue(null), + enqueue: jest.fn(), + }; beforeEach(async () => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: { platform: 'darwin' }, + }); storeSignals.setSelectedCategory.mockClear(); storeSignals.toggleFavorite.mockClear(); selectedChannels.set([]); @@ -72,6 +84,8 @@ describe('PortalChannelsListComponent', () => { currentPlaylist.set(null); selectedCategoryId.set(1); favoritesService.getFavorites.mockReturnValue(of([] as FavoriteItem[])); + epgQueueService.getCached.mockReturnValue(null); + epgQueueService.enqueue.mockClear(); await TestBed.configureTestingModule({ imports: [PortalChannelsListComponent, NoopAnimationsModule], @@ -112,11 +126,7 @@ describe('PortalChannelsListComponent', () => { }, { provide: EpgQueueService, - useValue: { - epgResult$: epgResults$, - getCached: jest.fn().mockReturnValue(null), - enqueue: jest.fn(), - }, + useValue: epgQueueService, }, { provide: SettingsStore, @@ -140,6 +150,11 @@ describe('PortalChannelsListComponent', () => { afterEach(() => { jest.useRealTimers(); + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: originalElectron, + }); }); it('renders a loading placeholder instead of the empty state while xtream live content is still loading', () => { @@ -234,6 +249,55 @@ describe('PortalChannelsListComponent', () => { expect(component.currentProgramsProgress.get(50)).toBeCloseTo(50, 1); }); + it('does not derive or subscribe to row EPG previews in browser/PWA mode', () => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: undefined, + }); + selectedTypeContentLoading.set(false); + selectedChannels.set([ + { + title: 'Cartoon Network', + xtream_id: 50, + }, + ]); + selectedItem.set({ xtream_id: 50 }); + epgItems.set([ + buildEpgItem({ + id: 'current', + title: 'Current Show', + start: new Date(Date.now() - 60_000).toISOString(), + stop: new Date(Date.now() + 60_000).toISOString(), + startTimestamp: Math.floor((Date.now() - 60_000) / 1000), + stopTimestamp: Math.floor((Date.now() + 60_000) / 1000), + }), + ]); + + fixture.destroy(); + fixture = TestBed.createComponent(PortalChannelsListComponent); + fixture.detectChanges(); + + epgResults$.next({ + streamId: 50, + items: [ + buildEpgItem({ + id: 'queued-current', + title: 'Queued Current Show', + start: new Date(Date.now() - 60_000).toISOString(), + stop: new Date(Date.now() + 60_000).toISOString(), + startTimestamp: Math.floor((Date.now() - 60_000) / 1000), + stopTimestamp: Math.floor((Date.now() + 60_000) / 1000), + }), + ], + }); + + const pwaComponent = fixture.componentInstance; + expect(pwaComponent.supportsEpg).toBe(false); + expect(pwaComponent.epgPrograms.size).toBe(0); + expect(pwaComponent.currentProgramsProgress.size).toBe(0); + }); + it('does not mark a live item as favorite when only a colliding movie ID is favorited', () => { favoritesService.getFavorites.mockReturnValue( of([ @@ -287,13 +351,10 @@ describe('PortalChannelsListComponent', () => { fixture.detectChanges(); - fixture.componentInstance.toggleFavorite( - new MouseEvent('click'), - { - title: 'Cartoon Network', - xtream_id: 253, - } - ); + fixture.componentInstance.toggleFavorite(new MouseEvent('click'), { + title: 'Cartoon Network', + xtream_id: 253, + }); await Promise.resolve(); expect(storeSignals.toggleFavorite).toHaveBeenCalledWith( 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 3e338fea3..c2677fadf 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 @@ -80,6 +80,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { private readonly favoritesService = inject(FavoritesService); private readonly epgQueueService = inject(EpgQueueService); private readonly route = inject(ActivatedRoute); + readonly supportsEpg = Boolean(window.electron); readonly isSelectedTypeContentLoading = this.xtreamStore.selectedTypeContentLoading; readonly channels = computed(() => { @@ -124,6 +125,10 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { constructor(private cdr: ChangeDetectorRef) { effect(() => { + if (!this.supportsEpg) { + return; + } + const selectedItem = this.xtreamStore.selectedItem(); const epgItems = this.xtreamStore.epgItems(); @@ -163,20 +168,27 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { }); } - // Subscribe to EPG results from the queue service - this.subscriptions.add( - this.epgQueueService.epgResult$.subscribe(({ streamId, items }) => { - const previewProgram = this.pickPreviewProgram(items); - if (previewProgram) { - this.applyProgram(streamId, previewProgram); - } - }) - ); + if (this.supportsEpg) { + this.subscriptions.add( + this.epgQueueService.epgResult$.subscribe( + ({ streamId, items }) => { + const previewProgram = this.pickPreviewProgram(items); + if (previewProgram) { + this.applyProgram(streamId, previewProgram); + } + } + ) + ); + } } ngAfterViewInit() { const vp = this.viewport(); - if (vp && this.xtreamStore.selectedContentType() === 'live') { + if ( + this.supportsEpg && + vp && + this.xtreamStore.selectedContentType() === 'live' + ) { this.subscriptions.add( vp.renderedRangeStream .pipe(debounceTime(300)) @@ -192,6 +204,10 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { } private loadEpgForVisibleChannels(channels: XtreamChannelListItem[]): void { + if (!this.supportsEpg) { + return; + } + const playlist = this.xtreamStore.currentPlaylist(); if (!playlist) return; @@ -202,7 +218,10 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { }; const visibleIds = new Set(channels.map((ch) => ch.xtream_id)); - const uncachedEntries: { streamId: number; epgChannelId?: string | null }[] = []; + const uncachedEntries: { + streamId: number; + epgChannelId?: string | null; + }[] = []; // Apply cached results immediately for (const channel of channels) { diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index fae4f3150..17b064575 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-delete-cleanup.token'; export * from './lib/playlist-backup.service'; export * from './lib/playlist-refresh.service'; export * from './lib/playlists.service'; diff --git a/libs/services/src/lib/playlist-delete-cleanup.token.ts b/libs/services/src/lib/playlist-delete-cleanup.token.ts new file mode 100644 index 000000000..f03f95eb2 --- /dev/null +++ b/libs/services/src/lib/playlist-delete-cleanup.token.ts @@ -0,0 +1,9 @@ +import { InjectionToken } from '@angular/core'; + +export type PlaylistDeleteCleanup = ( + playlistId: string +) => Promise | void; + +export const PLAYLIST_DELETE_CLEANUP = new InjectionToken< + PlaylistDeleteCleanup[] +>('PLAYLIST_DELETE_CLEANUP'); diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index 7ca08e8b8..c6f03aedb 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -17,11 +17,14 @@ describe('PlaylistsService', () => { }); function createService(overrides: Record = {}) { - const service = Object.create(PlaylistsService.prototype) as PlaylistsService; + const service = Object.create( + PlaylistsService.prototype + ) as PlaylistsService; Object.assign(service as object, { dbService: { clear: jest.fn(() => of(undefined)), + delete: jest.fn(() => of(undefined)), getAll: jest.fn(() => of([])), getByID: jest.fn(() => of(undefined)), update: jest.fn(() => of(undefined)), @@ -35,6 +38,7 @@ describe('PlaylistsService', () => { }, electronMigrationPromise: null, indexedDbMigrationPromise: null, + playlistDeleteCleanups: [], }); return service; @@ -50,6 +54,25 @@ describe('PlaylistsService', () => { ).toBe(parse); }); + it('runs registered cleanup hooks after deleting a browser playlist', async () => { + const cleanup = jest.fn().mockResolvedValue(undefined); + const deleteFromIndexedDb = jest.fn(() => of(undefined)); + const service = createService({ delete: deleteFromIndexedDb }); + Object.assign(service as object, { + playlistDeleteCleanups: [cleanup], + }); + + await expect( + firstValueFrom(service.deletePlaylist('playlist-1')) + ).resolves.toEqual({ success: true }); + + expect(deleteFromIndexedDb).toHaveBeenCalledWith( + DbStores.Playlists, + 'playlist-1' + ); + expect(cleanup).toHaveBeenCalledWith('playlist-1'); + }); + it('migrates legacy Stalker portal flags in SQLite before returning playlists', async () => { let storedPlaylists: Playlist[] = [ { @@ -88,7 +111,9 @@ describe('PlaylistsService', () => { const service = createService(); - await expect(firstValueFrom(service.getAllPlaylists())).resolves.toEqual([ + await expect( + firstValueFrom(service.getAllPlaylists()) + ).resolves.toEqual([ expect.objectContaining({ _id: 'stalker-1', isFullStalkerPortal: true, @@ -101,7 +126,9 @@ describe('PlaylistsService', () => { isFullStalkerPortal: true, }), ]); - expect(appState.get(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG)).toBe('1'); + expect(appState.get(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG)).toBe( + '1' + ); }); it('does not rerun the SQLite Stalker metadata migration after the flag is set', async () => { diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 29fb2685c..d9af054c4 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -30,6 +30,7 @@ import { StalkerPortalItem, normalizeStalkerDate, } from '@iptvnator/shared/interfaces'; +import { PLAYLIST_DELETE_CLEANUP } from './playlist-delete-cleanup.token'; const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1'; const STALKER_PLAYLIST_METADATA_MIGRATION_FLAG = @@ -81,6 +82,8 @@ export class PlaylistsService { private readonly dbService = inject(NgxIndexedDBService); private readonly snackBar = inject(MatSnackBar); private readonly translateService = inject(TranslateService); + private readonly playlistDeleteCleanups = + inject(PLAYLIST_DELETE_CLEANUP, { optional: true }) ?? []; private electronMigrationPromise: Promise | null = null; private indexedDbMigrationPromise: Promise | null = null; @@ -406,21 +409,48 @@ export class PlaylistsService { } deletePlaylist(playlistId: string): Observable<{ success: boolean }> { - if (this.isElectronStorageAvailable) { - return this.runOnSqlite(async () => { - const electron = this.electronApi; - if (!electron) { - return undefined; - } + const delete$: Observable = this.isElectronStorageAvailable + ? this.runOnSqlite(async () => { + const electron = this.electronApi; + if (!electron) { + return undefined; + } - await electron.dbDeletePlaylist(playlistId); - return undefined; - }).pipe(map(() => ({ success: true }))); + await electron.dbDeletePlaylist(playlistId); + return undefined; + }) + : this.dbService.delete(DbStores.Playlists, playlistId); + + return delete$.pipe( + switchMap(() => from(this.runPlaylistDeleteCleanups(playlistId))), + map(() => ({ success: true })) + ); + } + + private async runPlaylistDeleteCleanups(playlistId: string): Promise { + if (this.playlistDeleteCleanups.length === 0) { + return; } - return this.dbService - .delete(DbStores.Playlists, playlistId) - .pipe(map(() => ({ success: true }))); + const failures = ( + await Promise.all( + this.playlistDeleteCleanups.map(async (cleanup) => { + try { + await cleanup(playlistId); + return null; + } catch (error) { + return error; + } + }) + ) + ).filter((error) => error !== null); + + for (const failure of failures) { + console.warn( + 'Playlist cleanup failed after playlist deletion:', + failure + ); + } } updatePlaylist(playlistId: string, updatedPlaylist: Playlist) { diff --git a/libs/ui/playback/src/index.ts b/libs/ui/playback/src/index.ts index 64abacb1b..2055907c0 100644 --- a/libs/ui/playback/src/index.ts +++ b/libs/ui/playback/src/index.ts @@ -1,6 +1,8 @@ export * from './lib/art-player/art-player.component'; export * from './lib/audio-player/audio-player.component'; +export * from './lib/embedded-mpv-player/embedded-mpv-overlay-visibility.service'; export * from './lib/embedded-mpv-player/embedded-mpv-player.component'; +export * from './lib/embedded-mpv-player/embedded-mpv-session-controller'; export * from './lib/external-player-info-dialog/external-player-info-dialog.component'; export * from './lib/html-video-player/html-video-player.component'; export * from './lib/playback-diagnostics/playback-diagnostics.util'; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 2165d4a8d..dee105b3f 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -18,6 +18,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { RouterLink } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { firstValueFrom } from 'rxjs'; import { EmptyStateComponent, PlaylistInfoComponent, @@ -29,7 +30,7 @@ import { } from '@iptvnator/workspace/shell/util'; import { DialogService } from '@iptvnator/ui/components'; import { PlaylistActions } from '@iptvnator/m3u-state'; -import { DatabaseService } from '@iptvnator/services'; +import { DatabaseService, PlaylistsService } from '@iptvnator/services'; import { DashboardDataService, DashboardFavoriteItem, @@ -304,7 +305,10 @@ export function liveRailTitleKeyForSource( } export function buildPlaybackPositionReloadKey( - items: readonly Pick[] + items: readonly Pick< + GlobalRecentItem, + 'playlist_id' | 'type' | 'xtream_id' + >[] ): string { return items .filter((item) => item.type === 'movie' || item.type === 'series') @@ -423,6 +427,7 @@ export class WorkspaceDashboardRailsComponent { ); private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS); private readonly epgService = inject(EpgService); + private readonly playlistsService = inject(PlaylistsService); readonly hasPlaylists = computed(() => this.data.playlists().length > 0); readonly ready = this.data.dashboardReady; @@ -481,9 +486,7 @@ export class WorkspaceDashboardRailsComponent { // channel yet), fall back to recently-watched live so the rail still // has something to show. `liveRailSource` drives the title swap. readonly liveRailSource = computed<'favorites' | 'recent'>(() => - this.data.globalFavoriteLiveItems().length > 0 - ? 'favorites' - : 'recent' + this.data.globalFavoriteLiveItems().length > 0 ? 'favorites' : 'recent' ); readonly liveOnFavoritesCards = computed(() => { @@ -773,14 +776,9 @@ export class WorkspaceDashboardRailsComponent { } private async removePlaylist(playlist: PlaylistMeta): Promise { - const operationId = playlist.serverUrl - ? this.databaseService.createOperationId('playlist-delete') - : undefined; - - const deleted = await this.databaseService.deletePlaylist( - playlist._id, - operationId ? { operationId } : undefined - ); + const deleted = window.electron + ? await this.deletePlaylistInElectron(playlist) + : await this.deletePlaylistInBrowser(playlist); if (!deleted) { return; @@ -796,6 +794,28 @@ export class WorkspaceDashboardRailsComponent { ); } + private async deletePlaylistInElectron( + playlist: PlaylistMeta + ): Promise { + const operationId = playlist.serverUrl + ? this.databaseService.createOperationId('playlist-delete') + : undefined; + + return this.databaseService.deletePlaylist( + playlist._id, + operationId ? { operationId } : undefined + ); + } + + private async deletePlaylistInBrowser( + playlist: PlaylistMeta + ): Promise { + const result = await firstValueFrom( + this.playlistsService.deletePlaylist(playlist._id) + ); + return result.success; + } + private t(key: string): string { return this.translate.instant(key); }