fix(pwa): harden self-hosted runtime boundaries

This commit is contained in:
4gray committed 2026-05-22 02:09:57 +03:00
1 parent ff9c13058c
commit 55efc24608
57 files changed
+984 -257

No files matched your search

+7 -1
View File
@@ -114,7 +114,13 @@ docker compose -f docker/docker-compose.yml up --build -d
The application is available at <http://localhost:4333>. 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
+4 -4
View File
@@ -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,
@@ -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],
+4 -4
View File
@@ -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",
+4 -4
View File
@@ -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",
+4 -4
View File
@@ -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",
+4 -4
View File
@@ -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",
+4 -4
View File
@@ -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",
+4 -4
View File
@@ -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",
+4 -4
View File
@@ -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",
+4 -4
View File
@@ -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",
+4 -4
View File
@@ -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",
+4 -4
View File
@@ -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",
+4 -4
View File
@@ -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",
+4 -4
View File
@@ -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",
+4 -4
View File
@@ -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",
+4 -4
View File
@@ -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",
+5 -5
View File
@@ -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 часто имеют ограниченную поддержку этих кодеков.",
+4 -4
View File
@@ -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",
+4 -4
View File
@@ -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",
+4 -4
View File
@@ -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",
+15
View File
@@ -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:
+30 -8
View File
@@ -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
@@ -87,7 +87,7 @@
</div>
}
@if (activeChannel.radio !== 'true') {
@if (supportsEpg && activeChannel.radio !== 'true') {
@let canCollapseEpgPanel = shouldShowInlinePlayer(activeChannel);
<div
class="epg"
@@ -28,8 +28,17 @@ import {
LIVE_EPG_PANEL_STATE_STORAGE_KEY,
WorkspaceHeaderContextService,
} from '@iptvnator/portal/shared/util';
import { DataService, PlaylistsService, SettingsStore } from '@iptvnator/services';
import { Channel, EpgProgram, Settings, VideoPlayer } from '@iptvnator/shared/interfaces';
import {
DataService,
PlaylistsService,
SettingsStore,
} from '@iptvnator/services';
import {
Channel,
EpgProgram,
Settings,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
import { Overlay } from '@angular/cdk/overlay';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
@@ -166,6 +175,7 @@ describe('VideoPlayerComponent', () => {
const player = signal<VideoPlayer>(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,
@@ -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;
}
@@ -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<PlaylistSwitcherComponent>;
let component: PlaylistSwitcherComponent;
let playlistsSignal: ReturnType<typeof signal<PlaylistMeta[]>>;
@@ -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<string | null>(xtreamPlaylist._id);
activePlaylistSignal = signal<PlaylistMeta | null>(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<void>;
await confirm();
expect(playlistsService.deletePlaylist).toHaveBeenCalledWith(
xtreamPlaylist._id
);
expect(databaseService.deletePlaylist).not.toHaveBeenCalled();
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.removePlaylist({
playlistId: xtreamPlaylist._id,
})
);
});
});
@@ -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<typeof setTimeout> | null = null;
@@ -311,14 +313,9 @@ export class PlaylistSwitcherComponent {
private async removePlaylistConfirmed(
playlist: PlaylistMeta
): Promise<void> {
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<boolean> {
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<boolean> {
const result = await firstValueFrom(
this.playlistsService.deletePlaylist(playlist._id)
);
return result.success;
}
getPlaylistIcon(playlist: PlaylistMeta): string {
if (playlist.macAddress) {
return 'dashboard';
@@ -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<boolean>(false);
readonly addPlaylistClicked = output<WorkspacePlaylistType | undefined>();
readonly addPlaylistClicked = output<PlaylistType | undefined>();
readonly primaryActionClicked = output<void>();
readonly secondaryActionClicked = output<void>();
readonly featureCards = FEATURE_CARDS;
readonly sourceCards = SOURCE_CARDS;
onAddPlaylist(type?: WorkspacePlaylistType): void {
onAddPlaylist(type?: PlaylistType): void {
this.addPlaylistClicked.emit(type);
}
@@ -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,
@@ -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<string>('');
readonly playlistClicked = output<string>();
readonly addPlaylistClicked = output<WorkspacePlaylistType | undefined>();
readonly addPlaylistClicked = output<PlaylistType | undefined>();
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
@@ -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<Playlist>)
);
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({
@@ -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<Playlist | undefined> {
@@ -137,6 +141,10 @@ export class StreamResolverService {
items: UnifiedCollectionItem[]
): Promise<Map<string, EpgProgram | null>> {
const epgMap = new Map<string, EpgProgram | null>();
if (!this.supportsEpg) {
return epgMap;
}
const now = Date.now();
const xtreamByPlaylist = new Map<string, UnifiedCollectionItem[]>();
const stalkerByPlaylist = new Map<string, UnifiedCollectionItem[]>();
@@ -199,6 +207,14 @@ export class StreamResolverService {
item: UnifiedCollectionItem
): Promise<ResolvedLiveCollectionDetail> {
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<EpgItem[]> {
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<EpgItem[]> {
if (!this.supportsEpg) {
return [];
}
const params = {
action: StalkerPortalActions.GetShortEpg,
type: 'itv',
@@ -69,7 +69,7 @@
</div>
}
@if (!isRadioSelection()) {
@if (!isRadioSelection() && supportsEpg) {
<div
class="epg"
[class.epg-collapsed]="
@@ -162,8 +162,13 @@ describe('UnifiedLiveTabComponent', () => {
openResolvedPlayback: jest.Mock;
openExternalPlayback: jest.Mock;
};
const originalElectron = window.electron;
beforeEach(async () => {
window.electron = {
platform: 'darwin',
} as typeof window.electron;
localStorage.removeItem('live-epg-panel-state');
streamResolver = {
@@ -227,6 +232,11 @@ describe('UnifiedLiveTabComponent', () => {
component = fixture.componentInstance;
});
afterEach(() => {
fixture?.destroy();
window.electron = originalElectron;
});
it('renders controlled M3U EPG and records recent history on selection', async () => {
const item = buildLiveItem('m3u');
streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({
@@ -283,6 +293,64 @@ describe('UnifiedLiveTabComponent', () => {
expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull();
});
it('skips EPG loading and hides the EPG panel in browser/PWA playback', async () => {
fixture.destroy();
window.electron = undefined as unknown as typeof window.electron;
fixture = TestBed.createComponent(UnifiedLiveTabComponent);
component = fixture.componentInstance;
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
streamResolver.loadEpgForItems.mockClear();
streamResolver.loadM3uProgramsForItem.mockClear();
const item = buildLiveItem('m3u');
streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({
epgMode: 'm3u',
playback: {
streamUrl: 'https://example.com/m3u.m3u8',
title: 'M3U Live',
},
channel: {
id: 'm3u-channel',
name: 'M3U Live',
url: 'https://example.com/m3u.m3u8',
group: { title: 'News' },
tvg: {
id: 'm3u-channel',
name: 'M3U Live',
url: '',
logo: 'm3u.png',
rec: '',
},
http: { referrer: '', 'user-agent': '', origin: '' },
radio: 'false',
epgParams: '',
},
epgPrograms: [buildProgram('M3U Show')],
});
recentData.recordLivePlayback.mockResolvedValue({
...item,
viewedAt: '2026-03-26T12:00:00.000Z',
});
fixture.componentRef.setInput('items', [item]);
fixture.componentRef.setInput('mode', 'recent');
fixture.detectChanges();
await fixture.whenStable();
await component.onChannelSelected(component.channelsForList()[0]);
fixture.detectChanges();
await fixture.whenStable();
expect(streamResolver.loadEpgForItems).not.toHaveBeenCalled();
expect(streamResolver.loadM3uProgramsForItem).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-epg-view')).toBeNull();
});
it('passes recent mode and favorite state to the shared live collection list', async () => {
const item = buildLiveItem('m3u');
const favoriteUids = new Set<string>([item.uid]);
@@ -483,11 +551,15 @@ describe('UnifiedLiveTabComponent', () => {
throw new Error('Expected wrapper playback to be set');
}
expect(webPlayer.playerOverride()).toBe(VideoPlayer.VideoJs);
expect(fixture.nativeElement.querySelector('app-vjs-player')).toBeNull();
expect(
fixture.nativeElement.querySelector('app-vjs-player')
).toBeNull();
expect(
fixture.nativeElement.querySelector('app-html-video-player')
).toBeNull();
expect(fixture.nativeElement.querySelector('app-art-player')).toBeNull();
expect(
fixture.nativeElement.querySelector('app-art-player')
).toBeNull();
webPlayer.externalFallbackRequested.emit({
player: 'mpv',
@@ -97,6 +97,7 @@ export class UnifiedLiveTabComponent {
private readonly destroyRef = inject(DestroyRef);
readonly player = this.settingsStore.player;
readonly supportsEpg = Boolean(window.electron);
readonly isEmbeddedPlayer = computed(() =>
this.portalPlayer.isEmbeddedPlayer()
);
@@ -186,7 +187,11 @@ export class UnifiedLiveTabComponent {
constructor() {
effect(() => {
const items = this.items();
void this.loadEpgMap(items);
if (this.supportsEpg) {
void this.loadEpgMap(items);
} else {
this.epgMap.set(new Map());
}
const activeUid = this.activeUid();
if (activeUid && !items.some((item) => item.uid === activeUid)) {
@@ -326,7 +331,9 @@ export class UnifiedLiveTabComponent {
startPlayback &&
this.shouldOpenExternalPlayback(activeDetail, true)
) {
void this.portalPlayer.openResolvedPlayback(activeDetail.playback);
void this.portalPlayer.openResolvedPlayback(
activeDetail.playback
);
}
if (isAutoOpen) {
this.autoOpenHandled.emit();
@@ -350,7 +357,7 @@ export class UnifiedLiveTabComponent {
this.activeDetail.set(detail);
if (detail.epgMode === 'm3u') {
if (this.supportsEpg && detail.epgMode === 'm3u') {
void this.hydrateSelectedM3uPrograms(item, detail, requestId);
}
@@ -36,6 +36,7 @@ const TestStalkerEpgStore = signalStore(
describe('withStalkerEpg', () => {
let store: InstanceType<typeof TestStalkerEpgStore>;
let dataService: {
getAppEnvironment: jest.Mock<string, []>;
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
};
let stalkerSessionService: {
@@ -44,6 +45,7 @@ describe('withStalkerEpg', () => {
beforeEach(() => {
dataService = {
getAppEnvironment: jest.fn(() => 'electron'),
sendIpcEvent: jest.fn(),
};
stalkerSessionService = {
@@ -93,16 +95,43 @@ describe('withStalkerEpg', () => {
]);
});
it('does not request short EPG in browser/PWA mode', async () => {
dataService.getAppEnvironment.mockReturnValue('pwa');
const result = await store.fetchChannelEpg('10001');
expect(result).toEqual([]);
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
expect(
stalkerSessionService.makeAuthenticatedRequest
).not.toHaveBeenCalled();
});
it('loads bulk EPG once and projects selected-channel programs from the cache', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: {
data: {
'10001': [
buildEntry('10001', 'Morning Show', 1744358400, 1744362000),
buildEntry('10001', 'Current Show', 1744362000, 1744365600),
buildEntry(
'10001',
'Morning Show',
1744358400,
1744362000
),
buildEntry(
'10001',
'Current Show',
1744362000,
1744365600
),
],
'10002': [
buildEntry('10002', 'Other Channel', 1744362000, 1744365600),
buildEntry(
'10002',
'Other Channel',
1744362000,
1744365600
),
],
},
},
@@ -8,7 +8,11 @@ import {
} from '@ngrx/signals';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService } from '@iptvnator/services';
import { EpgItem, EpgProgram, StalkerPortalActions } from '@iptvnator/shared/interfaces';
import {
EpgItem,
EpgProgram,
StalkerPortalActions,
} from '@iptvnator/shared/interfaces';
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
import { StalkerSessionService } from '../../stalker-session.service';
import { StalkerEpgFeatureStoreContract } from '../stalker-store.contracts';
@@ -102,6 +106,9 @@ export function withStalkerEpg() {
dataService,
stalkerSession,
};
const supportsEpg = (): boolean =>
typeof dataService.getAppEnvironment !== 'function' ||
dataService.getAppEnvironment() !== 'pwa';
const requestEpg = async (
playlist: NonNullable<
@@ -122,6 +129,10 @@ export function withStalkerEpg() {
channelId: number | string,
size: number
): Promise<EpgItem[]> => {
if (!supportsEpg()) {
return [];
}
const playlist = storeContext.currentPlaylist();
if (!playlist) {
return [];
@@ -175,6 +186,17 @@ export function withStalkerEpg() {
}
const playlistId = String(playlist._id);
if (!supportsEpg()) {
patchState(store, {
bulkItvEpgByChannel: {},
bulkItvEpgLoaded: true,
bulkItvEpgPeriodHours: periodHours,
bulkItvEpgPlaylistId: playlistId,
isLoadingBulkItvEpg: false,
});
return;
}
const shouldReuseCache =
store.bulkItvEpgLoaded() &&
store.bulkItvEpgPlaylistId() === playlistId &&
@@ -57,7 +57,7 @@ export function resolveStalkerPlaybackUrl(
originalCmd: string,
responseCmd: string
): string {
let url = normalizeStalkerPlaybackCommand(responseCmd);
const url = normalizeStalkerPlaybackCommand(responseCmd);
if (!url) {
return '';
}
@@ -57,14 +57,18 @@
[logo]="item.logo"
[selected]="isSelectedChannel(item)"
[epgProgram]="
epgPreviewPrograms.get(
normalizeStalkerEntityId(item.id)
) ?? null
supportsEpg
? (epgPreviewPrograms.get(
normalizeStalkerEntityId(item.id)
) ?? null)
: null
"
[progressPercentage]="
currentProgramsProgress.get(
normalizeStalkerEntityId(item.id)
) ?? 0
supportsEpg
? (currentProgramsProgress.get(
normalizeStalkerEntityId(item.id)
) ?? 0)
: 0
"
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
@@ -132,7 +136,7 @@
}
</div>
}
@if (!isRadioMode()) {
@if (!isRadioMode() && supportsEpg) {
@let canCollapseEpgPanel = !!streamUrl() && isEmbeddedPlayer;
<div
class="epg"
@@ -234,8 +234,13 @@ describe('StalkerLiveStreamLayoutComponent', () => {
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');
@@ -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;
@@ -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);
};
},
},
];
}
@@ -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' },
@@ -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<EpgItem[]> {
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<EpgItem[]> {
if (!supportsEpg()) {
return [];
}
const credentials = getCredentialsFromStore();
if (!credentials) return [];
@@ -105,38 +105,42 @@
/>
</div>
}
<div
class="epg"
[class.epg-collapsed]="
isEmbeddedPlayer && isLiveEpgPanelCollapsed()
"
>
@if (isEmbeddedPlayer) {
<app-live-epg-panel
[collapsed]="isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()"
[loading]="isLoadingEpg()"
[showDateNavigator]="true"
[selectedDate]="selectedLiveEpgDate()"
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
(dateNavigation)="onLiveEpgDateNavigation($event)"
>
@if (supportsEpg) {
<div
class="epg"
[class.epg-collapsed]="
isEmbeddedPlayer && isLiveEpgPanelCollapsed()
"
>
@if (isEmbeddedPlayer) {
<app-live-epg-panel
[collapsed]="isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()"
[loading]="isLoadingEpg()"
[showDateNavigator]="true"
[selectedDate]="selectedLiveEpgDate()"
(collapsedChange)="
onLiveEpgPanelCollapsedChange($event)
"
(dateNavigation)="onLiveEpgDateNavigation($event)"
>
<ng-container
*ngTemplateOutlet="
epgPanelContent;
context: { unifiedDateNavigator: true }
"
/>
</app-live-epg-panel>
} @else {
<ng-container
*ngTemplateOutlet="
epgPanelContent;
context: { unifiedDateNavigator: true }
context: { unifiedDateNavigator: false }
"
/>
</app-live-epg-panel>
} @else {
<ng-container
*ngTemplateOutlet="
epgPanelContent;
context: { unifiedDateNavigator: false }
"
/>
}
</div>
}
</div>
}
} @else if (!showLiveChannelSidebar()) {
@if (isSelectedTypeContentLoading()) {
<div class="live-loading-content">
@@ -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');
@@ -120,6 +120,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
readonly selectedCategoryId = this.xtreamStore.selectedCategoryId;
readonly liveChannelSortMode = signal<PortalChannelSortMode>('server');
readonly isElectron = Boolean(window.electron);
readonly supportsEpg = this.isElectron;
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
private readonly routeSearchTerm = queryParamSignal(
this.route,
@@ -10,7 +10,7 @@
<div class="channels-loading-state__list">
<app-channel-list-skeleton
[count]="9"
[showEpg]="true"
[showEpg]="supportsEpg"
[actionCount]="1"
/>
</div>
@@ -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"
@@ -37,6 +37,8 @@ function buildEpgItem(params: {
}
describe('PortalChannelsListComponent', () => {
const testWindow = window as unknown as { electron?: unknown };
const originalElectron = testWindow.electron;
let fixture: ComponentFixture<PortalChannelsListComponent>;
const selectedChannels = signal<unknown[]>([]);
const selectedItem = signal<unknown>(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(
@@ -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<number>(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) {
+1
View File
@@ -2,6 +2,7 @@ export * from './lib/data.service';
export * from './lib/database-electron.service';
export * from './lib/downloads.service';
export * from './lib/playback-position.service';
export * from './lib/playlist-delete-cleanup.token';
export * from './lib/playlist-backup.service';
export * from './lib/playlist-refresh.service';
export * from './lib/playlists.service';
@@ -0,0 +1,9 @@
import { InjectionToken } from '@angular/core';
export type PlaylistDeleteCleanup = (
playlistId: string
) => Promise<void> | void;
export const PLAYLIST_DELETE_CLEANUP = new InjectionToken<
PlaylistDeleteCleanup[]
>('PLAYLIST_DELETE_CLEANUP');
@@ -17,11 +17,14 @@ describe('PlaylistsService', () => {
});
function createService(overrides: Record<string, unknown> = {}) {
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 () => {
+42 -12
View File
@@ -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<void> | null = null;
private indexedDbMigrationPromise: Promise<void> | 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<unknown> = 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<void> {
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) {
+2
View File
@@ -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';
@@ -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<GlobalRecentItem, 'playlist_id' | 'type' | 'xtream_id'>[]
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<DashboardRailCard[]>(() => {
@@ -773,14 +776,9 @@ export class WorkspaceDashboardRailsComponent {
}
private async removePlaylist(playlist: PlaylistMeta): Promise<void> {
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<boolean> {
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<boolean> {
const result = await firstValueFrom(
this.playlistsService.deletePlaylist(playlist._id)
);
return result.success;
}
private t(key: string): string {
return this.translate.instant(key);
}