diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 0e2d636d4..caa78d4ee 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -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 diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts new file mode 100644 index 000000000..c05a36804 --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -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'], + }, +]; diff --git a/apps/electron-backend/src/app/api/main.preload.spec.ts b/apps/electron-backend/src/app/api/main.preload.spec.ts new file mode 100644 index 000000000..27b3e9268 --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.spec.ts @@ -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 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 + ); + }); +}); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index d4b84936f..917a5a822 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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, diff --git a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts new file mode 100644 index 000000000..2993aaa5c --- /dev/null +++ b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts @@ -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' }, + }, +]; diff --git a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec.ts b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec.ts new file mode 100644 index 000000000..0a60ac8f8 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec.ts @@ -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; + +type MockIpcEvent = { + sender: { + isDestroyed: jest.Mock; + send: jest.Mock; + }; +}; + +const mockRegisteredHandlers = new Map(); +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 { + 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 }); + }); +}); diff --git a/apps/electron-backend/tsconfig.app.json b/apps/electron-backend/tsconfig.app.json index edca2598d..46b5f34af 100644 --- a/apps/electron-backend/tsconfig.app.json +++ b/apps/electron-backend/tsconfig.app.json @@ -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"] diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 11695285a..084c8e579 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -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); }); diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index efda3665f..2183b900c 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -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(STORE_KEY.Settings) .subscribe((settings: Settings) => { if (settings && Object.keys(settings).length > 0) { // No need to send settings to Electron on init diff --git a/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts b/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts index 24f0d42d6..3e72d8d58 100644 --- a/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts +++ b/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts @@ -6,7 +6,7 @@ import { EmbeddedMpvOverlayVisibilityService, EmbeddedMpvPlayerComponent, EmbeddedMpvSessionController, -} from '@iptvnator/ui/playback'; +} from '@iptvnator/ui/playback/embedded-mpv-player'; @Component({ imports: [EmbeddedMpvPlayerComponent], diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 0adccd9e4..0a7765fa5 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -117,7 +117,7 @@ export class ElectronService extends DataService { payload?: unknown ): Promise { if (type === PLAYLIST_PARSE_BY_URL) { - this.fetchM3uPlaylistFromUrl(payload); + this.fetchM3uPlaylistFromUrl(payload as Partial); 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) { + 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) { diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 25e2af2d5..c7a853b51 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -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(type: string, payload?: unknown): T { if (type === PLAYLIST_PARSE_BY_URL) { - this.fetchFromUrl(payload); + this.fetchFromUrl(payload as Partial); return undefined as T; } if (type === PLAYLIST_UPDATE) { - this.refreshPlaylist(payload); + this.refreshPlaylist(payload as Partial); return undefined as T; } @@ -138,6 +145,12 @@ export class PwaService extends DataService { } refreshPlaylist(payload: Partial) { + 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): 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 { return from(this.getProviderTargetId(url)).pipe( switchMap((targetId) => - this.http.get(`${this.corsProxyUrl}/parse`, { + this.http.get(`${this.corsProxyUrl}/parse`, { params: { targetId }, }) ) diff --git a/apps/web/src/app/services/settings.service.ts b/apps/web/src/app/services/settings.service.ts index da980f203..a75978e3e 100644 --- a/apps/web/src/app/services/settings.service.ts +++ b/apps/web/src/app/services/settings.service.ts @@ -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(key: STORE_KEY): Observable { + return this.storage.get(key) as Observable; } /** @@ -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); diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index b4f20bdf0..2cd48a890 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -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(); + }); }); diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index e2bcb8848..5c229ac67 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -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>([]) } + : {}), 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 ); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 1b233a9fa..680a15b6c 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 0cdbbfbea..c7c4cfb4c 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index c7d6c9994..2060c6ca3 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 96c4cba3e..95fd05a28 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 3e906a4e7..e28a5e744 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 1eb34700d..b257f2546 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 5132150c6..c897e37c2 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 91443af95..c920d55d6 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 613e3b622..362a8d6bd 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index c1047ff9d..e0ef7e25c 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index eca2c9ec0..60f7d98ae 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 915690590..0ad6db9c4 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index fcdc25b40..25ea5a431 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index bff895f08..334411167 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index ba14d6d36..32b022f5c 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index a477ec002..9b2dda25c 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 501fbfbe9..feb0602a4 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 55cb9b798..256c21258 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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", diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index 34d49ea50..aa9469371 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -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; - updateSettings: (settings: JsonObject) => Promise; + updateSettings: (settings: Partial) => Promise; getAiSettings: () => Promise<{ aiProvider: string; aiModelName: string; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index b17319d3b..83086f821 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -13,7 +13,7 @@ "typeRoots": ["../../node_modules/@types"], "lib": ["es2018", "es2017", "es2016", "es2015", "dom"], "resolveJsonModule": true, - "strict": false, + "strict": true, "skipLibCheck": true, "useDefineForClassFields": false }, diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 8c2905040..7002f5f62 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -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-/` 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. diff --git a/global.d.ts b/global.d.ts index e56f47274..d23dc5942 100644 --- a/global.d.ts +++ b/global.d.ts @@ -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, diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html index c78c7c66f..6bb2389e3 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html @@ -109,7 +109,7 @@ [disabled]="!textImport()?.textForm?.valid" (click)=" textImport()?.textAdded?.emit( - textImport()?.textForm?.value?.text + textImport()?.textForm?.value?.text ?? undefined ) " > diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index a812afa05..266a7b40b 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -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); diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.html b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.html index 897bb428c..dc30f54d8 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.html +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.html @@ -14,7 +14,7 @@ type="text" id="serverUrl" formControlName="serverUrl" - (input)="extractParams(form.value.serverUrl)" + (input)="extractParams(form.value.serverUrl ?? '')" /> {{ 'HOME.XTREAM_PLAYLIST.URL_VALIDATION_ERROR' | translate diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts index c0a850697..ef3f7e670 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts @@ -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 { diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index 73a002ae7..dc520bdbc 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -255,6 +255,9 @@ describe('VideoPlayerComponent', () => { ), }; const dataServiceMock = { + get supportsEpg() { + return Boolean(window.electron); + }, sendIpcEvent: jest.fn(), }; diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index c5f318037..d85f6811e 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -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 */ diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts index 641bea54a..5b9bc55f0 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts @@ -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, diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index 830032ea7..3c791d6dd 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -475,7 +475,7 @@ export class RecentPlaylistsComponent { this.translate.instant( 'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS' ), - null, + undefined, { duration: 2000 } ); } catch (error) { diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.ts b/libs/portal/downloads/feature/src/lib/downloads.component.ts index 25edc527c..6fd9b69e1 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.ts @@ -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 { diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index e79eee0ae..cc31575c9 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -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 = { diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 4c6d94cc9..1a7352f15 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -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( diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts index ee40affa2..2ab10870e 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts @@ -32,7 +32,7 @@ import { const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1'; -type PlaylistWithChannels = Playlist & { +type PlaylistWithChannels = Omit & { readonly playlist?: { readonly items?: Channel[] }; }; type StalkerPortalFavoriteItem = StalkerPortalItem & { diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts index 163c59653..cb743c8e4 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts @@ -31,7 +31,7 @@ import { XtreamContentItem, } from '@iptvnator/portal/xtream/data-access'; -type PlaylistWithChannels = Playlist & { +type PlaylistWithChannels = Omit & { readonly playlist?: { readonly items?: Channel[] }; }; diff --git a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.html b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.html index 10dfcd771..cbce1c381 100644 --- a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.html +++ b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.html @@ -1,4 +1,4 @@ -@if (items()?.length > 0) { +@if (items().length > 0) { - @if (showPaginator() && items()?.length > 0) { + @if (showPaginator() && items().length > 0) { (); - readonly isLoading = input(); + readonly items = input([]); + readonly isLoading = input(false); readonly showPaginator = input(true); readonly searchTerm = input(''); readonly itemClicked = output(); readonly pageChange = output(); - readonly pageIndex = input(); - readonly totalPages = input(); - readonly limit = input(); - readonly pageSizeOptions = input(); + readonly pageIndex = input(0); + readonly totalPages = input(0); + readonly limit = input(25); + readonly pageSizeOptions = input([]); protected readonly resolveRating = resolveGridRating; protected readonly hasActiveSearch = computed( () => (this.searchTerm() ?? '').trim().length > 0 diff --git a/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts b/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts index 387f32887..864100c3a 100644 --- a/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts +++ b/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts @@ -26,10 +26,10 @@ export class PlaylistErrorViewComponent { private readonly currentPlaylist = this.playlistContext.activePlaylist; - readonly description = input(undefined); + readonly description = input(undefined); readonly showIllustration = input(true); readonly showActionButtons = input(true); - readonly title = input(undefined); + readonly title = input(undefined); readonly viewType = input<'ERROR' | 'EMPTY_CATEGORY' | 'NO_SEARCH_RESULTS'>( 'ERROR' ); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts index 49a8c590e..7f2c42bfe 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts @@ -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: { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index f9e9fd8e2..4864f6434 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -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() ); diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts index 8f93b093f..f4b68cd9b 100644 --- a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts @@ -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') ) { diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts index 8368691ec..28a703c90 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts @@ -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>(); + private watchdogIntervals = new Map< + string, + ReturnType + >(); private watchdogPlaylists = new Map(); private watchdogInFlight = new Set(); 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); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts index d4f7466a6..088be301b 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts @@ -37,6 +37,7 @@ describe('withStalkerEpg', () => { let store: InstanceType; let dataService: { getAppEnvironment: jest.Mock; + supportsEpg: boolean; sendIpcEvent: jest.Mock, 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'); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts index 70db20cf2..2051cbb4a 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts @@ -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 => { - 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, diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index b0bc3d68e..1fcb7b1f8 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -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, }); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts index ccdadf6e8..9618de29b 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts @@ -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, - StalkerVodSource, - StalkerVodSource - > -{ +export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade< + Record, + StalkerVodSource, + StalkerVodSource +> { private readonly stalkerStore = inject(StalkerStore); private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); private readonly destroyRef = inject(DestroyRef); - private readonly stalkerPositions = signal>( - new Map() - ); + private readonly stalkerPositions = signal< + Map + >(new Map()); private readonly stalkerSeriesPositions = signal< Map >(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); } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts index 785376dac..5f8295d59 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts @@ -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 diff --git a/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts b/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts index 03eb10750..315f93494 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts @@ -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(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()) diff --git a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html index fa933a512..16cdd2eff 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html @@ -8,10 +8,10 @@ } @else if (isSeries()) { - } @else if (vodDetailsItem()) { + } @else if (vodDetailsItem(); as item) { { 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 }, diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index c4bd39ee0..3066f4881 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -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(); diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html index 985dbfb51..7284f767f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html @@ -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 @@
@for (item of searchResultsResource.value(); track item.id) { >({ series: false, vod: true, }); @@ -127,7 +129,7 @@ export class StalkerSearchComponent { return playlist?.macAddress ? playlist : null; }); - readonly selectedFilterType = signal('vod'); + readonly selectedFilterType = signal('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 = {}; - Object.keys(f).forEach((k) => { - newFilters[k] = k === key; + this.filters.update(() => { + const newFilters: Record = { + 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 && diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 7bcb88687..25e2a5d69 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -8,14 +8,10 @@ @if (serial.info.releasedate) { - {{ - serial.info.releasedate - }} + {{ serial.info.releasedate }} } @if (serial.info.genre) { - {{ - serial.info.genre - }} + {{ serial.info.genre }} } @if (serial.info.rating_imdb) { @@ -33,9 +29,7 @@ @if (serial.info.actors) {
- {{ - 'XTREAM.ACTORS' | translate - }} + {{ 'XTREAM.ACTORS' | translate }} {{ serial.info.actors }}
} @@ -75,10 +69,7 @@
} - +
@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 /> } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 1df5faad2..2b6a8276f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -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, }); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts index 27bc985f3..ed1f5df40 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts @@ -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, } diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts index e2eabcedc..a2365a76b 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts @@ -360,7 +360,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { ): Promise { return this.dbService.saveXtreamContent( playlistId, - streams, + streams as Parameters[1], type, onProgress, options diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/index.ts b/libs/portal/xtream/data-access/src/lib/data-sources/index.ts index 77b09ffb4..ff01727ba 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/index.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/index.ts @@ -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(); } diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index 4c07c657c..1530ff910 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -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; diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts index ebea2de06..d178dce39 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts @@ -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 }, diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts index b2cd628ea..7723705ee 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts @@ -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 { - if (!supportsEpg()) { + if (!dataService.supportsEpg) { patchState(store, { epgItems: [], isLoadingEpg: false, @@ -186,7 +182,7 @@ export function withEpg() { streamId: number, epgChannelId?: string | null ): Promise { - if (!supportsEpg()) { + if (!dataService.supportsEpg) { return []; } diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-player.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-player.feature.ts index 27fd022b5..199dd2e7d 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-player.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-player.feature.ts @@ -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 { /** diff --git a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts index 8d7fda351..ba60a3204 100644 --- a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts +++ b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts @@ -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 { - const playlistId = playlist().id; + const playlistId = playlist()?.id; const normalizedXtreamId = Number(xtreamId); const normalizedBackdropUrl = backdropUrl?.trim(); if ( diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index 387b02f11..a26756262 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -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 }, ], diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index 5777e7f4e..016b146fb 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -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('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, diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html index eac337946..901ac8d28 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html @@ -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 diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts index c0b8b0003..7d8682835 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts @@ -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: { diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index c2677fadf..f970f77db 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -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(() => { diff --git a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.html b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.html index de551d98f..ef5334448 100644 --- a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.html +++ b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.html @@ -32,7 +32,7 @@ > @for (item of recentlyAddedVod(); track item.xtream_id) { @for (item of recentlyAddedSeries(); track item.xtream_id) { @for (item of recentlyAddedLive(); track item.xtream_id) { { - return items.reduce>( - (groups, item) => { - const key = String(item.playlist_name); - if (!groups[key]) { - groups[key] = []; - } - groups[key].push(item); - return groups; - }, - {} - ); + return items.reduce>((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(); }); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index cd8558b1f..ad0ab5e9b 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -1,46 +1,47 @@ @let item = selectedItem(); -@if (item?.info) { +@if (item && item.info) { + @let info = item.info; - @if (item.info?.releaseDate) { + @if (info.releaseDate) { {{ - item.info.releaseDate | slice: 0 : 4 + info.releaseDate | slice: 0 : 4 }} } - @if (item.info?.genre) { - {{ item.info.genre }} + @if (info.genre) { + {{ info.genre }} } - @if (item.info?.episode_run_time) { + @if (info.episode_run_time) { {{ item.info.episode_run_time }} min/ep{{ info.episode_run_time }} min/ep } - @if (item.info?.rating) { + @if (info.rating) { - ⭐ {{ item.info.rating }} + ⭐ {{ info.rating }} } - @if (item.info?.cast) { + @if (info.cast) {
{{ 'XTREAM.CAST' | translate }} - {{ item.info.cast }} + {{ info.cast }}
} - @if (item.info?.director) { + @if (info.director) {
{{ 'XTREAM.DIRECTOR' | translate }} - {{ item.info.director }} + {{ info.director }}
}
@@ -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 @@
} @else if (detailsError()) { } @else { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index d67e95fdc..e7890f92c 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -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, } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index 9a8d1c12b..b13bbd318 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -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' + " > } @else if (detailsError()) { } @else { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index b094498e7..27c728178 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -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 & { - id?: string | number; - poster_url?: string; - title?: string; - xtream_id?: string | number; - }) | null + | (Partial & { + 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 { - 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 { + async downloadVod(vodItem: XtreamVodDetails | null): Promise { + 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, diff --git a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts index 815053f34..737acfa7c 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts @@ -41,7 +41,7 @@ interface XtreamCollectionStateSnapshot { } @else { diff --git a/libs/services/src/lib/data.service.ts b/libs/services/src/lib/data.service.ts index ed23adffc..d2b75065c 100644 --- a/libs/services/src/lib/data.service.ts +++ b/libs/services/src/lib/data.service.ts @@ -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() { diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html index 490185458..92006c4d0 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html @@ -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)" /> diff --git a/libs/ui/components/src/lib/season-container/season-container.component.html b/libs/ui/components/src/lib/season-container/season-container.component.html index e93d8f2a3..855ebd5d3 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.html +++ b/libs/ui/components/src/lib/season-container/season-container.component.html @@ -74,7 +74,10 @@ }
} @else if (showSeasonEmptyState()) { -
+
@@ -109,7 +112,7 @@
@if (episodeInfo?.movie_image) { - @if (getEpisodePositionText(episode); as time) { -
+ @if ( + getEpisodePositionText(episode); + as time + ) { +
{{ time }}
} @@ -141,17 +149,30 @@ - } @else if (isEpisodeDownloading(episode)) { + } @else if ( + isEpisodeDownloading(episode) + ) { @@ -159,8 +180,16 @@ @@ -173,7 +202,8 @@ [matTooltip]=" (isEpisodeWatched(episode) ? 'XTREAM.MARK_UNWATCHED' - : 'XTREAM.MARK_WATCHED') | translate + : 'XTREAM.MARK_WATCHED' + ) | translate " > {{ @@ -186,7 +216,9 @@
@if (isEpisodeLaunching(episode)) { - + } @else { {{ isEpisodeActiveExternal(episode) @@ -202,16 +234,17 @@

- {{ episode.episode_num }}. {{ episode.title }} + {{ episode.episode_num }}. + {{ episode.title }}

@if (episodeInfo?.plot) {

- {{ episodeInfo.plot }} + {{ episodeInfo?.plot }}

} @if (episodeInfo?.duration) { - {{ episodeInfo.duration }} + {{ episodeInfo?.duration }} }
@@ -226,10 +259,18 @@
{{ episode.episode_num }} @@ -239,17 +280,26 @@

{{ episode.title }} @if (isEpisodeWatched(episode)) { - check_circle + check_circle }

@if (episodeInfo?.duration) { - - {{ episodeInfo.duration }} + + {{ episodeInfo?.duration }} } - @if (getEpisodePositionText(episode); as time) { - + @if ( + getEpisodePositionText(episode); + as time + ) { + {{ time }} } @@ -257,14 +307,18 @@
@if (episodeInfo?.plot) {

- {{ episodeInfo.plot }} + {{ episodeInfo?.plot }}

} @if (getEpisodeProgress(episode) > 0) { -
+
} @@ -273,12 +327,17 @@ @@ -302,7 +366,10 @@ mat-icon-button class="action-btn" disabled - [matTooltip]="'DOWNLOADS.STATUS.DOWNLOADING' | translate" + [matTooltip]=" + 'DOWNLOADS.STATUS.DOWNLOADING' + | translate + " > downloading @@ -310,8 +377,12 @@ @@ -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)" > {{ isEpisodeWatched(episode) diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts index 22caea71d..f7c21358f 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts @@ -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({ diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/index.ts b/libs/ui/playback/src/lib/embedded-mpv-player/index.ts new file mode 100644 index 000000000..da8fbfccf --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/index.ts @@ -0,0 +1,3 @@ +export * from './embedded-mpv-overlay-visibility.service'; +export * from './embedded-mpv-player.component'; +export * from './embedded-mpv-session-controller'; diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index cc3e2a5c1..e5cacbb7b 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -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', {}); } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index 7e70e8642..f83ce3fe5 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -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' ); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index 2477f5403..8d381f286 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -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`; } diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html index bb70806f8..1082a6fb7 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html @@ -1,4 +1,4 @@ -@if (items()?.length > 0) { +@if ((items()?.length ?? 0) > 0) {