merge: resolve master conflicts for pwa hardening

- merge origin/master into PR #964 and keep embedded MPV test on the isolated playback sub-entrypoint

- centralize EPG capability through DataService.supportsEpg and update PWA web-e2e expectations

- split BrowserAccessError copy between Electron and PWA diagnostics
This commit is contained in:
4gray committed 2026-05-22 10:20:03 +03:00
commit 36bce47764
102 files changed
+1894 -439

No files matched your search

+70 -1
View File
@@ -88,10 +88,67 @@ jobs:
- name: Build frontend
run: pnpm nx build web --skip-nx-cache
- name: Resolve embedded MPV runtime cache key
# TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
if: matrix.os == 'macos' && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master')
id: embedded-mpv-runtime-cache-key
shell: bash
run: |
set -euo pipefail
deployment_target="${MACOSX_DEPLOYMENT_TARGET:-11.0}"
safe_deployment_target="$(printf '%s' "${deployment_target}" | tr -c 'A-Za-z0-9_.-' '-')"
xcode_version="$(xcodebuild -version | tr '\n' ' ' | sed 's/[[:space:]]*$//')"
xcode_hash="$(printf '%s' "${xcode_version}" | shasum -a 256 | awk '{print $1}')"
# Runtime source pins, source URLs, git tags, and build flags
# live in build-macos-runtime.mjs; staging layout and manifest
# normalization live in stage-macos-runtime.mjs.
source_hash="$(
shasum -a 256 \
tools/embedded-mpv/build-macos-runtime.mjs \
tools/embedded-mpv/stage-macos-runtime.mjs |
shasum -a 256 |
awk '{print $1}'
)"
cache_key="embedded-mpv-runtime-v1-${RUNNER_OS}-${{ matrix.arch }}-macos${safe_deployment_target}-xcode${xcode_hash}-${source_hash}"
echo "deployment-target=${deployment_target}" >> "${GITHUB_OUTPUT}"
echo "xcode-version=${xcode_version}" >> "${GITHUB_OUTPUT}"
echo "key=${cache_key}" >> "${GITHUB_OUTPUT}"
echo "Embedded MPV runtime cache key: ${cache_key}"
echo "Xcode version: ${xcode_version}"
- name: Restore embedded MPV runtime cache
# TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
if: matrix.os == 'macos' && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master')
id: embedded-mpv-runtime-cache
uses: actions/cache/restore@v4
with:
path: |
vendor/embedded-mpv/darwin-${{ matrix.arch }}/include
vendor/embedded-mpv/darwin-${{ matrix.arch }}/lib
vendor/embedded-mpv/darwin-${{ matrix.arch }}/runtime-manifest.json
key: ${{ steps.embedded-mpv-runtime-cache-key.outputs.key }}
- name: Clear stale embedded MPV runtime files
# TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
if: matrix.os == 'macos' && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true'
shell: bash
run: |
set -euo pipefail
rm -rf \
"vendor/embedded-mpv/darwin-${{ matrix.arch }}/include" \
"vendor/embedded-mpv/darwin-${{ matrix.arch }}/lib" \
"vendor/embedded-mpv/darwin-${{ matrix.arch }}/runtime-manifest.json"
- name: Build embedded MPV runtime (macOS release)
# TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
if: matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master')
if: matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true'
shell: bash
run: |
set -euo pipefail
@@ -322,6 +379,18 @@ jobs:
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && '1' || '0' }}
run: pnpm run verify:package-layout -- "$PACKAGE_OS" "$PACKAGE_ARCH"
- name: Save embedded MPV runtime cache
# TEMPORARY ARTIFACT TEST: remove `|| github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
if: matrix.os == 'macos' && !startsWith(github.ref, 'refs/tags/v') && github.repository == '4gray/iptvnator' && github.event_name != 'pull_request' && github.ref == 'refs/heads/master' && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: |
vendor/embedded-mpv/darwin-${{ matrix.arch }}/include
vendor/embedded-mpv/darwin-${{ matrix.arch }}/lib
vendor/embedded-mpv/darwin-${{ matrix.arch }}/runtime-manifest.json
key: ${{ steps.embedded-mpv-runtime-cache-key.outputs.key }}
- name: Smoke test packaged Flatpak launcher
if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak'
shell: bash
@@ -0,0 +1,321 @@
type PreloadInvokeCase = {
method: string;
args: unknown[];
channel: string;
forwardedArgs: unknown[];
};
const playlistId = 'playlist-1';
export const operationId = 'operation-1';
const playlist = { id: playlistId, name: 'Playlist', type: 'xtream' };
const playlists = [playlist];
const playlistUpdates = { name: 'Updated playlist' };
const categories = [{ category_id: '10', category_name: 'Live' }];
const streams = [{ stream_id: 42, name: 'Channel' }];
const favorites = [{ contentId: 1, playlistId }];
const recentlyViewed = [{ contentId: 2, playlistId }];
const categoryIds = [10, 11];
const reorderUpdates = [{ content_id: 12, position: 1 }];
const recentItemsBatch = [{ contentId: 13, playlistId }];
const playbackData = {
contentXtreamId: 42,
contentType: 'vod',
positionSeconds: 120,
};
export const dbPreloadCases: PreloadInvokeCase[] = [
{
method: 'dbCreatePlaylist',
args: [playlist],
channel: 'DB_CREATE_PLAYLIST',
forwardedArgs: [playlist],
},
{
method: 'dbGetPlaylist',
args: [playlistId],
channel: 'DB_GET_PLAYLIST',
forwardedArgs: [playlistId],
},
{
method: 'dbUpsertAppPlaylist',
args: [playlist],
channel: 'DB_UPSERT_APP_PLAYLIST',
forwardedArgs: [playlist],
},
{
method: 'dbUpsertAppPlaylists',
args: [playlists],
channel: 'DB_UPSERT_APP_PLAYLISTS',
forwardedArgs: [playlists],
},
{
method: 'dbGetAppPlaylists',
args: [],
channel: 'DB_GET_APP_PLAYLISTS',
forwardedArgs: [],
},
{
method: 'dbGetAppPlaylist',
args: [playlistId],
channel: 'DB_GET_APP_PLAYLIST',
forwardedArgs: [playlistId],
},
{
method: 'dbUpdatePlaylist',
args: [playlistId, playlistUpdates],
channel: 'DB_UPDATE_PLAYLIST',
forwardedArgs: [playlistId, playlistUpdates],
},
{
method: 'dbDeletePlaylist',
args: [playlistId, operationId],
channel: 'DB_DELETE_PLAYLIST',
forwardedArgs: [playlistId, operationId],
},
{
method: 'dbDeleteXtreamContent',
args: [playlistId, operationId],
channel: 'DB_DELETE_XTREAM_CONTENT',
forwardedArgs: [playlistId, operationId],
},
{
method: 'dbRestoreXtreamUserData',
args: [playlistId, favorites, recentlyViewed, operationId],
channel: 'DB_RESTORE_XTREAM_USER_DATA',
forwardedArgs: [playlistId, favorites, recentlyViewed, operationId],
},
{
method: 'dbHasCategories',
args: [playlistId, 'live'],
channel: 'DB_HAS_CATEGORIES',
forwardedArgs: [playlistId, 'live'],
},
{
method: 'dbGetCategories',
args: [playlistId, 'live'],
channel: 'DB_GET_CATEGORIES',
forwardedArgs: [playlistId, 'live'],
},
{
method: 'dbSaveCategories',
args: [playlistId, categories, 'live', categoryIds],
channel: 'DB_SAVE_CATEGORIES',
forwardedArgs: [playlistId, categories, 'live', categoryIds],
},
{
method: 'dbGetAllCategories',
args: [playlistId, 'live'],
channel: 'DB_GET_ALL_CATEGORIES',
forwardedArgs: [playlistId, 'live'],
},
{
method: 'dbUpdateCategoryVisibility',
args: [categoryIds, true],
channel: 'DB_UPDATE_CATEGORY_VISIBILITY',
forwardedArgs: [categoryIds, true],
},
{
method: 'dbHasContent',
args: [playlistId, 'movie'],
channel: 'DB_HAS_CONTENT',
forwardedArgs: [playlistId, 'movie'],
},
{
method: 'dbGetContent',
args: [playlistId, 'movie'],
channel: 'DB_GET_CONTENT',
forwardedArgs: [playlistId, 'movie'],
},
{
method: 'dbSaveContent',
args: [playlistId, streams, 'movie', operationId],
channel: 'DB_SAVE_CONTENT',
forwardedArgs: [playlistId, streams, 'movie', operationId],
},
{
method: 'dbClearXtreamImportCache',
args: [playlistId, 'movie'],
channel: 'DB_CLEAR_XTREAM_IMPORT_CACHE',
forwardedArgs: [playlistId, 'movie'],
},
{
method: 'dbSearchContent',
args: [playlistId, 'matrix', ['movie'], true],
channel: 'DB_SEARCH_CONTENT',
forwardedArgs: [playlistId, 'matrix', ['movie'], true],
},
{
method: 'dbGlobalSearch',
args: ['matrix', ['movie'], true],
channel: 'DB_GLOBAL_SEARCH',
forwardedArgs: ['matrix', ['movie'], true],
},
{
method: 'dbGetGlobalRecentlyAdded',
args: ['vod', 50, 'xtream'],
channel: 'DB_GET_GLOBAL_RECENTLY_ADDED',
forwardedArgs: ['vod', 50, 'xtream'],
},
{
method: 'dbGetRecentlyViewed',
args: [],
channel: 'DB_GET_RECENTLY_VIEWED',
forwardedArgs: [],
},
{
method: 'dbClearRecentlyViewed',
args: [],
channel: 'DB_CLEAR_RECENTLY_VIEWED',
forwardedArgs: [],
},
{
method: 'dbAddFavorite',
args: [12, playlistId, 'https://image.example/backdrop.jpg'],
channel: 'DB_ADD_FAVORITE',
forwardedArgs: [12, playlistId, 'https://image.example/backdrop.jpg'],
},
{
method: 'dbRemoveFavorite',
args: [12, playlistId],
channel: 'DB_REMOVE_FAVORITE',
forwardedArgs: [12, playlistId],
},
{
method: 'dbIsFavorite',
args: [12, playlistId],
channel: 'DB_IS_FAVORITE',
forwardedArgs: [12, playlistId],
},
{
method: 'dbGetFavorites',
args: [playlistId],
channel: 'DB_GET_FAVORITES',
forwardedArgs: [playlistId],
},
{
method: 'dbGetGlobalFavorites',
args: [],
channel: 'DB_GET_GLOBAL_FAVORITES',
forwardedArgs: [],
},
{
method: 'dbGetAllGlobalFavorites',
args: [],
channel: 'DB_GET_ALL_GLOBAL_FAVORITES',
forwardedArgs: [],
},
{
method: 'dbReorderGlobalFavorites',
args: [reorderUpdates],
channel: 'DB_REORDER_GLOBAL_FAVORITES',
forwardedArgs: [reorderUpdates],
},
{
method: 'dbGetRecentItems',
args: [playlistId],
channel: 'DB_GET_RECENT_ITEMS',
forwardedArgs: [playlistId],
},
{
method: 'dbAddRecentItem',
args: [13, playlistId, 'https://image.example/recent.jpg'],
channel: 'DB_ADD_RECENT_ITEM',
forwardedArgs: [13, playlistId, 'https://image.example/recent.jpg'],
},
{
method: 'dbClearPlaylistRecentItems',
args: [playlistId],
channel: 'DB_CLEAR_PLAYLIST_RECENT_ITEMS',
forwardedArgs: [playlistId],
},
{
method: 'dbRemoveRecentItem',
args: [13, playlistId],
channel: 'DB_REMOVE_RECENT_ITEM',
forwardedArgs: [13, playlistId],
},
{
method: 'dbRemoveRecentItemsBatch',
args: [recentItemsBatch],
channel: 'DB_REMOVE_RECENT_ITEMS_BATCH',
forwardedArgs: [recentItemsBatch],
},
{
method: 'dbGetContentByXtreamId',
args: [42, playlistId, 'movie'],
channel: 'DB_GET_CONTENT_BY_XTREAM_ID',
forwardedArgs: [42, playlistId, 'movie'],
},
{
method: 'dbSetContentBackdropIfMissing',
args: [12, 'https://image.example/backdrop.jpg'],
channel: 'DB_SET_CONTENT_BACKDROP_IF_MISSING',
forwardedArgs: [12, 'https://image.example/backdrop.jpg'],
},
{
method: 'dbDeleteAllPlaylists',
args: [operationId],
channel: 'DB_DELETE_ALL_PLAYLISTS',
forwardedArgs: [operationId],
},
{
method: 'dbCancelOperation',
args: [operationId],
channel: 'DB_CANCEL_OPERATION',
forwardedArgs: [operationId],
},
{
method: 'dbGetAppState',
args: ['workspace:last-route'],
channel: 'DB_GET_APP_STATE',
forwardedArgs: ['workspace:last-route'],
},
{
method: 'dbSetAppState',
args: ['workspace:last-route', '/workspace'],
channel: 'DB_SET_APP_STATE',
forwardedArgs: ['workspace:last-route', '/workspace'],
},
{
method: 'dbSavePlaybackPosition',
args: [playlistId, playbackData],
channel: 'DB_SAVE_PLAYBACK_POSITION',
forwardedArgs: [playlistId, playbackData],
},
{
method: 'dbGetPlaybackPosition',
args: [playlistId, 42, 'vod'],
channel: 'DB_GET_PLAYBACK_POSITION',
forwardedArgs: [playlistId, 42, 'vod'],
},
{
method: 'dbGetSeriesPlaybackPositions',
args: [playlistId, 88],
channel: 'DB_GET_SERIES_PLAYBACK_POSITIONS',
forwardedArgs: [playlistId, 88],
},
{
method: 'dbGetRecentPlaybackPositions',
args: [playlistId, 20],
channel: 'DB_GET_RECENT_PLAYBACK_POSITIONS',
forwardedArgs: [playlistId, 20],
},
{
method: 'dbGetAllPlaybackPositions',
args: [playlistId],
channel: 'DB_GET_ALL_PLAYBACK_POSITIONS',
forwardedArgs: [playlistId],
},
{
method: 'dbClearAllPlaybackPositions',
args: [playlistId],
channel: 'DB_CLEAR_ALL_PLAYBACK_POSITIONS',
forwardedArgs: [playlistId],
},
{
method: 'dbClearPlaybackPosition',
args: [playlistId, 42, 'vod'],
channel: 'DB_CLEAR_PLAYBACK_POSITION',
forwardedArgs: [playlistId, 42, 'vod'],
},
];
@@ -0,0 +1,146 @@
import {
DB_WORKER_OPERATIONS,
DbWorkerOperation,
} from '../workers/database-worker.types';
import { dbPreloadCases, operationId } from './main.preload.spec-data';
type ExposedElectronApi = Record<string, (...args: unknown[]) => unknown>;
type MockIpcRenderer = {
invoke: jest.Mock;
on: jest.Mock;
off: jest.Mock;
send: jest.Mock;
};
let mockExposedApi: ExposedElectronApi | null;
let mockIpcRenderer: MockIpcRenderer;
let mockGetPathForFile: jest.Mock;
function getExposedApi(): ExposedElectronApi {
if (!mockExposedApi) {
throw new Error('Expected preload API to be exposed');
}
return mockExposedApi;
}
describe('main preload DB IPC contract', () => {
beforeEach(async () => {
jest.resetModules();
mockExposedApi = null;
mockGetPathForFile = jest.fn();
mockIpcRenderer = {
invoke: jest.fn().mockResolvedValue({ success: true }),
on: jest.fn(),
off: jest.fn(),
send: jest.fn(),
};
jest.doMock('electron', () => ({
contextBridge: {
exposeInMainWorld: jest.fn(
(_name: string, api: ExposedElectronApi) => {
mockExposedApi = api;
}
),
},
ipcRenderer: mockIpcRenderer,
webUtils: {
getPathForFile: mockGetPathForFile,
},
}));
await import('./main.preload');
});
afterEach(() => {
jest.dontMock('electron');
});
it('covers every worker-backed DB operation exposed by the preload bridge', () => {
const workerChannels = dbPreloadCases
.map((contractCase) => contractCase.channel)
.filter(
(channel): channel is DbWorkerOperation =>
channel !== 'DB_CANCEL_OPERATION' &&
DB_WORKER_OPERATIONS.includes(channel as DbWorkerOperation)
);
expect(new Set(workerChannels)).toEqual(new Set(DB_WORKER_OPERATIONS));
});
it.each(dbPreloadCases)(
'$method invokes $channel with the expected arguments',
async ({ method, args, channel, forwardedArgs }) => {
const api = getExposedApi();
await api[method](...args);
expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith(
channel,
...forwardedArgs
);
}
);
it('forwards request-scoped DB operation events and unregisters the listener', () => {
const api = getExposedApi();
const callback = jest.fn();
const event = {
operationId,
operation: 'save-content',
status: 'progress',
current: 5,
total: 10,
};
const unsubscribe = api.onDbOperationEvent(callback) as () => void;
const handler = mockIpcRenderer.on.mock.calls[0][1];
handler({}, event);
expect(mockIpcRenderer.on).toHaveBeenCalledWith(
'DB_OPERATION_EVENT',
expect.any(Function)
);
expect(callback).toHaveBeenCalledWith(event);
unsubscribe();
expect(mockIpcRenderer.off).toHaveBeenCalledWith(
'DB_OPERATION_EVENT',
handler
);
});
it('keeps the legacy save-content progress bridge scoped to progress events', () => {
const api = getExposedApi();
const callback = jest.fn();
api.onDbSaveContentProgress(callback);
const handler = mockIpcRenderer.on.mock.calls[0][1];
handler({}, { operation: 'delete-playlist', status: 'progress' });
handler({}, { operation: 'save-content', status: 'started' });
handler(
{},
{
operation: 'save-content',
status: 'progress',
current: 25,
increment: 5,
}
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(5);
api.removeDbSaveContentProgress();
expect(mockIpcRenderer.off).toHaveBeenCalledWith(
'DB_OPERATION_EVENT',
handler
);
});
});
@@ -305,7 +305,7 @@ const electronApi = {
url: string,
title: string,
thumbnail: string,
userAgent: string,
userAgent: string | undefined,
referer?: string,
origin?: string,
contentInfo?: any,
@@ -328,7 +328,7 @@ const electronApi = {
url: string,
title: string,
thumbnail: string,
userAgent: string,
userAgent: string | undefined,
referer?: string,
origin?: string,
contentInfo?: any,
@@ -0,0 +1,299 @@
import { DbWorkerOperation } from '../../workers/database-worker.types';
type WorkerIpcContractCase = {
operation: DbWorkerOperation;
args: unknown[];
payload: unknown;
forwardsEvents?: boolean;
};
export const playlistId = 'playlist-1';
export const operationId = 'operation-1';
const playlist = { id: playlistId, name: 'Playlist', type: 'xtream' };
const playlists = [playlist];
const playlistUpdates = { name: 'Updated playlist' };
const categories = [{ category_id: '10', category_name: 'Live' }];
export const streams = [{ stream_id: 42, name: 'Channel' }];
const favorites = [{ contentId: 1, playlistId }];
const recentlyViewed = [{ contentId: 2, playlistId }];
const categoryIds = [10, 11];
const reorderUpdates = [{ content_id: 12, position: 1 }];
const recentItemsBatch = [{ contentId: 13, playlistId }];
const playbackData = {
contentXtreamId: 42,
contentType: 'vod',
positionSeconds: 120,
};
export const workerIpcContractCases: WorkerIpcContractCase[] = [
{
operation: 'DB_CREATE_PLAYLIST',
args: [playlist],
payload: playlist,
},
{
operation: 'DB_UPSERT_APP_PLAYLIST',
args: [playlist],
payload: playlist,
},
{
operation: 'DB_UPSERT_APP_PLAYLISTS',
args: [playlists],
payload: playlists,
},
{
operation: 'DB_GET_APP_PLAYLISTS',
args: [],
payload: {},
},
{
operation: 'DB_GET_APP_PLAYLIST',
args: [playlistId],
payload: { playlistId },
},
{
operation: 'DB_GET_PLAYLIST',
args: [playlistId],
payload: { playlistId },
},
{
operation: 'DB_UPDATE_PLAYLIST',
args: [playlistId, playlistUpdates],
payload: { playlistId, updates: playlistUpdates },
},
{
operation: 'DB_GET_APP_STATE',
args: ['workspace:last-route'],
payload: { key: 'workspace:last-route' },
},
{
operation: 'DB_SET_APP_STATE',
args: ['workspace:last-route', '/workspace'],
payload: { key: 'workspace:last-route', value: '/workspace' },
},
{
operation: 'DB_HAS_CATEGORIES',
args: [playlistId, 'live'],
payload: { playlistId, type: 'live' },
},
{
operation: 'DB_GET_CATEGORIES',
args: [playlistId, 'live'],
payload: { playlistId, type: 'live' },
},
{
operation: 'DB_SAVE_CATEGORIES',
args: [playlistId, categories, 'live', categoryIds],
payload: {
playlistId,
categories,
type: 'live',
hiddenCategoryXtreamIds: categoryIds,
},
},
{
operation: 'DB_GET_ALL_CATEGORIES',
args: [playlistId, 'live'],
payload: { playlistId, type: 'live' },
},
{
operation: 'DB_UPDATE_CATEGORY_VISIBILITY',
args: [categoryIds, true],
payload: { categoryIds, hidden: true },
},
{
operation: 'DB_HAS_CONTENT',
args: [playlistId, 'movie'],
payload: { playlistId, type: 'movie' },
},
{
operation: 'DB_GET_CONTENT',
args: [playlistId, 'movie'],
payload: { playlistId, type: 'movie' },
},
{
operation: 'DB_GET_GLOBAL_RECENTLY_ADDED',
args: ['vod', 50, 'xtream'],
payload: { kind: 'vod', limit: 50, playlistType: 'xtream' },
},
{
operation: 'DB_SAVE_CONTENT',
args: [playlistId, streams, 'movie', operationId],
payload: { playlistId, streams, type: 'movie', operationId },
forwardsEvents: true,
},
{
operation: 'DB_CLEAR_XTREAM_IMPORT_CACHE',
args: [playlistId, 'movie'],
payload: { playlistId, type: 'movie' },
},
{
operation: 'DB_GET_CONTENT_BY_XTREAM_ID',
args: [42, playlistId, 'movie'],
payload: { xtreamId: 42, playlistId, contentType: 'movie' },
},
{
operation: 'DB_SET_CONTENT_BACKDROP_IF_MISSING',
args: [12, 'https://image.example/backdrop.jpg'],
payload: {
contentId: 12,
backdropUrl: 'https://image.example/backdrop.jpg',
},
},
{
operation: 'DB_SEARCH_CONTENT',
args: [playlistId, 'matrix', ['movie'], true],
payload: {
playlistId,
searchTerm: 'matrix',
types: ['movie'],
excludeHidden: true,
},
},
{
operation: 'DB_GLOBAL_SEARCH',
args: ['matrix', ['movie'], true],
payload: {
searchTerm: 'matrix',
types: ['movie'],
excludeHidden: true,
},
},
{
operation: 'DB_DELETE_PLAYLIST',
args: [playlistId, operationId],
payload: { playlistId, operationId },
forwardsEvents: true,
},
{
operation: 'DB_DELETE_ALL_PLAYLISTS',
args: [operationId],
payload: { operationId },
forwardsEvents: true,
},
{
operation: 'DB_DELETE_XTREAM_CONTENT',
args: [playlistId, operationId],
payload: { playlistId, operationId },
forwardsEvents: true,
},
{
operation: 'DB_RESTORE_XTREAM_USER_DATA',
args: [playlistId, favorites, recentlyViewed, operationId],
payload: { playlistId, favorites, recentlyViewed, operationId },
forwardsEvents: true,
},
{
operation: 'DB_ADD_FAVORITE',
args: [12, playlistId, 'https://image.example/backdrop.jpg'],
payload: {
contentId: 12,
playlistId,
backdropUrl: 'https://image.example/backdrop.jpg',
},
},
{
operation: 'DB_REMOVE_FAVORITE',
args: [12, playlistId],
payload: { contentId: 12, playlistId },
},
{
operation: 'DB_IS_FAVORITE',
args: [12, playlistId],
payload: { contentId: 12, playlistId },
},
{
operation: 'DB_GET_FAVORITES',
args: [playlistId],
payload: { playlistId },
},
{
operation: 'DB_GET_GLOBAL_FAVORITES',
args: [],
payload: {},
},
{
operation: 'DB_GET_ALL_GLOBAL_FAVORITES',
args: [],
payload: {},
},
{
operation: 'DB_REORDER_GLOBAL_FAVORITES',
args: [reorderUpdates],
payload: { updates: reorderUpdates },
},
{
operation: 'DB_GET_RECENTLY_VIEWED',
args: [],
payload: {},
},
{
operation: 'DB_CLEAR_RECENTLY_VIEWED',
args: [],
payload: {},
},
{
operation: 'DB_GET_RECENT_ITEMS',
args: [playlistId],
payload: { playlistId },
},
{
operation: 'DB_ADD_RECENT_ITEM',
args: [13, playlistId, 'https://image.example/recent.jpg'],
payload: {
contentId: 13,
playlistId,
backdropUrl: 'https://image.example/recent.jpg',
},
},
{
operation: 'DB_CLEAR_PLAYLIST_RECENT_ITEMS',
args: [playlistId],
payload: { playlistId },
},
{
operation: 'DB_REMOVE_RECENT_ITEM',
args: [13, playlistId],
payload: { contentId: 13, playlistId },
},
{
operation: 'DB_REMOVE_RECENT_ITEMS_BATCH',
args: [recentItemsBatch],
payload: { items: recentItemsBatch },
},
{
operation: 'DB_SAVE_PLAYBACK_POSITION',
args: [playlistId, playbackData],
payload: { playlistId, data: playbackData },
},
{
operation: 'DB_GET_PLAYBACK_POSITION',
args: [playlistId, 42, 'vod'],
payload: { playlistId, contentXtreamId: 42, contentType: 'vod' },
},
{
operation: 'DB_GET_SERIES_PLAYBACK_POSITIONS',
args: [playlistId, 88],
payload: { playlistId, seriesXtreamId: 88 },
},
{
operation: 'DB_GET_RECENT_PLAYBACK_POSITIONS',
args: [playlistId, 20],
payload: { playlistId, limit: 20 },
},
{
operation: 'DB_GET_ALL_PLAYBACK_POSITIONS',
args: [playlistId],
payload: { playlistId },
},
{
operation: 'DB_CLEAR_ALL_PLAYBACK_POSITIONS',
args: [playlistId],
payload: { playlistId },
},
{
operation: 'DB_CLEAR_PLAYBACK_POSITION',
args: [playlistId, 42, 'vod'],
payload: { playlistId, contentXtreamId: 42, contentType: 'vod' },
},
];
@@ -0,0 +1,169 @@
import {
DB_WORKER_OPERATIONS,
DbOperationEvent,
} from '../../workers/database-worker.types';
import {
operationId,
playlistId,
streams,
workerIpcContractCases,
} from './worker-ipc-contract.spec-data';
type IpcHandler = (event: MockIpcEvent, ...args: unknown[]) => Promise<unknown>;
type MockIpcEvent = {
sender: {
isDestroyed: jest.Mock<boolean, []>;
send: jest.Mock;
};
};
const mockRegisteredHandlers = new Map<string, IpcHandler>();
const mockWorkerRequest = jest.fn();
const mockWorkerCancel = jest.fn();
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn((channel: string, handler: IpcHandler) => {
mockRegisteredHandlers.set(channel, handler);
}),
},
}));
jest.mock('../../services/database-worker-client', () => ({
databaseWorkerClient: {
request: (...args: unknown[]) => mockWorkerRequest(...args),
cancel: (...args: unknown[]) => mockWorkerCancel(...args),
},
}));
async function importDatabaseEventModules(): Promise<void> {
await import('./category.events');
await import('./content.events');
await import('./favorites.events');
await import('./playback-position.events');
await import('./playlist.events');
await import('./recently-viewed.events');
await import('./xtream.events');
}
function createIpcEvent(): MockIpcEvent {
return {
sender: {
isDestroyed: jest.fn(() => false),
send: jest.fn(),
},
};
}
function getHandler(channel: string): IpcHandler {
const handler = mockRegisteredHandlers.get(channel);
if (!handler) {
throw new Error(`Expected IPC handler for ${channel}`);
}
return handler;
}
function getLastWorkerRequestCall(): unknown[] {
return mockWorkerRequest.mock.calls[
mockWorkerRequest.mock.calls.length - 1
];
}
describe('database worker IPC contract', () => {
beforeEach(async () => {
jest.resetModules();
mockRegisteredHandlers.clear();
mockWorkerRequest.mockReset().mockResolvedValue({ success: true });
mockWorkerCancel.mockReset().mockResolvedValue({ success: true });
await importDatabaseEventModules();
});
it('registers an IPC handler for every worker operation', () => {
const registeredDbChannels = [...mockRegisteredHandlers.keys()]
.filter((channel) => channel !== 'DB_CANCEL_OPERATION')
.sort();
expect(registeredDbChannels).toEqual([...DB_WORKER_OPERATIONS].sort());
expect(mockRegisteredHandlers.has('DB_CANCEL_OPERATION')).toBe(true);
});
it.each(workerIpcContractCases)(
'$operation builds the expected worker request payload',
async ({ operation, args, payload, forwardsEvents }) => {
const ipcEvent = createIpcEvent();
const handler = getHandler(operation);
await handler(ipcEvent, ...args);
if (forwardsEvents) {
expect(mockWorkerRequest).toHaveBeenLastCalledWith(
operation,
payload,
{ onEvent: expect.any(Function) }
);
} else {
expect(mockWorkerRequest).toHaveBeenLastCalledWith(
operation,
payload
);
}
}
);
it('covers every worker operation in the payload contract cases', () => {
expect(
new Set(workerIpcContractCases.map(({ operation }) => operation))
).toEqual(new Set(DB_WORKER_OPERATIONS));
});
it('forwards request-scoped worker events only while the renderer exists', async () => {
const ipcEvent = createIpcEvent();
const workerEvent: DbOperationEvent = {
operationId,
operation: 'save-content',
status: 'progress',
current: 5,
total: 10,
};
await getHandler('DB_SAVE_CONTENT')(
ipcEvent,
playlistId,
streams,
'movie',
operationId
);
const options = getLastWorkerRequestCall()[2] as {
onEvent: (event: DbOperationEvent) => void;
};
options.onEvent(workerEvent);
expect(ipcEvent.sender.send).toHaveBeenCalledWith(
'DB_OPERATION_EVENT',
workerEvent
);
ipcEvent.sender.send.mockClear();
ipcEvent.sender.isDestroyed.mockReturnValue(true);
options.onEvent(workerEvent);
expect(ipcEvent.sender.send).not.toHaveBeenCalled();
});
it('routes DB cancellation through the worker client cancel channel', async () => {
const result = await getHandler('DB_CANCEL_OPERATION')(
createIpcEvent(),
operationId
);
expect(mockWorkerCancel).toHaveBeenCalledWith(operationId);
expect(result).toEqual({ success: true });
});
});
+2
View File
@@ -7,9 +7,11 @@
"files": ["../../global.d.ts"],
"exclude": [
"**/*.spec.ts",
"**/*.spec-data.ts",
"**/*.test.ts",
"jest.config.ts",
"src/**/*.spec.ts",
"src/**/*.spec-data.ts",
"src/**/*.test.ts"
],
"include": ["**/*.ts"]
+19 -20
View File
@@ -220,13 +220,18 @@ test('@stalker minimal scenario — correct item counts', async ({ page }) => {
expect(count).toBeGreaterThanOrEqual(2);
});
test('@stalker EPG data loads for ITV channel', async ({ page }) => {
test('@stalker PWA hides EPG for ITV channel', async ({ page }) => {
await addStalkerPortal(page);
const epgInfoRequests: string[] = [];
const shortEpgRequests: string[] = [];
page.on('request', (request) => {
if (request.url().includes('action=get_short_epg')) {
shortEpgRequests.push(request.url());
const url = request.url();
if (url.includes('action=get_epg_info')) {
epgInfoRequests.push(url);
}
if (url.includes('action=get_short_epg')) {
shortEpgRequests.push(url);
}
});
@@ -244,17 +249,16 @@ test('@stalker EPG data loads for ITV channel', async ({ page }) => {
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
expect(shortEpgRequests).toHaveLength(0);
// Click a channel — EPG info should appear
// Click a channel — PWA/browser playback must not expose Electron EPG UI.
await channels.first().click();
await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 });
await expect(page.locator('app-epg-list')).toBeVisible({
await expect(page.locator('app-web-player-view')).toBeVisible({
timeout: 20_000,
});
await expect(
page.locator(
'app-live-epg-panel .selected-date, app-epg-list .selected-date'
)
).toBeVisible({ timeout: 20_000 });
await expect(page.locator('app-epg-list')).toHaveCount(0);
await expect(page.locator('app-live-epg-panel')).toHaveCount(0);
expect(epgInfoRequests).toHaveLength(0);
expect(shortEpgRequests).toHaveLength(0);
});
test('@stalker radio — stations use the inline audio player without EPG', async ({
@@ -309,7 +313,7 @@ test('@stalker radio — stations use the inline audio player without EPG', asyn
expect(epgRequests).toHaveLength(0);
});
test('@stalker bulk EPG is fetched once and reused across channel switches', async ({
test('@stalker PWA skips bulk EPG across channel switches', async ({
page,
}) => {
await addStalkerPortal(page);
@@ -337,18 +341,13 @@ test('@stalker bulk EPG is fetched once and reused across channel switches', asy
expect(shortEpgRequests).toHaveLength(0);
await channels.first().click();
await expect(page.locator('app-epg-list')).toBeVisible({
timeout: 20_000,
});
await expect
.poll(() => epgInfoRequests.length, { timeout: 20_000 })
.toBe(1);
await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 });
await expect(page.locator('app-epg-list')).toHaveCount(0);
await channels.nth(1).click();
await expect(channels.nth(1)).toHaveClass(/active/, { timeout: 20_000 });
await expect
.poll(() => epgInfoRequests.length, { timeout: 20_000 })
.toBe(1);
await expect(page.locator('app-epg-list')).toHaveCount(0);
expect(epgInfoRequests).toHaveLength(0);
expect(shortEpgRequests).toHaveLength(0);
});
+9 -7
View File
@@ -49,14 +49,16 @@ export class AppComponent implements OnInit {
private readonly DEFAULT_LANG = Language.ENGLISH;
constructor() {
const electronProcess = this.dataService.remote?.process;
if (
((this.dataService.isElectron &&
this.dataService?.remote?.process.platform === 'linux') ||
this.dataService?.remote?.process.platform === 'win32') &&
this.dataService.remote.process.argv.length > 2
this.dataService.isElectron &&
electronProcess &&
(electronProcess.platform === 'linux' ||
electronProcess.platform === 'win32') &&
electronProcess.argv.length > 2
) {
const filePath = this.dataService.remote.process.argv.find(
(filepath) =>
const filePath = electronProcess.argv.find(
(filepath: string) =>
filepath.endsWith('.m3u') || filepath.endsWith('.m3u8')
);
if (filePath) {
@@ -102,7 +104,7 @@ export class AppComponent implements OnInit {
*/
initSettings(): void {
this.settingsService
.getValueFromLocalStorage(STORE_KEY.Settings)
.getValueFromLocalStorage<Settings>(STORE_KEY.Settings)
.subscribe((settings: Settings) => {
if (settings && Object.keys(settings).length > 0) {
// No need to send settings to Electron on init
@@ -6,7 +6,7 @@ import {
EmbeddedMpvOverlayVisibilityService,
EmbeddedMpvPlayerComponent,
EmbeddedMpvSessionController,
} from '@iptvnator/ui/playback';
} from '@iptvnator/ui/playback/embedded-mpv-player';
@Component({
imports: [EmbeddedMpvPlayerComponent],
@@ -117,7 +117,7 @@ export class ElectronService extends DataService {
payload?: unknown
): Promise<T> {
if (type === PLAYLIST_PARSE_BY_URL) {
this.fetchM3uPlaylistFromUrl(payload);
this.fetchM3uPlaylistFromUrl(payload as Partial<Playlist>);
return undefined as T;
}
@@ -219,7 +219,7 @@ export class ElectronService extends DataService {
this.translateService.instant(
'HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_SUCCESS'
),
null,
undefined,
{ duration: 2000 }
);
return playlists as T;
@@ -266,6 +266,10 @@ export class ElectronService extends DataService {
}
private async fetchM3uPlaylistFromUrl(payload: Partial<Playlist>) {
if (!payload.url) {
return;
}
const title = payload.title?.trim() || undefined;
window.electron
@@ -353,7 +357,7 @@ export class ElectronService extends DataService {
this.translateService.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
null,
undefined,
{ duration: 2000 }
);
} catch (error: unknown) {
+28 -9
View File
@@ -5,7 +5,14 @@ import { SwUpdate } from '@angular/service-worker';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { catchError, firstValueFrom, from, switchMap, throwError } from 'rxjs';
import {
catchError,
firstValueFrom,
from,
Observable,
switchMap,
throwError,
} from 'rxjs';
import { DataService } from '@iptvnator/services';
import {
ERROR,
@@ -108,12 +115,12 @@ export class PwaService extends DataService {
*/
sendIpcEvent<T = unknown>(type: string, payload?: unknown): T {
if (type === PLAYLIST_PARSE_BY_URL) {
this.fetchFromUrl(payload);
this.fetchFromUrl(payload as Partial<Playlist>);
return undefined as T;
}
if (type === PLAYLIST_UPDATE) {
this.refreshPlaylist(payload);
this.refreshPlaylist(payload as Partial<Playlist & { id: string }>);
return undefined as T;
}
@@ -138,6 +145,12 @@ export class PwaService extends DataService {
}
refreshPlaylist(payload: Partial<Playlist & { id: string }>) {
if (!payload.url || !payload.id) {
return;
}
const playlistId = payload.id;
this.getPlaylistFromUrl(payload.url)
.pipe(
catchError((error) => {
@@ -155,7 +168,7 @@ export class PwaService extends DataService {
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlist,
playlistId: payload.id,
playlistId,
})
);
@@ -163,7 +176,7 @@ export class PwaService extends DataService {
this.translateService.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
null,
undefined,
{ duration: 2000 }
);
});
@@ -193,13 +206,19 @@ export class PwaService extends DataService {
* @param payload playlist payload
*/
fetchFromUrl(payload: Partial<Playlist>): void {
if (!payload.url) {
return;
}
const title = payload.title?.trim() || undefined;
this.getPlaylistFromUrl(payload.url)
.pipe(
catchError((error) => {
this.snackBar.open(
this.getErrorMessageByStatusCode(error.status),
this.getErrorMessageByStatusCode(
this.extractHttpStatusCode(error)
),
'Close',
{
duration: 5000,
@@ -226,7 +245,7 @@ export class PwaService extends DataService {
});
}
getErrorMessageByStatusCode(status: number) {
getErrorMessageByStatusCode(status: number | null | undefined) {
let messageKey = 'HOME.URL_UPLOAD.ERROR_FETCH_FAILED';
switch (status) {
case 413:
@@ -524,10 +543,10 @@ export class PwaService extends DataService {
}
}
getPlaylistFromUrl(url: string) {
getPlaylistFromUrl(url: string): Observable<Playlist> {
return from(this.getProviderTargetId(url)).pipe(
switchMap((targetId) =>
this.http.get(`${this.corsProxyUrl}/parse`, {
this.http.get<Playlist>(`${this.corsProxyUrl}/parse`, {
params: { targetId },
})
)
+10 -10
View File
@@ -115,7 +115,10 @@ export class SettingsService {
const mediaQuery = this.systemThemeMediaQuery as LegacyMediaQueryList;
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', this.systemThemeChangeHandler);
mediaQuery.addEventListener(
'change',
this.systemThemeChangeHandler
);
} else {
mediaQuery.addListener?.(this.systemThemeChangeHandler);
}
@@ -147,8 +150,8 @@ export class SettingsService {
* @param key key to get
* @returns returns the value of the given key
*/
getValueFromLocalStorage(key: STORE_KEY) {
return this.storage.get(key);
getValueFromLocalStorage<T = unknown>(key: STORE_KEY): Observable<T> {
return this.storage.get(key) as Observable<T>;
}
/**
@@ -175,9 +178,9 @@ export class SettingsService {
*/
getAppVersion() {
return this.http
.get<{ created_at: string; name: string }[]>(
'https://api.github.com/repos/4gray/iptvnator/releases'
)
.get<
{ created_at: string; name: string }[]
>('https://api.github.com/repos/4gray/iptvnator/releases')
.pipe(
map((response) => {
// Filter out pre-release versions (beta, alpha, rc, etc.)
@@ -215,10 +218,7 @@ export class SettingsService {
* @param latestVersion latest stable version from GitHub
* @returns true if current version is outdated
*/
isVersionOutdated(
currentVersion: string,
latestVersion: string
): boolean {
isVersionOutdated(currentVersion: string, latestVersion: string): boolean {
const cleanCurrent = parseVersion(currentVersion);
const cleanLatest = parseVersion(latestVersion);
@@ -494,7 +494,10 @@ describe('SettingsComponent', () => {
.some((player) => player.id === VideoPlayer.EmbeddedMpv)
).toBe(false);
resolveSupport!({
if (!resolveSupport) {
throw new Error('Expected embedded MPV support resolver');
}
resolveSupport({
supported: true,
platform: 'darwin',
});
@@ -982,4 +985,39 @@ describe('SettingsComponent', () => {
})
);
});
it('preserves saved EPG settings when saving from the web settings form', async () => {
fixture.destroy();
window.electron = undefined as unknown as typeof window.electron;
const mockStore = settingsStore as unknown as MockSettingsStore;
mockStore._setSettings({
epgUrl: ['https://example.com/guide.xml'],
preferUploadedEpgOverXtream: true,
});
mockStore.updateSettings.mockResolvedValue(undefined);
const webFixture = TestBed.createComponent(SettingsComponent);
const webComponent = webFixture.componentInstance;
webComponent.checkAppVersion = jest.fn();
webComponent.fetchLocalIpAddresses = jest
.fn()
.mockResolvedValue(undefined);
webFixture.detectChanges();
await webFixture.whenStable();
webComponent.settingsForm.patchValue({ theme: Theme.DarkTheme });
webComponent.onSubmit();
await webFixture.whenStable();
expect(mockStore.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
epgUrl: ['https://example.com/guide.xml'],
preferUploadedEpgOverXtream: true,
theme: Theme.DarkTheme,
})
);
webFixture.destroy();
});
});
+62 -22
View File
@@ -12,6 +12,7 @@ import {
import {
FormArray,
FormBuilder,
FormControl,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
@@ -48,6 +49,7 @@ import {
CoverSize,
Language,
normalizeExternalPlayerArguments,
Settings,
StartupBehavior,
StreamFormat,
Theme,
@@ -162,10 +164,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
]);
/** Current version of the app */
version: string;
version = '';
/** Update message to show */
updateMessage: string;
updateMessage = '';
/** EPG availability flag */
epgAvailable$ = this.store.select(selectIsEpgAvailable);
@@ -178,7 +180,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
/** Settings form object */
settingsForm = this.formBuilder.group({
player: [VideoPlayer.VideoJs],
...(this.isDesktop ? { epgUrl: new FormArray([]) } : {}),
...(this.isDesktop
? { epgUrl: new FormArray<FormControl<string | null>>([]) }
: {}),
streamFormat: StreamFormat.M3u8StreamFormat,
openStreamOnDoubleClick: false,
language: Language.ENGLISH,
@@ -468,21 +472,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
* the indexed db store
*/
onSubmit(): void {
const settings = {
...this.settingsForm.value,
mpvPlayerPath: this.normalizeExternalPlayerPath(
this.settingsForm.value.mpvPlayerPath
),
vlcPlayerPath: this.normalizeExternalPlayerPath(
this.settingsForm.value.vlcPlayerPath
),
mpvPlayerArguments: normalizeExternalPlayerArguments(
this.settingsForm.value.mpvPlayerArguments
),
vlcPlayerArguments: normalizeExternalPlayerArguments(
this.settingsForm.value.vlcPlayerArguments
),
};
const settings = this.createSettingsFromFormValue();
this.settingsStore.updateSettings(settings).then(() => {
this.applyChangedSettings();
@@ -505,6 +495,51 @@ export class SettingsComponent implements OnInit, OnDestroy {
return playerPath?.trim() ?? '';
}
private createSettingsFromFormValue(): Settings {
const currentSettings = this.settingsStore.getSettings();
const value = this.settingsForm.value;
const epgUrl = Array.isArray(value.epgUrl)
? value.epgUrl.filter(
(url): url is string => typeof url === 'string'
)
: (currentSettings.epgUrl ?? []);
return {
player: value.player ?? VideoPlayer.VideoJs,
streamFormat: value.streamFormat ?? StreamFormat.M3u8StreamFormat,
openStreamOnDoubleClick: value.openStreamOnDoubleClick ?? false,
language: value.language ?? Language.ENGLISH,
showCaptions: value.showCaptions ?? false,
showDashboard: value.showDashboard ?? true,
startupBehavior: value.startupBehavior ?? StartupBehavior.FirstView,
showExternalPlaybackBar: value.showExternalPlaybackBar ?? true,
theme: value.theme ?? Theme.SystemTheme,
mpvPlayerPath: this.normalizeExternalPlayerPath(
value.mpvPlayerPath
),
mpvPlayerArguments: normalizeExternalPlayerArguments(
value.mpvPlayerArguments
),
mpvReuseInstance: value.mpvReuseInstance ?? false,
vlcPlayerPath: this.normalizeExternalPlayerPath(
value.vlcPlayerPath
),
vlcPlayerArguments: normalizeExternalPlayerArguments(
value.vlcPlayerArguments
),
vlcReuseInstance: value.vlcReuseInstance ?? false,
remoteControl: value.remoteControl ?? false,
remoteControlPort: Number(value.remoteControlPort ?? 8765),
recordingFolder: value.recordingFolder ?? '',
coverSize: value.coverSize ?? 'medium',
epgUrl,
preferUploadedEpgOverXtream:
value.preferUploadedEpgOverXtream ??
currentSettings.preferUploadedEpgOverXtream ??
false,
};
}
/**
* Applies the changed settings to the app
*/
@@ -516,14 +551,19 @@ export class SettingsComponent implements OnInit, OnDestroy {
if (!Array.isArray(epgUrls)) {
epgUrls = [epgUrls];
}
epgUrls = epgUrls.filter((url) => url !== '');
if (epgUrls.length > 0) {
const validEpgUrls = epgUrls.filter(
(url): url is string =>
typeof url === 'string' && url !== ''
);
if (validEpgUrls.length > 0) {
// Fetch all EPG URLs at once
this.epgService.fetchEpg(epgUrls);
this.epgService.fetchEpg(validEpgUrls);
}
}
}
this.translate.use(this.settingsForm.value.language);
this.translate.use(
this.settingsForm.value.language ?? Language.ENGLISH
);
this.settingsService.changeTheme(
this.settingsForm.value.theme ?? Theme.SystemTheme
);
+2 -1
View File
@@ -407,7 +407,8 @@
},
"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. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits."
"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.",
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it manually 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",
+2 -1
View File
@@ -407,7 +407,8 @@
},
"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. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits."
"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.",
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it manually 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",
+2 -1
View File
@@ -407,7 +407,8 @@
},
"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. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits."
"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.",
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it manually 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",
+2 -1
View File
@@ -407,7 +407,8 @@
},
"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. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits."
"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.",
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it manually 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",
+2 -1
View File
@@ -407,7 +407,8 @@
},
"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. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits."
"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.",
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it manually 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",
+2 -1
View File
@@ -407,7 +407,8 @@
},
"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. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits."
"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.",
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it manually 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",
+2 -1
View File
@@ -407,7 +407,8 @@
},
"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. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits."
"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.",
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it manually 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",
+2 -1
View File
@@ -407,7 +407,8 @@
},
"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. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits."
"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.",
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it manually 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",
+2 -1
View File
@@ -407,7 +407,8 @@
},
"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. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits."
"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.",
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it manually 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",
+2 -1
View File
@@ -407,7 +407,8 @@
},
"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. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits."
"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.",
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it manually 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",
+2 -1
View File
@@ -407,7 +407,8 @@
},
"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. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits."
"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.",
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it manually 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",
+2 -1
View File
@@ -407,7 +407,8 @@
},
"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. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits."
"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.",
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it manually 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",
+2 -1
View File
@@ -407,7 +407,8 @@
},
"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. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits."
"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.",
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it manually 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",
+2 -1
View File
@@ -407,7 +407,8 @@
},
"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. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits."
"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.",
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it manually 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",
+2 -1
View File
@@ -407,7 +407,8 @@
},
"BROWSER_ACCESS_ERROR": {
"TITLE": "Браузерный плеер не получил доступ к потоку",
"DESCRIPTION": "Запрос может быть заблокирован CORS, mixed content или требованиями провайдера/прокси к заголовкам. Скопируйте URL потока и откройте его во внешнем плеере, например MPV, VLC или IINA."
"DESCRIPTION": "Запрос может быть заблокирован CORS, mixed content или требованиями провайдера/прокси к заголовкам. MPV или VLC часто обходят такие ограничения браузерного плеера.",
"PWA_DESCRIPTION": "Запрос может быть заблокирован CORS, mixed content или требованиями провайдера/прокси к заголовкам. Скопируйте URL потока и откройте его вручную во внешнем плеере, например MPV, VLC или IINA."
},
"DRM_OR_ENCRYPTION": {
"TITLE": "Поток может быть зашифрован или защищен DRM",
+2 -1
View File
@@ -407,7 +407,8 @@
},
"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. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits."
"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.",
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it manually 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",
+2 -1
View File
@@ -407,7 +407,8 @@
},
"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. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits."
"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.",
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it manually 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",
+2 -1
View File
@@ -407,7 +407,8 @@
},
"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. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA to bypass browser-player access limits."
"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.",
"PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. Copy the stream URL and open it manually 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",
+4 -3
View File
@@ -19,6 +19,7 @@ import {
PlaylistRefreshPayload,
PortalDebugEvent,
ResolvedPortalPlayback,
Settings,
XtreamBackupFavoriteItem,
XtreamBackupHiddenCategory,
XtreamBackupRecentlyViewedItem,
@@ -71,7 +72,7 @@ declare global {
url: string,
title: string,
thumbnail: string,
userAgent: string,
userAgent: string | undefined,
referer?: string,
origin?: string,
contentInfo?: unknown,
@@ -82,7 +83,7 @@ declare global {
url: string,
title: string,
thumbnail: string,
userAgent: string,
userAgent: string | undefined,
referer?: string,
origin?: string,
contentInfo?: unknown,
@@ -119,7 +120,7 @@ declare global {
searchTerm: string,
limit?: number
) => Promise<EpgProgram[]>;
updateSettings: (settings: JsonObject) => Promise<void>;
updateSettings: (settings: Partial<Settings>) => Promise<void>;
getAiSettings: () => Promise<{
aiProvider: string;
aiModelName: string;
+1 -1
View File
@@ -13,7 +13,7 @@
"typeRoots": ["../../node_modules/@types"],
"lib": ["es2018", "es2017", "es2016", "es2015", "dom"],
"resolveJsonModule": true,
"strict": false,
"strict": true,
"skipLibCheck": true,
"useDefineForClassFields": false
},
+2
View File
@@ -192,6 +192,8 @@ pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix
pnpm embedded-mpv:stage-runtime -- arm64 /tmp/embedded-mpv-prefix
```
During temporary PR and `master` artifact testing, CI can restore an exact-keyed GitHub Actions cache for the staged `vendor/embedded-mpv/darwin-<arch>/` runtime and skip the expensive source build. The cache only contains `include/`, `lib/`, and `runtime-manifest.json`; it never contains the compiled `embedded_mpv.node` addon because that target depends on Electron headers, ABI, architecture, and build environment. Runtime cache entries are saved only from trusted repository refs, and tagged public release builds continue to rebuild from pinned sources until a dedicated signed and attested runtime artifact flow exists.
The CI builder pins FFmpeg `8.1`, mpv `0.41.0`, libplacebo `7.360.1`, libass `0.17.3`, FreeType `2.13.3`, FriBidi `1.0.16`, and HarfBuzz `8.5.0`. FFmpeg disables autodetected external libraries so Homebrew libraries cannot silently enter the runtime. Libplacebo is checked out from git with the submodules required by its Meson build because the generated GitHub archive does not include submodule contents. Even with Vulkan disabled, libplacebo still compiles Vulkan stubs and needs `3rdparty/Vulkan-Headers`. The generated manifest records source URLs, archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, FFmpeg configure flags, and mpv Meson flags. The staging step normalizes that manifest to `origin: vendored-lgpl`, which release package validation requires.
The Electron backend build consumes the staged runtime, copies Mach-O runtime files into the native build output, and rewrites Mach-O paths so `embedded_mpv.node` loads `@loader_path/lib/libmpv.2.dylib` instead of a machine-local Homebrew path. After `install_name_tool` rewrites any addon or runtime binary, the build re-signs that binary with an ad-hoc signature for local development. Release packaging still performs the normal app signing and notarization later.
Vendored
+2 -2
View File
@@ -68,7 +68,7 @@ declare global {
url: string,
title: string,
thumbnail: string,
userAgent: string,
userAgent: string | undefined,
referer?: string,
origin?: string,
contentInfo?: any,
@@ -79,7 +79,7 @@ declare global {
url: string,
title: string,
thumbnail: string,
userAgent: string,
userAgent: string | undefined,
referer?: string,
origin?: string,
contentInfo?: any,
@@ -109,7 +109,7 @@
[disabled]="!textImport()?.textForm?.valid"
(click)="
textImport()?.textAdded?.emit(
textImport()?.textForm?.value?.text
textImport()?.textForm?.value?.text ?? undefined
)
"
>
@@ -112,11 +112,11 @@ export class StalkerPortalImportComponent {
const isFullStalkerPortal =
this.isFullStalkerPortalUrl(originalUrl);
const stalkerIdentity = normalizeStalkerPortalIdentity({
serialNumber: formValue.serialNumber,
deviceId1: formValue.deviceId1,
deviceId2: formValue.deviceId2,
signature1: formValue.signature1,
signature2: formValue.signature2,
serialNumber: formValue.serialNumber ?? undefined,
deviceId1: formValue.deviceId1 ?? undefined,
deviceId2: formValue.deviceId2 ?? undefined,
signature1: formValue.signature1 ?? undefined,
signature2: formValue.signature2 ?? undefined,
});
let stalkerToken: string | undefined;
@@ -151,7 +151,7 @@ export class StalkerPortalImportComponent {
);
this.snackBar.open(
`Portal validated. Expires: ${expireDate.toLocaleDateString()}`,
null,
undefined,
{ duration: 3000 }
);
}
@@ -162,7 +162,7 @@ export class StalkerPortalImportComponent {
);
this.snackBar.open(
'Failed to authenticate with portal. Please check URL and MAC address.',
null,
undefined,
{ duration: 5000 }
);
this.isLoading.set(false);
@@ -14,7 +14,7 @@
type="text"
id="serverUrl"
formControlName="serverUrl"
(input)="extractParams(form.value.serverUrl)"
(input)="extractParams(form.value.serverUrl ?? '')"
/>
<mat-hint>{{
'HOME.XTREAM_PLAYLIST.URL_VALIDATION_ERROR' | translate
@@ -155,8 +155,8 @@ export class XtreamCodeImportComponent {
extractParams(urlAsString: string): void {
if (
this.form.get('username').value !== '' ||
this.form.get('password').value !== ''
this.form.get('username')?.value !== '' ||
this.form.get('password')?.value !== ''
)
return;
try {
@@ -255,6 +255,9 @@ describe('VideoPlayerComponent', () => {
),
};
const dataServiceMock = {
get supportsEpg() {
return Boolean(window.electron);
},
sendIpcEvent: jest.fn(),
};
@@ -251,7 +251,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
};
readonly isDesktop = !!window['electron'];
readonly supportsEpg = this.isDesktop;
readonly supportsEpg = this.dataService.supportsEpg;
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
/** EPG overlay reference */
@@ -430,8 +430,16 @@ export class PlaylistSwitcherComponent {
this.portalStatusAbortController = controller;
const xtreamPlaylists = playlists.filter(
(playlist) =>
playlist.serverUrl && playlist.username && playlist.password
(
playlist
): playlist is PlaylistMeta & {
serverUrl: string;
username: string;
password: string;
} =>
Boolean(
playlist.serverUrl && playlist.username && playlist.password
)
);
if (xtreamPlaylists.length === 0) {
return;
@@ -441,7 +449,11 @@ export class PlaylistSwitcherComponent {
// 'checking' in a single signal write so the UI flips from blank →
// cached/checking dots in one render, not one per playlist.
const next = new Map(this.portalStatuses());
const toFetch: PlaylistMeta[] = [];
const toFetch: (PlaylistMeta & {
serverUrl: string;
username: string;
password: string;
})[] = [];
for (const playlist of xtreamPlaylists) {
const cached = this.portalStatusService.getCachedStatus(
playlist.serverUrl,
@@ -475,7 +475,7 @@ export class RecentPlaylistsComponent {
this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
null,
undefined,
{ duration: 2000 }
);
} catch (error) {
@@ -15,7 +15,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { DialogService } from '@iptvnator/ui/components';
import { firstValueFrom, map } from 'rxjs';
import { firstValueFrom, map, startWith } from 'rxjs';
import {
DatabaseService,
type DownloadItem,
@@ -83,16 +83,22 @@ export class DownloadsComponent {
readonly searchTerm = queryParamSignal(this.route, 'q', (value) =>
(value ?? '').trim().toLowerCase()
);
readonly playlists = toSignal(this.playlistsService.getAllPlaylists(), {
initialValue: null as Playlist[] | null,
});
readonly playlists = toSignal(
this.playlistsService.getAllPlaylists().pipe(startWith(null)),
{
initialValue: null as Playlist[] | null,
}
);
readonly playlistsLoaded = computed(() => this.playlists() !== null);
readonly playlistItems = computed(() => this.playlists() ?? []);
readonly hasNoPlaylists = computed(
() => this.playlistsLoaded() && this.playlistItems().length === 0
);
readonly skeletonItems = Array.from({ length: 6 }, (_, index) => index);
readonly skeletonActionSlots = Array.from({ length: 4 }, (_, index) => index);
readonly skeletonActionSlots = Array.from(
{ length: 4 },
(_, index) => index
);
addPlaylist(): void {
this.shellActions.openAddPlaylistDialog();
@@ -275,7 +281,9 @@ export class DownloadsComponent {
async reveal(item: DownloadItem) {
if (item.filePath) {
const result = await this.downloadsService.revealFile(item.filePath);
const result = await this.downloadsService.revealFile(
item.filePath
);
if (!result.success) {
this.handleFileActionError(result.error);
}
@@ -309,7 +317,10 @@ export class DownloadsComponent {
}
hasPoster(item: DownloadItem): boolean {
return !!item.posterUrl && !this.failedPosterKeys()[this.getPosterKey(item)];
return (
!!item.posterUrl &&
!this.failedPosterKeys()[this.getPosterKey(item)]
);
}
markPosterFailed(item: DownloadItem): void {
@@ -18,7 +18,7 @@ describe('StreamResolverService', () => {
let playlistsService: { getPlaylistById: jest.Mock };
let xtreamApi: { getShortEpg: jest.Mock };
let xtreamUrl: { constructLiveUrl: jest.Mock };
let dataService: { sendIpcEvent: jest.Mock };
let dataService: { readonly supportsEpg: boolean; sendIpcEvent: jest.Mock };
let stalkerSession: { makeAuthenticatedRequest: jest.Mock };
beforeEach(() => {
@@ -32,6 +32,9 @@ describe('StreamResolverService', () => {
constructLiveUrl: jest.fn(),
};
dataService = {
get supportsEpg() {
return Boolean(window.electron);
},
sendIpcEvent: jest.fn(),
};
stalkerSession = {
@@ -68,7 +68,7 @@ export class StreamResolverService {
private readonly xtreamEpgFailureCooldownMs = 60 * 1000;
private get supportsEpg(): boolean {
return typeof window !== 'undefined' && !!window.electron;
return this.dataService.supportsEpg;
}
private async getElectronPlaylist(
@@ -32,7 +32,7 @@ import {
const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1';
type PlaylistWithChannels = Playlist & {
type PlaylistWithChannels = Omit<Playlist, 'playlist'> & {
readonly playlist?: { readonly items?: Channel[] };
};
type StalkerPortalFavoriteItem = StalkerPortalItem & {
@@ -31,7 +31,7 @@ import {
XtreamContentItem,
} from '@iptvnator/portal/xtream/data-access';
type PlaylistWithChannels = Playlist & {
type PlaylistWithChannels = Omit<Playlist, 'playlist'> & {
readonly playlist?: { readonly items?: Channel[] };
};
@@ -1,4 +1,4 @@
@if (items()?.length > 0) {
@if (items().length > 0) {
<div class="nav-list">
@for (item of items(); track $index) {
<a
@@ -7,7 +7,10 @@ import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
import { TranslatePipe } from '@ngx-translate/core';
import { ResizableDirective } from '@iptvnator/ui/components';
import { StalkerPortalItem, XtreamCategory } from '@iptvnator/shared/interfaces';
import {
StalkerPortalItem,
XtreamCategory,
} from '@iptvnator/shared/interfaces';
import { CategoryViewComponent } from '../category-view/category-view.component';
import { ContentCardComponent } from '../content-card/content-card.component';
@@ -78,7 +81,11 @@ export class FavoritesLayoutComponent {
readonly openItem = output<FavoriteLayoutItem>();
readonly headerActionClicked = output<void>();
setCategoryId(categoryId: string | number) {
setCategoryId(categoryId: string | number | undefined) {
if (categoryId === undefined) {
return;
}
this.categoryClicked.emit({ category_id: categoryId });
}
@@ -162,7 +162,7 @@ export function resolveGridRating(
}
}
</div>
@if (showPaginator() && items()?.length > 0) {
@if (showPaginator() && items().length > 0) {
<mat-paginator
[pageIndex]="pageIndex()"
[length]="totalPages() * limit()"
@@ -186,17 +186,17 @@ export function resolveGridRating(
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GridListComponent {
readonly items = input<GridListItem[]>();
readonly isLoading = input<boolean>();
readonly items = input<GridListItem[]>([]);
readonly isLoading = input<boolean>(false);
readonly showPaginator = input(true);
readonly searchTerm = input<string>('');
readonly itemClicked = output<GridListItem>();
readonly pageChange = output<PageEvent>();
readonly pageIndex = input<number>();
readonly totalPages = input<number>();
readonly limit = input<number>();
readonly pageSizeOptions = input<number[]>();
readonly pageIndex = input<number>(0);
readonly totalPages = input<number>(0);
readonly limit = input<number>(25);
readonly pageSizeOptions = input<number[]>([]);
protected readonly resolveRating = resolveGridRating;
protected readonly hasActiveSearch = computed(
() => (this.searchTerm() ?? '').trim().length > 0
@@ -26,10 +26,10 @@ export class PlaylistErrorViewComponent {
private readonly currentPlaylist = this.playlistContext.activePlaylist;
readonly description = input<string>(undefined);
readonly description = input<string | undefined>(undefined);
readonly showIllustration = input(true);
readonly showActionButtons = input(true);
readonly title = input<string>(undefined);
readonly title = input<string | undefined>(undefined);
readonly viewType = input<'ERROR' | 'EMPTY_CATEGORY' | 'NO_SEARCH_RESULTS'>(
'ERROR'
);
@@ -23,7 +23,7 @@ import {
shiftEpgDateKey,
} from '@iptvnator/ui/epg';
import { ResizableDirective } from '@iptvnator/ui/components';
import { SettingsStore } from '@iptvnator/services';
import { DataService, SettingsStore } from '@iptvnator/services';
import {
Channel,
EpgItem,
@@ -192,6 +192,14 @@ describe('UnifiedLiveTabComponent', () => {
providers: [
{ provide: StreamResolverService, useValue: streamResolver },
{ provide: UnifiedRecentDataService, useValue: recentData },
{
provide: DataService,
useValue: {
get supportsEpg() {
return Boolean(window.electron);
},
},
},
{
provide: SettingsStore,
useValue: {
@@ -45,7 +45,7 @@ import {
WebPlayerViewComponent,
} from '@iptvnator/ui/playback';
import { ResizableDirective } from '@iptvnator/ui/components';
import { SettingsStore } from '@iptvnator/services';
import { DataService, SettingsStore } from '@iptvnator/services';
import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces';
import {
EpgViewComponent,
@@ -92,12 +92,13 @@ export class UnifiedLiveTabComponent {
private readonly streamResolver = inject(StreamResolverService);
private readonly recentData = inject(UnifiedRecentDataService);
private readonly dataService = inject(DataService);
private readonly settingsStore = inject(SettingsStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly destroyRef = inject(DestroyRef);
readonly player = this.settingsStore.player;
readonly supportsEpg = Boolean(window.electron);
readonly supportsEpg = this.dataService.supportsEpg;
readonly isEmbeddedPlayer = computed(() =>
this.portalPlayer.isEmbeddedPlayer()
);
@@ -49,7 +49,7 @@ export function getRecentItemNavigation(
if (item.type === 'live') {
return buildLiveCollectionNavigationTarget({
mode: 'recent',
sourceType: item.source,
sourceType: normalizeCollectionSourceType(item.source) ?? 'm3u',
playlistId: item.playlist_id,
itemId: item.xtream_id ?? item.id,
title: item.title,
@@ -91,7 +91,7 @@ export function getGlobalFavoriteNavigation(
if (item.type === 'live') {
return buildLiveCollectionNavigationTarget({
mode: 'favorites',
sourceType: item.source,
sourceType: normalizeCollectionSourceType(item.source) ?? 'm3u',
playlistId: item.playlist_id,
itemId: item.xtream_id ?? item.id,
title: item.title,
@@ -231,7 +231,7 @@ export function buildOpenCollectionDetailItemState(
item: UnifiedCollectionItem
): OpenCollectionDetailItemState {
return {
item: normalizeCollectionDetailItem(item),
item: normalizeCollectionDetailItem(item) ?? item,
};
}
@@ -539,6 +539,14 @@ function normalizeCollectionScope(
return value === 'playlist' || value === 'all' ? value : null;
}
function normalizeCollectionSourceType(
value: unknown
): CollectionSourceType | null {
return value === 'm3u' || value === 'xtream' || value === 'stalker'
? value
: null;
}
function buildDashboardCollectionDetailItem(
item: PortalFavoriteItem | PortalRecentItem
): UnifiedCollectionItem | null {
@@ -669,9 +677,7 @@ function normalizeCollectionDetailItem(
}
const contentType = toPathSegment(record['contentType']);
const sourceType = toPathSegment(
record['sourceType']
) as CollectionSourceType;
const sourceType = normalizeCollectionSourceType(record['sourceType']);
const uid = toPathSegment(record['uid']);
const name = toPathSegment(record['name']);
const playlistId = toPathSegment(record['playlistId']);
@@ -680,6 +686,7 @@ function normalizeCollectionDetailItem(
!uid ||
!name ||
!playlistId ||
!sourceType ||
(contentType !== 'movie' && contentType !== 'series') ||
(sourceType !== 'xtream' && sourceType !== 'stalker')
) {
@@ -8,10 +8,7 @@ import {
type StalkerPortalIdentity,
} from './stalker-identity.utils';
export {
getStalkerPortalIdentityFromPlaylist,
normalizeStalkerPortalIdentity,
};
export { getStalkerPortalIdentityFromPlaylist, normalizeStalkerPortalIdentity };
export type { StalkerPortalIdentity };
/**
@@ -100,7 +97,10 @@ export class StalkerSessionService {
string,
Promise<{ token: string; serialNumber?: string }>
>();
private watchdogIntervals = new Map<string, ReturnType<typeof setInterval>>();
private watchdogIntervals = new Map<
string,
ReturnType<typeof setInterval>
>();
private watchdogPlaylists = new Map<string, Playlist>();
private watchdogInFlight = new Set<string>();
private activeWatchdogPlaylistId: string | null = null;
@@ -494,14 +494,16 @@ export class StalkerSessionService {
console.error('[StalkerSession] Missing portal URL or MAC address');
throw new Error('Portal URL and MAC address are required');
}
const portalUrl = playlist.portalUrl;
const macAddress = playlist.macAddress;
// Create the authentication promise and store it to prevent concurrent auth attempts
// Use async/await wrapper to properly clean up on both success and failure
const authPromise = (async () => {
try {
const { token } = await this.authenticate(
playlist.portalUrl,
playlist.macAddress,
portalUrl,
macAddress,
identity
);
this.setCachedToken(playlist._id, token);
@@ -37,6 +37,7 @@ describe('withStalkerEpg', () => {
let store: InstanceType<typeof TestStalkerEpgStore>;
let dataService: {
getAppEnvironment: jest.Mock<string, []>;
supportsEpg: boolean;
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
};
let stalkerSessionService: {
@@ -46,6 +47,7 @@ describe('withStalkerEpg', () => {
beforeEach(() => {
dataService = {
getAppEnvironment: jest.fn(() => 'electron'),
supportsEpg: true,
sendIpcEvent: jest.fn(),
};
stalkerSessionService = {
@@ -97,6 +99,7 @@ describe('withStalkerEpg', () => {
it('does not request short EPG in browser/PWA mode', async () => {
dataService.getAppEnvironment.mockReturnValue('pwa');
dataService.supportsEpg = false;
const result = await store.fetchChannelEpg('10001');
@@ -106,10 +106,6 @@ export function withStalkerEpg() {
dataService,
stalkerSession,
};
const supportsEpg = (): boolean =>
typeof dataService.getAppEnvironment !== 'function' ||
dataService.getAppEnvironment() !== 'pwa';
const requestEpg = async (
playlist: NonNullable<
ReturnType<
@@ -129,7 +125,7 @@ export function withStalkerEpg() {
channelId: number | string,
size: number
): Promise<EpgItem[]> => {
if (!supportsEpg()) {
if (!dataService.supportsEpg) {
return [];
}
@@ -186,7 +182,7 @@ export function withStalkerEpg() {
}
const playlistId = String(playlist._id);
if (!supportsEpg()) {
if (!dataService.supportsEpg) {
patchState(store, {
bulkItvEpgByChannel: {},
bulkItvEpgLoaded: true,
@@ -94,9 +94,9 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
const item = this.selectedItem();
return Boolean(
item &&
(this.contentType() === 'series' ||
item.is_series === true ||
String(item.is_series) === '1')
(this.contentType() === 'series' ||
item.is_series === true ||
String(item.is_series) === '1')
);
});
@@ -257,7 +257,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
undefined,
{
duration: 2000,
}
@@ -381,7 +381,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: this.translateService.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, null, {
this.snackBar.open(errorMessage, undefined, {
duration: 3000,
});
}
@@ -27,8 +27,7 @@ function calculateProgress(position: PlaybackPositionData | undefined): number {
return 0;
}
const percent =
(position.positionSeconds / position.durationSeconds) * 100;
const percent = (position.positionSeconds / position.durationSeconds) * 100;
if (position.positionSeconds > 10 && percent < 1) {
return 1;
@@ -38,20 +37,17 @@ function calculateProgress(position: PlaybackPositionData | undefined): number {
}
@Injectable()
export class StalkerCatalogFacadeService
implements
StalkerPortalCatalogFacade<
Record<string, unknown>,
StalkerVodSource,
StalkerVodSource
>
{
export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade<
Record<string, unknown>,
StalkerVodSource,
StalkerVodSource
> {
private readonly stalkerStore = inject(StalkerStore);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly destroyRef = inject(DestroyRef);
private readonly stalkerPositions = signal<Map<string, PlaybackPositionData>>(
new Map()
);
private readonly stalkerPositions = signal<
Map<string, PlaybackPositionData>
>(new Map());
private readonly stalkerSeriesPositions = signal<
Map<number, PlaybackPositionData[]>
>(new Map());
@@ -72,9 +68,9 @@ export class StalkerCatalogFacadeService
this.stalkerStore.isPaginatedContentLoading;
readonly selectedCategoryTitle = computed(() => {
const category = this.selectedCategory();
const fromCategory = String(
category?.['category_name'] ?? category?.['name'] ?? ''
);
const fromCategory = category
? String(category.category_name ?? '')
: '';
if (fromCategory) {
return fromCategory;
@@ -125,7 +121,10 @@ export class StalkerCatalogFacadeService
if (window.electron?.onPlaybackPositionUpdate) {
const unsubscribe = window.electron.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
if (data.playlistId !== this.playlist()?.id) {
if (
!data.playlistId ||
data.playlistId !== this.playlist()?.id
) {
return;
}
@@ -138,10 +137,7 @@ export class StalkerCatalogFacadeService
this.updateVodPlaybackPosition(data);
}
if (
data.contentType === 'episode' &&
data.seriesXtreamId
) {
if (data.contentType === 'episode' && data.seriesXtreamId) {
this.updateSeriesPlaybackPosition(data);
}
}
@@ -181,7 +181,7 @@ export class StalkerCollectionDetailComponent {
this.originalState.selectedContentType
);
this.stalkerStore.setSelectedCategory(
this.originalState.selectedCategoryId
this.originalState.selectedCategoryId ?? null
);
this.stalkerStore.setSelectedItem(
this.originalState.selectedItem as never
@@ -2,7 +2,7 @@ import { AsyncPipe } from '@angular/common';
import { Component, inject, input } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { BehaviorSubject, map, switchMap } from 'rxjs';
import { BehaviorSubject, map, of, switchMap } from 'rxjs';
import { PlaylistsService } from '@iptvnator/services';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
@@ -33,11 +33,12 @@ export class FavoritesButtonComponent {
private readonly favoritesChanged$ = new BehaviorSubject<void>(undefined);
readonly isFavorite$ = this.favoritesChanged$.pipe(
switchMap(() =>
this.playlistService.getPortalFavorites(
this.stalkerStore.currentPlaylist()?._id
)
),
switchMap(() => {
const playlistId = this.stalkerStore.currentPlaylist()?._id;
return playlistId
? this.playlistService.getPortalFavorites(playlistId)
: of([]);
}),
map((favorites) =>
favorites.some((favorite) =>
matchesFavoriteById(favorite, this.itemId())
@@ -8,10 +8,10 @@
} @else if (isSeries()) {
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
<app-stalker-series-view (backClicked)="onBackClicked()" />
} @else if (vodDetailsItem()) {
} @else if (vodDetailsItem(); as item) {
<!-- Regular VOD item -->
<app-vod-details
[item]="vodDetailsItem()"
[item]="item"
[isFavorite]="isFavorite()"
[playbackPosition]="playbackPosition()"
[inlinePlayback]="inlinePlayback()"
@@ -16,7 +16,11 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ChannelListItemComponent } from '@iptvnator/ui/components';
import { MockPipe } from 'ng-mocks';
import { of } from 'rxjs';
import { PlaylistsService, SettingsStore } from '@iptvnator/services';
import {
DataService,
PlaylistsService,
SettingsStore,
} from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
import {
@@ -308,6 +312,14 @@ describe('StalkerLiveStreamLayoutComponent', () => {
imports: [StalkerLiveStreamLayoutComponent, NoopAnimationsModule],
providers: [
{ provide: StalkerStore, useValue: stalkerStore },
{
provide: DataService,
useValue: {
get supportsEpg() {
return Boolean(window.electron);
},
},
},
{ provide: PlaylistsService, useValue: playlistService },
{ provide: SettingsStore, useValue: settingsStore },
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
@@ -25,12 +25,17 @@ import {
ChannelListSkeletonComponent,
ResizableDirective,
} from '@iptvnator/ui/components';
import { PlaylistsService, SettingsStore } from '@iptvnator/services';
import {
DataService,
PlaylistsService,
SettingsStore,
} from '@iptvnator/services';
import {
Channel,
EpgItem,
EpgProgram,
ResolvedPortalPlayback,
StalkerPortalItem,
} from '@iptvnator/shared/interfaces';
import {
EpgDateNavigationDirection,
@@ -66,6 +71,11 @@ import {
normalizeStalkerEntityId,
} from '@iptvnator/portal/stalker/data-access';
type StalkerPlayableChannel = StalkerPortalItem & {
cmd?: string;
has_files?: unknown;
};
@Component({
selector: 'app-stalker-live-stream-layout',
templateUrl: './stalker-live-stream-layout.component.html',
@@ -90,6 +100,7 @@ import {
})
export class StalkerLiveStreamLayoutComponent implements OnDestroy {
readonly stalkerStore = inject(StalkerStore);
private readonly dataService = inject(DataService);
private readonly playlistService = inject(PlaylistsService);
private readonly settingsStore = inject(SettingsStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
@@ -139,7 +150,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
readonly selectedChannelId = this.stalkerStore.selectedItvId;
protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId;
readonly isElectron = Boolean(window.electron);
readonly supportsEpg = this.isElectron;
readonly supportsEpg = this.dataService.supportsEpg;
readonly openStreamOnDoubleClick = computed(() =>
this.settingsStore.openStreamOnDoubleClick()
);
@@ -248,19 +259,22 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
constructor() {
// Load favorites for current playlist
this.playlistService
.getPortalFavorites(this.stalkerStore.currentPlaylist()?._id)
.pipe(takeUntilDestroyed())
.subscribe((favs) => {
favs.forEach((fav: StalkerFavoriteItem) => {
if (fav.id !== undefined) {
this.favorites.set(
normalizeStalkerEntityId(fav.id),
true
);
}
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (playlistId) {
this.playlistService
.getPortalFavorites(playlistId)
.pipe(takeUntilDestroyed())
.subscribe((favs) => {
favs.forEach((fav: StalkerFavoriteItem) => {
if (fav.id !== undefined) {
this.favorites.set(
normalizeStalkerEntityId(fav.id),
true
);
}
});
});
});
}
// Reset channels/page on category change
effect(() => {
@@ -431,10 +445,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.logger.error('Playback failed', error);
const errorMessage =
error?.message === 'nothing_to_play'
error instanceof Error && error.message === 'nothing_to_play'
? this.translate.instant('PORTALS.CONTENT_NOT_AVAILABLE')
: this.translate.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, null, { duration: 3000 });
this.snackBar.open(errorMessage, undefined, { duration: 3000 });
}
}
@@ -450,9 +464,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
return this.playbackResolution.promise;
}
const playableItem = this.toPlayableChannel(item);
const promise = this.isRadioMode()
? this.stalkerStore.resolveRadioPlayback(item)
: this.stalkerStore.resolveItvPlayback(item);
? this.stalkerStore.resolveRadioPlayback(playableItem)
: this.stalkerStore.resolveItvPlayback(playableItem);
this.playbackResolution = { channelId: playbackChannelId, promise };
const cleanup = () => {
@@ -472,8 +487,9 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.stalkerStore.removeFromFavorites(itemId);
this.favorites.delete(itemId);
} else {
const playableItem = this.toPlayableChannel(item);
this.stalkerStore.addToFavorites({
...item,
...playableItem,
category_id: this.isRadioMode() ? 'radio' : 'itv',
title: item.o_name || item.name,
cover: item.logo,
@@ -631,6 +647,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
const nowMs = Date.now();
if (
startMs !== null &&
stopMs !== null &&
Number.isFinite(startMs) &&
Number.isFinite(stopMs) &&
nowMs >= startMs &&
@@ -647,6 +665,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.currentProgramsProgress.delete(channelId);
}
private toPlayableChannel(item: StalkerItvChannel): StalkerPlayableChannel {
const { is_series, ...rest } = item;
return is_series == null ? rest : { ...rest, is_series };
}
private setupScrollListener() {
this.removeScrollListener();
@@ -23,7 +23,9 @@
(inlineTimeUpdated)="handleInlineTimeUpdate($event)"
(inlinePlaybackClosed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
(inlineExternalFallbackRequested)="handleExternalFallbackRequest($event)"
(inlineExternalFallbackRequested)="
handleExternalFallbackRequest($event)
"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
@@ -46,7 +48,7 @@
<div class="results-grid">
@for (item of searchResultsResource.value(); track item.id) {
<app-content-card
[title]="item.o_name || item.name"
[title]="item.o_name || item.name || ''"
[posterUrl]="item.screenshot_uri"
[type]="selectedFilterType() === 'vod' ? 'movie' : 'series'"
[showPlaceholder]="true"
@@ -55,11 +55,13 @@ import {
import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller';
interface StalkerFilter {
key: string;
key: StalkerSearchContentType;
label: string;
translationKey: string;
}
type StalkerSearchContentType = 'vod' | 'series';
interface StalkerSearchResponse {
js?: {
data?: StalkerVodSource[];
@@ -96,7 +98,7 @@ export class StalkerSearchComponent {
private readonly translateService = inject(TranslateService);
private readonly logger = createLogger('StalkerSearch');
readonly filters = signal({
readonly filters = signal<Record<StalkerSearchContentType, boolean>>({
series: false,
vod: true,
});
@@ -127,7 +129,7 @@ export class StalkerSearchComponent {
return playlist?.macAddress ? playlist : null;
});
readonly selectedFilterType = signal('vod');
readonly selectedFilterType = signal<StalkerSearchContentType>('vod');
private readonly favoritesRefresh = createRefreshTrigger();
itemDetails: StalkerSelectedVodItem | null = null;
@@ -167,6 +169,10 @@ export class StalkerSearchComponent {
const playlist = this.currentPlaylist();
if (!playlist) return [];
const { portalUrl, macAddress } = playlist;
if (!portalUrl || !macAddress) {
return [];
}
const contentType = params.contentType;
let token: string | undefined;
let serialNumber: string | undefined;
@@ -189,9 +195,9 @@ export class StalkerSearchComponent {
url: portalUrl,
macAddress,
params: {
action: StalkerContentTypes[params.contentType]
action: StalkerContentTypes[contentType]
.getContentAction,
type: params.contentType,
type: contentType,
search: params.search,
max_page_items: 100,
},
@@ -199,16 +205,10 @@ export class StalkerSearchComponent {
serialNumber,
}
);
if (response) {
const items = response.js?.data || [];
return items.map((item: StalkerVodSource) =>
this.processItemUrls(item, portalUrl)
);
} else {
throw new Error(
`Error: ${response.message} (Status: ${response.status})`
);
}
const items = response.js?.data || [];
return items.map((item: StalkerVodSource) =>
this.processItemUrls(item, portalUrl)
);
},
});
@@ -243,23 +243,26 @@ export class StalkerSearchComponent {
this.searchTerm.set(term);
}
updateFilter(key: string, value: boolean) {
updateFilter(key: StalkerSearchContentType, value: boolean) {
if (value) {
// Single selection mode - set clicked filter, disable others
this.selectedFilterType.set(key);
this.filters.update((f) => {
const newFilters: Record<string, boolean> = {};
Object.keys(f).forEach((k) => {
newFilters[k] = k === key;
this.filters.update(() => {
const newFilters: Record<StalkerSearchContentType, boolean> = {
series: false,
vod: false,
};
this.filterConfig.forEach((filter) => {
newFilters[filter.key] = filter.key === key;
});
return newFilters as typeof f;
return newFilters;
});
}
}
selectItem(item: StalkerVodSource) {
this.closeInlinePlayer();
const hasEmbeddedSeries = item.series?.length > 0;
const hasEmbeddedSeries = (item.series?.length ?? 0) > 0;
const needsSeriesFetch =
this.selectedFilterType() === 'vod' &&
!hasEmbeddedSeries &&
@@ -8,14 +8,10 @@
<!-- Tags: Year, Genre, Rating -->
<ng-container hero-tags>
@if (serial.info.releasedate) {
<span class="details__tag">{{
serial.info.releasedate
}}</span>
<span class="details__tag">{{ serial.info.releasedate }}</span>
}
@if (serial.info.genre) {
<span class="details__tag">{{
serial.info.genre
}}</span>
<span class="details__tag">{{ serial.info.genre }}</span>
}
@if (serial.info.rating_imdb) {
<span class="details__tag details__tag--rating">
@@ -33,9 +29,7 @@
<ng-container hero-meta>
@if (serial.info.actors) {
<div class="details__meta-item">
<span class="label">{{
'XTREAM.ACTORS' | translate
}}</span>
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
<span class="value">{{ serial.info.actors }}</span>
</div>
}
@@ -75,10 +69,7 @@
</span>
</button>
}
<app-favorites-button
[itemId]="serial.id"
[item]="serial"
/>
<app-favorites-button [itemId]="serial.id" [item]="serial" />
</ng-container>
@if (inlinePlayback(); as playback) {
@@ -99,19 +90,20 @@
[seasons]="mappedSeasons()"
[seriesId]="toSeriesId(serial.id)"
[playlistId]="stalkerStore.currentPlaylist()?._id ?? ''"
[seriesTitle]="serial.info?.name"
[seriesTitle]="serial.info.name"
[playbackPositions]="episodePlaybackPositions()"
[openingEpisodeId]="openingEpisodeId()"
[activeEpisodeId]="activeEpisodeId()"
[isLoading]="
isVodSeriesSeasonsLoading() ||
(seasonContainer?.selectedSeason &&
isCurrentSeasonLoading(seasonContainer.selectedSeason))
isCurrentSeasonLoading(seasonContainer?.selectedSeason)
"
(seasonSelected)="onSeasonSelected($event)"
(episodeClicked)="onEpisodeClicked($event)"
(episodeDownloadRequested)="downloadEpisode($event)"
(playbackToggleRequested)="handlePlaybackToggleRequested($event)"
(playbackToggleRequested)="
handlePlaybackToggleRequested($event)
"
#seasonContainer
/>
}
@@ -334,7 +334,8 @@ export class StalkerSeriesViewComponent implements OnDestroy {
/**
* Determines if the current selected season is loading
*/
isCurrentSeasonLoading(seasonKey: string): boolean {
isCurrentSeasonLoading(seasonKey?: string): boolean {
if (!seasonKey) return false;
if (!this.isVodSeries()) return false;
const season = this.vodSeriesSeasons().find(
(s) => getVodSeriesSeasonKey(s) === seasonKey
@@ -498,7 +499,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
undefined,
{
duration: 2000,
}
@@ -546,7 +547,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: this.translateService.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, null, {
this.snackBar.open(errorMessage, undefined, {
duration: 3000,
});
}
@@ -56,7 +56,7 @@ export class StalkerVodPlaybackController {
: this.config.translateService.instant(
'PORTALS.PLAYBACK_ERROR'
);
this.config.snackBar.open(errorMessage, null, {
this.config.snackBar.open(errorMessage, undefined, {
duration: 3000,
});
}
@@ -122,7 +122,7 @@ export class StalkerVodPlaybackController {
showCopyNotification(): void {
this.config.snackBar.open(
this.config.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
undefined,
{
duration: 2000,
}
@@ -360,7 +360,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
): Promise<number> {
return this.dbService.saveXtreamContent(
playlistId,
streams,
streams as Parameters<DatabaseService['saveXtreamContent']>[1],
type,
onProgress,
options
@@ -47,6 +47,9 @@ export function provideXtreamDataSource(): Provider[] {
return (playlistId: string) => {
if (typeof window !== 'undefined' && window.electron) {
// Electron playlist deletion must use DatabaseService so
// SQLite content and sidecars are cleaned together; do
// not invoke this cleanup path through PlaylistsService.
return Promise.resolve();
}
@@ -616,8 +616,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// Sort by updatedAt descending
playlistPositions.sort(
(a, b) =>
new Date(b.updatedAt).getTime() -
new Date(a.updatedAt).getTime()
new Date(b.updatedAt ?? 0).getTime() -
new Date(a.updatedAt ?? 0).getTime()
);
return limit ? playlistPositions.slice(0, limit) : playlistPositions;
@@ -85,6 +85,9 @@ function configureStore(setup: TestStoreSetup) {
() => setup.appEnvironment ?? 'electron'
),
isElectron: setup.isElectron ?? true,
supportsEpg:
setup.appEnvironment !== 'pwa' &&
(setup.isElectron ?? true),
},
},
{ provide: XtreamApiService, useValue: xtreamApiService },
@@ -84,10 +84,6 @@ 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
*/
@@ -130,7 +126,7 @@ export function withEpg() {
* sets `preferUploadedEpgOverXtream`.
*/
async loadEpg(): Promise<EpgItem[]> {
if (!supportsEpg()) {
if (!dataService.supportsEpg) {
patchState(store, {
epgItems: [],
isLoadingEpg: false,
@@ -186,7 +182,7 @@ export function withEpg() {
streamId: number,
epgChannelId?: string | null
): Promise<EpgItem[]> {
if (!supportsEpg()) {
if (!dataService.supportsEpg) {
return [];
}
@@ -6,7 +6,10 @@ import {
withState,
} from '@ngrx/signals';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import { XtreamSerieEpisode, XtreamVodDetails } from '@iptvnator/shared/interfaces';
import {
XtreamSerieEpisode,
XtreamVodDetails,
} from '@iptvnator/shared/interfaces';
import { XtreamCredentials } from '../../services/xtream-api.service';
import { XtreamUrlService } from '../../services/xtream-url.service';
@@ -80,10 +83,11 @@ export function withPlayer() {
/**
* Helper to get playlist with headers from parent store
*/
const getPlaylistFromStore = (): XtreamPlaylistHeadersLike | null => {
const storeAny = store as ParentPlayerStoreLike;
return storeAny.currentPlaylist?.();
};
const getPlaylistFromStore =
(): XtreamPlaylistHeadersLike | null => {
const storeAny = store as ParentPlayerStoreLike;
return storeAny.currentPlaylist?.() ?? null;
};
return {
/**
@@ -51,7 +51,7 @@ function mapDbRecentItem(
export const withRecentItems = function () {
const logger = createLogger('withRecentItems');
return signalStoreFeature(
withState({
withState<{ recentItems: RecentlyViewedItem[] }>({
recentItems: [],
}),
withMethods((store, dataSource = inject(XTREAM_DATA_SOURCE)) => ({
@@ -81,7 +81,7 @@ export const withRecentItems = function () {
addRecentItem: rxMethod<{
xtreamId: number | string;
contentType: 'live' | 'movie' | 'series';
playlist: Signal<{ id: string }>;
playlist: Signal<{ id: string } | null | undefined>;
backdropUrl?: string;
}>(
pipe(
@@ -92,7 +92,7 @@ export const withRecentItems = function () {
playlist,
backdropUrl,
}) => {
const playlistId = playlist().id;
const playlistId = playlist()?.id;
const normalizedXtreamId = Number(xtreamId);
if (
!playlistId ||
@@ -145,10 +145,10 @@ export const withRecentItems = function () {
}: {
xtreamId: number | string;
contentType: 'live' | 'movie' | 'series';
playlist: Signal<{ id: string }>;
playlist: Signal<{ id: string } | null | undefined>;
backdropUrl?: string;
}): Promise<void> {
const playlistId = playlist().id;
const playlistId = playlist()?.id;
const normalizedXtreamId = Number(xtreamId);
const normalizedBackdropUrl = backdropUrl?.trim();
if (
@@ -28,7 +28,7 @@ import {
import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { LiveStreamLayoutComponent } from './live-stream-layout.component';
import { SettingsStore } from '@iptvnator/services';
import { DataService, SettingsStore } from '@iptvnator/services';
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
@@ -224,6 +224,14 @@ describe('LiveStreamLayoutComponent', () => {
{ provide: XtreamStore, useValue: xtreamStore },
{ provide: FavoritesService, useValue: favoritesService },
{ provide: XtreamUrlService, useValue: xtreamUrlService },
{
provide: DataService,
useValue: {
get supportsEpg() {
return Boolean(window.electron);
},
},
},
{ provide: SettingsStore, useValue: settingsStore },
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
],
@@ -63,7 +63,7 @@ import {
} from '@iptvnator/shared/interfaces';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { ActivatedRoute } from '@angular/router';
import { SettingsStore } from '@iptvnator/services';
import { DataService, SettingsStore } from '@iptvnator/services';
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
@@ -104,6 +104,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
private readonly favoritesService = inject(FavoritesService);
private readonly xtreamStore = inject(XtreamStore);
private readonly xtreamUrlService = inject(XtreamUrlService);
private readonly dataService = inject(DataService);
private readonly settingsStore = inject(SettingsStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly liveSidebarStateService = inject(
@@ -120,7 +121,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 supportsEpg = this.dataService.supportsEpg;
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
private readonly routeSearchTerm = queryParamSignal(
this.route,
@@ -26,7 +26,7 @@
trackBy: trackBy;
templateCacheSize: 0
"
[name]="item.title || item.name"
[name]="item.title || item.name || ''"
[logo]="item.stream_icon ?? item.poster_url"
[selected]="
xtreamStore.selectedItem()?.xtream_id === item.xtream_id
@@ -10,7 +10,7 @@ import {
FavoritesService,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { SettingsStore } from '@iptvnator/services';
import { DataService, SettingsStore } from '@iptvnator/services';
import { PortalChannelsListComponent } from './portal-channels-list.component';
function buildEpgItem(params: {
@@ -128,6 +128,14 @@ describe('PortalChannelsListComponent', () => {
provide: EpgQueueService,
useValue: epgQueueService,
},
{
provide: DataService,
useValue: {
get supportsEpg() {
return Boolean(window.electron);
},
},
},
{
provide: SettingsStore,
useValue: {
@@ -38,6 +38,7 @@ import { EpgQueueService } from '@iptvnator/portal/xtream/data-access';
import { XtreamCredentials } from '@iptvnator/portal/xtream/data-access';
import { FavoritesService } from '@iptvnator/portal/xtream/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { DataService } from '@iptvnator/services';
export interface XtreamChannelListItem {
readonly category_id?: string | number;
@@ -77,10 +78,11 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
readonly searchTermInput = input('');
readonly xtreamStore = inject(XtreamStore);
private readonly dataService = inject(DataService);
private readonly favoritesService = inject(FavoritesService);
private readonly epgQueueService = inject(EpgQueueService);
private readonly route = inject(ActivatedRoute);
readonly supportsEpg = Boolean(window.electron);
readonly supportsEpg = this.dataService.supportsEpg;
readonly isSelectedTypeContentLoading =
this.xtreamStore.selectedTypeContentLoading;
readonly channels = computed(() => {
@@ -32,7 +32,7 @@
>
@for (item of recentlyAddedVod(); track item.xtream_id) {
<app-content-card
[title]="item.title || item.name"
[title]="item.title || item.name || ''"
[posterUrl]="item.poster_url || item.stream_icon"
[type]="'movie'"
[date]="getDate(item)"
@@ -52,7 +52,7 @@
>
@for (item of recentlyAddedSeries(); track item.xtream_id) {
<app-content-card
[title]="item.title || item.name"
[title]="item.title || item.name || ''"
[posterUrl]="item.poster_url || item.cover"
[type]="'series'"
[date]="getDate(item, true)"
@@ -72,7 +72,7 @@
>
@for (item of recentlyAddedLive(); track item.xtream_id) {
<app-content-card
[title]="item.title || item.name"
[title]="item.title || item.name || ''"
[posterUrl]="item.poster_url || item.stream_icon"
[type]="'live'"
[date]="getDate(item)"
@@ -44,17 +44,14 @@ interface SearchResultsData {
function groupResultsByPlaylistName(
items: XtreamContentItem[]
): Record<string, XtreamContentItem[]> {
return items.reduce<Record<string, XtreamContentItem[]>>(
(groups, item) => {
const key = String(item.playlist_name);
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(item);
return groups;
},
{}
);
return items.reduce<Record<string, XtreamContentItem[]>>((groups, item) => {
const key = String(item.playlist_name);
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(item);
return groups;
}, {});
}
@Component({
@@ -199,7 +196,7 @@ export class SearchResultsComponent implements AfterViewInit {
}
ngAfterViewInit() {
this.xtreamStore.setSelectedContentType(undefined);
this.xtreamStore.setSelectedContentType('vod');
setTimeout(() => {
this.searchLayoutComponent()?.focusSearchInput();
});
@@ -1,46 +1,47 @@
@let item = selectedItem();
@if (item?.info) {
@if (item && item.info) {
@let info = item.info;
<app-content-hero
[title]="item.info?.name"
[description]="item.info?.plot"
[posterUrl]="item.info?.cover"
[backdropUrl]="item.info?.backdrop_path?.[0]"
[title]="info.name"
[description]="info.plot"
[posterUrl]="info.cover"
[backdropUrl]="info.backdrop_path?.[0]"
(backClicked)="goBack()"
>
<ng-container hero-tags>
@if (item.info?.releaseDate) {
@if (info.releaseDate) {
<span class="details__tag">{{
item.info.releaseDate | slice: 0 : 4
info.releaseDate | slice: 0 : 4
}}</span>
}
@if (item.info?.genre) {
<span class="details__tag">{{ item.info.genre }}</span>
@if (info.genre) {
<span class="details__tag">{{ info.genre }}</span>
}
@if (item.info?.episode_run_time) {
@if (info.episode_run_time) {
<span class="details__tag"
>{{ item.info.episode_run_time }} min/ep</span
>{{ info.episode_run_time }} min/ep</span
>
}
@if (item.info?.rating) {
@if (info.rating) {
<span class="details__tag details__tag--rating">
⭐ {{ item.info.rating }}
⭐ {{ info.rating }}
</span>
}
</ng-container>
<ng-container hero-meta>
@if (item.info?.cast) {
@if (info.cast) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.CAST' | translate }}</span>
<span class="value">{{ item.info.cast }}</span>
<span class="value">{{ info.cast }}</span>
</div>
}
@if (item.info?.director) {
@if (info.director) {
<div class="details__meta-item">
<span class="label">{{
'XTREAM.DIRECTOR' | translate
}}</span>
<span class="value">{{ item.info.director }}</span>
<span class="value">{{ info.director }}</span>
</div>
}
</ng-container>
@@ -100,7 +101,7 @@
[seasons]="item.episodes"
[seriesId]="item.series_id"
[playlistId]="currentPlaylistId()"
[seriesTitle]="item.info?.name"
[seriesTitle]="info.name ?? ''"
[playbackPositions]="episodePlaybackPositions()"
[xtreamDownloadContext]="xtreamDownloadContext()"
[openingEpisodeId]="openingEpisodeId()"
@@ -111,7 +112,7 @@
</app-content-hero>
} @else if (detailsError()) {
<app-content-hero
[errorMessage]="detailsError()"
[errorMessage]="detailsError() ?? undefined"
(backClicked)="goBack()"
/>
} @else {
@@ -154,7 +154,12 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
const xtreamId = Number(selectedItem?.series_id ?? 0);
const backdropUrl = selectedItem?.info?.backdrop_path?.[0]?.trim();
if (!playlistId || !Number.isFinite(xtreamId) || xtreamId <= 0 || !backdropUrl) {
if (
!playlistId ||
!Number.isFinite(xtreamId) ||
xtreamId <= 0 ||
!backdropUrl
) {
return;
}
@@ -246,14 +251,20 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
}
playEpisode(episode: XtreamSerieEpisode): void {
const playlist = this.xtreamStore.currentPlaylist();
const selectedItem = this.selectedItem();
if (!playlist || !selectedItem) {
return;
}
this.addToRecentlyViewed(this.route.snapshot.params.serialId);
const streamUrl = this.xtreamStore.constructEpisodeStreamUrl(episode);
const contentInfo: PlayerContentInfo = {
playlistId: this.xtreamStore.currentPlaylist().id,
playlistId: playlist.id,
contentXtreamId: Number(episode.id),
contentType: 'episode',
seriesXtreamId: Number(this.selectedItem().series_id),
seriesXtreamId: Number(selectedItem.series_id),
seasonNumber: Number(episode.season),
episodeNumber: Number(episode.episode_num),
};
@@ -265,7 +276,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
const playback: ResolvedPortalPlayback = {
streamUrl,
title: episode.title,
thumbnail: this.selectedItem().info.cover,
thumbnail: selectedItem.info.cover,
startTime: position?.positionSeconds,
contentInfo,
};
@@ -283,9 +294,14 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
}
toggleFavorite(): void {
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
return;
}
this.xtreamStore.toggleFavorite(
this.route.snapshot.params.serialId,
this.xtreamStore.currentPlaylist().id,
playlist.id,
'series',
this.selectedItem()?.info?.backdrop_path?.[0]
);
@@ -327,7 +343,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
undefined,
{
duration: 2000,
}
@@ -60,8 +60,12 @@
class="play-btn play-btn--resume"
(click)="onPrimaryAction(item)"
[disabled]="isExternalLaunchPending()"
[class.play-btn--state-launching]="externalPrimaryButtonState() === 'launching'"
[class.play-btn--state-stop]="externalPrimaryButtonState() === 'stop'"
[class.play-btn--state-launching]="
externalPrimaryButtonState() === 'launching'
"
[class.play-btn--state-stop]="
externalPrimaryButtonState() === 'stop'
"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{
@@ -91,8 +95,12 @@
class="play-btn"
(click)="onPrimaryAction(item)"
[disabled]="isExternalLaunchPending()"
[class.play-btn--state-launching]="externalPrimaryButtonState() === 'launching'"
[class.play-btn--state-stop]="externalPrimaryButtonState() === 'stop'"
[class.play-btn--state-launching]="
externalPrimaryButtonState() === 'launching'
"
[class.play-btn--state-stop]="
externalPrimaryButtonState() === 'stop'
"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{
@@ -170,7 +178,15 @@
info.youtube_trailer | safe
"
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allow="
accelerometer;
autoplay;
clipboard-write;
encrypted-media;
gyroscope;
picture-in-picture;
web-share;
"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
@@ -218,10 +234,7 @@
@for (fact of fallback.facts; track fact.labelKey) {
<div class="details__meta-item details__meta-item--fallback">
<span class="label">{{ fact.labelKey | translate }}</span>
<span
class="value"
[class.value--mono]="fact.monospace"
>
<span class="value" [class.value--mono]="fact.monospace">
{{ fact.value }}
</span>
</div>
@@ -230,7 +243,7 @@
</app-content-hero>
} @else if (detailsError()) {
<app-content-hero
[errorMessage]="detailsError()"
[errorMessage]="detailsError() ?? undefined"
(backClicked)="goBack()"
/>
} @else {
@@ -98,21 +98,28 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
.find(
(category) =>
String(
(category as XtreamCategory & { id?: string | number })
.category_id ??
(category as XtreamCategory & { id?: string | number })
.id
(
category as XtreamCategory & {
id?: string | number;
}
).category_id ??
(
category as XtreamCategory & {
id?: string | number;
}
).id
) === String(categoryId)
) ?? null
);
});
readonly selectedCatalogItem = computed<
(Partial<XtreamVodStream> & {
id?: string | number;
poster_url?: string;
title?: string;
xtream_id?: string | number;
}) | null
| (Partial<XtreamVodStream> & {
id?: string | number;
poster_url?: string;
title?: string;
xtream_id?: string | number;
})
| null
>(() => {
const vodId = this.selectedVodId();
if (!Number.isFinite(vodId) || vodId <= 0) {
@@ -120,29 +127,27 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
}
return (
this.xtreamStore
.vodStreams()
.find(
(item) =>
Number(
this.xtreamStore.vodStreams().find(
(item) =>
Number(
(
item as XtreamVodStream & {
id?: string | number;
xtream_id?: string | number;
}
).xtream_id ??
(
item as XtreamVodStream & {
id?: string | number;
xtream_id?: string | number;
}
).xtream_id ??
(
item as XtreamVodStream & {
id?: string | number;
}
).stream_id ??
(
item as XtreamVodStream & {
id?: string | number;
}
).id
) === vodId
) ?? null
).stream_id ??
(
item as XtreamVodStream & {
id?: string | number;
}
).id
) === vodId
) ?? null
);
});
readonly selectedVodInfo = computed(() => {
@@ -278,9 +283,15 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
effect(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
const vodId = Number(this.route.snapshot.params.vodId);
const backdropUrl = this.selectedVodInfo()?.backdrop_path?.[0]?.trim();
const backdropUrl =
this.selectedVodInfo()?.backdrop_path?.[0]?.trim();
if (!playlistId || !Number.isFinite(vodId) || vodId <= 0 || !backdropUrl) {
if (
!playlistId ||
!Number.isFinite(vodId) ||
vodId <= 0 ||
!backdropUrl
) {
return;
}
@@ -302,7 +313,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.unsubscribePositionUpdates =
window.electron.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
const playlistId =
this.xtreamStore.currentPlaylist()?.id;
const vodId = Number(this.route.snapshot.params.vodId);
if (
@@ -325,7 +337,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.logger.warn('Deferring VOD details init: playlist not ready');
return;
}
this.initializeVodDetails(currentPlaylist.id, Number(this.route.snapshot.params.vodId));
this.initializeVodDetails(
currentPlaylist.id,
Number(this.route.snapshot.params.vodId)
);
this.detailsInitDone.set(true);
}
@@ -335,7 +350,16 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.xtreamStore.setSelectedItem(null);
}
playVod(vodItem: XtreamVodDetails): void {
playVod(vodItem: XtreamVodDetails | null): void {
if (!vodItem) {
return;
}
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
return;
}
const info = getXtreamVodInfo(vodItem);
this.addToRecentlyViewed();
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
@@ -350,13 +374,13 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.logger.debug('playVod resolved ID', { id, vodItem });
const contentInfo: PlayerContentInfo = {
playlistId: this.xtreamStore.currentPlaylist().id,
playlistId: playlist.id,
contentXtreamId: id,
contentType: 'vod',
};
const playback: ResolvedPortalPlayback = {
streamUrl,
title: info?.name ?? vodItem.movie_data?.name,
title: info?.name ?? vodItem.movie_data?.name ?? 'Unknown',
thumbnail: info?.movie_image,
contentInfo,
};
@@ -364,7 +388,16 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.startPlayback(playback);
}
resumeVod(vodItem: XtreamVodDetails): void {
resumeVod(vodItem: XtreamVodDetails | null): void {
if (!vodItem) {
return;
}
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
return;
}
const info = getXtreamVodInfo(vodItem);
this.addToRecentlyViewed();
const vodId = Number(this.route.snapshot.params.vodId);
@@ -372,13 +405,13 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
const contentInfo: PlayerContentInfo = {
playlistId: this.xtreamStore.currentPlaylist().id,
playlistId: playlist.id,
contentXtreamId: vodId,
contentType: 'vod',
};
const playback: ResolvedPortalPlayback = {
streamUrl,
title: info?.name ?? vodItem.movie_data?.name,
title: info?.name ?? vodItem.movie_data?.name ?? 'Unknown',
thumbnail: info?.movie_image,
startTime: position?.positionSeconds,
contentInfo,
@@ -387,7 +420,11 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.startPlayback(playback);
}
onPrimaryAction(vodItem: XtreamVodDetails): void {
onPrimaryAction(vodItem: XtreamVodDetails | null): void {
if (!vodItem) {
return;
}
if (this.isExternalStopAction()) {
void this.stopExternalPlayback();
return;
@@ -402,7 +439,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
}
async stopExternalPlayback(): Promise<void> {
await this.externalPlayback.closeSession(this.matchedExternalPlayback());
await this.externalPlayback.closeSession(
this.matchedExternalPlayback()
);
}
formatPosition(): string {
@@ -412,15 +451,18 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
const date = new Date(0);
date.setSeconds(position.positionSeconds);
const timeString = date.toISOString().substr(11, 8);
return timeString.startsWith('00:')
? timeString.substr(3)
: timeString;
return timeString.startsWith('00:') ? timeString.substr(3) : timeString;
}
toggleFavorite(): void {
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
return;
}
this.xtreamStore.toggleFavorite(
this.route.snapshot.params.vodId,
this.xtreamStore.currentPlaylist().id,
playlist.id,
'movie',
this.selectedVodInfo()?.backdrop_path?.[0]
);
@@ -462,7 +504,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
undefined,
{
duration: 2000,
}
@@ -476,7 +518,11 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
);
}
async downloadVod(vodItem: XtreamVodDetails): Promise<void> {
async downloadVod(vodItem: XtreamVodDetails | null): Promise<void> {
if (!vodItem) {
return;
}
const info = getXtreamVodInfo(vodItem);
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
const routeVodId = this.route.snapshot.params.vodId;
@@ -488,6 +534,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
);
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
return;
}
await this.downloadsService.startDownload({
playlistId: playlist.id,
@@ -41,7 +41,7 @@ interface XtreamCollectionStateSnapshot {
<ng-container
*ngComponentOutlet="
detailComponent();
injector: detailInjector()
injector: detailInjector() ?? undefined
"
/>
} @else {
+28 -1
View File
@@ -4,12 +4,39 @@ type ElectronProcessWindow = Window & {
};
};
export interface ElectronRemoteProcess {
argv: string[];
platform:
| 'aix'
| 'android'
| 'darwin'
| 'freebsd'
| 'haiku'
| 'linux'
| 'openbsd'
| 'sunos'
| 'win32'
| 'cygwin'
| 'netbsd'
| string;
}
export interface ElectronRemote {
app?: {
getVersion: () => string;
};
process: ElectronRemoteProcess;
}
export abstract class DataService {
get isElectron(): boolean {
const currentWindow = window as ElectronProcessWindow;
return Boolean(currentWindow.process?.type);
}
get remote() {
get supportsEpg(): boolean {
return typeof window !== 'undefined' && Boolean(window.electron);
}
get remote(): ElectronRemote | null {
return null;
}
get ipcRenderer() {
@@ -93,7 +93,7 @@
(contextMenuRequested)="onChannelContextMenu(channel, $event)"
[selected]="activeChannelUrl() === channel?.url"
[showFavoriteButton]="true"
[isFavorite]="favoriteIds().has(channel?.url)"
[isFavorite]="favoriteIds().has(channel?.url ?? '')"
(favoriteToggled)="onFavoriteToggle(channel, $event)"
/>
</cdk-virtual-scroll-viewport>
@@ -74,7 +74,10 @@
}
</div>
} @else if (showSeasonEmptyState()) {
<div class="empty-state-panel empty-state-panel--season" role="status">
<div
class="empty-state-panel empty-state-panel--season"
role="status"
>
<div class="empty-state-panel__icon" aria-hidden="true">
<mat-icon>video_library</mat-icon>
</div>
@@ -109,7 +112,7 @@
<div class="episode-card__thumbnail">
@if (episodeInfo?.movie_image) {
<img
[src]="episodeInfo.movie_image"
[src]="episodeInfo?.movie_image ?? ''"
(error)="
$event.target.src =
'./assets/images/default-episode.png'
@@ -126,8 +129,13 @@
<app-progress-capsule
[progress]="getEpisodeProgress(episode)"
/>
@if (getEpisodePositionText(episode); as time) {
<div class="episode-card__position-text">
@if (
getEpisodePositionText(episode);
as time
) {
<div
class="episode-card__position-text"
>
{{ time }}
</div>
}
@@ -141,17 +149,30 @@
<button
mat-icon-button
class="download-btn download-btn--completed"
(click)="playFromLocal($event, episode)"
[matTooltip]="'DOWNLOADS.PLAY_LOCAL' | translate"
(click)="
playFromLocal(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.PLAY_LOCAL'
| translate
"
>
<mat-icon>play_circle</mat-icon>
</button>
} @else if (isEpisodeDownloading(episode)) {
} @else if (
isEpisodeDownloading(episode)
) {
<button
mat-icon-button
class="download-btn download-btn--downloading"
disabled
[matTooltip]="'DOWNLOADS.STATUS.DOWNLOADING' | translate"
[matTooltip]="
'DOWNLOADS.STATUS.DOWNLOADING'
| translate
"
>
<mat-icon>downloading</mat-icon>
</button>
@@ -159,8 +180,16 @@
<button
mat-icon-button
class="download-btn"
(click)="downloadEpisode($event, episode)"
[matTooltip]="'DOWNLOADS.DOWNLOAD' | translate"
(click)="
downloadEpisode(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.DOWNLOAD'
| translate
"
>
<mat-icon>download</mat-icon>
</button>
@@ -173,7 +202,8 @@
[matTooltip]="
(isEpisodeWatched(episode)
? 'XTREAM.MARK_UNWATCHED'
: 'XTREAM.MARK_WATCHED') | translate
: 'XTREAM.MARK_WATCHED'
) | translate
"
>
<mat-icon>{{
@@ -186,7 +216,9 @@
<div class="episode-card__overlay">
<div class="episode-card__play">
@if (isEpisodeLaunching(episode)) {
<mat-spinner diameter="26"></mat-spinner>
<mat-spinner
diameter="26"
></mat-spinner>
} @else {
<mat-icon>{{
isEpisodeActiveExternal(episode)
@@ -202,16 +234,17 @@
</div>
<div class="episode-card__info">
<h4 class="episode-card__title">
{{ episode.episode_num }}. {{ episode.title }}
{{ episode.episode_num }}.
{{ episode.title }}
</h4>
@if (episodeInfo?.plot) {
<p class="episode-card__plot">
{{ episodeInfo.plot }}
{{ episodeInfo?.plot }}
</p>
}
@if (episodeInfo?.duration) {
<span class="episode-card__duration">
{{ episodeInfo.duration }}
{{ episodeInfo?.duration }}
</span>
}
</div>
@@ -226,10 +259,18 @@
<article
class="episode-list-item"
(click)="selectEpisode(episode)"
[class.episode-list-item--watched]="isEpisodeWatched(episode)"
[class.episode-list-item--in-progress]="isEpisodeInProgress(episode)"
[class.episode-list-item--launching]="isEpisodeLaunching(episode)"
[class.episode-list-item--active-external]="isEpisodeActiveExternal(episode)"
[class.episode-list-item--watched]="
isEpisodeWatched(episode)
"
[class.episode-list-item--in-progress]="
isEpisodeInProgress(episode)
"
[class.episode-list-item--launching]="
isEpisodeLaunching(episode)
"
[class.episode-list-item--active-external]="
isEpisodeActiveExternal(episode)
"
>
<div class="episode-list-item__number">
{{ episode.episode_num }}
@@ -239,17 +280,26 @@
<h4 class="episode-list-item__title">
{{ episode.title }}
@if (isEpisodeWatched(episode)) {
<mat-icon class="watched-icon">check_circle</mat-icon>
<mat-icon class="watched-icon"
>check_circle</mat-icon
>
}
</h4>
<div class="episode-list-item__meta">
@if (episodeInfo?.duration) {
<span class="episode-list-item__duration">
{{ episodeInfo.duration }}
<span
class="episode-list-item__duration"
>
{{ episodeInfo?.duration }}
</span>
}
@if (getEpisodePositionText(episode); as time) {
<span class="episode-list-item__progress-text">
@if (
getEpisodePositionText(episode);
as time
) {
<span
class="episode-list-item__progress-text"
>
{{ time }}
</span>
}
@@ -257,14 +307,18 @@
</div>
@if (episodeInfo?.plot) {
<p class="episode-list-item__description">
{{ episodeInfo.plot }}
{{ episodeInfo?.plot }}
</p>
}
@if (getEpisodeProgress(episode) > 0) {
<div class="episode-list-item__progress-bar">
<div
class="episode-list-item__progress-bar"
>
<div
class="episode-list-item__progress-fill"
[style.width.%]="getEpisodeProgress(episode)"
[style.width.%]="
getEpisodeProgress(episode)
"
></div>
</div>
}
@@ -273,12 +327,17 @@
<button
mat-icon-button
class="action-btn play-btn"
(click)="selectEpisode(episode); $event.stopPropagation()"
(click)="
selectEpisode(episode);
$event.stopPropagation()
"
[matTooltip]="'Play'"
[disabled]="isEpisodeLaunching(episode)"
>
@if (isEpisodeLaunching(episode)) {
<mat-spinner diameter="18"></mat-spinner>
<mat-spinner
diameter="18"
></mat-spinner>
} @else {
<mat-icon>{{
isEpisodeActiveExternal(episode)
@@ -292,8 +351,13 @@
<button
mat-icon-button
class="action-btn action-btn--success"
(click)="playFromLocal($event, episode)"
[matTooltip]="'DOWNLOADS.PLAY_LOCAL' | translate"
(click)="
playFromLocal($event, episode)
"
[matTooltip]="
'DOWNLOADS.PLAY_LOCAL'
| translate
"
>
<mat-icon>folder_open</mat-icon>
</button>
@@ -302,7 +366,10 @@
mat-icon-button
class="action-btn"
disabled
[matTooltip]="'DOWNLOADS.STATUS.DOWNLOADING' | translate"
[matTooltip]="
'DOWNLOADS.STATUS.DOWNLOADING'
| translate
"
>
<mat-icon>downloading</mat-icon>
</button>
@@ -310,8 +377,12 @@
<button
mat-icon-button
class="action-btn"
(click)="downloadEpisode($event, episode)"
[matTooltip]="'DOWNLOADS.DOWNLOAD' | translate"
(click)="
downloadEpisode($event, episode)
"
[matTooltip]="
'DOWNLOADS.DOWNLOAD' | translate
"
>
<mat-icon>download</mat-icon>
</button>
@@ -324,9 +395,12 @@
[matTooltip]="
(isEpisodeWatched(episode)
? 'XTREAM.MARK_UNWATCHED'
: 'XTREAM.MARK_WATCHED') | translate
: 'XTREAM.MARK_WATCHED'
) | translate
"
[class.action-btn--success]="
isEpisodeWatched(episode)
"
[class.action-btn--success]="isEpisodeWatched(episode)"
>
<mat-icon>{{
isEpisodeWatched(episode)
@@ -51,7 +51,7 @@ export class EmbeddedMpvSessionController {
this.session.set(session);
});
if (window.electron?.getEmbeddedMpvSupport) {
if (typeof window.electron?.getEmbeddedMpvSupport === 'function') {
void this.loadSupport();
} else {
this.support.set({
@@ -0,0 +1,3 @@
export * from './embedded-mpv-overlay-visibility.service';
export * from './embedded-mpv-player.component';
export * from './embedded-mpv-session-controller';
@@ -174,7 +174,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
audioTracks.addEventListener('addtrack', () => {
debugVjsPlayer(
'[AudioTrack] addtrack event fired, total tracks:',
this.player.audioTracks().length
audioTracks.length
);
this.logAudioTracks();
this.setupAudioTrackMenu();
@@ -182,7 +182,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
audioTracks.addEventListener('removetrack', () => {
debugVjsPlayer(
'[AudioTrack] removetrack event fired, total tracks:',
this.player.audioTracks().length
audioTracks.length
);
this.logAudioTracks();
this.setupAudioTrackMenu();
@@ -193,19 +193,19 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
}
this.player.on('volumechange', () => {
const currentVolume = this.player.volume();
const currentVolume = this.player.volume() ?? this.volume();
localStorage.setItem('volume', currentVolume.toString());
});
this.player.on('timeupdate', () => {
this.timeUpdate.emit({
currentTime: this.player.currentTime(),
duration: this.player.duration(),
currentTime: this.player.currentTime() ?? 0,
duration: this.player.duration() ?? 0,
});
});
// Attach mpegts.js after Video.js is ready
if (isMpegTs) {
if (isMpegTs && source) {
this.initMpegTs(source.src);
}
}) as unknown as VideoJsPlayer;
@@ -311,7 +311,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
private initMpegTs(url: string): void {
const videoEl = this.player
.tech({ IWillNotUseThisInPlugins: true })
?.el();
?.el?.();
if (!videoEl) return;
debugVjsPlayer('Using mpegts.js for TS stream:', url);
@@ -473,7 +473,11 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
'[AudioTrack] Audio tracks count:',
audioTracks?.length ?? 0
);
for (let i = 0; i < (audioTracks?.length ?? 0); i++) {
if (!audioTracks) {
return;
}
for (let i = 0; i < audioTracks.length; i++) {
const t = audioTracks[i];
debugVjsPlayer(
`[AudioTrack] Track ${i}: label="${t.label}", language="${t.language}", enabled=${t.enabled}, kind="${t.kind}"`
@@ -529,9 +533,9 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
}
let audioButton =
controlBar.getChild('audioTrackButton') ??
controlBar.getChild('AudioTrackButton');
if (!audioButton) {
controlBar.getChild?.('audioTrackButton') ??
controlBar.getChild?.('AudioTrackButton');
if (!audioButton && controlBar.addChild) {
audioButton = controlBar.addChild('audioTrackButton', {});
}
@@ -236,7 +236,10 @@ describe('WebPlayerViewComponent', () => {
it('preserves playback HTTP metadata for channel-based players', () => {
const streamUrl = 'https://example.com/live/channel.m3u8';
fixture.componentRef.setInput('playerOverride', VideoPlayer.Html5Player);
fixture.componentRef.setInput(
'playerOverride',
VideoPlayer.Html5Player
);
fixture.componentRef.setInput('playback', {
streamUrl,
title: 'Header Locked Channel',
@@ -270,7 +273,10 @@ describe('WebPlayerViewComponent', () => {
it('falls back to playback headers when explicit HTTP metadata is absent', () => {
const streamUrl = 'https://example.com/live/channel.m3u8';
fixture.componentRef.setInput('playerOverride', VideoPlayer.Html5Player);
fixture.componentRef.setInput(
'playerOverride',
VideoPlayer.Html5Player
);
fixture.componentRef.setInput('playback', {
streamUrl,
title: 'Header Fallback Channel',
@@ -297,12 +303,28 @@ describe('WebPlayerViewComponent', () => {
);
});
it('uses browser access diagnostic translation keys', () => {
it('uses the PWA browser access diagnostic description key outside desktop', () => {
const issue = createBrowserAccessDiagnostic();
expect(component.getDiagnosticTitleKey(issue)).toBe(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.TITLE'
);
expect(component.getDiagnosticDescriptionKey(issue)).toBe(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION'
);
});
it('keeps the desktop browser access diagnostic description key', () => {
fixture.destroy();
window.electron = {} as typeof window.electron;
fixture = TestBed.createComponent(WebPlayerViewComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
'https://example.com/archive/movie.mkv'
);
const issue = createBrowserAccessDiagnostic();
expect(component.getDiagnosticDescriptionKey(issue)).toBe(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION'
);
@@ -222,6 +222,13 @@ export class WebPlayerViewComponent {
}
getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string {
if (
issue.code === PlaybackDiagnosticCode.BrowserAccessError &&
!this.isDesktop()
) {
return 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION';
}
return `${this.getDiagnosticTranslationBase(issue)}.DESCRIPTION`;
}
@@ -1,4 +1,4 @@
@if (items()?.length > 0) {
@if ((items()?.length ?? 0) > 0) {
<div class="nav-list">
@if (statusText()) {
<div class="context-inline-status">
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more