mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
fix(pwa): harden self-hosted runtime boundaries
This commit is contained in:
1 parent
ff9c13058c
commit
55efc24608
57 files changed
+984
-257
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 часто имеют ограниченную поддержку этих кодеков.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
+37
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+66
-15
@@ -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';
|
||||
|
||||
+4
-4
@@ -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',
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!isRadioSelection()) {
|
||||
@if (!isRadioSelection() && supportsEpg) {
|
||||
<div
|
||||
class="epg"
|
||||
[class.epg-collapsed]="
|
||||
|
||||
+74
-2
@@ -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',
|
||||
|
||||
+10
-3
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+32
-3
@@ -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
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
+23
-1
@@ -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 &&
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ export function resolveStalkerPlaybackUrl(
|
||||
originalCmd: string,
|
||||
responseCmd: string
|
||||
): string {
|
||||
let url = normalizeStalkerPlaybackCommand(responseCmd);
|
||||
const url = normalizeStalkerPlaybackCommand(responseCmd);
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
+11
-7
@@ -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"
|
||||
|
||||
+29
@@ -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');
|
||||
|
||||
+13
-2
@@ -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 [];
|
||||
|
||||
|
||||
+31
-27
@@ -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">
|
||||
|
||||
+21
@@ -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,
|
||||
|
||||
+9
-3
@@ -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"
|
||||
|
||||
+73
-12
@@ -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(
|
||||
|
||||
+30
-11
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
+33
-13
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user