diff --git a/.dockerignore b/.dockerignore index b1254b53c..103a72d8f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,13 +7,53 @@ .angular .electron .husky +.nx +.agent +.agents +.aider* +.amp +.augment +.claude +.codeium +.codex +.continue +.crush +.cursor +.entire +.gemini +.goose +.idea +.kiro +.opencode +.plans +.qodo +.roo +.serena +.supermaven +.tabnine +.windsurf +.zed +.worktrees +worktrees Dockerfile README.md dist e2e node_modules +**/node_modules screenshots test-results coverage build release +tmp +out-tsc +playwright-report +.astro +.cache +.next +.parcel-cache +.svelte-kit +.turbo +.vercel +apps/xtream-mock-server/public/marketing diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 74ba2dc4b..ed059fefc 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,39 +1,113 @@ name: docker-build -on: [workflow_dispatch] +on: + pull_request: + paths: + - '.github/workflows/docker.yml' + - 'apps/web/**' + - 'apps/web-backend/**' + - 'docker/**' + - 'libs/**' + - 'package.json' + - 'pnpm-lock.yaml' + push: + branches: + - master + tags: + - 'v*' + workflow_dispatch: + inputs: + push: + description: 'Push the image to Docker Hub' + required: true + type: boolean + default: false + +permissions: + contents: read jobs: - build: - runs-on: ubuntu-latest - steps: - - - name: Checkout - uses: actions/checkout@v3 - - - name: Install jq - run: sudo apt-get update && sudo apt-get install -y jq - - - name: Get version from package.json - id: package-version - run: echo "VERSION=$(cat package.json | jq -r .version)" >> $GITHUB_OUTPUT - - - name: Login to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Build and push - uses: docker/build-push-action@v4 - with: - context: . - file: ./docker/Dockerfile - push: true - tags: | - 4gray/iptvnator:latest - 4gray/iptvnator:${{ github.sha }} - 4gray/iptvnator:${{ steps.package-version.outputs.VERSION }} - platforms: linux/amd64,linux/arm64 + build: + name: Build Docker image + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Prepare Docker metadata + id: docker-meta + shell: bash + run: | + set -euo pipefail + + version="$(node -p "require('./package.json').version")" + short_sha="${GITHUB_SHA::12}" + publish="false" + platforms="linux/amd64" + tags="4gray/iptvnator:pr-${{ github.event.pull_request.number || 'manual' }}" + + if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/master" ]]; then + publish="true" + platforms="linux/amd64,linux/arm64" + tags=$(cat <> "${GITHUB_OUTPUT}" + + - name: Login to Docker Hub + if: steps.docker-meta.outputs.publish == 'true' + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build Docker image + uses: docker/build-push-action@v7 + with: + context: . + file: ./docker/Dockerfile + push: ${{ steps.docker-meta.outputs.publish == 'true' }} + tags: ${{ steps.docker-meta.outputs.tags }} + platforms: ${{ steps.docker-meta.outputs.platforms }} + cache-from: type=gha + cache-to: type=gha,mode=max,ignore-error=true diff --git a/README.md b/README.md index a7d1c594b..d56cbbc45 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,28 @@ The application is a cross-platform, open-source project built with Electron and - Polish - Custom "User Agent" header configuration for playlists - Light and Dark themes -- Docker version available for self-hosting +- Docker image available for self-hosting the PWA and web backend together + +## Keyboard shortcuts + +Press `?` or `Shift+/` in the workspace to open the in-app shortcuts list. + +| Area | Shortcut | Action | +| --- | --- | --- | +| Global | `Ctrl/Cmd+K` | Open command palette | +| Global | `Ctrl/Cmd+F` | Open global search in the desktop app | +| Global | `Ctrl/Cmd+R` | Open recently viewed in the desktop app | +| Global | `Enter` in workspace search | Submit the current search | +| Navigation | `Ctrl/Cmd+B` | Toggle the live sidebar | +| Navigation | `0-9` | Select an M3U channel by number | +| Playback | `Space` / `K` | Play or pause embedded MPV playback in the desktop app | +| Playback | `F` | Toggle embedded MPV fullscreen in the desktop app | +| Playback | `ArrowLeft` / `ArrowRight` | Seek embedded MPV playback by 5 seconds in the desktop app | +| Playback | `ArrowUp` / `ArrowDown` | Adjust volume by 5% | +| Playback | `M` | Mute audio | +| Dialogs and lists | `ArrowUp` / `ArrowDown` | Move command palette selection | +| Dialogs and lists | `Enter` | Run the selected command or open a focused item | +| Dialogs and lists | `Escape` | Close dialogs and dismiss overlays | ## Screenshots: @@ -79,6 +100,22 @@ The application is a cross-platform, open-source project built with Electron and _Note: First version of the application which was developed as a PWA is available in an extra git branch._ +## Self-hosted PWA + +The Docker setup builds the Angular PWA and the monorepo web backend into one +image. The backend handles remote M3U parsing plus Xtream and Stalker proxy +requests under `/api`, so a separate `4gray/iptvnator-backend` container is not +required for the default self-hosted flow. + +```bash +docker compose -f docker/docker-compose.yml up --build -d +``` + +The application is available at . See +[`docker/docker-compose.yml`](./docker/docker-compose.yml) for the ready-to-run +compose file and [`docker/README.md`](./docker/README.md) for environment +variables, reverse proxy notes, and build details. + ## Download Download the latest version of the application for macOS, Windows, and Linux from the [release page](https://github.com/4gray/iptvnator/releases). diff --git a/apps/electron-backend/src/app/database/operations/content.operations.spec.ts b/apps/electron-backend/src/app/database/operations/content.operations.spec.ts index 7563642df..1688b1fb5 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.spec.ts @@ -93,7 +93,9 @@ describe('content.operations', () => { }); it('uses a synchronous better-sqlite transaction callback when saving content', async () => { - const existingContentWhere = jest.fn().mockResolvedValue([{ count: 0 }]); + const existingContentWhere = jest + .fn() + .mockResolvedValue([{ count: 0 }]); const existingContentInnerJoin = jest .fn() .mockReturnValue({ where: existingContentWhere }); @@ -166,4 +168,91 @@ describe('content.operations', () => { ]); expect(run).toHaveBeenCalled(); }); + + it('does not persist far-future provider timestamps as valid recently-added dates', async () => { + const dateNowSpy = jest + .spyOn(Date, 'now') + .mockReturnValue(Date.parse('2026-05-19T00:00:00.000Z')); + const existingContentWhere = jest + .fn() + .mockResolvedValue([{ count: 0 }]); + const existingContentInnerJoin = jest + .fn() + .mockReturnValue({ where: existingContentWhere }); + const existingContentFrom = jest + .fn() + .mockReturnValue({ innerJoin: existingContentInnerJoin }); + + const categoriesWhere = jest.fn().mockResolvedValue([ + { + id: 42, + xtreamId: 201, + }, + ]); + const categoriesFrom = jest + .fn() + .mockReturnValue({ where: categoriesWhere }); + + const select = jest + .fn() + .mockReturnValueOnce({ from: existingContentFrom }) + .mockReturnValueOnce({ from: categoriesFrom }); + + const run = jest.fn(); + const onConflictDoNothing = jest.fn().mockReturnValue({ run }); + const values = jest.fn().mockReturnValue({ onConflictDoNothing }); + const insert = jest.fn().mockReturnValue({ values }); + const transaction = jest.fn((callback: (tx: unknown) => unknown) => + callback({ insert }) + ); + const db = { + select, + transaction, + } as unknown as AppDatabase; + + try { + await saveContent( + db, + 'playlist-1', + [ + { + added: '1893456000', + category_id: '201', + name: 'Future Movie', + stream_id: '100', + }, + { + added: '1779062400', + category_id: '201', + name: 'Fresh Movie', + stream_id: '101', + }, + { + category_id: '201', + last_modified: '1778976000', + name: 'Modified Movie', + stream_id: '102', + }, + ], + 'movie' + ); + + expect(values).toHaveBeenCalledWith([ + expect.objectContaining({ + title: 'Future Movie', + added: '', + }), + expect.objectContaining({ + title: 'Fresh Movie', + added: '1779062400', + }), + expect.objectContaining({ + title: 'Modified Movie', + added: '1778976000', + }), + ]); + } finally { + dateNowSpy.mockRestore(); + } + }); }); diff --git a/apps/electron-backend/src/app/database/operations/content.operations.ts b/apps/electron-backend/src/app/database/operations/content.operations.ts index 738b63e3d..200bb7cf3 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -1,5 +1,9 @@ import { and, asc, desc, eq, inArray, or, sql } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; +import { + getXtreamRecentlyAddedMaxEpochSeconds, + toXtreamRecentlyAddedEpochSeconds, +} from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; import { checkpointOperation, @@ -129,18 +133,19 @@ export async function getGlobalRecentlyAdded( inArray(schema.content.type, contentTypes), eq(schema.categories.hidden, false), sql`${schema.content.added} <> ''`, + sql`${schema.content.added} <= ${getXtreamRecentlyAddedMaxEpochSeconds()}`, ]; if (playlistType) { whereConditions.push(eq(schema.playlists.type, playlistType)); } - // Sort by `added` directly. Xtream stores Unix-epoch timestamps as - // 10-digit numeric strings (since 2001-09-09), so lexicographic sort - // is equivalent to numeric sort. Wrapping the column in CAST(... AS - // INTEGER) — as we used to — blocks SQLite from using - // idx_content_type_added and forces a full table scan + sort on the - // entire content table (often 100k+ rows) on every dashboard load. + // Sort by `added` directly. Xtream import and DB startup migrations + // normalize recently-added epochs to 10-digit seconds strings, so + // lexicographic sort is equivalent to numeric sort. Wrapping the column in + // CAST(... AS INTEGER) blocks SQLite from using idx_content_type_added and + // forces a full table scan + sort on the entire content table (often 100k+ + // rows) on every dashboard load. return db .select({ ...selectContentFields(), @@ -183,16 +188,16 @@ type XtreamContentSource = Record & { last_modified?: string; added?: string; stream_icon?: string; - poster?: string; - cover?: string; - name?: string; - title?: string; - epg_channel_id?: string; - tv_archive?: string | number; - tv_archive_duration?: string | number; - direct_source?: string; - series_id?: string | number; - stream_id?: string | number; + poster?: string; + cover?: string; + name?: string; + title?: string; + epg_channel_id?: string; + tv_archive?: string | number; + tv_archive_duration?: string | number; + direct_source?: string; + series_id?: string | number; + stream_id?: string | number; }; function toXtreamContentValue( @@ -224,10 +229,11 @@ function toXtreamContentValue( categoryId, title, rating: String(source.rating || source.rating_imdb || ''), - added: + added: toXtreamRecentlyAddedEpochSeconds( type === 'series' - ? String(source.last_modified || '') - : String(source.added || ''), + ? source.last_modified || source.added + : source.added || source.last_modified + ), posterUrl: String( source.stream_icon || source.poster || source.cover || '' ), @@ -300,10 +306,9 @@ export async function saveContent( ) ); - const categoryMap = new Map(categories.map((category) => [ - category.xtreamId, - category.id, - ])); + const categoryMap = new Map( + categories.map((category) => [category.xtreamId, category.id]) + ); const values = streams .map((stream) => toXtreamContentValue(stream, type, categoryMap)) @@ -317,8 +322,7 @@ export async function saveContent( await checkpointOperation(control); const chunk = values.slice(index, index + chunkSize); await db.transaction((tx) => { - tx - .insert(schema.content) + tx.insert(schema.content) .values(chunk) .onConflictDoNothing({ target: [ @@ -374,8 +378,7 @@ export async function clearXtreamImportCache( 100 )) { await db.transaction((tx) => { - tx - .delete(schema.content) + tx.delete(schema.content) .where(inArray(schema.content.id, chunk)) .run(); }); @@ -383,8 +386,7 @@ export async function clearXtreamImportCache( for (const chunk of chunkValues(categoryIds, 100)) { await db.transaction((tx) => { - tx - .delete(schema.categories) + tx.delete(schema.categories) .where(inArray(schema.categories.id, chunk)) .run(); }); @@ -440,7 +442,10 @@ export async function searchContent( const conditions = [ eq(schema.categories.playlistId, playlistId), - inArray(schema.content.type, types as Array<'live' | 'movie' | 'series'>), + inArray( + schema.content.type, + types as Array<'live' | 'movie' | 'series'> + ), or(...likeConditions), ]; @@ -482,7 +487,10 @@ export async function globalSearch( ); const conditions = [ - inArray(schema.content.type, types as Array<'live' | 'movie' | 'series'>), + inArray( + schema.content.type, + types as Array<'live' | 'movie' | 'series'> + ), or(...likeConditions), ]; diff --git a/apps/web-e2e/src/basic.e2e.ts b/apps/web-e2e/src/basic.e2e.ts index 6e8040ed5..462d5f53a 100644 --- a/apps/web-e2e/src/basic.e2e.ts +++ b/apps/web-e2e/src/basic.e2e.ts @@ -30,3 +30,21 @@ test('basic test', async ({ page }) => { await expect(page.getByText('1. Channel 1')).toBeVisible(); await expect(page.getByText('4. HappyKids TV')).toBeVisible(); }); + +test('keyboard shortcuts help opens from question mark', async ({ page }) => { + await page.goto('/'); + + await page.getByRole('button', { name: 'Open keyboard shortcuts' }).focus(); + await page.keyboard.press('?'); + + const dialog = page.getByRole('dialog'); + await expect( + dialog.getByRole('heading', { name: 'Keyboard shortcuts' }) + ).toBeVisible(); + await expect(dialog.getByText('Open command palette')).toBeVisible(); + await expect(dialog.getByText('Toggle sidebar')).toBeVisible(); + await expect(dialog.getByText('Mute audio')).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); +}); diff --git a/apps/web-e2e/src/self-hosted.e2e.ts b/apps/web-e2e/src/self-hosted.e2e.ts index 332bb6479..85047cab0 100644 --- a/apps/web-e2e/src/self-hosted.e2e.ts +++ b/apps/web-e2e/src/self-hosted.e2e.ts @@ -51,6 +51,27 @@ async function addXtreamPortal(page: Page): Promise { await page.waitForURL(/xtreams.*vod/); } +async function addM3uPlaylist(page: Page): Promise { + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + + await setInputValue( + dialog.getByRole('textbox', { name: /Playlist URL/ }), + `${XTREAM_MOCK_SERVER}/playlist.m3u` + ); + await setInputValue( + dialog.getByRole('textbox', { name: 'Playlist title' }), + 'Self-hosted M3U' + ); + + await dialog + .getByRole('button', { name: 'Add playlist', exact: true }) + .click(); + await expect(dialog).toBeHidden(); + await page.waitForURL(/playlists.*all/); +} + async function addStalkerPortal(page: Page): Promise { await page.getByRole('button', { name: 'Add playlist' }).click(); const dialog = page.locator('mat-dialog-container'); @@ -69,6 +90,29 @@ async function addStalkerPortal(page: Page): Promise { await page.waitForURL(/stalker.*vod/); } +function collectBackendRequests(page: Page, path: string): string[] { + const requests: string[] = []; + page.on('request', (request) => { + const requestUrl = request.url(); + const url = new URL(requestUrl); + if (url.origin === WEB_BACKEND_URL && url.pathname === path) { + requests.push(requestUrl); + } + }); + return requests; +} + +function expectRequestsUseTargetId(requests: string[], path: string): void { + expect(requests.length).toBeGreaterThan(0); + for (const requestUrl of requests) { + const url = new URL(requestUrl); + expect(url.pathname).toBe(path); + expect(url.searchParams.get('targetId')).not.toBeNull(); + expect(url.searchParams.get('targetId')).not.toBe(''); + expect(url.searchParams.has('url')).toBe(false); + } +} + test.beforeEach(async ({ page, request }) => { await request.post(`${XTREAM_MOCK_SERVER}/reset`); await request.post(`${STALKER_MOCK_SERVER}/reset`); @@ -94,17 +138,62 @@ test('@self-hosted runtime config points PWA calls at the monorepo backend', asy test('@self-hosted Xtream portal loads through web-backend proxy', async ({ page, }) => { + const xtreamRequests = collectBackendRequests(page, '/xtream'); + const consoleErrors: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + await addXtreamPortal(page); + const rail = page.locator('app-workspace-shell-rail'); + await expect(rail.locator('a[aria-label="Movies"]')).toBeVisible(); + await expect(rail.locator('a[aria-label="Live TV"]')).toBeVisible(); + await expect(rail.locator('a[aria-label="Series"]')).toBeVisible(); + await expect(rail.locator('a[aria-label="Recently added"]')).toBeVisible(); + await expect(rail.locator('a[aria-label="Advanced search"]')).toBeVisible(); + const categoryItems = page.locator('.category-item'); await expect(categoryItems.first()).toBeVisible({ timeout: 15_000 }); + const vodItem = page.locator('app-grid-list mat-card').first(); + await expect(vodItem).toBeVisible({ timeout: 30_000 }); + await vodItem.click(); + await expect(page).toHaveURL(/\/workspace\/xtreams\/[^/]+\/vod\/\d+\/\d+/); + await expect( + page.getByRole('button', { name: 'Play', exact: true }) + ).toBeVisible({ timeout: 15_000 }); + + expect( + consoleErrors.filter((message) => + /db(SetAppState|GetContentByXtreamId)/.test(message) + ) + ).toEqual([]); + expectRequestsUseTargetId(xtreamRequests, '/xtream'); +}); + +test('@self-hosted M3U URL loads through web-backend proxy', async ({ + page, +}) => { + const parseRequests = collectBackendRequests(page, '/parse'); + + await addM3uPlaylist(page); + + await expect(page.getByText('4 channels')).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText('1. Channel 1')).toBeVisible(); + await expect(page.getByText('4. HappyKids TV')).toBeVisible(); + expectRequestsUseTargetId(parseRequests, '/parse'); }); test('@self-hosted Stalker portal loads through web-backend proxy', async ({ page, }) => { + const stalkerRequests = collectBackendRequests(page, '/stalker'); + await addStalkerPortal(page); const categoryItems = page.locator('.category-item'); await expect(categoryItems.first()).toBeVisible({ timeout: 15_000 }); + expectRequestsUseTargetId(stalkerRequests, '/stalker'); }); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 76e26f667..8793c3da9 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1057,6 +1057,41 @@ "GROUP_SECTION": "هذا القسم", "GROUP_RECENT": "المستخدمة مؤخرًا" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "المفضلة العامة", "SUBTITLE": "البث المباشر من جميع قوائم التشغيل", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index fedc92b2c..e3818bebd 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1057,6 +1057,41 @@ "GROUP_SECTION": "هاد القسم", "GROUP_RECENT": "مستعملة مؤخراً" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "المفضلة العامة", "SUBTITLE": "البث المباشر من جميع قوائم التشغيل", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index e10779332..28166c96e 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1057,6 +1057,41 @@ "GROUP_SECTION": "Гэты раздзел", "GROUP_RECENT": "Нядаўна выкарыстаныя" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Усе фаварыты", "SUBTITLE": "Прамы эфір з усіх плэйлістоў", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 4de9bb902..c03f42e6b 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1057,6 +1057,41 @@ "GROUP_SECTION": "Dieser Abschnitt", "GROUP_RECENT": "Kürzlich verwendet" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Globale Favoriten", "SUBTITLE": "Live-TV aus allen Playlists", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 02df9c93a..026ee5d9d 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1057,6 +1057,41 @@ "GROUP_SECTION": "Αυτή η ενότητα", "GROUP_RECENT": "Πρόσφατα χρησιμοποιημένες" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Βασικά Αγαπημένα", "SUBTITLE": "Ζωντανή TV από όλες τις λίστες αναπαραγωγής", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 3f1ab3fb5..30aa8e0b0 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1057,6 +1057,41 @@ "GROUP_SECTION": "This section", "GROUP_RECENT": "Recently used" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Global Favorites", "SUBTITLE": "Live TV from all playlists", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 915a13d36..2a46d3a5c 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1057,6 +1057,41 @@ "GROUP_SECTION": "Esta sección", "GROUP_RECENT": "Usados recientemente" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Favoritos globales", "SUBTITLE": "TV en vivo de todas las listas", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index d725f6ba9..bf5fb1041 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1057,6 +1057,41 @@ "GROUP_SECTION": "Cette section", "GROUP_RECENT": "Récemment utilisées" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Favoris globaux", "SUBTITLE": "TV en direct de toutes les listes de lecture", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 8495b34cb..e3827b296 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1057,6 +1057,41 @@ "GROUP_SECTION": "Questa sezione", "GROUP_RECENT": "Usati di recente" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Preferiti globali", "SUBTITLE": "TV in diretta da tutte le playlist", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index d0ce7368a..1bb34bd1a 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1057,6 +1057,41 @@ "GROUP_SECTION": "このセクション", "GROUP_RECENT": "最近使用" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "グローバルお気に入り", "SUBTITLE": "すべてのプレイリストのライブTV", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index bb72c1867..609035586 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1057,6 +1057,41 @@ "GROUP_SECTION": "이 섹션", "GROUP_RECENT": "최근 사용" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "전체 즐겨찾기", "SUBTITLE": "모든 재생목록의 라이브 TV", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 1130411fe..315933711 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1057,6 +1057,41 @@ "GROUP_SECTION": "Deze sectie", "GROUP_RECENT": "Recent gebruikt" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Algemene favorieten", "SUBTITLE": "Live tv uit alle afspeellijsten", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index e472e8d85..8e1b107e4 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1057,6 +1057,41 @@ "GROUP_SECTION": "Ta sekcja", "GROUP_RECENT": "Ostatnio używane" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Globalne ulubione", "SUBTITLE": "Telewizja na żywo ze wszystkich list odtwarzania", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 62fd8bd5f..c1617c139 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1057,6 +1057,41 @@ "GROUP_SECTION": "Esta seção", "GROUP_RECENT": "Usados recentemente" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Favoritos globais", "SUBTITLE": "TV ao vivo de todas as playlists", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index d601f5a73..e2d94ce57 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1057,6 +1057,41 @@ "GROUP_SECTION": "Этот раздел", "GROUP_RECENT": "Недавно использованные" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Глобальные избранные", "SUBTITLE": "Live TV из всех плейлистов", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 8e14010d4..a7e6cf1c3 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1057,6 +1057,41 @@ "GROUP_SECTION": "Bu bölüm", "GROUP_RECENT": "Son kullanılanlar" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Tüm Favoriler", "SUBTITLE": "Tüm oynatma listelerinden canlı TV", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 0fdc1f1f5..07ad2e46a 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1057,6 +1057,41 @@ "GROUP_SECTION": "本章节", "GROUP_RECENT": "最近使用" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "全局收藏", "SUBTITLE": "来自所有播放列表的直播电视", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index ca6c6a89d..0f4e9830a 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1057,6 +1057,41 @@ "GROUP_SECTION": "此區段", "GROUP_RECENT": "最近使用" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "全域我的最愛", "SUBTITLE": "來自所有播放清單的直播電視", diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index dc0e6ecb5..6470250f7 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -200,6 +200,20 @@ textarea, overflow: visible; } +.workspace-shortcuts-overlay, +.workspace-shortcuts-overlay .mat-mdc-dialog-surface, +.workspace-shortcuts-overlay .mat-mdc-dialog-container { + app-region: no-drag; +} + +.workspace-shortcuts-overlay .mat-mdc-dialog-surface { + background: transparent; + box-shadow: none; + border-radius: 14px; + padding: 0; + overflow: visible; +} + .mat-mdc-snack-bar-container.settings-snackbar { margin-bottom: calc(76px + env(safe-area-inset-bottom, 0px)); } diff --git a/apps/website/README.md b/apps/website/README.md new file mode 100644 index 000000000..031816021 --- /dev/null +++ b/apps/website/README.md @@ -0,0 +1,28 @@ +# IPTVnator Website + +The website is an Astro static site deployed to GitHub Pages at `https://4gray.github.io/iptvnator/`. + +## Blog Comments + +Blog posts render Giscus comments from `apps/website/src/components/GiscusComments.astro`. +Giscus stores comments in GitHub Discussions for `4gray/iptvnator` and maps each page to a discussion by `pathname`, including the GitHub Pages base path such as `/iptvnator/blog/why-external-players-help/`. + +The embed is wired to the dedicated `Blog comments` discussion category: + +- Repository id: `MDEwOlJlcG9zaXRvcnkyMTMxOTQ3Mzg=` +- Category id: `DIC_kwDODLUX8s4C9eBJ` +- Mapping: `pathname` +- Theme: `transparent_dark` + +If the category is recreated, query the new category id: + +```bash +gh api graphql \ + -f owner=4gray \ + -f name=iptvnator \ + -f query='query($owner:String!, $name:String!) { repository(owner:$owner, name:$name) { discussionCategories(first:25) { nodes { id name slug isAnswerable } } } }' +``` + +Then update `data-category-id` in `GiscusComments.astro`. + +Moderation happens in GitHub Discussions. Maintainers can hide, delete, lock, or move discussions and comments from the repository Discussions UI. diff --git a/apps/website/project.json b/apps/website/project.json index d4ebcea22..5b07c6306 100644 --- a/apps/website/project.json +++ b/apps/website/project.json @@ -14,6 +14,13 @@ "cwd": "apps/website" } }, + "test": { + "executor": "nx:run-commands", + "dependsOn": ["build"], + "options": { + "command": "node --test tools/testing/website-giscus-comments.test.mjs" + } + }, "serve": { "executor": "nx:run-commands", "options": { diff --git a/apps/website/public/blog/external-players/diagnostics-anatomy-v0-22.jpg b/apps/website/public/blog/external-players/diagnostics-anatomy-v0-22.jpg new file mode 100644 index 000000000..ca777771d Binary files /dev/null and b/apps/website/public/blog/external-players/diagnostics-anatomy-v0-22.jpg differ diff --git a/apps/website/public/blog/external-players/external-player-handoff.jpg b/apps/website/public/blog/external-players/external-player-handoff.jpg new file mode 100644 index 000000000..79ef60259 Binary files /dev/null and b/apps/website/public/blog/external-players/external-player-handoff.jpg differ diff --git a/apps/website/public/blog/external-players/playback-diagnostics-v0-22.jpg b/apps/website/public/blog/external-players/playback-diagnostics-v0-22.jpg new file mode 100644 index 000000000..40ed1b46d Binary files /dev/null and b/apps/website/public/blog/external-players/playback-diagnostics-v0-22.jpg differ diff --git a/apps/website/src/components/GiscusComments.astro b/apps/website/src/components/GiscusComments.astro new file mode 100644 index 000000000..614601812 --- /dev/null +++ b/apps/website/src/components/GiscusComments.astro @@ -0,0 +1,27 @@ +
+
+

+ Comments +

+
+ +
+ +
diff --git a/apps/website/src/content/blog/why-external-players-help.mdx b/apps/website/src/content/blog/why-external-players-help.mdx new file mode 100644 index 000000000..ca30134ec --- /dev/null +++ b/apps/website/src/content/blog/why-external-players-help.mdx @@ -0,0 +1,176 @@ +--- +title: Why some streams need an external player +description: "Why some IPTV streams do not play in IPTVnator's built-in browser players, and when MPV, VLC, or IINA is the right workaround." +featured: false +pubDate: 2026-05-14 +author: 4gray +heroImage: /iptvnator/screenshots/external-player-support-mpv.webp +tags: + - playback + - external-players + - diagnostics + - mpv + - vlc + - codecs +draft: false +--- + +import Alert from '../../components/blog/Alert.astro'; +import BlogImageSlider from '../../components/blog/BlogImageSlider.astro'; +import LinkCards from '../../components/blog/LinkCards.astro'; + +Some IPTV streams work perfectly in one player and fail in another. That can be confusing, especially when the same playlist opens in VLC, MPV, Kodi, or a TV app but does not start inside IPTVnator's built-in players. + +The short version: IPTVnator's internal players run inside Electron/Chromium, so they inherit browser media support and browser security rules. That makes them convenient and integrated, but it also means they cannot play every codec, container, protocol, DRM setup, or stream variant that a native media player can handle. + + + + + IPTVnator does not provide, sell, host, or redistribute any playlists, + channels, subscriptions, portals, EPG data, or streams. It is only a media + player for sources provided by the user. Please use it only with content you + are authorized to access, and do not share private playlist URLs or + credentials in public bug reports. + + +## Why the built-in players may fail + +The built-in Video.js, ArtPlayer, and HTML5-based players are browser-based. They are great for many HLS and web-friendly streams, but they are still limited by what Chromium can decode and what the browser playback stack allows. + +Common cases where this matters: + +- unsupported audio codecs such as AC3 or MPEG audio in some streams; +- video formats such as MPEG-TS, HEVC/H.265, high-bitrate 4K, or unusual HLS variants; +- DASH/MPD, DRM-protected, or provider-specific streams; +- streams that require behavior normally handled by a full native media player; +- platform-specific Chromium/Electron differences between Windows, Linux, macOS, and the web app. + +When this happens, the playlist itself may be valid and the stream may still work in VLC or MPV. That does not always mean IPTVnator can make the internal browser player decode it. + +## New in version 0.22: playback diagnostics + +Starting with IPTVnator 0.22, the app shows a clearer diagnostic screen when a stream cannot be played by the selected built-in browser player. + +Instead of only showing a generic media error, IPTVnator can explain the likely reason, for example that the stream codec is probably unsupported by the Chromium player. The diagnostics view can also show technical details such as the selected player, detected container, MIME type, native error code, and native browser error message. + +When the problem looks like a browser-player limitation, IPTVnator offers quick actions to open the same stream in external players such as MPV or VLC, plus an option to copy the stream URL. This makes it easier to understand whether the issue is a playlist/source problem, an IPTVnator bug, or simply a codec/container that needs a native player. + + + +This diagnostic flow was introduced in [PR #939](https://github.com/4gray/iptvnator/pull/939). + +## The recommended workaround: use an external player + +This is exactly why IPTVnator supports external players. + +If a stream does not work in the built-in player, try opening it with: + +- **MPV** — lightweight, powerful, and very codec-friendly; +- **VLC** — widely installed and handles many IPTV edge cases; +- **IINA** — a macOS-friendly player based on MPV. + +External players use their own native playback engines instead of Chromium's media stack. For many streams, that is the simplest and most reliable solution. + + + +## Another benefit: multiple streams at once + +External players are also useful when you want more control over your desktop. + +Because MPV, VLC, or IINA open their own windows, you can start multiple streams in parallel and arrange them however you like: side by side, on another monitor, in a corner of the screen, or across a multi-display setup. + +That workflow is especially useful for monitoring several live channels at the same time. + +## When should I report an issue? + +Please still report playback problems when they look like IPTVnator bugs, but include enough detail to separate app issues from stream compatibility limits: + +- IPTVnator version; +- operating system; +- selected player: Video.js, ArtPlayer, HTML5, MPV, VLC, IINA, or embedded MPV; +- stream type if known: HLS, MPEG-TS, DASH/MPD, live TV, VOD, series; +- whether the same stream works in VLC, MPV, IINA, or another player; +- any visible error message. + + + If your playlist URL contains credentials, tokens, a private portal address, + or subscription details, do not paste it into a public GitHub issue. Share a + public sample stream when possible, or describe the format and error + instead. + + +## Useful links + + + +## Summary + +IPTVnator will keep improving the built-in playback experience, but no Electron/Chromium-based player can guarantee support for every IPTV stream in the wild. + +If a stream fails internally but works in VLC, MPV, or IINA, using an external player is not a hack — it is one of the intended playback paths in IPTVnator. diff --git a/apps/website/src/layouts/BlogPost.astro b/apps/website/src/layouts/BlogPost.astro index 27475b8dc..1a0dcbb45 100644 --- a/apps/website/src/layouts/BlogPost.astro +++ b/apps/website/src/layouts/BlogPost.astro @@ -1,5 +1,6 @@ --- import BaseLayout from './BaseLayout.astro'; +import GiscusComments from '../components/GiscusComments.astro'; interface Props { title: string; @@ -150,6 +151,8 @@ const blogPostSchema = { > + + diff --git a/apps/xtream-mock-server/src/main.ts b/apps/xtream-mock-server/src/main.ts index 34d8b8e18..648a15c34 100644 --- a/apps/xtream-mock-server/src/main.ts +++ b/apps/xtream-mock-server/src/main.ts @@ -9,6 +9,16 @@ import { renderMarketingAssetSvg } from './app/generators/marketing.generator.js const app = express(); const PORT = parseInt(process.env['PORT'] ?? '3211', 10); +const M3U_FIXTURE = `#EXTM3U +#EXTINF:0 tvg-id="1" tvg-logo="http://channel.icons.url/img/1.png" group-title="News", Channel 1 +https://example.channels/path-to-file/1.m3u8 +#EXTINF:0 tvg-id="2" tvg-logo="http://channel.icons.url/img/2.png" group-title="News", Positive News TV +https://example.channels/path-to-file/2.m3u8 +#EXTINF:0 tvg-id="3" tvg-logo="http://channel.icons.url/img/3.png" group-title="Sport", Sport TVX +https://example.channels/path-to-file/3.m3u8 +#EXTINF:0 tvg-id="4" tvg-logo="http://channel.icons.url/img/4.png" group-title="Kids", HappyKids TV +https://example.channels/path-to-file/4.m3u8 +`; const marketingRasterAssetRoot = join( process.cwd(), 'apps/xtream-mock-server/public/marketing' @@ -28,6 +38,13 @@ app.post('/reset', (_req, res) => { res.json({ status: 'reset' }); }); +// ─── M3U fixture endpoint for self-hosted PWA URL import tests ─────────────── +app.get('/playlist.m3u', (_req, res) => { + res.type('audio/x-mpegurl') + .set('Cache-Control', 'no-store') + .send(M3U_FIXTURE); +}); + // ─── Local fictional artwork for release screenshots ────────────────────────── app.get('/assets/marketing/:kind/:slug', (req, res) => { const kind = req.params['kind'] as @@ -46,18 +63,20 @@ app.get('/assets/marketing/:kind/:slug', (req, res) => { } const rasterSlug = slug.replace(/\.(svg|png)$/i, ''); - const rasterPath = join(marketingRasterAssetRoot, kind, `${rasterSlug}.png`); + const rasterPath = join( + marketingRasterAssetRoot, + kind, + `${rasterSlug}.png` + ); if (existsSync(rasterPath)) { - res - .type('image/png') + res.type('image/png') .set('Cache-Control', 'public, max-age=3600') .send(readFileSync(rasterPath)); return; } - res - .type('image/svg+xml') + res.type('image/svg+xml') .set('Cache-Control', 'public, max-age=3600') .send(renderMarketingAssetSvg(kind, slug, size)); }); @@ -149,8 +168,12 @@ server.on('error', (err: NodeJS.ErrnoException) => { server.listen(PORT, () => { console.log(`[xtream-mock] Listening on http://localhost:${PORT}`); - console.log(`[xtream-mock] Direct API: http://localhost:${PORT}/player_api.php?username=user1&password=pass1&action=get_account_info`); - console.log(`[xtream-mock] PWA proxy: http://localhost:${PORT}/xtream?url=http://localhost:${PORT}&username=user1&password=pass1&action=get_account_info`); + console.log( + `[xtream-mock] Direct API: http://localhost:${PORT}/player_api.php?username=user1&password=pass1&action=get_account_info` + ); + console.log( + `[xtream-mock] PWA proxy: http://localhost:${PORT}/xtream?url=http://localhost:${PORT}&username=user1&password=pass1&action=get_account_info` + ); console.log(`[xtream-mock] Health: http://localhost:${PORT}/health`); }); @@ -173,4 +196,6 @@ process.on('unhandledRejection', (reason) => { // When Nx (or any process manager) closes stdin, prevent auto-exit. // The HTTP server handle is what keeps the process alive. process.stdin.resume(); -process.stdin.on('end', () => { /* ignore stdin close */ }); +process.stdin.on('end', () => { + /* ignore stdin close */ +}); diff --git a/docker/Dockerfile b/docker/Dockerfile index 04fe1d685..240597b51 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,32 +1,40 @@ -# Stage 1 - build environment FROM node:22-alpine AS build RUN apk add --no-cache python3 make g++ git -# Create app directory WORKDIR /usr/src/app -# Swtich to node user -#RUN chown node:node ./ -#USER node - COPY .npmrc ./ COPY package.json pnpm-lock.yaml ./ +COPY patches ./patches -# Install app dependencies -RUN corepack enable && pnpm install --frozen-lockfile +RUN corepack enable && pnpm install --frozen-lockfile --ignore-scripts -# Copy all required files COPY . . -# Build the application -RUN pnpm run build:web +RUN pnpm nx build web --configuration=pwa +RUN pnpm nx build web-backend -# Stage 2 - the production environment -FROM nginx:stable-alpine +FROM node:22-alpine -# Copy artifacts and nignx.conf -COPY --from=build /usr/src/app/dist/browser /usr/share/nginx/html -COPY --from=build /usr/src/app/docker/nginx.conf /etc/nginx/conf.d/default.conf +RUN apk add --no-cache gettext nginx -CMD sed -i "s#http://localhost:3333#$BACKEND_URL#g" /usr/share/nginx/html/main.js && nginx -g 'daemon off;' +WORKDIR /opt/iptvnator + +ENV PORT=3000 +ENV BACKEND_URL=/api +ENV CLIENT_URL=http://localhost:4333 + +COPY --from=build /usr/src/app/dist/apps/web /usr/share/nginx/html +COPY --from=build /usr/src/app/dist/apps/web-backend ./web-backend +COPY docker/nginx.conf /etc/nginx/http.d/default.conf.template +COPY docker/docker-entrypoint.sh /usr/local/bin/iptvnator-entrypoint + +RUN chmod +x /usr/local/bin/iptvnator-entrypoint + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://127.0.0.1/api/health >/dev/null || exit 1 + +CMD ["iptvnator-entrypoint"] diff --git a/docker/README.md b/docker/README.md index 120944a0f..7906c6fa8 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,20 +1,113 @@ -# Self-hosted version of IPTVnator +# Self-hosted IPTVnator -You can deploy and run the PWA version of IPTVnator on your own machine with `docker-compose` using the following command: +The self-hosted image contains both pieces required for the browser PWA: - $ cd docker - $ docker-compose up -d +- Angular PWA static files served by nginx +- The monorepo `web-backend` Express app proxied under `/api` -This command will launch the frontend and backend applications. By default, the application will be available at: http://localhost:4333/. The ports can be configured in the `docker-compose.yml` file. +The historical standalone `4gray/iptvnator-backend` image is no longer needed +for the default Docker deployment. -The web backend proxy accepts only `http` and `https` provider URLs. The PWA first registers provider URLs through `/provider-targets`, then uses the returned `targetId` for playlist, Xtream, and Stalker proxy calls. The backend blocks loopback, private, link-local, and reserved network targets by default to avoid exposing the self-hosted server as a generic internal-network fetcher. If you intentionally need to test against local mock servers or LAN-only IPTV sources, set `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1` on the backend container and avoid exposing that instance to untrusted users. +## Run With Docker Compose -For providers that use private certificate authorities, keep TLS validation enabled and pass the CA bundle to Node with `NODE_EXTRA_CA_CERTS=/path/to/ca.pem`. +From the repository root: -## Build frontend +```bash +docker compose -f docker/docker-compose.yml up --build -d +``` - $ docker build -t 4gray/iptvnator -f docker/Dockerfile . +The ready-to-run compose file is [`docker-compose.yml`](./docker-compose.yml). +By default the app is available at . No additional +environment variables, backend repository checkout, or separate backend +container are required for the default local deployment. -## Build backend +## Build The Image -You can find the backend app with all instructions in a separate GitHub repository - https://github.com/4gray/iptvnator-backend +```bash +docker build -t 4gray/iptvnator -f docker/Dockerfile . +``` + +The image build runs: + +```bash +pnpm nx build web --configuration=pwa +pnpm nx build web-backend +``` + +## Published Docker Tags + +Pull request builds validate the Dockerfile without pushing an image. Docker +Hub publishing happens only from trusted repository events. +Publishing requires the repository secrets `DOCKERHUB_USERNAME` and +`DOCKERHUB_TOKEN`; pull request builds and default manual runs do not use those +secrets. + +| Tag pattern | Published from | Use case | +| -------------------------- | ------------------------ | ------------------------------------------------------------------------- | +| `latest` | `master` pushes | Default self-hosted image for users who want the newest merged PWA build. | +| `-pwa` | `master` pushes | Latest PWA image for the current `package.json` version. | +| `-pwa-` | `master` pushes | Immutable PWA image for a specific merged commit within a version. | +| `sha-` | `master` pushes | Commit-addressable image, useful for rollback and support diagnostics. | +| `` / `v` | `v*` release tags | Release image aligned with a repository release tag. | +| `stable` | Stable `v*` release tags | Most recent non-prerelease tagged release image. | +| `manual-` | Manual runs with `push` | Explicit maintainer-triggered rebuilds outside normal publish events. | + +Use `latest` for the simplest self-hosted setup. Pin `sha-` or +`-pwa-` when you need reproducible deployments. Use release tags +when you want the Docker image to track a tagged IPTVnator release rather than +every merge to `master`. + +## Runtime Configuration + +The container writes `/usr/share/nginx/html/assets/app-config.js` on startup. +That file sets `window.__IPTVNATOR_CONFIG__.BACKEND_URL`, which the PWA reads +before it creates `PwaService`. + +These variables are supported by the Docker image. The compose file sets the +safe local defaults shown below. + +| Variable | Default | Purpose | +| ---------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `BACKEND_URL` | `/api` | Browser-facing backend URL used by the PWA. Keep `/api` for the bundled nginx proxy. | +| `CLIENT_URL` | `http://localhost:4333` | Allowed browser origin for backend CORS. Use the public URL when hosting behind a reverse proxy. Multiple origins can be comma-separated. | +| `PORT` | `3000` | Internal Express backend port. nginx proxy config is rendered from the template to match it at startup. | +| `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS` | `0` | Set to `1` or `true` only for trusted local/LAN deployments that intentionally proxy private network IPTV or mock endpoints. | +| `NODE_EXTRA_CA_CERTS` | unset | Optional Node.js CA bundle path for providers using private certificate authorities. Mount the CA file into the container and set this path. | + +The web backend proxy accepts only `http` and `https` provider URLs. The PWA +first registers provider URLs through `/provider-targets`, then uses the +returned `targetId` for playlist, Xtream, and Stalker proxy calls. The backend +blocks loopback, private, link-local, and reserved network targets by default so +a publicly exposed instance cannot be used as a generic internal-network +fetcher. If you enable `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1`, keep the +instance restricted to trusted users. + +For providers that use private certificate authorities, keep TLS validation +enabled and pass the CA bundle to Node: + +```yaml +services: + iptvnator: + volumes: + - ./ca.pem:/etc/ssl/private/provider-ca.pem:ro + environment: + NODE_EXTRA_CA_CERTS: /etc/ssl/private/provider-ca.pem +``` + +The entrypoint renders the nginx config from `docker/nginx.conf`, starts the +backend, waits for `/health`, and only then starts nginx. The nginx config +serves the PWA with SPA fallback, avoids caching `assets/app-config.js`, and +proxies `/api/*` to the internal backend. The Dockerfile and compose file both +define a health check against `/api/health`. If nginx or the backend exits +after startup, the entrypoint exits the container so Docker Compose can apply +the `restart: unless-stopped` policy. + +## Local Validation + +```bash +pnpm nx test web-backend +pnpm nx build web --configuration=pwa --skip-nx-cache +pnpm nx build web-backend +pnpm nx run web-e2e:e2e -- --project=chromium --grep @self-hosted +docker compose -f docker/docker-compose.yml config +``` diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index d36ab1c08..4e03f53bc 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,18 +1,21 @@ --- services: - backend: - image: 4gray/iptvnator-backend:latest - ports: - - "7333:3000" - environment: - - CLIENT_URL=http://localhost:4333 - # this one should match with the address and port in frontend CLIENT_URL env - - frontend: + iptvnator: image: 4gray/iptvnator:latest + build: + context: .. + dockerfile: docker/Dockerfile + restart: unless-stopped ports: - "4333:80" environment: - - BACKEND_URL=http://localhost:7333 - # this one should match with the address of the backend service - + BACKEND_URL: /api + CLIENT_URL: http://localhost:4333 + PORT: "3000" + IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS: "0" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/api/health >/dev/null || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh new file mode 100644 index 000000000..2ca44aeda --- /dev/null +++ b/docker/docker-entrypoint.sh @@ -0,0 +1,109 @@ +#!/bin/sh +set -eu + +export PORT="${PORT:-3000}" +export BACKEND_URL="${BACKEND_URL:-/api}" + +envsubst '${PORT}' < /etc/nginx/http.d/default.conf.template > /etc/nginx/http.d/default.conf + +node <<'NODE' +const fs = require('node:fs'); + +fs.writeFileSync( + '/usr/share/nginx/html/assets/app-config.js', + `window.__IPTVNATOR_CONFIG__ = ${JSON.stringify( + { BACKEND_URL: process.env.BACKEND_URL || '/api' }, + null, + 2 + )};\n` +); +NODE + +node /opt/iptvnator/web-backend/main.cjs & +BACKEND_PID=$! + +cleanup_done=0 +cleanup() { + if [ "$cleanup_done" -eq 1 ]; then + return + fi + + cleanup_done=1 + trap - INT TERM EXIT + + if [ -n "${NGINX_PID:-}" ]; then + kill "$NGINX_PID" 2>/dev/null || true + wait "$NGINX_PID" 2>/dev/null || true + fi + + if [ -n "${BACKEND_PID:-}" ]; then + kill "$BACKEND_PID" 2>/dev/null || true + wait "$BACKEND_PID" 2>/dev/null || true + fi +} + +handle_signal() { + cleanup + exit 0 +} + +process_is_running() { + if [ ! -r "/proc/$1/stat" ]; then + return 1 + fi + + state="$(awk '{ print $3 }' "/proc/$1/stat" 2>/dev/null || true)" + [ -n "$state" ] && [ "$state" != "Z" ] +} + +trap handle_signal INT TERM +trap cleanup EXIT + +backend_ready=0 + +for _ in $(seq 1 30); do + if wget -qO- "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; then + backend_ready=1 + break + fi + + if ! process_is_running "$BACKEND_PID"; then + set +e + wait "$BACKEND_PID" + BACKEND_STATUS=$? + set -e + exit "$BACKEND_STATUS" + fi + + sleep 1 +done + +if [ "$backend_ready" -ne 1 ]; then + echo "IPTVnator web backend did not become healthy on port ${PORT}." + exit 1 +fi + +nginx -g 'daemon off;' & +NGINX_PID=$! + +while :; do + if ! process_is_running "$BACKEND_PID"; then + set +e + wait "$BACKEND_PID" + EXIT_STATUS=$? + set -e + echo "IPTVnator web backend exited with status ${EXIT_STATUS}." + exit "$EXIT_STATUS" + fi + + if ! process_is_running "$NGINX_PID"; then + set +e + wait "$NGINX_PID" + EXIT_STATUS=$? + set -e + echo "nginx exited with status ${EXIT_STATUS}." + exit "$EXIT_STATUS" + fi + + sleep 1 +done diff --git a/docker/nginx.conf b/docker/nginx.conf index 3eacd9674..1abb8aea8 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -1,11 +1,26 @@ server { - listen 80; - - location / { - root /usr/share/nginx/html; - index index.html index.htm; - try_files $uri $uri/ /index.html =404; - } - - include /etc/nginx/extra-conf.d/*.conf; -} \ No newline at end of file + listen 80; + listen [::]:80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://127.0.0.1:${PORT}/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /assets/app-config.js { + add_header Cache-Control "no-store"; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/docs/architecture/date-handling.md b/docs/architecture/date-handling.md index 467bf4fec..363cccffa 100644 --- a/docs/architecture/date-handling.md +++ b/docs/architecture/date-handling.md @@ -12,14 +12,26 @@ - Date display should follow the user-selected app language from `TranslateService`. - When a template renders localized month or weekday names, pass the normalized app locale explicitly to `DatePipe`. -- Angular locale data is registered in [apps/web/src/app/app-date-locales.ts](/Users/4gray/Code/iptvnator/apps/web/src/app/app-date-locales.ts). -- App language aliases are normalized in [libs/ui/pipes/src/lib/date-format.util.ts](/Users/4gray/Code/iptvnator/libs/ui/pipes/src/lib/date-format.util.ts): - - `ary` -> `ar-MA` - - `by` -> `be` - - `zhtw` -> `zh-Hant` +- Angular locale data is registered in [apps/web/src/app/app-date-locales.ts](/apps/web/src/app/app-date-locales.ts). +- App language aliases are normalized in [libs/ui/pipes/src/lib/date-format.util.ts](/libs/ui/pipes/src/lib/date-format.util.ts): + - `ary` -> `ar-MA` + - `by` -> `be` + - `zhtw` -> `zh-Hant` ## Parsing Boundaries - Normalize provider-specific or legacy date strings to ISO as early as possible. - Keep optional epoch fields such as `startTimestamp` and `stopTimestamp` when the provider already supplies them. - Avoid new non-standard `Date.parse(...)` usage for provider formats; prefer explicit `date-fns` parsing when the input is not ISO. +- Xtream `added` / `last_modified` values are provider-supplied epoch fields. + Normalize them through `toXtreamRecentlyAddedTimestamp()` / + `toXtreamRecentlyAddedEpochSeconds()` from `@iptvnator/shared/interfaces` + before ranking or storing recently-added content. Values more than 24 hours + in the future are treated as invalid so provider placeholders such as + `2030-01-01` cannot permanently pin the top of recently-added rails. + Recently-added ranking uses `added` before `last_modified` for live/VOD + content and `last_modified` before `added` for series. The VOD/live fallback + is intentional for providers that omit `added` but still expose a valid + `last_modified` epoch. Legacy cached millisecond epochs in `content.added` + are migrated to epoch seconds during database startup so indexed dashboard + queries can continue to compare and sort text timestamps directly. diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md new file mode 100644 index 000000000..6920af21b --- /dev/null +++ b/docs/architecture/pwa-self-hosted.md @@ -0,0 +1,150 @@ +# PWA Self-hosted Architecture + +This document describes the browser PWA and self-hosted Docker path. + +## Ownership + +- `apps/web` owns the Angular browser UI and PWA service worker configuration. +- `apps/web-backend` owns the browser-only backend proxy for remote playlist, + Xtream, and Stalker requests. +- `docker/` owns the production self-hosted image that bundles the PWA and + `web-backend` into one container. + +The old external `4gray/iptvnator-backend` repository is not required for the +default self-hosted deployment. Sync behavior from that repository only when a +change intentionally restores or imports missing backend capabilities. + +## Runtime Backend Configuration + +The PWA reads `window.__IPTVNATOR_CONFIG__.BACKEND_URL` through +`apps/web/src/app/services/runtime-config.ts`. The static placeholder lives at +`apps/web/src/assets/app-config.js` and keeps hosted builds working without +Docker-specific values. + +The Docker entrypoint rewrites `assets/app-config.js` at container startup. +`ngsw-config.json` explicitly excludes this file from Angular service worker +asset hashing so a runtime rewrite does not break cache validation. + +## Service Worker Build + +Use the PWA build configuration for browser deployments: + +```bash +pnpm nx build web --configuration=pwa +``` + +The build must emit these files in `dist/apps/web`: + +- `ngsw-worker.js` +- `ngsw.json` +- `safety-worker.js` +- `worker-basic.min.js` + +`web:serve-static` serves `dist/apps/web` and builds with `web:build:pwa`, so it +exercises the same output layout as Docker. If Nx daemon state returns stale +service worker outputs while changing build options, run: + +```bash +pnpm nx reset +pnpm nx build web --configuration=pwa --skip-nx-cache +``` + +## Web Backend + +The current self-hosted PWA uses these `apps/web-backend` routes: + +- `GET /health` +- `GET /config.js` +- `POST /provider-targets` with `{ "url": "" }` +- `GET /parse?targetId=` +- `GET /xtream?targetId=&username=&password=

&action=` +- `GET /stalker?targetId=&macAddress=&action=` + +The PWA continues to use `PwaService`; only the backend base URL is resolved at +runtime. Electron routes remain owned by the Electron backend and preload +bridge. + +EPG/XMLTV is not a supported self-hosted PWA capability yet. Do not document it +as part of the Docker user flow or use it as the readiness signal for this path. + +Provider URLs are registered before proxy calls so the proxy endpoints do not +accept raw target URLs in query strings. Registration validates the target URL +before any outbound request: + +- only `http:` and `https:` provider URLs are accepted +- URL credentials are rejected +- loopback, private, link-local, and reserved network targets are blocked by + default +- `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1` explicitly enables trusted + local/LAN targets for development, mock servers, or private deployments + +Do not disable TLS certificate validation in the backend proxy. For private +certificate authorities, configure Node with `NODE_EXTRA_CA_CERTS`. + +## PWA Portal User Data + +Xtream favorites and recently viewed items use the browser-side +`PwaXtreamDataSource` when Electron DB preload APIs are unavailable. The PWA +stores this user activity in localStorage: + +- `xtream-favorites` +- `xtream-recent-items` + +Entries should include a content snapshot when the item is added. Global +collection routes and the dashboard can then restore titles, posters, content +type, and category IDs after navigation or a page reload without relying on the +Electron SQLite content table. + +Shared collection services must not import `@iptvnator/portal/xtream/data-access` +directly. Use `XTREAM_COLLECTION_DATA_SOURCE` from +`@iptvnator/portal/shared/util` and bind it to `XTREAM_DATA_SOURCE` at the app +provider boundary (`apps/web/src/app/app.config.ts`). This keeps +`portal-shared-util` provider-neutral and avoids adding new Nx boundary cycles. + +## Docker Runtime + +The Docker image has two stages: + +1. Build stage installs dependencies and runs `web:pwa` plus `web-backend`. +2. Runtime stage uses `node:22-alpine` with nginx installed. nginx serves + `dist/apps/web` and proxies `/api/*` to the local Express backend. + The entrypoint renders the nginx config from a `${PORT}` template, starts the + backend, waits for `/health`, and then starts nginx. If either process exits + after startup, the entrypoint exits the container so the compose restart + policy can recover the service. + +Default runtime values: + +- `BACKEND_URL=/api` +- `CLIENT_URL=http://localhost:4333` +- `PORT=3000` +- `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=0` +- `NODE_EXTRA_CA_CERTS` unset + +When hosting behind another domain, set `CLIENT_URL` to the browser origin and +keep `BACKEND_URL=/api` unless the reverse proxy exposes the backend elsewhere. +Only set `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1` when the self-hosted +instance is restricted to trusted users and intentionally needs private network +IPTV targets. For providers using private certificate authorities, mount the CA +bundle into the container and set `NODE_EXTRA_CA_CERTS` to that mounted path. + +## Validation + +Use the narrow validation ladder for self-hosted changes: + +```bash +pnpm nx test web-backend +pnpm nx test web --runTestsByPath apps/web/src/app/services/runtime-config.spec.ts +pnpm nx build web --configuration=pwa --skip-nx-cache +pnpm nx build web-backend +pnpm nx run web-e2e:e2e -- --project=chromium --grep @self-hosted +docker compose -f docker/docker-compose.yml config +``` + +Run `docker build -t iptvnator:self-hosted-test -f docker/Dockerfile .` when a +Docker daemon is available. + +For manual Docker smoke testing, run the Xtream and Stalker mock servers plus a +small M3U fixture, then verify in the browser that M3U, Xtream, and Stalker can +add sources, play an item, toggle favorites, populate global favorites, +populate recently viewed, and appear on the dashboard rails. diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index b3e37bd75..67d9fe3a8 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -150,6 +150,24 @@ Command palette behavior is shell-owned but view-extensible: value is disabled. The new player setting applies to the next playback session; an existing stream is not re-mounted. +Keyboard shortcut help is shell-owned: + +1. `WorkspaceKeyboardShortcutsService` is provided by `WorkspaceShellComponent`. + It owns the workspace-scoped `document:keydown` listener for `?` / + `Shift+/`. +2. The listener ignores events from inputs, textareas, selects, and + content-editable elements via `isTypingInInput(...)`. +3. `libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts` is the + metadata registry for shortcuts shown in the help dialog and documented in + README. `keyboard-shortcuts.ts` owns the display transformation and help + trigger detection. + Shortcuts that only work through the Electron bridge, such as embedded MPV + controls, must set `electronOnly: true` so the PWA dialog does not advertise + unavailable commands. +4. New custom shortcuts should be added to that registry when the handler is + added. Do not include native browser/editor behavior such as `Tab` or + platform text editing shortcuts. + ## Maintenance Guidance Use this document as the source of truth when changing workspace shell behavior. @@ -160,5 +178,7 @@ Use this document as the source of truth when changing workspace shell behavior. not duplicated inside the shell. 3. If a provider route changes how playlist/session bootstrap works, update the route-session provider and shell-facing route contract together. -4. Historical migration notes, cleanup lists, and one-off refactor steps should +4. When adding a non-native keyboard shortcut, update the shared shortcuts + registry, the help dialog tests, README, and the closest behavior test. +5. Historical migration notes, cleanup lists, and one-off refactor steps should stay out of this file; track them in issues or PR notes instead. diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index 7e52683b2..a41f89880 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -95,6 +95,16 @@ Response: `{ payload: , action: }` This mirrors the backend proxy in `apps/electron-backend` so the same Angular service code works in both environments. +### M3U fixture endpoint + +``` +GET /playlist.m3u +``` + +Returns a small deterministic four-channel playlist. The self-hosted PWA E2E +suite uses this endpoint to verify M3U URL imports through `apps/web-backend` +and the provider target registry. + ### Stream stub endpoints ``` diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts index 91826b2af..79f3a90d8 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts @@ -10,6 +10,7 @@ import { StalkerPortalItem, } from '@iptvnator/shared/interfaces'; import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; +import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access'; import { UnifiedFavoritesDataService } from './unified-favorites-data.service'; describe('UnifiedFavoritesDataService', () => { @@ -24,6 +25,12 @@ describe('UnifiedFavoritesDataService', () => { getContentByXtreamId: jest.Mock; getFavorites: jest.Mock; }; + let xtreamDataSource: { + addFavorite: jest.Mock; + getContentByXtreamId: jest.Mock; + getFavorites: jest.Mock; + removeFavorite: jest.Mock; + }; let store: { dispatch: jest.Mock; select: jest.Mock; @@ -111,6 +118,12 @@ describe('UnifiedFavoritesDataService', () => { getContentByXtreamId: jest.fn().mockResolvedValue(null), getFavorites: jest.fn().mockResolvedValue([]), }; + xtreamDataSource = { + addFavorite: jest.fn().mockResolvedValue(undefined), + getContentByXtreamId: jest.fn().mockResolvedValue(null), + getFavorites: jest.fn().mockResolvedValue([]), + removeFavorite: jest.fn().mockResolvedValue(undefined), + }; store = { dispatch: jest.fn(), select: jest.fn(() => @@ -141,6 +154,10 @@ describe('UnifiedFavoritesDataService', () => { provide: DatabaseService, useValue: databaseService, }, + { + provide: XTREAM_DATA_SOURCE, + useValue: xtreamDataSource, + }, { provide: PlaylistsService, useValue: playlistsService, @@ -223,6 +240,80 @@ describe('UnifiedFavoritesDataService', () => { ); }); + it('loads Xtream playlist favorites through the active data source in PWA', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + store.select.mockReturnValue( + of([ + { + _id: 'xtream-1', + title: 'Xtream PWA', + serverUrl: 'https://example.com', + } satisfies Partial, + ]) + ); + xtreamDataSource.getFavorites.mockResolvedValue([ + { + id: 202, + category_id: 20, + title: 'Movie One', + type: 'movie', + poster_url: 'movie.png', + xtream_id: 202, + added_at: '2026-05-21T12:00:00.000Z', + }, + ]); + + const items = await service.getFavorites( + 'playlist', + 'xtream-1', + 'xtream' + ); + + expect(xtreamDataSource.getFavorites).toHaveBeenCalledWith('xtream-1'); + expect(databaseService.getFavorites).not.toHaveBeenCalled(); + expect(items).toEqual([ + expect.objectContaining({ + uid: 'xtream::xtream-1::movie:202', + sourceType: 'xtream', + contentType: 'movie', + name: 'Movie One', + playlistName: 'Xtream PWA', + posterUrl: 'movie.png', + }), + ]); + }); + + it('does not load Stalker portals through the PWA Xtream global favorites path', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + store.select.mockReturnValue( + of([ + { + _id: 'xtream-1', + title: 'Xtream PWA', + serverUrl: 'https://xtream.example.com', + }, + { + _id: 'stalker-1', + title: 'Stalker Portal', + serverUrl: 'https://stalker.example.com', + macAddress: '00:11:22:33:44:55', + favorites: stalkerFavorites, + }, + ] satisfies Partial[]) + ); + + await service.getFavorites('all'); + + expect(xtreamDataSource.getFavorites).toHaveBeenCalledTimes(1); + expect(xtreamDataSource.getFavorites).toHaveBeenCalledWith('xtream-1'); + }); + it('preserves persisted M3U favorites order when extracting playlist favorites', async () => { playlistsService.getPlaylistById.mockReturnValue( of({ @@ -400,6 +491,37 @@ describe('UnifiedFavoritesDataService', () => { ); }); + it('uses the Xtream id as the favorite key in PWA when cached content is cold', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + xtreamDataSource.getContentByXtreamId.mockResolvedValue(null); + + await service.addFavorite({ + uid: 'xtream::xtream-1::movie:101', + name: 'Xtream Movie', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream One', + posterUrl: 'movie.png', + xtreamId: 101, + } satisfies UnifiedCollectionItem); + + expect(xtreamDataSource.getContentByXtreamId).toHaveBeenCalledWith( + 101, + 'xtream-1', + 'movie' + ); + expect(xtreamDataSource.addFavorite).toHaveBeenCalledWith( + 101, + 'xtream-1', + 'movie.png' + ); + expect(electronApi.dbAddFavorite).not.toHaveBeenCalled(); + }); + it('adds Stalker favorites through portal favorites', async () => { await service.addFavorite({ uid: 'stalker::stalker-1::101', 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 cb53d8c00..ee40affa2 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 @@ -25,6 +25,10 @@ import { xtreamContentType, XtreamFavoriteRow, } from '@iptvnator/portal/shared/util'; +import { + XTREAM_DATA_SOURCE, + XtreamContentItem, +} from '@iptvnator/portal/xtream/data-access'; const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1'; @@ -43,6 +47,7 @@ export class UnifiedFavoritesDataService { private readonly dbService = inject(DatabaseService); private readonly playlistsService = inject(PlaylistsService); private readonly translate = inject(TranslateService); + private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE); async getFavorites( scope: CollectionScope, @@ -86,12 +91,20 @@ export class UnifiedFavoritesDataService { break; } case 'xtream': - if (window.electron && item.contentId != null) { + if (item.contentId == null) { + return; + } + if (window.electron) { await window.electron.dbRemoveFavorite( item.contentId, item.playlistId ); + return; } + await this.xtreamDataSource.removeFavorite( + item.contentId, + item.playlistId + ); break; case 'stalker': { const sourceItemId = item.uid.split('::')[2]; @@ -134,26 +147,25 @@ export class UnifiedFavoritesDataService { private async addXtreamFavorite( item: UnifiedCollectionItem ): Promise { - if (!window.electron) { - return; - } - const contentId = item.contentId ?? (item.xtreamId != null - ? ( - await this.dbService.getContentByXtreamId( - item.xtreamId, - item.playlistId, - item.contentType - ) - )?.id + ? await this.resolveXtreamContentId(item) : null); if (contentId == null) { return; } + if (!window.electron) { + await this.xtreamDataSource.addFavorite( + contentId, + item.playlistId, + item.posterUrl ?? item.logo ?? undefined + ); + return; + } + await window.electron.dbAddFavorite( contentId, item.playlistId, @@ -346,6 +358,22 @@ export class UnifiedFavoritesDataService { ): Promise { const electron = window.electron; if (!electron) { + await Promise.all( + items + .filter( + ( + item + ): item is UnifiedCollectionItem & { + readonly contentId: number; + } => item.contentId != null + ) + .map((item) => + this.xtreamDataSource.removeFavorite( + item.contentId, + item.playlistId + ) + ) + ); return; } @@ -486,6 +514,19 @@ export class UnifiedFavoritesDataService { } private async getXtreamAllFavorites(): Promise { + if (!window.electron) { + const allMeta = await this.getAllMeta(); + const results: UnifiedCollectionItem[] = []; + for (const meta of allMeta.filter( + (p) => p._id && this.isXtreamPlaylist(p) + )) { + results.push( + ...(await this.getXtreamPlaylistFavorites(meta._id)) + ); + } + return results; + } + if (!window.electron?.dbGetAllGlobalFavorites) return []; try { const rows = @@ -499,10 +540,17 @@ export class UnifiedFavoritesDataService { private async getXtreamPlaylistFavorites( playlistId: string ): Promise { - if (!window.electron) return []; try { - const rows = await this.dbService.getFavorites(playlistId); const meta = await this.getPlaylistMeta(playlistId); + if (!window.electron) { + const rows = + await this.xtreamDataSource.getFavorites(playlistId); + return rows.map((row) => + this.mapXtreamContentItem(row, playlistId, meta?.title) + ); + } + + const rows = await this.dbService.getFavorites(playlistId); return (rows as unknown as XtreamFavoriteRow[]).map((r) => ({ ...this.mapXtreamRow(r), playlistId, @@ -513,6 +561,31 @@ export class UnifiedFavoritesDataService { } } + private async resolveXtreamContentId( + item: UnifiedCollectionItem + ): Promise { + if (item.xtreamId == null) { + return null; + } + + if (!window.electron) { + const content = await this.xtreamDataSource.getContentByXtreamId( + item.xtreamId, + item.playlistId, + item.contentType + ); + return content?.id ?? item.xtreamId; + } + + const content = await this.dbService.getContentByXtreamId( + item.xtreamId, + item.playlistId, + item.contentType + ); + + return content?.id ?? null; + } + private mapXtreamRow(row: XtreamFavoriteRow): UnifiedCollectionItem { const ct = xtreamContentType(row.type); return { @@ -535,6 +608,33 @@ export class UnifiedFavoritesDataService { }; } + private mapXtreamContentItem( + item: XtreamContentItem, + playlistId: string, + playlistName?: string + ): UnifiedCollectionItem { + const ct = xtreamContentType(item.type); + return { + uid: buildXtreamCollectionUid(playlistId, ct, item.xtream_id), + name: item.title, + contentType: ct, + sourceType: 'xtream', + playlistId, + playlistName: playlistName ?? item.playlist_name ?? 'Xtream', + logo: ct === 'live' ? (item.poster_url ?? null) : null, + posterUrl: ct !== 'live' ? (item.poster_url ?? null) : null, + xtreamId: item.xtream_id, + categoryId: item.category_id, + tvgId: ct === 'live' ? String(item.xtream_id) : undefined, + rating: item.rating ?? undefined, + addedAt: + normalizeStalkerDate(item.added_at ?? item.added) || + new Date(0).toISOString(), + position: item.position ?? 0, + contentId: item.id, + }; + } + private async getStalkerAllFavorites(): Promise { const allMeta = await this.getAllMeta(); const results: UnifiedCollectionItem[] = []; @@ -726,6 +826,12 @@ export class UnifiedFavoritesDataService { }, new Map()); } + private isXtreamPlaylist( + playlist: Pick + ): boolean { + return Boolean(playlist.serverUrl) && !playlist.macAddress; + } + private getStalkerFavoriteId( favorite: | Pick diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts index 2557e7dd9..017b3fc8a 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts @@ -9,6 +9,7 @@ import { PlaylistMeta, } from '@iptvnator/shared/interfaces'; import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; +import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access'; import { UnifiedRecentDataService } from './unified-recent-data.service'; describe('UnifiedRecentDataService', () => { @@ -33,6 +34,13 @@ describe('UnifiedRecentDataService', () => { addRecentItem: jest.Mock; getContentByXtreamId: jest.Mock; }; + let xtreamDataSource: { + addRecentItem: jest.Mock; + clearRecentItems: jest.Mock; + getContentByXtreamId: jest.Mock; + getRecentItems: jest.Mock; + removeRecentItem: jest.Mock; + }; const playlistMeta = { _id: 'm3u-1', @@ -87,6 +95,10 @@ describe('UnifiedRecentDataService', () => { ]; beforeEach(() => { + Object.defineProperty(window, 'electron', { + value: {} as Window['electron'], + configurable: true, + }); store = { select: jest.fn(() => of([playlistMeta])), dispatch: jest.fn(), @@ -136,6 +148,13 @@ describe('UnifiedRecentDataService', () => { addRecentItem: jest.fn().mockResolvedValue(true), getContentByXtreamId: jest.fn().mockResolvedValue(null), }; + xtreamDataSource = { + addRecentItem: jest.fn().mockResolvedValue(undefined), + clearRecentItems: jest.fn().mockResolvedValue(undefined), + getContentByXtreamId: jest.fn().mockResolvedValue(null), + getRecentItems: jest.fn().mockResolvedValue([]), + removeRecentItem: jest.fn().mockResolvedValue(undefined), + }; TestBed.configureTestingModule({ providers: [ @@ -143,6 +162,10 @@ describe('UnifiedRecentDataService', () => { { provide: Store, useValue: store }, { provide: PlaylistsService, useValue: playlistsService }, { provide: DatabaseService, useValue: dbService }, + { + provide: XTREAM_DATA_SOURCE, + useValue: xtreamDataSource, + }, ], }); @@ -235,6 +258,43 @@ describe('UnifiedRecentDataService', () => { ); }); + it('uses the Xtream id as the PWA recent key when cached content is cold', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + xtreamDataSource.getContentByXtreamId.mockResolvedValue(null); + + const item = { + uid: 'xtream::xtream-1::movie:290', + name: 'PWA Movie', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream One', + xtreamId: 290, + } satisfies UnifiedCollectionItem; + + const recorded = await service.recordLivePlayback(item); + + expect(xtreamDataSource.getContentByXtreamId).toHaveBeenCalledWith( + 290, + 'xtream-1', + 'movie' + ); + expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith( + 290, + 'xtream-1' + ); + expect(dbService.addRecentItem).not.toHaveBeenCalled(); + expect(recorded).toEqual( + expect.objectContaining({ + contentId: 290, + viewedAt: expect.any(String), + }) + ); + }); + it('builds distinct Xtream recent UIDs when live and series share an xtream id', async () => { store.select.mockReturnValue( of([ @@ -288,6 +348,131 @@ describe('UnifiedRecentDataService', () => { ); }); + it('loads Xtream playlist recent items through the active data source in PWA', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + store.select.mockReturnValue( + of([ + { + _id: 'xtream-1', + title: 'Xtream PWA', + serverUrl: 'https://example.com', + } satisfies Partial, + ]) + ); + xtreamDataSource.getRecentItems.mockResolvedValue([ + { + id: 202, + category_id: 20, + title: 'Movie One', + type: 'movie', + poster_url: 'movie.png', + backdrop_url: 'backdrop.png', + xtream_id: 202, + viewed_at: '2026-05-21T12:00:00.000Z', + }, + ]); + + const items = await service.getRecentItems( + 'playlist', + 'xtream-1', + 'xtream' + ); + + expect(xtreamDataSource.getRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + expect(dbService.getRecentItems).not.toHaveBeenCalled(); + expect(items).toEqual([ + expect.objectContaining({ + uid: 'xtream::xtream-1::movie:202', + sourceType: 'xtream', + contentType: 'movie', + name: 'Movie One', + playlistName: 'Xtream PWA', + posterUrl: 'movie.png', + viewedAt: '2026-05-21T12:00:00.000Z', + }), + ]); + }); + + it('does not load Stalker portals through the PWA Xtream global recent path', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + store.select.mockReturnValue( + of([ + { + _id: 'xtream-1', + title: 'Xtream PWA', + serverUrl: 'https://xtream.example.com', + }, + { + _id: 'stalker-1', + title: 'Stalker Portal', + serverUrl: 'https://stalker.example.com', + macAddress: '00:11:22:33:44:55', + }, + ] satisfies Partial[]) + ); + + await service.getRecentItems('all'); + + expect(xtreamDataSource.getRecentItems).toHaveBeenCalledTimes(1); + expect(xtreamDataSource.getRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + }); + + it('clears Xtream recent localStorage during global PWA clear', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + store.select.mockReturnValue( + of([ + { + _id: 'xtream-1', + title: 'Xtream PWA', + serverUrl: 'https://xtream.example.com', + } satisfies Partial, + ]) + ); + playlistsService.getAllPlaylists.mockReturnValue( + of([ + { + _id: 'm3u-1', + title: 'M3U List', + }, + { + _id: 'stalker-1', + title: 'Stalker Portal', + serverUrl: 'https://stalker.example.com', + macAddress: '00:11:22:33:44:55', + }, + ] satisfies Partial[]) + ); + + await service.clearRecentItems('all'); + + expect(dbService.clearGlobalRecentlyViewed).not.toHaveBeenCalled(); + expect(xtreamDataSource.clearRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + expect( + playlistsService.clearPlaylistRecentlyViewed + ).toHaveBeenCalledWith('m3u-1'); + expect( + playlistsService.clearPlaylistRecentlyViewed + ).toHaveBeenCalledWith('stalker-1'); + expect( + playlistsService.clearPlaylistRecentlyViewed + ).not.toHaveBeenCalledWith('xtream-1'); + }); + it('keeps Stalker radio recent items in the live collection with radio metadata', async () => { store.select.mockReturnValue( of([ 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 bb1f657dc..163c59653 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 @@ -26,6 +26,10 @@ import { UnifiedCollectionItem, xtreamContentType, } from '@iptvnator/portal/shared/util'; +import { + XTREAM_DATA_SOURCE, + XtreamContentItem, +} from '@iptvnator/portal/xtream/data-access'; type PlaylistWithChannels = Playlist & { readonly playlist?: { readonly items?: Channel[] }; @@ -36,6 +40,7 @@ export class UnifiedRecentDataService { private readonly store = inject(Store); private readonly dbService = inject(DatabaseService); private readonly playlistsService = inject(PlaylistsService); + private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE); async getRecentItems( scope: CollectionScope, @@ -55,6 +60,14 @@ export class UnifiedRecentDataService { return; } + if (!window.electron) { + await this.xtreamDataSource.removeRecentItem( + item.contentId, + item.playlistId + ); + return; + } + await this.dbService.removeRecentItem( item.contentId, item.playlistId @@ -126,7 +139,18 @@ export class UnifiedRecentDataService { const tasks: Promise[] = []; if (xtreamBatch.length > 0) { - tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch)); + if (window.electron) { + tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch)); + } else { + tasks.push( + ...xtreamBatch.map((item) => + this.xtreamDataSource.removeRecentItem( + item.contentId, + item.playlistId + ) + ) + ); + } } for (const [playlistId, identities] of groupedByPlaylist) { @@ -153,7 +177,11 @@ export class UnifiedRecentDataService { playlistId?: string ): Promise { if (scope === 'playlist' && playlistId) { - await this.dbService.clearPlaylistRecentItems(playlistId); + if (window.electron) { + await this.dbService.clearPlaylistRecentItems(playlistId); + } else { + await this.xtreamDataSource.clearRecentItems(playlistId); + } const updatedPlaylist = await firstValueFrom( this.playlistsService.clearPlaylistRecentlyViewed(playlistId) ); @@ -161,18 +189,26 @@ export class UnifiedRecentDataService { return; } - await this.dbService.clearGlobalRecentlyViewed(); + if (window.electron) { + await this.dbService.clearGlobalRecentlyViewed(); + } else { + const allMeta = await this.getAllMeta(); + await Promise.all( + allMeta + .filter((playlist) => playlist._id) + .filter((playlist) => this.isXtreamPlaylist(playlist)) + .map((playlist) => + this.xtreamDataSource.clearRecentItems(playlist._id) + ) + ); + } const playlists = (await firstValueFrom( this.playlistsService.getAllPlaylists() )) as Playlist[]; await Promise.all( - playlists - .filter( - (playlist) => - Boolean(playlist.macAddress) || !playlist.serverUrl - ) - .map(async (playlist) => { + playlists.map(async (playlist) => { + if (this.isPlaylistBackedRecentPlaylist(playlist)) { const updatedPlaylist = await firstValueFrom( this.playlistsService.clearPlaylistRecentlyViewed( playlist._id @@ -182,7 +218,8 @@ export class UnifiedRecentDataService { playlist._id, updatedPlaylist ); - }) + } + }) ); } @@ -223,17 +260,21 @@ export class UnifiedRecentDataService { const contentId = item.contentId ?? (item.xtreamId != null - ? ( - await this.dbService.getContentByXtreamId( - item.xtreamId, - item.playlistId, - item.contentType - ) - )?.id + ? await this.resolveXtreamContentId(item) : null); if (contentId != null) { - await this.dbService.addRecentItem(contentId, item.playlistId); + if (window.electron) { + await this.dbService.addRecentItem( + contentId, + item.playlistId + ); + } else { + await this.xtreamDataSource.addRecentItem( + contentId, + item.playlistId + ); + } } return { @@ -309,6 +350,17 @@ export class UnifiedRecentDataService { } private async getXtreamGlobalRecent(): Promise { + if (!window.electron) { + const allMeta = await this.getAllMeta(); + const results: UnifiedCollectionItem[] = []; + for (const meta of allMeta.filter( + (playlist) => playlist._id && this.isXtreamPlaylist(playlist) + )) { + results.push(...(await this.getXtreamPlaylistRecent(meta._id))); + } + return results; + } + try { const rows = await this.dbService.getGlobalRecentlyViewed(); return (rows || []).map((row) => ({ @@ -340,9 +392,16 @@ export class UnifiedRecentDataService { playlistId: string ): Promise { try { - const rows = await this.dbService.getRecentItems(playlistId); const meta = await this.getPlaylistMeta(playlistId); + if (!window.electron) { + const rows = + await this.xtreamDataSource.getRecentItems(playlistId); + return rows.map((row) => + this.mapXtreamContentItem(row, playlistId, meta?.title) + ); + } + const rows = await this.dbService.getRecentItems(playlistId); return (rows || []).map((row) => ({ uid: buildXtreamCollectionUid( playlistId, @@ -368,6 +427,60 @@ export class UnifiedRecentDataService { } } + private async resolveXtreamContentId( + item: UnifiedCollectionItem + ): Promise { + if (item.xtreamId == null) { + return null; + } + + if (!window.electron) { + const content = await this.xtreamDataSource.getContentByXtreamId( + item.xtreamId, + item.playlistId, + item.contentType + ); + return content?.id ?? item.xtreamId; + } + + const content = await this.dbService.getContentByXtreamId( + item.xtreamId, + item.playlistId, + item.contentType + ); + + return content?.id ?? null; + } + + private mapXtreamContentItem( + item: XtreamContentItem, + playlistId: string, + playlistName?: string + ): UnifiedCollectionItem { + const contentType = xtreamContentType(item.type); + + return { + uid: buildXtreamCollectionUid( + playlistId, + contentType, + item.xtream_id + ), + name: item.title, + contentType, + sourceType: 'xtream', + playlistId, + playlistName: playlistName ?? item.playlist_name ?? 'Xtream', + logo: contentType === 'live' ? (item.poster_url ?? null) : null, + posterUrl: + contentType !== 'live' ? (item.poster_url ?? null) : null, + xtreamId: item.xtream_id, + categoryId: item.category_id, + tvgId: contentType === 'live' ? String(item.xtream_id) : undefined, + contentId: item.id, + viewedAt: normalizeStalkerDate(item.viewed_at), + }; + } + private async getM3uGlobalRecent(): Promise { const allMeta = await this.getAllMeta(); const results: UnifiedCollectionItem[] = []; @@ -589,4 +702,16 @@ export class UnifiedRecentDataService { ): boolean { return !playlist.serverUrl && !playlist.macAddress; } + + private isXtreamPlaylist( + playlist: Pick + ): boolean { + return Boolean(playlist.serverUrl) && !playlist.macAddress; + } + + private isPlaylistBackedRecentPlaylist( + playlist: Pick + ): boolean { + return Boolean(playlist.macAddress) || this.isM3uPlaylist(playlist); + } } diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index 6ce6a72c9..425e617b2 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -17,6 +17,7 @@ export * from './lib/live-epg-panel-state'; export * from './lib/live-sidebar-state'; export * from './lib/live-layout-sidebar-state.service'; export * from './lib/keyboard'; +export * from './lib/keyboard-shortcuts'; export * from './lib/remote-channel-navigation'; export * from './lib/workspace-header-context.service'; export * from './lib/workspace-view-command.types'; diff --git a/libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts b/libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts new file mode 100644 index 000000000..306e95abe --- /dev/null +++ b/libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts @@ -0,0 +1,202 @@ +export type KeyboardShortcutGroupId = + | 'global' + | 'navigation' + | 'playback' + | 'dialogs'; + +export interface PlatformShortcutChord { + mac: readonly string[]; + other: readonly string[]; +} + +export type KeyboardShortcutChord = + | string + | readonly string[] + | PlatformShortcutChord; + +export interface KeyboardShortcutDefinition { + id: string; + group: KeyboardShortcutGroupId; + labelKey: string; + icon: string; + keys: readonly KeyboardShortcutChord[]; + order: number; + electronOnly?: boolean; +} + +export const KEYBOARD_SHORTCUT_GROUPS: readonly { + id: KeyboardShortcutGroupId; + labelKey: string; + icon: string; +}[] = [ + { + id: 'global', + labelKey: 'WORKSPACE.SHORTCUTS.GROUPS.GLOBAL', + icon: 'keyboard', + }, + { + id: 'navigation', + labelKey: 'WORKSPACE.SHORTCUTS.GROUPS.NAVIGATION', + icon: 'explore', + }, + { + id: 'playback', + labelKey: 'WORKSPACE.SHORTCUTS.GROUPS.PLAYBACK', + icon: 'play_circle', + }, + { + id: 'dialogs', + labelKey: 'WORKSPACE.SHORTCUTS.GROUPS.DIALOGS', + icon: 'web_asset', + }, +]; + +const commandChord = (key: string): PlatformShortcutChord => ({ + mac: ['Cmd', key], + other: ['Ctrl', key], +}); + +export const APP_KEYBOARD_SHORTCUTS: readonly KeyboardShortcutDefinition[] = [ + { + id: 'open-keyboard-shortcuts', + group: 'global', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.OPEN_KEYBOARD_SHORTCUTS', + icon: 'help_outline', + keys: ['?'], + order: 0, + }, + { + id: 'open-command-palette', + group: 'global', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.OPEN_COMMAND_PALETTE', + icon: 'terminal', + keys: [commandChord('K')], + order: 10, + }, + { + id: 'open-global-search', + group: 'global', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.OPEN_GLOBAL_SEARCH', + icon: 'search', + keys: [commandChord('F')], + order: 20, + electronOnly: true, + }, + { + id: 'open-recently-viewed', + group: 'global', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.OPEN_RECENTLY_VIEWED', + icon: 'history', + keys: [commandChord('R')], + order: 30, + electronOnly: true, + }, + { + id: 'submit-shell-search', + group: 'global', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.SUBMIT_SHELL_SEARCH', + icon: 'keyboard_return', + keys: ['Enter'], + order: 40, + }, + { + id: 'toggle-sidebar', + group: 'navigation', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.TOGGLE_SIDEBAR', + icon: 'view_sidebar', + keys: [commandChord('B')], + order: 10, + }, + { + id: 'm3u-channel-number', + group: 'navigation', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.M3U_CHANNEL_NUMBER', + icon: 'dialpad', + keys: ['0-9'], + order: 20, + }, + { + id: 'embedded-mpv-play-pause', + group: 'playback', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.PLAY_PAUSE', + icon: 'play_arrow', + keys: ['Space', 'K'], + order: 10, + electronOnly: true, + }, + { + id: 'embedded-mpv-fullscreen', + group: 'playback', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.TOGGLE_FULLSCREEN', + icon: 'fullscreen', + keys: ['F'], + order: 20, + electronOnly: true, + }, + { + id: 'embedded-mpv-seek', + group: 'playback', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.SEEK', + icon: 'swap_horiz', + keys: ['ArrowLeft', 'ArrowRight'], + order: 30, + electronOnly: true, + }, + { + id: 'adjust-volume', + group: 'playback', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.ADJUST_VOLUME', + icon: 'volume_up', + keys: ['ArrowUp', 'ArrowDown'], + order: 40, + }, + { + id: 'mute-audio', + group: 'playback', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.MUTE_AUDIO', + icon: 'volume_off', + keys: ['M'], + order: 50, + }, + { + id: 'close-player-popovers', + group: 'playback', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.CLOSE_PLAYER_POPOVERS', + icon: 'close', + keys: ['Escape'], + order: 60, + electronOnly: true, + }, + { + id: 'command-palette-navigation', + group: 'dialogs', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.COMMAND_PALETTE_NAVIGATION', + icon: 'unfold_more', + keys: ['ArrowUp', 'ArrowDown'], + order: 10, + }, + { + id: 'command-palette-run', + group: 'dialogs', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.COMMAND_PALETTE_RUN', + icon: 'subdirectory_arrow_right', + keys: ['Enter'], + order: 20, + }, + { + id: 'close-dialogs', + group: 'dialogs', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.CLOSE_DIALOGS', + icon: 'close', + keys: ['Escape'], + order: 30, + }, + { + id: 'downloads-open-item', + group: 'dialogs', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.DOWNLOADS_OPEN_ITEM', + icon: 'download', + keys: ['Enter', 'Space'], + order: 40, + }, +]; diff --git a/libs/portal/shared/util/src/lib/keyboard-shortcuts.spec.ts b/libs/portal/shared/util/src/lib/keyboard-shortcuts.spec.ts new file mode 100644 index 000000000..4852806bd --- /dev/null +++ b/libs/portal/shared/util/src/lib/keyboard-shortcuts.spec.ts @@ -0,0 +1,95 @@ +import { + APP_KEYBOARD_SHORTCUTS, + getKeyboardShortcutGroups, + isKeyboardShortcutHelpTrigger, +} from './keyboard-shortcuts'; + +describe('keyboard shortcuts registry', () => { + it('keeps shortcut ids unique', () => { + const ids = APP_KEYBOARD_SHORTCUTS.map((shortcut) => shortcut.id); + + expect(new Set(ids).size).toBe(ids.length); + }); + + it('omits Electron-only shortcuts outside Electron', () => { + const groups = getKeyboardShortcutGroups({ + isMac: false, + isElectron: false, + }); + const ids = groups.flatMap((group) => + group.items.map((item) => item.id) + ); + + expect(ids).not.toContain('open-global-search'); + expect(ids).not.toContain('open-recently-viewed'); + expect(ids).not.toContain('embedded-mpv-play-pause'); + expect(ids).not.toContain('embedded-mpv-fullscreen'); + expect(ids).not.toContain('embedded-mpv-seek'); + expect(ids).not.toContain('close-player-popovers'); + expect(ids).toContain('open-command-palette'); + expect(ids).toContain('adjust-volume'); + expect(ids).toContain('mute-audio'); + }); + + it('uses platform-specific modifier labels', () => { + const macGroups = getKeyboardShortcutGroups({ + isMac: true, + isElectron: true, + }); + const linuxGroups = getKeyboardShortcutGroups({ + isMac: false, + isElectron: true, + }); + + expect(findChordLabels(macGroups, 'open-command-palette')).toEqual([ + ['Cmd', 'K'], + ]); + expect(findChordLabels(linuxGroups, 'open-command-palette')).toEqual([ + ['Ctrl', 'K'], + ]); + }); + + it('normalizes display labels for compact keycaps', () => { + const groups = getKeyboardShortcutGroups({ + isMac: false, + isElectron: true, + }); + + expect(findChordLabels(groups, 'embedded-mpv-seek')).toEqual([ + ['←'], + ['→'], + ]); + expect(findChordLabels(groups, 'close-dialogs')).toEqual([['Esc']]); + }); + + it('detects the shortcuts help trigger', () => { + expect( + isKeyboardShortcutHelpTrigger( + new KeyboardEvent('keydown', { key: '?' }) + ) + ).toBe(true); + expect( + isKeyboardShortcutHelpTrigger( + new KeyboardEvent('keydown', { key: '/', shiftKey: true }) + ) + ).toBe(true); + expect( + isKeyboardShortcutHelpTrigger( + new KeyboardEvent('keydown', { key: '/', ctrlKey: true }) + ) + ).toBe(false); + }); +}); + +function findChordLabels( + groups: ReturnType, + id: string +): readonly (readonly string[])[] { + const item = groups + .flatMap((group) => group.items) + .find((shortcut) => shortcut.id === id); + + return ( + item?.chords.map((chord) => chord.keys.map((key) => key.label)) ?? [] + ); +} diff --git a/libs/portal/shared/util/src/lib/keyboard-shortcuts.ts b/libs/portal/shared/util/src/lib/keyboard-shortcuts.ts new file mode 100644 index 000000000..7a2ebbd8f --- /dev/null +++ b/libs/portal/shared/util/src/lib/keyboard-shortcuts.ts @@ -0,0 +1,126 @@ +import { + APP_KEYBOARD_SHORTCUTS, + KEYBOARD_SHORTCUT_GROUPS, +} from './keyboard-shortcut-definitions'; +import type { + KeyboardShortcutChord, + KeyboardShortcutGroupId, +} from './keyboard-shortcut-definitions'; + +export { APP_KEYBOARD_SHORTCUTS }; +export type { KeyboardShortcutGroupId }; + +export interface KeyboardShortcutDisplayItem { + id: string; + labelKey: string; + icon: string; + chords: readonly KeyboardShortcutDisplayChord[]; +} + +export interface KeyboardShortcutDisplayChord { + id: string; + ariaLabel: string; + keys: readonly KeyboardShortcutDisplayKey[]; +} + +export interface KeyboardShortcutDisplayKey { + id: string; + label: string; + ariaLabel: string; + isModifier: boolean; +} + +export interface KeyboardShortcutDisplayGroup { + id: KeyboardShortcutGroupId; + labelKey: string; + icon: string; + items: readonly KeyboardShortcutDisplayItem[]; +} + +const KEY_LABELS = new Map([ + ['ArrowLeft', '←'], + ['ArrowRight', '→'], + ['ArrowUp', '↑'], + ['ArrowDown', '↓'], + ['Escape', 'Esc'], +]); + +const KEY_ARIA_LABELS = new Map([ + ['Cmd', 'Command'], + ['Ctrl', 'Control'], + ['ArrowLeft', 'Left arrow'], + ['ArrowRight', 'Right arrow'], + ['ArrowUp', 'Up arrow'], + ['ArrowDown', 'Down arrow'], + ['Escape', 'Escape'], +]); + +const MODIFIER_KEYS = new Set(['Cmd', 'Ctrl', 'Alt', 'Shift']); + +export function getKeyboardShortcutGroups(options: { + isMac: boolean; + isElectron: boolean; +}): readonly KeyboardShortcutDisplayGroup[] { + return KEYBOARD_SHORTCUT_GROUPS.map((group) => ({ + ...group, + items: APP_KEYBOARD_SHORTCUTS.filter( + (shortcut) => + shortcut.group === group.id && + (!shortcut.electronOnly || options.isElectron) + ) + .sort((first, second) => first.order - second.order) + .map((shortcut) => ({ + id: shortcut.id, + labelKey: shortcut.labelKey, + icon: shortcut.icon, + chords: shortcut.keys.map((key) => + resolveShortcutChord(key, options.isMac) + ), + })), + })).filter((group) => group.items.length > 0); +} + +export function isKeyboardShortcutHelpTrigger(event: KeyboardEvent): boolean { + if (event.ctrlKey || event.metaKey || event.altKey) { + return false; + } + + return event.key === '?' || (event.key === '/' && event.shiftKey); +} + +function resolveShortcutChord( + key: KeyboardShortcutChord, + isMac: boolean +): KeyboardShortcutDisplayChord { + const keys = resolveShortcutKeys(key, isMac); + + return { + id: keys.join('+'), + ariaLabel: keys + .map( + (shortcutKey) => KEY_ARIA_LABELS.get(shortcutKey) ?? shortcutKey + ) + .join(' + '), + keys: keys.map((shortcutKey) => ({ + id: shortcutKey.toLowerCase().replace(/[^a-z0-9]+/g, '-'), + label: KEY_LABELS.get(shortcutKey) ?? shortcutKey, + ariaLabel: KEY_ARIA_LABELS.get(shortcutKey) ?? shortcutKey, + isModifier: MODIFIER_KEYS.has(shortcutKey), + })), + }; +} + +function resolveShortcutKeys( + key: KeyboardShortcutChord, + isMac: boolean +): readonly string[] { + if (typeof key === 'string') { + return [key]; + } + + if ('mac' in key) { + return isMac ? key.mac : key.other; + } + + return key; +} diff --git a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts index b5bb6f5a1..9bdd78e5c 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts @@ -24,7 +24,7 @@ describe('buildPortalRailLinks', () => { expect(links.secondary[2]?.tooltip).toBe('Downloads (this playlist)'); }); - it('builds workspace Xtream links without downloads on web', () => { + it('builds workspace Xtream content links on web without downloads', () => { const links = buildPortalRailLinks({ provider: 'xtreams', playlistId: 'xtream-web', @@ -32,24 +32,26 @@ describe('buildPortalRailLinks', () => { workspace: true, }); - expect(links.primary).toEqual([ - { - icon: 'movie', - tooltip: 'Xtream library (this playlist)', - path: ['/workspace', 'xtreams', 'xtream-web'], - exact: true, - section: 'library', - }, + expect(links.primary.map((link) => link.section)).toEqual([ + 'vod', + 'live', + 'series', ]); - expect(links.secondary).toEqual([]); + expect(links.secondary.map((link) => link.section)).toEqual([ + 'recently-added', + 'search', + ]); + expect( + links.secondary.some((link) => link.section === 'downloads') + ).toBe(false); }); - it('builds Stalker links with scoped tooltip labels', () => { + it('builds workspace Stalker links with scoped tooltip labels on web', () => { const links = buildPortalRailLinks({ provider: 'stalker', playlistId: 'portal-1', isElectron: false, - workspace: false, + workspace: true, }); expect(links.primary.map((link) => link.section)).toEqual([ @@ -63,6 +65,9 @@ describe('buildPortalRailLinks', () => { expect(links.primary[1]?.tooltip).toBe('Live TV (this playlist)'); expect(links.primary[2]?.tooltip).toBe('Radio (this playlist)'); expect(links.secondary[0]?.tooltip).toBe('Search (this playlist)'); + expect( + links.secondary.some((link) => link.section === 'downloads') + ).toBe(false); }); it('builds M3U playlist links with scoped tooltip labels', () => { diff --git a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts index cb335cefb..895476704 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts @@ -46,17 +46,6 @@ export function buildPortalRailLinks( const primary: PortalRailLink[] = []; const secondary: PortalRailLink[] = []; - if (workspace && !isElectron) { - primary.push({ - icon: 'movie', - tooltip: 'Xtream library (this playlist)', - path: root, - exact: true, - section: 'library', - }); - return { primary, secondary }; - } - primary.push( { icon: 'movie', 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 111180599..e2eabcedc 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 @@ -452,6 +452,18 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { ); } + async setContentBackdropIfMissing( + contentId: number, + playlistId: string, + backdropUrl: string + ): Promise { + void playlistId; + await this.dbService.setContentBackdropIfMissing( + contentId, + backdropUrl + ); + } + // ========================================================================= // Playback Position Operations // ========================================================================= diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts new file mode 100644 index 000000000..d149d691b --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts @@ -0,0 +1,503 @@ +import { TestBed } from '@angular/core/testing'; +import { PwaXtreamDataSource } from './pwa-xtream-data-source'; +import { + XtreamApiService, + XtreamCredentials, +} from '../services/xtream-api.service'; + +describe('PwaXtreamDataSource', () => { + let dataSource: PwaXtreamDataSource; + let apiService: { + getStreams: jest.Mock; + }; + + const credentials: XtreamCredentials = { + serverUrl: 'http://localhost:3211', + username: 'demo', + password: 'secret', + }; + + beforeEach(() => { + localStorage.clear(); + + apiService = { + getStreams: jest.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + PwaXtreamDataSource, + { + provide: XtreamApiService, + useValue: apiService, + }, + ], + }); + + dataSource = TestBed.inject(PwaXtreamDataSource); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('keeps Xtream passwords out of localStorage playlist metadata', async () => { + await dataSource.createPlaylist({ + id: 'playlist-1', + name: 'Xtream PWA', + serverUrl: credentials.serverUrl, + username: credentials.username, + password: credentials.password, + type: 'xtream', + }); + + const storedPlaylists = JSON.parse( + localStorage.getItem('xtream-playlists') || '[]' + ); + expect(storedPlaylists).toEqual([ + expect.not.objectContaining({ + password: credentials.password, + }), + ]); + await expect(dataSource.getPlaylist('playlist-1')).resolves.toEqual( + expect.objectContaining({ + password: credentials.password, + }) + ); + }); + + it('normalizes Xtream API stream identifiers for PWA catalog navigation', async () => { + apiService.getStreams.mockImplementation( + (_credentials: XtreamCredentials, type: string) => { + switch (type) { + case 'live': + return Promise.resolve([ + { + id: '0', + stream_id: 101, + name: 'News Live', + stream_icon: 'live.png', + category_id: '10', + added: '1', + }, + ]); + case 'movie': + return Promise.resolve([ + { + id: 0, + stream_id: 202, + name: 'Movie One', + stream_icon: 'movie.png', + category_id: '20', + added: '2', + }, + ]); + case 'series': + return Promise.resolve([ + { + series_id: 303, + name: 'Series One', + cover: 'series.png', + category_id: 30, + last_modified: '3', + }, + ]); + default: + return Promise.resolve([]); + } + } + ); + + const live = (await dataSource.getContent( + 'playlist-1', + credentials, + 'live' + )) as Array>; + const vod = (await dataSource.getContent( + 'playlist-1', + credentials, + 'movie' + )) as Array>; + const series = (await dataSource.getContent( + 'playlist-1', + credentials, + 'series' + )) as Array>; + + expect(live[0]).toEqual( + expect.objectContaining({ + id: 101, + stream_id: 101, + title: 'News Live', + poster_url: 'live.png', + type: 'live', + xtream_id: 101, + }) + ); + expect(vod[0]).toEqual( + expect.objectContaining({ + id: 202, + stream_id: 202, + title: 'Movie One', + poster_url: 'movie.png', + type: 'movie', + xtream_id: 202, + }) + ); + expect(series[0]).toEqual( + expect.objectContaining({ + id: 303, + series_id: 303, + title: 'Series One', + poster_url: 'series.png', + type: 'series', + xtream_id: 303, + }) + ); + + await expect( + dataSource.getContentByXtreamId(202, 'playlist-1', 'movie') + ).resolves.toEqual( + expect.objectContaining({ + title: 'Movie One', + type: 'movie', + xtream_id: 202, + }) + ); + }); + + it('matches legacy string favorite and recent ids against numeric content identities', async () => { + apiService.getStreams.mockResolvedValue([ + { + stream_id: 202, + name: 'Movie One', + stream_icon: 'movie.png', + category_id: '20', + added: '2', + }, + ]); + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ 'playlist-1': ['202'] }) + ); + localStorage.setItem( + 'xtream-recent-items', + JSON.stringify({ + 'playlist-1': [ + { + id: '202', + viewedAt: '2026-05-21T12:00:00.000Z', + }, + ], + }) + ); + + await dataSource.getContent('playlist-1', credentials, 'movie'); + + await expect(dataSource.isFavorite(202, 'playlist-1')).resolves.toBe( + true + ); + await expect(dataSource.getFavorites('playlist-1')).resolves.toEqual([ + expect.objectContaining({ + title: 'Movie One', + xtream_id: 202, + }), + ]); + await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([ + expect.objectContaining({ + title: 'Movie One', + viewed_at: '2026-05-21T12:00:00.000Z', + xtream_id: 202, + }), + ]); + }); + + it('does not persist zero or invalid Xtream identities as user collections', async () => { + apiService.getStreams.mockResolvedValue([ + { + id: 0, + name: 'Headless Zero', + category_id: '20', + }, + { + id: 'not-a-number', + name: 'Headless Invalid', + category_id: '20', + }, + ]); + + const content = (await dataSource.getContent( + 'playlist-1', + credentials, + 'movie' + )) as Array>; + + expect(content).toEqual([ + expect.objectContaining({ + id: -1, + xtream_id: -1, + }), + expect.objectContaining({ + id: -1, + xtream_id: -1, + }), + ]); + + await dataSource.addFavorite(-1, 'playlist-1'); + await dataSource.addRecentItem(-1, 'playlist-1'); + + expect(localStorage.getItem('xtream-favorites')).toBeNull(); + expect(localStorage.getItem('xtream-recent-items')).toBeNull(); + + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ 'playlist-1': [-1, 0, 'not-a-number'] }) + ); + localStorage.setItem( + 'xtream-recent-items', + JSON.stringify({ + 'playlist-1': [ + { + id: -1, + viewedAt: '2026-05-21T12:00:00.000Z', + }, + { + id: 0, + viewedAt: '2026-05-21T12:00:00.000Z', + }, + { + id: 'not-a-number', + viewedAt: '2026-05-21T12:00:00.000Z', + }, + ], + }) + ); + + await expect(dataSource.getFavorites('playlist-1')).resolves.toEqual( + [] + ); + await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual( + [] + ); + }); + + it('hydrates stored favorites and recent items from the API when content cache is cold', async () => { + await dataSource.createPlaylist({ + id: 'playlist-1', + name: 'Xtream PWA', + serverUrl: credentials.serverUrl, + username: credentials.username, + password: credentials.password, + type: 'xtream', + }); + apiService.getStreams.mockImplementation( + (_credentials: XtreamCredentials, type: string) => + Promise.resolve( + type === 'movie' + ? [ + { + stream_id: 202, + name: 'Movie One', + stream_icon: 'movie.png', + category_id: '20', + }, + ] + : [] + ) + ); + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ 'playlist-1': [202] }) + ); + localStorage.setItem( + 'xtream-recent-items', + JSON.stringify({ + 'playlist-1': [ + { + id: 202, + viewedAt: '2026-05-21T12:00:00.000Z', + }, + ], + }) + ); + + await expect(dataSource.getFavorites('playlist-1')).resolves.toEqual([ + expect.objectContaining({ + title: 'Movie One', + xtream_id: 202, + }), + ]); + await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([ + expect.objectContaining({ + title: 'Movie One', + viewed_at: '2026-05-21T12:00:00.000Z', + xtream_id: 202, + }), + ]); + expect(apiService.getStreams).toHaveBeenCalledWith( + credentials, + 'movie' + ); + }); + + it('uses the Xtream stream ID as the PWA content ID when raw id differs', async () => { + apiService.getStreams.mockResolvedValue([ + { + id: 5, + stream_id: 202, + name: 'Movie One', + stream_icon: 'movie.png', + category_id: '20', + }, + ]); + + const content = (await dataSource.getContent( + 'playlist-1', + credentials, + 'movie' + )) as Array>; + + expect(content[0]).toEqual( + expect.objectContaining({ + id: 202, + xtream_id: 202, + }) + ); + + await dataSource.addFavorite(Number(content[0].id), 'playlist-1'); + await dataSource.addRecentItem(Number(content[0].id), 'playlist-1'); + + await expect(dataSource.getFavorites('playlist-1')).resolves.toEqual([ + expect.objectContaining({ + id: 202, + title: 'Movie One', + xtream_id: 202, + }), + ]); + await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([ + expect.objectContaining({ + id: 202, + title: 'Movie One', + xtream_id: 202, + }), + ]); + }); + + it('backfills PWA recent-item backdrop metadata without rewriting recency', async () => { + apiService.getStreams.mockResolvedValue([ + { + stream_id: 202, + name: 'Movie One', + stream_icon: 'movie.png', + category_id: '20', + }, + ]); + + await dataSource.getContent('playlist-1', credentials, 'movie'); + await dataSource.addRecentItem(202, 'playlist-1'); + const before = JSON.parse( + localStorage.getItem('xtream-recent-items') || '{}' + )['playlist-1'][0].viewedAt; + + await dataSource.setContentBackdropIfMissing( + 202, + 'playlist-1', + ' https://example.com/backdrop.png ' + ); + + const stored = JSON.parse( + localStorage.getItem('xtream-recent-items') || '{}' + )['playlist-1'][0]; + const storedSnapshot = JSON.parse( + localStorage.getItem('xtream-collection-items') || '{}' + )['playlist-1']['202']; + expect(stored).toEqual( + expect.objectContaining({ + id: 202, + backdropUrl: 'https://example.com/backdrop.png', + viewedAt: before, + }) + ); + expect(storedSnapshot).toEqual( + expect.objectContaining({ + backdrop_url: 'https://example.com/backdrop.png', + title: 'Movie One', + xtream_id: 202, + }) + ); + await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([ + expect.objectContaining({ + backdrop_url: 'https://example.com/backdrop.png', + title: 'Movie One', + xtream_id: 202, + }), + ]); + }); + + it('uses stored collection snapshots for fresh-session favorites and recent items', async () => { + await dataSource.createPlaylist({ + id: 'playlist-1', + name: 'Xtream PWA', + serverUrl: credentials.serverUrl, + username: credentials.username, + password: credentials.password, + type: 'xtream', + }); + apiService.getStreams.mockResolvedValue([ + { + stream_id: 202, + name: 'Movie One', + stream_icon: 'movie.png', + category_id: '20', + }, + ]); + + await dataSource.getContent('playlist-1', credentials, 'movie'); + await dataSource.addFavorite(202, 'playlist-1'); + await dataSource.addRecentItem( + 202, + 'playlist-1', + 'https://example.com/backdrop.png' + ); + + const storedCollectionItems = JSON.parse( + localStorage.getItem('xtream-collection-items') || '{}' + ); + expect(storedCollectionItems['playlist-1']['202']).toEqual( + expect.objectContaining({ + title: 'Movie One', + xtream_id: 202, + }) + ); + + TestBed.resetTestingModule(); + apiService = { + getStreams: jest.fn(), + }; + TestBed.configureTestingModule({ + providers: [ + PwaXtreamDataSource, + { + provide: XtreamApiService, + useValue: apiService, + }, + ], + }); + dataSource = TestBed.inject(PwaXtreamDataSource); + + await expect(dataSource.getFavorites('playlist-1')).resolves.toEqual([ + expect.objectContaining({ + title: 'Movie One', + xtream_id: 202, + }), + ]); + await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([ + expect.objectContaining({ + backdrop_url: 'https://example.com/backdrop.png', + title: 'Movie One', + xtream_id: 202, + }), + ]); + expect(apiService.getStreams).not.toHaveBeenCalled(); + }); +}); 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 26d3b3e70..4c07c657c 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 @@ -28,6 +28,7 @@ import { * LocalStorage keys for PWA persistence */ const STORAGE_KEYS = { + COLLECTION_ITEMS: 'xtream-collection-items', FAVORITES: 'xtream-favorites', RECENT_ITEMS: 'xtream-recent-items', PLAYLISTS: 'xtream-playlists', @@ -36,10 +37,17 @@ const STORAGE_KEYS = { interface XtreamCachedContentItem { readonly added?: string; + readonly backdrop_url?: string | null; readonly category_id?: string | number; - readonly id?: number; + readonly cover?: string; + readonly cover_big?: string; + readonly id?: number | string; + readonly last_modified?: string; + readonly movie_image?: string; readonly name?: string; + readonly poster?: string; readonly poster_url?: string; + readonly rating?: string | number; readonly series_id?: number; readonly stream_display_name?: string; readonly stream_id?: number; @@ -47,13 +55,19 @@ interface XtreamCachedContentItem { readonly title?: string; readonly type?: string; readonly viewed_at?: string; + readonly xtream_id?: number | string; } interface StoredRecentItem { readonly id: number; readonly viewedAt: string; + readonly backdropUrl?: string; } +type StoredXtreamPlaylistData = Omit & { + readonly password?: string; +}; + /** * PWA implementation of the Xtream data source. * Uses API-only strategy: always fetch from API, no database caching. @@ -63,10 +77,12 @@ interface StoredRecentItem { export class PwaXtreamDataSource implements IXtreamDataSource { private readonly apiService = inject(XtreamApiService); private readonly logger = createLogger('PwaXtreamDataSource'); + private readonly contentTypes = ['live', 'movie', 'series'] as const; // In-memory cache for the current session private categoryCache = new Map(); private contentCache = new Map(); + private playlistPasswords = new Map(); // ========================================================================= // Playlist Operations (localStorage) @@ -74,10 +90,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource { async getPlaylist(playlistId: string): Promise { const playlists = this.getPlaylistsFromStorage(); - return playlists.find((p) => p.id === playlistId) || null; + const playlist = playlists.find((p) => p.id === playlistId); + return playlist?.password ? playlist : null; } async createPlaylist(playlist: XtreamPlaylistData): Promise { + this.rememberPlaylistPassword(playlist); const playlists = this.getPlaylistsFromStorage(); playlists.push(playlist); this.savePlaylistsToStorage(playlists); @@ -87,6 +105,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource { playlistId: string, updates: Partial ): Promise { + if (updates.password) { + this.playlistPasswords.set(playlistId, updates.password); + } + const playlists = this.getPlaylistsFromStorage(); const index = playlists.findIndex((p) => p.id === playlistId); if (index !== -1) { @@ -101,6 +123,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { this.savePlaylistsToStorage(filtered); // Also clear favorites and recent items for this playlist + this.clearCollectionItemsForPlaylist(playlistId); this.clearFavoritesForPlaylist(playlistId); this.clearRecentItemsForPlaylist(playlistId); this.clearPlaybackPositionsForPlaylist(playlistId); @@ -112,14 +135,75 @@ export class PwaXtreamDataSource implements IXtreamDataSource { private getPlaylistsFromStorage(): XtreamPlaylistData[] { try { const data = localStorage.getItem(STORAGE_KEYS.PLAYLISTS); - return data ? JSON.parse(data) : []; + const playlists = data + ? (JSON.parse(data) as StoredXtreamPlaylistData[]) + : []; + + return playlists.map((playlist) => + this.fromStoredPlaylist(playlist) + ); } catch { return []; } } private savePlaylistsToStorage(playlists: XtreamPlaylistData[]): void { - localStorage.setItem(STORAGE_KEYS.PLAYLISTS, JSON.stringify(playlists)); + const persistedPlaylists = playlists.map((playlist) => + this.toStoredPlaylist(playlist) + ); + localStorage.setItem( + STORAGE_KEYS.PLAYLISTS, + JSON.stringify(persistedPlaylists) + ); + } + + private toStoredPlaylist( + playlist: XtreamPlaylistData + ): StoredXtreamPlaylistData { + return { + id: playlist.id, + name: playlist.name, + title: playlist.title, + updateDate: playlist.updateDate, + serverUrl: playlist.serverUrl, + username: playlist.username, + type: playlist.type, + userAgent: playlist.userAgent, + referrer: playlist.referrer, + origin: playlist.origin, + serverTimezone: playlist.serverTimezone, + }; + } + + private fromStoredPlaylist( + playlist: StoredXtreamPlaylistData + ): XtreamPlaylistData { + const legacyPassword = + typeof playlist.password === 'string' ? playlist.password : ''; + if (legacyPassword) { + this.playlistPasswords.set(playlist.id, legacyPassword); + } + + return { + id: playlist.id, + name: playlist.name, + title: playlist.title, + updateDate: playlist.updateDate, + serverUrl: playlist.serverUrl, + username: playlist.username, + password: this.playlistPasswords.get(playlist.id) ?? legacyPassword, + type: playlist.type, + userAgent: playlist.userAgent, + referrer: playlist.referrer, + origin: playlist.origin, + serverTimezone: playlist.serverTimezone, + }; + } + + private rememberPlaylistPassword(playlist: XtreamPlaylistData): void { + if (playlist.password) { + this.playlistPasswords.set(playlist.id, playlist.password); + } } // ========================================================================= @@ -219,7 +303,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource { onProgress?: (count: number) => void, onTotal?: (total: number) => void, options?: XtreamOperationOptions - ): Promise { + ): Promise< + | XtreamLiveStream[] + | XtreamVodStream[] + | XtreamSerieItem[] + | XtreamContentItem[] + > { void options; const cacheKey = `${playlistId}-${type}-content`; @@ -233,7 +322,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource { } // Fetch from API - const content = await this.apiService.getStreams(credentials, type); + const content = this.normalizeContentItems( + await this.apiService.getStreams(credentials, type), + type + ); // Report total and progress (PWA doesn't have incremental save, so report all at once) if (onTotal) { @@ -270,15 +362,72 @@ export class PwaXtreamDataSource implements IXtreamDataSource { options?: XtreamOperationOptions ): Promise { void options; - // In PWA mode, we just cache in memory + // In PWA mode, we just cache normalized API items in memory. const cacheKey = `${playlistId}-${type}-content`; - this.contentCache.set(cacheKey, streams); + const normalizedStreams = this.normalizeContentItems(streams, type); + this.contentCache.set(cacheKey, normalizedStreams); if (onProgress) { - onProgress(streams.length); + onProgress(normalizedStreams.length); } - return streams.length; + return normalizedStreams.length; + } + + private normalizeContentItems( + streams: + | XtreamLiveStream[] + | XtreamVodStream[] + | XtreamSerieItem[] + | XtreamContentItem[], + type: 'live' | 'movie' | 'series' + ): XtreamContentItem[] { + return streams.map((item) => + this.normalizeContentItem(item as XtreamCachedContentItem, type) + ); + } + + private normalizeContentItem( + item: XtreamCachedContentItem, + type: 'live' | 'movie' | 'series' + ): XtreamContentItem { + const xtreamId = this.getItemIdentity(item, type); + const title = item.title ?? item.name ?? item.stream_display_name ?? ''; + const posterUrl = + item.poster_url ?? + item.stream_icon ?? + item.cover ?? + item.cover_big ?? + item.movie_image ?? + item.poster ?? + ''; + + return { + ...item, + added: item.added ?? item.last_modified ?? '', + category_id: item.category_id ?? '', + id: xtreamId, + name: item.name ?? title, + poster_url: posterUrl, + rating: String(item.rating ?? ''), + title, + type, + xtream_id: xtreamId, + } as XtreamContentItem; + } + + private getItemIdentity( + item: XtreamCachedContentItem, + type?: 'live' | 'movie' | 'series' + ): number { + const preferredId = + item.xtream_id ?? + (type === 'series' ? item.series_id : item.stream_id) ?? + item.stream_id ?? + item.series_id ?? + item.id; + const numericId = Number(preferredId); + return Number.isFinite(numericId) && numericId > 0 ? numericId : -1; } // ========================================================================= @@ -318,60 +467,68 @@ export class PwaXtreamDataSource implements IXtreamDataSource { async getFavorites(playlistId: string): Promise { const allFavorites = this.getFavoritesFromStorage(); const playlistFavorites = allFavorites[playlistId] || []; + const contentById = await this.getCollectionItemsWithHydration( + playlistId, + playlistFavorites + ); - // Match favorites with cached content - const results: XtreamCachedContentItem[] = []; - for (const type of ['live', 'movie', 'series']) { - const cacheKey = `${playlistId}-${type}-content`; - const content = this.contentCache.get(cacheKey) || []; - - for (const item of content) { - const itemId = item.stream_id || item.series_id || item.id; - if (playlistFavorites.includes(itemId)) { - results.push(item); - } - } - } - - return results as XtreamContentItem[]; + return Array.from(contentById.values()); } async addFavorite( contentId: number, playlistId: string, - // PWA uses localStorage with no content table, so backdrop persistence - // is electron-only. Accept the param for interface parity. - _backdropUrl?: string + backdropUrl?: string ): Promise { + const normalizedContentId = this.normalizeStoredId(contentId); + if (normalizedContentId == null) { + return; + } + const allFavorites = this.getFavoritesFromStorage(); if (!allFavorites[playlistId]) { allFavorites[playlistId] = []; } - if (!allFavorites[playlistId].includes(contentId)) { - allFavorites[playlistId].push(contentId); + if (!allFavorites[playlistId].includes(normalizedContentId)) { + allFavorites[playlistId].push(normalizedContentId); } this.saveFavoritesToStorage(allFavorites); + this.saveCollectionItemSnapshot( + playlistId, + normalizedContentId, + backdropUrl + ); } async removeFavorite(contentId: number, playlistId: string): Promise { + const normalizedContentId = this.normalizeStoredId(contentId); + if (normalizedContentId == null) { + return; + } + const allFavorites = this.getFavoritesFromStorage(); if (allFavorites[playlistId]) { allFavorites[playlistId] = allFavorites[playlistId].filter( - (id: number) => id !== contentId + (id: number) => id !== normalizedContentId ); } this.saveFavoritesToStorage(allFavorites); } async isFavorite(contentId: number, playlistId: string): Promise { + const normalizedContentId = this.normalizeStoredId(contentId); + if (normalizedContentId == null) { + return false; + } + const allFavorites = this.getFavoritesFromStorage(); - return (allFavorites[playlistId] || []).includes(contentId); + return (allFavorites[playlistId] || []).includes(normalizedContentId); } private getFavoritesFromStorage(): Record { try { const data = localStorage.getItem(STORAGE_KEYS.FAVORITES); - return data ? JSON.parse(data) : {}; + return this.normalizeFavoriteStorage(data ? JSON.parse(data) : {}); } catch { return {}; } @@ -525,23 +682,22 @@ export class PwaXtreamDataSource implements IXtreamDataSource { async getRecentItems(playlistId: string): Promise { const allRecent = this.getRecentItemsFromStorage(); const playlistRecent = allRecent[playlistId] || []; - - // Match recent items with cached content - const results: (XtreamCachedContentItem & { viewed_at: string })[] = []; - for (const type of ['live', 'movie', 'series']) { - const cacheKey = `${playlistId}-${type}-content`; - const content = this.contentCache.get(cacheKey) || []; - - for (const item of content) { - const itemId = item.stream_id || item.series_id || item.id; - const recentEntry = playlistRecent.find((r) => r.id === itemId); - if (recentEntry) { - results.push({ - ...item, - viewed_at: recentEntry.viewedAt, - }); - } + const contentById = await this.getCollectionItemsWithHydration( + playlistId, + playlistRecent.map((item) => item.id) + ); + const results: (XtreamContentItem & { viewed_at: string })[] = []; + for (const recentEntry of playlistRecent) { + const item = contentById.get(recentEntry.id); + if (!item) { + continue; } + + results.push({ + ...item, + backdrop_url: recentEntry.backdropUrl ?? item.backdrop_url, + viewed_at: recentEntry.viewedAt, + }); } // Sort by viewed_at descending @@ -559,6 +715,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource { playlistId: string, _backdropUrl?: string ): Promise { + const normalizedContentId = this.normalizeStoredId(contentId); + if (normalizedContentId == null) { + return; + } + const normalizedBackdropUrl = _backdropUrl?.trim(); + const allRecent = this.getRecentItemsFromStorage(); if (!allRecent[playlistId]) { allRecent[playlistId] = []; @@ -566,29 +728,42 @@ export class PwaXtreamDataSource implements IXtreamDataSource { // Remove existing entry if present allRecent[playlistId] = allRecent[playlistId].filter( - (r) => r.id !== contentId + (r) => r.id !== normalizedContentId ); // Add new entry at the beginning allRecent[playlistId].unshift({ - id: contentId, + id: normalizedContentId, viewedAt: new Date().toISOString(), + ...(normalizedBackdropUrl + ? { backdropUrl: normalizedBackdropUrl } + : {}), }); // Keep only last 50 items allRecent[playlistId] = allRecent[playlistId].slice(0, 50); this.saveRecentItemsToStorage(allRecent); + this.saveCollectionItemSnapshot( + playlistId, + normalizedContentId, + normalizedBackdropUrl + ); } async removeRecentItem( contentId: number, playlistId: string ): Promise { + const normalizedContentId = this.normalizeStoredId(contentId); + if (normalizedContentId == null) { + return; + } + const allRecent = this.getRecentItemsFromStorage(); if (allRecent[playlistId]) { allRecent[playlistId] = allRecent[playlistId].filter( - (r) => r.id !== contentId + (r) => r.id !== normalizedContentId ); } this.saveRecentItemsToStorage(allRecent); @@ -601,7 +776,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { private getRecentItemsFromStorage(): Record { try { const data = localStorage.getItem(STORAGE_KEYS.RECENT_ITEMS); - return data ? JSON.parse(data) : {}; + return this.normalizeRecentStorage(data ? JSON.parse(data) : {}); } catch { return {}; } @@ -622,6 +797,266 @@ export class PwaXtreamDataSource implements IXtreamDataSource { this.saveRecentItemsToStorage(allRecent); } + private normalizeStoredId(value: unknown): number | null { + const numericValue = Number(value); + return Number.isFinite(numericValue) && numericValue > 0 + ? numericValue + : null; + } + + private normalizeFavoriteStorage(value: unknown): Record { + if (!value || typeof value !== 'object') { + return {}; + } + + const normalized: Record = {}; + Object.entries(value as Record).forEach( + ([playlistId, ids]) => { + if (!Array.isArray(ids)) { + return; + } + + normalized[playlistId] = ids + .map((id) => this.normalizeStoredId(id)) + .filter((id): id is number => id !== null); + } + ); + return normalized; + } + + private normalizeRecentStorage( + value: unknown + ): Record { + if (!value || typeof value !== 'object') { + return {}; + } + + const normalized: Record = {}; + Object.entries(value as Record).forEach( + ([playlistId, items]) => { + if (!Array.isArray(items)) { + return; + } + + normalized[playlistId] = items + .map((item) => { + const rawItem = item as { + readonly id?: unknown; + readonly viewedAt?: unknown; + readonly backdropUrl?: unknown; + readonly backdrop_url?: unknown; + }; + const id = this.normalizeStoredId(rawItem.id); + if ( + id == null || + typeof rawItem.viewedAt !== 'string' + ) { + return null; + } + + return { + id, + viewedAt: rawItem.viewedAt, + ...this.normalizeStoredBackdrop(rawItem), + }; + }) + .filter((item): item is StoredRecentItem => item !== null); + } + ); + return normalized; + } + + private normalizeStoredBackdrop(item: { + readonly backdropUrl?: unknown; + readonly backdrop_url?: unknown; + }): Pick | Record { + const value = item.backdropUrl ?? item.backdrop_url; + if (typeof value !== 'string') { + return {}; + } + + const backdropUrl = value.trim(); + return backdropUrl ? { backdropUrl } : {}; + } + + private getCollectionItemsFromStorage(): Record< + string, + Record + > { + try { + const data = localStorage.getItem(STORAGE_KEYS.COLLECTION_ITEMS); + const parsed = data ? JSON.parse(data) : {}; + if (!parsed || typeof parsed !== 'object') { + return {}; + } + return parsed as Record>; + } catch { + return {}; + } + } + + private saveCollectionItemsToStorage( + items: Record> + ): void { + localStorage.setItem( + STORAGE_KEYS.COLLECTION_ITEMS, + JSON.stringify(items) + ); + } + + private clearCollectionItemsForPlaylist(playlistId: string): void { + const allItems = this.getCollectionItemsFromStorage(); + delete allItems[playlistId]; + this.saveCollectionItemsToStorage(allItems); + } + + private saveCollectionItemSnapshot( + playlistId: string, + contentId: number, + backdropUrl?: string + ): void { + const item = this.findCachedContentItemById(playlistId, contentId); + if (!item) { + return; + } + + const normalizedBackdropUrl = backdropUrl?.trim(); + const allItems = this.getCollectionItemsFromStorage(); + const playlistItems = allItems[playlistId] ?? {}; + playlistItems[String(contentId)] = { + ...item, + ...(normalizedBackdropUrl && !item.backdrop_url + ? { backdrop_url: normalizedBackdropUrl } + : {}), + }; + this.saveCollectionItemsToStorage({ + ...allItems, + [playlistId]: playlistItems, + }); + } + + private setCollectionItemBackdropIfMissing( + playlistId: string, + contentId: number, + backdropUrl: string + ): void { + const allItems = this.getCollectionItemsFromStorage(); + const playlistItems = allItems[playlistId]; + const item = playlistItems?.[String(contentId)]; + if (!item || item.backdrop_url) { + return; + } + + this.saveCollectionItemsToStorage({ + ...allItems, + [playlistId]: { + ...playlistItems, + [String(contentId)]: { + ...item, + backdrop_url: backdropUrl, + }, + }, + }); + } + + private getCollectionItemsById( + playlistId: string, + ids: readonly number[] + ): Map { + const idSet = new Set(ids); + const results = new Map(); + + for (const type of this.contentTypes) { + const cacheKey = `${playlistId}-${type}-content`; + const content = this.contentCache.get(cacheKey) || []; + + for (const item of content) { + const itemId = this.getItemIdentity(item, type); + if (idSet.has(itemId)) { + results.set(itemId, item as XtreamContentItem); + } + } + } + + const storedItems = this.getCollectionItemsFromStorage()[playlistId]; + if (!storedItems) { + return results; + } + + for (const id of ids) { + if (!results.has(id) && storedItems[String(id)]) { + results.set(id, storedItems[String(id)]); + } + } + + return results; + } + + private async getCollectionItemsWithHydration( + playlistId: string, + ids: readonly number[] + ): Promise> { + const contentById = this.getCollectionItemsById(playlistId, ids); + const missingIds = ids.filter((id) => !contentById.has(id)); + if (missingIds.length === 0) { + return contentById; + } + + await this.hydrateStoredCollectionContent(playlistId, missingIds); + return this.getCollectionItemsById(playlistId, ids); + } + + private findCachedContentItemById( + playlistId: string, + contentId: number + ): XtreamContentItem | null { + return ( + this.getCollectionItemsById(playlistId, [contentId]).get( + contentId + ) ?? null + ); + } + + private async hydrateStoredCollectionContent( + playlistId: string, + ids: readonly number[] + ): Promise { + if (ids.length === 0) { + return; + } + + const missingTypes = this.contentTypes.filter( + (type) => !this.contentCache.has(`${playlistId}-${type}-content`) + ); + if (missingTypes.length === 0) { + return; + } + + const playlist = await this.getPlaylist(playlistId); + if (!playlist) { + return; + } + + const credentials: XtreamCredentials = { + serverUrl: playlist.serverUrl, + username: playlist.username, + password: playlist.password, + }; + + await Promise.all( + missingTypes.map(async (type) => { + try { + await this.getContent(playlistId, credentials, type); + } catch (error) { + this.logger.warn( + 'Failed to hydrate stored PWA Xtream collection content', + { playlistId, type, error } + ); + } + }) + ); + } + // ========================================================================= // Content Lookup // ========================================================================= @@ -640,8 +1075,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const content = this.contentCache.get(cacheKey) || []; const found = content.find((item) => { - const itemXtreamId = - item.stream_id || item.series_id || item.id; + const itemXtreamId = this.getItemIdentity(item, type); return itemXtreamId === xtreamId; }); @@ -653,6 +1087,67 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return null; } + async setContentBackdropIfMissing( + contentId: number, + playlistId: string, + backdropUrl: string + ): Promise { + const normalizedContentId = this.normalizeStoredId(contentId); + const normalizedBackdropUrl = backdropUrl.trim(); + if (normalizedContentId == null || !normalizedBackdropUrl) { + return; + } + + for (const type of this.contentTypes) { + const cacheKey = `${playlistId}-${type}-content`; + const content = this.contentCache.get(cacheKey); + if (!content) { + continue; + } + + this.contentCache.set( + cacheKey, + content.map((item) => { + const itemId = this.getItemIdentity(item, type); + if (itemId !== normalizedContentId || item.backdrop_url) { + return item; + } + + return { + ...item, + backdrop_url: normalizedBackdropUrl, + }; + }) + ); + } + + this.setCollectionItemBackdropIfMissing( + playlistId, + normalizedContentId, + normalizedBackdropUrl + ); + + const allRecent = this.getRecentItemsFromStorage(); + const playlistRecent = allRecent[playlistId]; + if (!playlistRecent) { + return; + } + + this.saveRecentItemsToStorage({ + ...allRecent, + [playlistId]: playlistRecent.map((item) => { + if (item.id !== normalizedContentId || item.backdropUrl) { + return item; + } + + return { + ...item, + backdropUrl: normalizedBackdropUrl, + }; + }), + }); + } + private findContentIdentity( playlistId: string, xtreamId: number, @@ -667,8 +1162,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const content = this.contentCache.get(cacheKey) || []; const found = content.find((item) => { - const itemXtreamId = - item.stream_id || item.series_id || item.id; + const itemXtreamId = this.getItemIdentity(item, type); return itemXtreamId === xtreamId; }); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index af2ee954c..831ead825 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -47,6 +47,7 @@ export interface XtreamContentItem { rating: string; added: string; poster_url: string; + backdrop_url?: string | null; epg_channel_id?: string | null; tv_archive?: number | null; tv_archive_duration?: number | null; @@ -358,6 +359,16 @@ export interface IXtreamDataSource { contentType?: 'live' | 'movie' | 'series' ): Promise; + /** + * Persist a backdrop URL for an already-known content item without changing + * favorites or recent ordering. + */ + setContentBackdropIfMissing( + contentId: number, + playlistId: string, + backdropUrl: string + ): Promise; + // ========================================================================= // Playback Position Operations // ========================================================================= diff --git a/libs/portal/xtream/data-access/src/lib/services/favorites.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/favorites.service.spec.ts new file mode 100644 index 000000000..db9f2ffe6 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/services/favorites.service.spec.ts @@ -0,0 +1,80 @@ +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom } from 'rxjs'; +import { XTREAM_DATA_SOURCE } from '../data-sources/xtream-data-source.interface'; +import { FavoritesService } from './favorites.service'; + +describe('FavoritesService', () => { + let service: FavoritesService; + let dataSource: { + addFavorite: jest.Mock; + getFavorites: jest.Mock; + isFavorite: jest.Mock; + removeFavorite: jest.Mock; + }; + + beforeEach(() => { + dataSource = { + addFavorite: jest.fn().mockResolvedValue(undefined), + getFavorites: jest.fn().mockResolvedValue([ + { + id: 202, + type: 'movie', + title: 'Movie One', + poster_url: 'movie.png', + added_at: '2026-05-21T12:00:00.000Z', + category_id: '20', + xtream_id: 202, + }, + ]), + isFavorite: jest.fn().mockResolvedValue(true), + removeFavorite: jest.fn().mockResolvedValue(undefined), + }; + + TestBed.configureTestingModule({ + providers: [ + FavoritesService, + { + provide: XTREAM_DATA_SOURCE, + useValue: dataSource, + }, + ], + }); + + service = TestBed.inject(FavoritesService); + }); + + it('uses the active Xtream data source for favorite mutations and reads', async () => { + await service.addToFavorites({ + content_id: 202, + playlist_id: 'playlist-1', + backdrop_url: 'backdrop.jpg', + }); + await service.removeFromFavorites(202, 'playlist-1'); + await expect(service.isFavorite(202, 'playlist-1')).resolves.toBe(true); + await expect( + firstValueFrom(service.getFavorites('playlist-1')) + ).resolves.toEqual([ + expect.objectContaining({ + content_id: 202, + playlist_id: 'playlist-1', + type: 'movie', + title: 'Movie One', + poster_url: 'movie.png', + category_id: 20, + xtream_id: 202, + }), + ]); + + expect(dataSource.addFavorite).toHaveBeenCalledWith( + 202, + 'playlist-1', + 'backdrop.jpg' + ); + expect(dataSource.removeFavorite).toHaveBeenCalledWith( + 202, + 'playlist-1' + ); + expect(dataSource.isFavorite).toHaveBeenCalledWith(202, 'playlist-1'); + expect(dataSource.getFavorites).toHaveBeenCalledWith('playlist-1'); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts b/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts index 7d6ea7ff5..4929d4471 100644 --- a/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts @@ -1,20 +1,25 @@ import { inject, Injectable } from '@angular/core'; import { from, map, Observable } from 'rxjs'; -import { DatabaseService } from '@iptvnator/services'; +import { XTREAM_DATA_SOURCE } from '../data-sources/xtream-data-source.interface'; import { FavoriteItem } from './favorite-item.interface'; +function normalizeCategoryId(categoryId: string | number): number { + const numericCategoryId = Number(categoryId); + return Number.isFinite(numericCategoryId) ? numericCategoryId : 0; +} + @Injectable({ providedIn: 'root', }) export class FavoritesService { - private dbService = inject(DatabaseService); + private dataSource = inject(XTREAM_DATA_SOURCE); async addToFavorites(item: { content_id: number; playlist_id: string; backdrop_url?: string; }): Promise { - await this.dbService.addToFavorites( + await this.dataSource.addFavorite( item.content_id, item.playlist_id, item.backdrop_url @@ -25,15 +30,15 @@ export class FavoritesService { contentId: number, playlistId: string ): Promise { - await this.dbService.removeFromFavorites(contentId, playlistId); + await this.dataSource.removeFavorite(contentId, playlistId); } async isFavorite(contentId: number, playlistId: string): Promise { - return await this.dbService.isFavorite(contentId, playlistId); + return await this.dataSource.isFavorite(contentId, playlistId); } getFavorites(playlistId: string): Observable { - return from(this.dbService.getFavorites(playlistId)).pipe( + return from(this.dataSource.getFavorites(playlistId)).pipe( map((items) => items.map((item) => ({ content_id: item.id, @@ -42,7 +47,7 @@ export class FavoritesService { title: item.title, poster_url: item.poster_url, added_at: item.added_at, - category_id: item.category_id, + category_id: normalizeCategoryId(item.category_id), xtream_id: item.xtream_id, })) ) diff --git a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts index 8ebc10e40..3580e67ec 100644 --- a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts @@ -1,7 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { patchState, signalStore } from '@ngrx/signals'; -import { DatabaseService } from '@iptvnator/services'; -import { FavoritesService } from './services/favorites.service'; +import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface'; import { withFavorites } from './with-favorites.feature'; jest.mock('@iptvnator/portal/shared/util', () => ({ @@ -16,36 +15,29 @@ jest.mock('@iptvnator/portal/shared/util', () => ({ const TestFavoritesStore = signalStore(withFavorites()); describe('withFavorites', () => { + const originalElectron = window.electron; let store: InstanceType; - let databaseService: { + let dataSource: { + addFavorite: jest.Mock; getContentByXtreamId: jest.Mock; - }; - let favoritesService: { - addToFavorites: jest.Mock; isFavorite: jest.Mock; - removeFromFavorites: jest.Mock; + removeFavorite: jest.Mock; }; beforeEach(() => { - databaseService = { + dataSource = { + addFavorite: jest.fn().mockResolvedValue(undefined), getContentByXtreamId: jest.fn(), - }; - favoritesService = { - addToFavorites: jest.fn().mockResolvedValue(undefined), isFavorite: jest.fn().mockResolvedValue(false), - removeFromFavorites: jest.fn().mockResolvedValue(undefined), + removeFavorite: jest.fn().mockResolvedValue(undefined), }; TestBed.configureTestingModule({ providers: [ TestFavoritesStore, { - provide: DatabaseService, - useValue: databaseService, - }, - { - provide: FavoritesService, - useValue: favoritesService, + provide: XTREAM_DATA_SOURCE, + useValue: dataSource, }, ], }); @@ -53,8 +45,16 @@ describe('withFavorites', () => { store = TestBed.inject(TestFavoritesStore); }); + afterEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: originalElectron, + }); + }); + it('looks favorites up with the requested content type before adding one', async () => { - databaseService.getContentByXtreamId.mockResolvedValue({ + dataSource.getContentByXtreamId.mockResolvedValue({ id: 3941697, title: 'Krypton', type: 'series', @@ -63,21 +63,72 @@ describe('withFavorites', () => { const result = await store.toggleFavorite(290, 'playlist-1', 'series'); - expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith( + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( 290, 'playlist-1', 'series' ); - expect(favoritesService.addToFavorites).toHaveBeenCalledWith({ - content_id: 3941697, - playlist_id: 'playlist-1', - }); + expect(dataSource.addFavorite).toHaveBeenCalledWith( + 3941697, + 'playlist-1', + undefined + ); expect(result).toBe(true); expect(store.isFavorite()).toBe(true); }); + it('uses the Xtream ID as the PWA favorite key when cached content is cold', async () => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: undefined, + }); + dataSource.getContentByXtreamId.mockResolvedValue(null); + + const result = await store.toggleFavorite( + 1767451, + 'playlist-1', + 'movie' + ); + + expect(dataSource.addFavorite).toHaveBeenCalledWith( + 1767451, + 'playlist-1', + undefined + ); + expect(result).toBe(true); + expect(store.isFavorite()).toBe(true); + }); + + it('normalizes route-param Xtream IDs before using the PWA favorite fallback', async () => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: undefined, + }); + dataSource.getContentByXtreamId.mockResolvedValue(null); + + const result = await store.toggleFavorite( + '1767451', + 'playlist-1', + 'movie' + ); + + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( + 1767451, + 'playlist-1', + 'movie' + ); + expect(dataSource.addFavorite).toHaveBeenCalledWith( + 1767451, + 'playlist-1', + undefined + ); + expect(result).toBe(true); + }); + it('looks favorites up with the requested content type before removing one', async () => { - databaseService.getContentByXtreamId.mockResolvedValue({ + dataSource.getContentByXtreamId.mockResolvedValue({ id: 3867578, title: 'SE: V Film Premiere FHD', type: 'live', @@ -87,12 +138,12 @@ describe('withFavorites', () => { const result = await store.toggleFavorite(290, 'playlist-1', 'live'); - expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith( + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( 290, 'playlist-1', 'live' ); - expect(favoritesService.removeFromFavorites).toHaveBeenCalledWith( + expect(dataSource.removeFavorite).toHaveBeenCalledWith( 3867578, 'playlist-1' ); @@ -101,25 +152,43 @@ describe('withFavorites', () => { }); it('checks favorite state against the matching content type', async () => { - databaseService.getContentByXtreamId.mockResolvedValue({ + dataSource.getContentByXtreamId.mockResolvedValue({ id: 3829429, title: 'Dragon Ball Heroes', type: 'series', xtream_id: 31, }); - favoritesService.isFavorite.mockResolvedValue(true); + dataSource.isFavorite.mockResolvedValue(true); await store.checkFavoriteStatus(31, 'playlist-1', 'series'); - expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith( + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( 31, 'playlist-1', 'series' ); - expect(favoritesService.isFavorite).toHaveBeenCalledWith( + expect(dataSource.isFavorite).toHaveBeenCalledWith( 3829429, 'playlist-1' ); expect(store.isFavorite()).toBe(true); }); + + it('checks PWA favorite state against the Xtream ID when cached content is cold', async () => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: undefined, + }); + dataSource.getContentByXtreamId.mockResolvedValue(null); + dataSource.isFavorite.mockResolvedValue(true); + + await store.checkFavoriteStatus(1767451, 'playlist-1', 'movie'); + + expect(dataSource.isFavorite).toHaveBeenCalledWith( + 1767451, + 'playlist-1' + ); + expect(store.isFavorite()).toBe(true); + }); }); diff --git a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts index 19dad0f54..49c3168f9 100644 --- a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts @@ -5,9 +5,8 @@ import { withMethods, withState, } from '@ngrx/signals'; -import { DatabaseService } from '@iptvnator/services'; -import { FavoritesService } from './services/favorites.service'; import { createLogger } from '@iptvnator/portal/shared/util'; +import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface'; export const withFavorites = function () { const logger = createLogger('withFavorites'); @@ -15,85 +14,94 @@ export const withFavorites = function () { withState({ isFavorite: false, }), - withMethods( - ( - store, - dbService = inject(DatabaseService), - favoritesService = inject(FavoritesService) - ) => ({ - async toggleFavorite( - xtreamId: number, - playlistId: string, - contentType: 'live' | 'movie' | 'series', - backdropUrl?: string + withMethods((store, dataSource = inject(XTREAM_DATA_SOURCE)) => ({ + async toggleFavorite( + xtreamId: number | string, + playlistId: string, + contentType: 'live' | 'movie' | 'series', + backdropUrl?: string + ) { + const normalizedXtreamId = Number(xtreamId); + if ( + !Number.isFinite(normalizedXtreamId) || + normalizedXtreamId <= 0 || + !playlistId ) { - if (!xtreamId || !playlistId) { - return false; - } + return false; + } - const content = await dbService.getContentByXtreamId( - xtreamId, - playlistId, - contentType + const content = await dataSource.getContentByXtreamId( + normalizedXtreamId, + playlistId, + contentType + ); + const contentId = + content?.id ?? + (!window.electron ? normalizedXtreamId : null); + + if (contentId == null) { + logger.error( + 'Content not found for xtream ID', + normalizedXtreamId ); - if (!content) { - logger.error( - 'Content not found for xtream ID', - xtreamId - ); - return false; - } + return false; + } - const currentStatus = store.isFavorite(); + const currentStatus = store.isFavorite(); - if (currentStatus) { - // Remove from favorites - await favoritesService.removeFromFavorites( - content.id, - playlistId - ); - patchState(store, { isFavorite: false }); - return false; - } else { - // Add to favorites - await favoritesService.addToFavorites({ - content_id: content.id, - playlist_id: playlistId, - backdrop_url: backdropUrl, - }); - patchState(store, { isFavorite: true }); - return true; - } - }, + if (currentStatus) { + // Remove from favorites + await dataSource.removeFavorite(contentId, playlistId); + patchState(store, { isFavorite: false }); + return false; + } else { + // Add to favorites + await dataSource.addFavorite( + contentId, + playlistId, + backdropUrl + ); + patchState(store, { isFavorite: true }); + return true; + } + }, - async checkFavoriteStatus( - xtreamId: number, - playlistId: string, - contentType: 'live' | 'movie' | 'series' + async checkFavoriteStatus( + xtreamId: number | string, + playlistId: string, + contentType: 'live' | 'movie' | 'series' + ) { + const normalizedXtreamId = Number(xtreamId); + if ( + !Number.isFinite(normalizedXtreamId) || + normalizedXtreamId <= 0 || + !playlistId ) { - if (!xtreamId || !playlistId) { - patchState(store, { isFavorite: false }); - return; - } + patchState(store, { isFavorite: false }); + return; + } - const content = await dbService.getContentByXtreamId( - xtreamId, - playlistId, - contentType - ); - if (!content) { - patchState(store, { isFavorite: false }); - return; - } + const content = await dataSource.getContentByXtreamId( + normalizedXtreamId, + playlistId, + contentType + ); + const contentId = + content?.id ?? + (!window.electron ? normalizedXtreamId : null); - const isFavorite = await favoritesService.isFavorite( - content.id, - playlistId - ); + if (contentId == null) { + patchState(store, { isFavorite: false }); + return; + } - patchState(store, { isFavorite }); - }, - }) - ) + const isFavorite = await dataSource.isFavorite( + contentId, + playlistId + ); + + patchState(store, { isFavorite }); + }, + })) ); }; diff --git a/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts index c3b4e0bd3..ea2698fc8 100644 --- a/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts @@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing'; import { signalStore } from '@ngrx/signals'; import { of } from 'rxjs'; import { DatabaseService, PlaylistsService } from '@iptvnator/services'; +import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface'; import { withRecentItems } from './with-recent-items'; jest.mock('@iptvnator/portal/shared/util', () => ({ @@ -17,13 +18,28 @@ jest.mock('@iptvnator/portal/shared/util', () => ({ const TestRecentItemsStore = signalStore(withRecentItems()); describe('withRecentItems', () => { + const originalElectron = window.electron; let store: InstanceType; let databaseService: { - addRecentItem: jest.Mock; + clearGlobalRecentlyViewed: jest.Mock; + clearPlaylistRecentItems: jest.Mock; getContentByXtreamId: jest.Mock; getRecentItems: jest.Mock; + removeRecentItem: jest.Mock; setContentBackdropIfMissing: jest.Mock; }; + let dataSource: { + addRecentItem: jest.Mock; + clearRecentItems: jest.Mock; + getContentByXtreamId: jest.Mock; + getRecentItems: jest.Mock; + removeRecentItem: jest.Mock; + setContentBackdropIfMissing: jest.Mock; + }; + let playlistsService: { + clearPlaylistRecentlyViewed: jest.Mock; + getAllPlaylists: jest.Mock; + }; beforeEach(() => { Object.defineProperty(window, 'electron', { @@ -32,7 +48,8 @@ describe('withRecentItems', () => { }); databaseService = { - addRecentItem: jest.fn().mockResolvedValue(undefined), + clearGlobalRecentlyViewed: jest.fn().mockResolvedValue(undefined), + clearPlaylistRecentItems: jest.fn().mockResolvedValue(undefined), getContentByXtreamId: jest.fn(), getRecentItems: jest.fn().mockResolvedValue([ { @@ -46,8 +63,34 @@ describe('withRecentItems', () => { category_id: 17, }, ]), + removeRecentItem: jest.fn().mockResolvedValue(undefined), setContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined), }; + dataSource = { + addRecentItem: jest.fn().mockResolvedValue(undefined), + clearRecentItems: jest.fn().mockResolvedValue(undefined), + getContentByXtreamId: jest.fn(), + getRecentItems: jest.fn().mockResolvedValue([ + { + id: 3941697, + title: 'Krypton', + type: 'series', + poster_url: 'https://example.com/krypton.png', + backdrop_url: 'https://example.com/krypton-backdrop.png', + viewed_at: '2026-04-21T20:42:27.000Z', + xtream_id: 290, + category_id: 17, + }, + ]), + removeRecentItem: jest.fn().mockResolvedValue(undefined), + setContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined), + }; + playlistsService = { + clearPlaylistRecentlyViewed: jest + .fn() + .mockReturnValue(of(undefined)), + getAllPlaylists: jest.fn().mockReturnValue(of([])), + }; TestBed.configureTestingModule({ providers: [ @@ -56,14 +99,13 @@ describe('withRecentItems', () => { provide: DatabaseService, useValue: databaseService, }, + { + provide: XTREAM_DATA_SOURCE, + useValue: dataSource, + }, { provide: PlaylistsService, - useValue: { - clearPlaylistRecentlyViewed: jest.fn().mockReturnValue( - of(undefined) - ), - getAllPlaylists: jest.fn().mockReturnValue(of([])), - }, + useValue: playlistsService, }, ], }); @@ -71,8 +113,16 @@ describe('withRecentItems', () => { store = TestBed.inject(TestRecentItemsStore); }); + afterEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: originalElectron, + }); + }); + it('looks recent items up with the requested content type before saving one', async () => { - databaseService.getContentByXtreamId.mockResolvedValue({ + dataSource.getContentByXtreamId.mockResolvedValue({ id: 3941697, title: 'Krypton', type: 'series', @@ -86,12 +136,12 @@ describe('withRecentItems', () => { }); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith( + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( 290, 'playlist-1', 'series' ); - expect(databaseService.addRecentItem).toHaveBeenCalledWith( + expect(dataSource.addRecentItem).toHaveBeenCalledWith( 3941697, 'playlist-1', undefined @@ -107,8 +157,56 @@ describe('withRecentItems', () => { ]); }); + it('uses the Xtream ID as the PWA recent key when cached content is cold', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + dataSource.getContentByXtreamId.mockResolvedValue(null); + + store.addRecentItem({ + xtreamId: 1767451, + contentType: 'movie', + playlist: signal({ id: 'playlist-1' }), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(dataSource.addRecentItem).toHaveBeenCalledWith( + 1767451, + 'playlist-1', + undefined + ); + expect(databaseService.getRecentItems).not.toHaveBeenCalled(); + }); + + it('normalizes route-param Xtream IDs before using the PWA recent fallback', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + dataSource.getContentByXtreamId.mockResolvedValue(null); + + store.addRecentItem({ + xtreamId: '1767451', + contentType: 'movie', + playlist: signal({ id: 'playlist-1' }), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( + 1767451, + 'playlist-1', + 'movie' + ); + expect(dataSource.addRecentItem).toHaveBeenCalledWith( + 1767451, + 'playlist-1', + undefined + ); + }); + it('forwards backdrop urls on recent-item saves', async () => { - databaseService.getContentByXtreamId.mockResolvedValue({ + dataSource.getContentByXtreamId.mockResolvedValue({ id: 3941697, title: 'Krypton', type: 'series', @@ -123,7 +221,7 @@ describe('withRecentItems', () => { }); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(databaseService.addRecentItem).toHaveBeenCalledWith( + expect(dataSource.addRecentItem).toHaveBeenCalledWith( 3941697, 'playlist-1', 'https://example.com/krypton-backdrop.png' @@ -131,7 +229,7 @@ describe('withRecentItems', () => { }); it('backfills a backdrop without rewriting recent ordering', async () => { - databaseService.getContentByXtreamId.mockResolvedValue({ + dataSource.getContentByXtreamId.mockResolvedValue({ id: 3941697, title: 'Krypton', type: 'series', @@ -145,17 +243,113 @@ describe('withRecentItems', () => { backdropUrl: ' https://example.com/krypton-backdrop.png ', }); - expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith( + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( 290, 'playlist-1', 'series' ); - expect( - databaseService.setContentBackdropIfMissing - ).toHaveBeenCalledWith( + expect(dataSource.setContentBackdropIfMissing).toHaveBeenCalledWith( 3941697, + 'playlist-1', 'https://example.com/krypton-backdrop.png' ); - expect(databaseService.addRecentItem).not.toHaveBeenCalled(); + expect(dataSource.addRecentItem).not.toHaveBeenCalled(); + expect(databaseService.getContentByXtreamId).not.toHaveBeenCalled(); + expect( + databaseService.setContentBackdropIfMissing + ).not.toHaveBeenCalled(); + }); + + it('clears recent items through the active data source in PWA', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + + store.clearRecentItems({ id: 'playlist-1' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(dataSource.clearRecentItems).toHaveBeenCalledWith('playlist-1'); + expect(databaseService.clearPlaylistRecentItems).not.toHaveBeenCalled(); + expect(store.recentItems()).toEqual([]); + }); + + it('clears Xtream recent items during global PWA clear', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + playlistsService.getAllPlaylists.mockReturnValue( + of([ + { + _id: 'xtream-1', + serverUrl: 'https://xtream.example.com', + }, + { + _id: 'm3u-1', + }, + { + _id: 'stalker-1', + serverUrl: 'https://stalker.example.com', + macAddress: '00:11:22:33:44:55', + }, + ]) + ); + + await store.clearGlobalRecentlyViewed(); + + expect( + databaseService.clearGlobalRecentlyViewed + ).not.toHaveBeenCalled(); + expect(dataSource.clearRecentItems).toHaveBeenCalledWith('xtream-1'); + expect( + playlistsService.clearPlaylistRecentlyViewed + ).toHaveBeenCalledWith('m3u-1'); + expect( + playlistsService.clearPlaylistRecentlyViewed + ).toHaveBeenCalledWith('stalker-1'); + expect( + playlistsService.clearPlaylistRecentlyViewed + ).not.toHaveBeenCalledWith('xtream-1'); + expect(store.recentItems()).toEqual([]); + }); + + it('removes recent items through the active data source in PWA', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + + store.removeRecentItem({ itemId: 3941697, playlistId: 'playlist-1' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(dataSource.removeRecentItem).toHaveBeenCalledWith( + 3941697, + 'playlist-1' + ); + expect(databaseService.removeRecentItem).not.toHaveBeenCalled(); + expect(store.recentItems()).toEqual([ + expect.objectContaining({ + id: 3941697, + title: 'Krypton', + }), + ]); + }); + + it('removes recent items through the active data source in Electron mode', async () => { + store.removeRecentItem({ itemId: 3941697, playlistId: 'playlist-1' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(dataSource.removeRecentItem).toHaveBeenCalledWith( + 3941697, + 'playlist-1' + ); + expect(databaseService.removeRecentItem).not.toHaveBeenCalled(); + expect(store.recentItems()).toEqual([ + expect.objectContaining({ + id: 3941697, + title: 'Krypton', + }), + ]); }); }); 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 a56bd5723..8d7fda351 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 @@ -14,6 +14,7 @@ import { PortalRecentItem, } from '@iptvnator/shared/interfaces'; import { createLogger } from '@iptvnator/portal/shared/util'; +import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface'; export interface RecentlyViewedItem extends PortalRecentItem { /** @deprecated Redundant — always equals `id`. Retained for compat. */ @@ -29,7 +30,7 @@ function mapDbRecentItem( backdrop_url?: string | null; viewed_at?: string; xtream_id: number; - category_id: number; + category_id: number | string; }, playlistId: string ): RecentlyViewedItem { @@ -53,11 +54,11 @@ export const withRecentItems = function () { withState({ recentItems: [], }), - withMethods((store, dbService = inject(DatabaseService)) => ({ + withMethods((store, dataSource = inject(XTREAM_DATA_SOURCE)) => ({ loadRecentItems: rxMethod<{ id: string }>( pipe( switchMap(async (playlist) => { - const items = await dbService.getRecentItems( + const items = await dataSource.getRecentItems( playlist.id ); return items.map((item) => @@ -74,10 +75,11 @@ export const withRecentItems = function () { ( store, dbService = inject(DatabaseService), - playlistsService = inject(PlaylistsService) + playlistsService = inject(PlaylistsService), + dataSource = inject(XTREAM_DATA_SOURCE) ) => ({ addRecentItem: rxMethod<{ - xtreamId: number; + xtreamId: number | string; contentType: 'live' | 'movie' | 'series'; playlist: Signal<{ id: string }>; backdropUrl?: string; @@ -90,30 +92,49 @@ export const withRecentItems = function () { playlist, backdropUrl, }) => { - const playlistId = playlist().id; - const content = await dbService.getContentByXtreamId( - xtreamId, - playlistId, - contentType - ); - if (content) { - await dbService.addRecentItem( - content.id, - playlistId, - backdropUrl - ); + const playlistId = playlist().id; + const normalizedXtreamId = Number(xtreamId); + if ( + !playlistId || + !Number.isFinite(normalizedXtreamId) || + normalizedXtreamId <= 0 + ) { + return; + } - // Reload after add/update so re-watched items - // immediately move to the top in recently-viewed. - const items = - await dbService.getRecentItems(playlistId); - patchState(store, { - recentItems: items.map((item) => - mapDbRecentItem(item, playlistId) - ), - }); + const content = + await dataSource.getContentByXtreamId( + normalizedXtreamId, + playlistId, + contentType + ); + const contentId = + content?.id ?? + (!window.electron + ? normalizedXtreamId + : null); + + if (contentId != null) { + await dataSource.addRecentItem( + contentId, + playlistId, + backdropUrl + ); + + // Reload after add/update so re-watched items + // immediately move to the top in recently-viewed. + const items = + await dataSource.getRecentItems( + playlistId + ); + patchState(store, { + recentItems: items.map((item) => + mapDbRecentItem(item, playlistId) + ), + }); + } } - }) + ) ) ), async backfillContentBackdrop({ @@ -122,23 +143,25 @@ export const withRecentItems = function () { playlist, backdropUrl, }: { - xtreamId: number; + xtreamId: number | string; contentType: 'live' | 'movie' | 'series'; playlist: Signal<{ id: string }>; backdropUrl?: string; }): Promise { - if (!window.electron) { - return; - } - const playlistId = playlist().id; + const normalizedXtreamId = Number(xtreamId); const normalizedBackdropUrl = backdropUrl?.trim(); - if (!playlistId || !normalizedBackdropUrl) { + if ( + !playlistId || + !Number.isFinite(normalizedXtreamId) || + normalizedXtreamId <= 0 || + !normalizedBackdropUrl + ) { return; } - const content = await dbService.getContentByXtreamId( - xtreamId, + const content = await dataSource.getContentByXtreamId( + normalizedXtreamId, playlistId, contentType ); @@ -146,17 +169,22 @@ export const withRecentItems = function () { return; } - await dbService.setContentBackdropIfMissing( + await dataSource.setContentBackdropIfMissing( content.id, + playlistId, normalizedBackdropUrl ); }, clearRecentItems: rxMethod<{ id: string }>( pipe( switchMap(async (playlist) => { - await dbService.clearPlaylistRecentItems( - playlist.id - ); + if (window.electron) { + await dbService.clearPlaylistRecentItems( + playlist.id + ); + } else { + await dataSource.clearRecentItems(playlist.id); + } patchState(store, { recentItems: [] }); }) ) @@ -167,13 +195,13 @@ export const withRecentItems = function () { }>( pipe( switchMap(async ({ itemId, playlistId }) => { - await dbService.removeRecentItem( + await dataSource.removeRecentItem( itemId, playlistId ); // Reload recent items to update UI const items = - await dbService.getRecentItems(playlistId); + await dataSource.getRecentItems(playlistId); patchState(store, { recentItems: items.map((item) => mapDbRecentItem(item, playlistId) @@ -189,14 +217,16 @@ export const withRecentItems = function () { const playlists = (await firstValueFrom( playlistsService.getAllPlaylists() )) as Playlist[]; - const playlistBackedItems = - buildPlaylistRecentItems(playlists, { + const playlistBackedItems = buildPlaylistRecentItems( + playlists, + { stalker: 'Stalker Portal', m3u: 'M3U', - }).map((item) => ({ - ...item, - content_id: item.id, - })) as RecentlyViewedItem[]; + } + ).map((item) => ({ + ...item, + content_id: item.id, + })) as RecentlyViewedItem[]; const normalizedXtream: RecentlyViewedItem[] = ( xtreamItems || [] @@ -236,24 +266,35 @@ export const withRecentItems = function () { }, async clearGlobalRecentlyViewed() { try { - await dbService.clearGlobalRecentlyViewed(); + if (window.electron) { + await dbService.clearGlobalRecentlyViewed(); + } const playlists = (await firstValueFrom( playlistsService.getAllPlaylists() )) as Playlist[]; await Promise.all( - playlists - .filter( - (playlist) => - Boolean(playlist.macAddress) || - !playlist.serverUrl - ) - .map((playlist) => - firstValueFrom( + playlists.map(async (playlist) => { + if ( + !window.electron && + playlist.serverUrl && + !playlist.macAddress + ) { + await dataSource.clearRecentItems( + playlist._id + ); + } + + if ( + Boolean(playlist.macAddress) || + !playlist.serverUrl + ) { + await firstValueFrom( playlistsService.clearPlaylistRecentlyViewed( playlist._id ) - ) - ) + ); + } + }) ); patchState(store, { recentItems: [] }); } catch (error) { 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 5acf14702..de551d98f 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 @@ -55,7 +55,7 @@ [title]="item.title || item.name" [posterUrl]="item.poster_url || item.cover" [type]="'series'" - [date]="getDate(item)" + [date]="getDate(item, true)" (cardClick)="openItem(item, 'series')" /> } diff --git a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.spec.ts b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.spec.ts new file mode 100644 index 000000000..c7caac068 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.spec.ts @@ -0,0 +1,164 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { RecentlyAddedComponent } from './recently-added.component'; + +jest.mock('@iptvnator/portal/shared/ui', () => ({ + ContentCardComponent: class {}, + ContentRailShellComponent: class {}, +})); + +interface TestRecentlyAddedItem { + readonly added?: string; + readonly category_id: number; + readonly last_modified?: string; + readonly poster_url: string; + readonly title: string; + readonly xtream_id: number; +} + +function toEpochSeconds(iso: string): string { + return String(Math.floor(Date.parse(iso) / 1000)); +} + +function createItem( + index: number, + overrides: Partial = {} +): TestRecentlyAddedItem { + return { + added: toEpochSeconds('2026-05-18T00:00:00.000Z'), + category_id: 10, + poster_url: '', + title: `Item ${index}`, + xtream_id: index, + ...overrides, + }; +} + +class MockXtreamStore { + readonly liveStreams = signal([]); + readonly vodStreams = signal([]); + readonly serialStreams = signal([]); + readonly currentPlaylist = signal({ id: 'playlist-1' }); + readonly isLoadingContent = signal(false); + readonly isLoadingCategories = signal(false); + readonly isImporting = signal(false); + readonly setSelectedContentType = jest.fn(); +} + +describe('RecentlyAddedComponent', () => { + let store: MockXtreamStore; + let dateNowSpy: jest.SpyInstance; + + beforeEach(() => { + dateNowSpy = jest + .spyOn(Date, 'now') + .mockReturnValue(Date.parse('2026-05-19T00:00:00.000Z')); + + TestBed.configureTestingModule({ + providers: [ + { + provide: XtreamStore, + useClass: MockXtreamStore, + }, + { + provide: Router, + useValue: { + navigate: jest.fn(), + }, + }, + { + provide: ActivatedRoute, + useValue: {}, + }, + { + provide: TranslateService, + useValue: { + currentLang: 'en', + defaultLang: 'en', + instant: jest.fn((key: string) => key), + onLangChange: of(null), + }, + }, + ], + }); + + store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; + }); + + afterEach(() => { + dateNowSpy.mockRestore(); + }); + + it('does not let far-future provider dates pin the first VOD rail slots', () => { + const freshItems = Array.from({ length: 21 }, (_, index) => + createItem(index + 1, { + added: String( + Math.floor( + (Date.parse('2026-05-18T00:00:00.000Z') - + index * 60_000) / + 1000 + ) + ), + title: `Fresh ${index + 1}`, + xtream_id: index + 1, + }) + ); + const futureItem = createItem(999, { + added: toEpochSeconds('2030-01-01T00:00:00.000Z'), + title: 'Future Provider Item', + xtream_id: 999, + }); + + store.vodStreams.set([futureItem, ...freshItems]); + + const component = TestBed.runInInjectionContext( + () => new RecentlyAddedComponent() + ); + const titles = component.recentlyAddedVod().map((item) => item.title); + + expect(titles).toHaveLength(20); + expect(titles[0]).toBe('Fresh 1'); + expect(titles).not.toContain('Future Provider Item'); + }); + + it('filters invalid provider dates before slicing small VOD rails', () => { + const futureItem = createItem(999, { + added: toEpochSeconds('2030-01-01T00:00:00.000Z'), + title: 'Future Provider Item', + xtream_id: 999, + }); + + store.vodStreams.set([ + futureItem, + createItem(1, { title: 'Fresh 1', xtream_id: 1 }), + createItem(2, { title: 'Fresh 2', xtream_id: 2 }), + ]); + + const component = TestBed.runInInjectionContext( + () => new RecentlyAddedComponent() + ); + const titles = component.recentlyAddedVod().map((item) => item.title); + + expect(titles).toEqual(['Fresh 1', 'Fresh 2']); + }); + + it('uses the series sort timestamp priority for displayed dates', () => { + const component = TestBed.runInInjectionContext( + () => new RecentlyAddedComponent() + ); + + expect( + component.getDate( + createItem(1, { + added: toEpochSeconds('2026-05-01T00:00:00.000Z'), + last_modified: toEpochSeconds('2026-05-15T00:00:00.000Z'), + }), + true + ) + ).toBe(Date.parse('2026-05-15T00:00:00.000Z')); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts index 9e9be6356..a37a4714b 100644 --- a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts +++ b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts @@ -1,4 +1,9 @@ -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, +} from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; @@ -7,6 +12,7 @@ import { ContentCardComponent, ContentRailShellComponent, } from '@iptvnator/portal/shared/ui'; +import { toXtreamRecentlyAddedTimestamp } from '@iptvnator/shared/interfaces'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { ContentType } from '@iptvnator/portal/xtream/data-access'; @@ -97,9 +103,7 @@ export class RecentlyAddedComponent { ); readonly vodSeeAllLink = computed(() => this.buildSectionLink('vod')); - readonly seriesSeeAllLink = computed(() => - this.buildSectionLink('series') - ); + readonly seriesSeeAllLink = computed(() => this.buildSectionLink('series')); readonly liveSeeAllLink = computed(() => this.buildSectionLink('live')); readonly vodSeeAllLabel = computed(() => @@ -112,7 +116,9 @@ export class RecentlyAddedComponent { this.translateWithTick('PORTALS.BROWSE_ALL_LIVE') ); - private buildSectionLink(section: 'vod' | 'series' | 'live'): string[] | null { + private buildSectionLink( + section: 'vod' | 'series' | 'live' + ): string[] | null { const id = this.playlistId(); if (!id) return null; return ['/workspace', 'xtreams', id, section]; @@ -127,20 +133,64 @@ export class RecentlyAddedComponent { items: T[], isSeries = false ): T[] { - return [...items] + const nowMs = Date.now(); + + return items + .map((item) => ({ + item, + sortTimestamp: this.getSortTimestamp(item, isSeries, nowMs), + })) + .filter(({ sortTimestamp }) => sortTimestamp > 0) .sort((a, b) => { - const dateA = - parseInt(isSeries ? a.last_modified : a.added) || 0; - const dateB = - parseInt(isSeries ? b.last_modified : b.added) || 0; - return dateB - dateA; + return b.sortTimestamp - a.sortTimestamp; }) - .slice(0, 20); + .slice(0, 20) + .map(({ item }) => item); } - getDate(item: RecentlyAddedItem): number { - const timestamp = item.added || item.last_modified; - return parseInt(timestamp) * 1000; + getDate(item: RecentlyAddedItem, isSeries = false): number { + const nowMs = Date.now(); + return ( + toXtreamRecentlyAddedTimestamp( + this.getPrimaryTimestamp(item, isSeries), + nowMs + ) || + toXtreamRecentlyAddedTimestamp( + this.getFallbackTimestamp(item, isSeries), + nowMs + ) + ); + } + + private getSortTimestamp( + item: RecentlyAddedItem, + isSeries: boolean, + nowMs: number + ): number { + return ( + toXtreamRecentlyAddedTimestamp( + this.getPrimaryTimestamp(item, isSeries), + nowMs + ) || + toXtreamRecentlyAddedTimestamp( + this.getFallbackTimestamp(item, isSeries), + nowMs + ) + ); + } + + private getPrimaryTimestamp( + item: RecentlyAddedItem, + isSeries: boolean + ): string | undefined { + return isSeries ? item.last_modified : item.added; + } + + private getFallbackTimestamp( + item: RecentlyAddedItem, + isSeries: boolean + ): string | undefined { + return isSeries ? item.added : item.last_modified; } openItem(item: RecentlyAddedItem, type: ContentType) { diff --git a/libs/services/src/lib/database-electron.service.spec.ts b/libs/services/src/lib/database-electron.service.spec.ts new file mode 100644 index 000000000..3c04e76d4 --- /dev/null +++ b/libs/services/src/lib/database-electron.service.spec.ts @@ -0,0 +1,78 @@ +import { DatabaseService } from './database-electron.service'; + +describe('DatabaseService browser guards', () => { + const originalElectron = window.electron; + let service: DatabaseService; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: undefined, + }); + consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + service = new DatabaseService(); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: originalElectron, + }); + }); + + it('treats app state persistence as unavailable without the Electron bridge', async () => { + await expect(service.getAppState('xtream-key')).resolves.toBeNull(); + await expect( + service.setAppState('xtream-key', 'completed') + ).resolves.toBe(false); + await expect( + service.getXtreamImportStatus('playlist-1', 'movie') + ).resolves.toBe('idle'); + await expect( + service.setXtreamImportStatus('playlist-1', 'movie', 'completed') + ).resolves.toBe(false); + await expect( + service.getContentByXtreamId(20229, 'playlist-1', 'movie') + ).resolves.toBeNull(); + await expect(service.getGlobalRecentlyViewed()).resolves.toEqual([]); + await expect(service.getGlobalFavorites()).resolves.toEqual([]); + await expect(service.getAllGlobalFavorites()).resolves.toEqual([]); + await expect( + service.clearGlobalRecentlyViewed() + ).resolves.toBeUndefined(); + await expect(service.addToFavorites(20229, 'playlist-1')).resolves.toBe( + false + ); + await expect( + service.removeFromFavorites(20229, 'playlist-1') + ).resolves.toBe(false); + await expect(service.isFavorite(20229, 'playlist-1')).resolves.toBe( + false + ); + await expect(service.getFavorites('playlist-1')).resolves.toEqual([]); + await expect(service.getRecentItems('playlist-1')).resolves.toEqual([]); + await expect(service.addRecentItem(20229, 'playlist-1')).resolves.toBe( + false + ); + await expect( + service.clearPlaylistRecentItems('playlist-1') + ).resolves.toBe(false); + await expect( + service.removeRecentItem(20229, 'playlist-1') + ).resolves.toBe(false); + await expect( + service.removeRecentItemsBatch([ + { contentId: 20229, playlistId: 'playlist-1' }, + ]) + ).resolves.toBe(false); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index 49d3cf002..e03a9489c 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -157,11 +157,11 @@ export class DatabaseService { } supportsDbOperationEvents(): boolean { - return typeof window.electron.onDbOperationEvent === 'function'; + return typeof window.electron?.onDbOperationEvent === 'function'; } supportsDbOperationCancellation(): boolean { - return typeof window.electron.dbCancelOperation === 'function'; + return typeof window.electron?.dbCancelOperation === 'function'; } async cancelOperation(operationId: string): Promise { @@ -496,6 +496,10 @@ export class DatabaseService { playlistId: string, type: 'live' | 'movie' | 'series' ): Promise { + if (typeof window.electron?.dbClearXtreamImportCache !== 'function') { + return false; + } + try { await window.electron.dbClearXtreamImportCache(playlistId, type); return true; @@ -506,6 +510,10 @@ export class DatabaseService { } async getAppState(key: string): Promise { + if (typeof window.electron?.dbGetAppState !== 'function') { + return null; + } + try { return await window.electron.dbGetAppState(key); } catch (error) { @@ -515,6 +523,10 @@ export class DatabaseService { } async setAppState(key: string, value: string): Promise { + if (typeof window.electron?.dbSetAppState !== 'function') { + return false; + } + try { await window.electron.dbSetAppState(key, value); return true; @@ -615,6 +627,10 @@ export class DatabaseService { * Get recently viewed items */ async getGlobalRecentlyViewed(): Promise { + if (typeof window.electron?.dbGetRecentlyViewed !== 'function') { + return []; + } + try { const items = await window.electron.dbGetRecentlyViewed(); return items || []; @@ -628,6 +644,10 @@ export class DatabaseService { * Get global favorites across all playlists */ async getGlobalFavorites(): Promise { + if (typeof window.electron?.dbGetGlobalFavorites !== 'function') { + return []; + } + try { const items = await window.electron.dbGetGlobalFavorites(); return items || []; @@ -641,6 +661,10 @@ export class DatabaseService { * Get global favorites across all playlists (all content types) */ async getAllGlobalFavorites(): Promise { + if (typeof window.electron?.dbGetAllGlobalFavorites !== 'function') { + return []; + } + try { const items = await window.electron.dbGetAllGlobalFavorites(); return items || []; @@ -654,6 +678,10 @@ export class DatabaseService { * Clear recently viewed items */ async clearGlobalRecentlyViewed(): Promise { + if (typeof window.electron?.dbClearRecentlyViewed !== 'function') { + return; + } + try { await window.electron.dbClearRecentlyViewed(); } catch (error) { @@ -694,6 +722,10 @@ export class DatabaseService { playlistId: string, backdropUrl?: string ): Promise { + if (typeof window.electron?.dbAddFavorite !== 'function') { + return false; + } + try { await window.electron.dbAddFavorite( contentId, @@ -714,6 +746,10 @@ export class DatabaseService { contentId: number, playlistId: string ): Promise { + if (typeof window.electron?.dbRemoveFavorite !== 'function') { + return false; + } + try { await window.electron.dbRemoveFavorite(contentId, playlistId); return true; @@ -727,6 +763,10 @@ export class DatabaseService { * Check if content is favorited */ async isFavorite(contentId: number, playlistId: string): Promise { + if (typeof window.electron?.dbIsFavorite !== 'function') { + return false; + } + try { return await window.electron.dbIsFavorite(contentId, playlistId); } catch (error) { @@ -739,6 +779,10 @@ export class DatabaseService { * Get all favorites for a playlist */ async getFavorites(playlistId: string): Promise { + if (typeof window.electron?.dbGetFavorites !== 'function') { + return []; + } + try { return await window.electron.dbGetFavorites(playlistId); } catch (error) { @@ -751,6 +795,10 @@ export class DatabaseService { * Get recently viewed items for a specific playlist */ async getRecentItems(playlistId: string): Promise { + if (typeof window.electron?.dbGetRecentItems !== 'function') { + return []; + } + try { return await window.electron.dbGetRecentItems(playlistId); } catch (error) { @@ -767,6 +815,10 @@ export class DatabaseService { playlistId: string, backdropUrl?: string ): Promise { + if (typeof window.electron?.dbAddRecentItem !== 'function') { + return false; + } + try { await window.electron.dbAddRecentItem( contentId, @@ -813,6 +865,10 @@ export class DatabaseService { * Clear recently viewed for a specific playlist */ async clearPlaylistRecentItems(playlistId: string): Promise { + if (typeof window.electron?.dbClearPlaylistRecentItems !== 'function') { + return false; + } + try { await window.electron.dbClearPlaylistRecentItems(playlistId); return true; @@ -829,6 +885,10 @@ export class DatabaseService { contentId: number, playlistId: string ): Promise { + if (typeof window.electron?.dbRemoveRecentItem !== 'function') { + return false; + } + try { await window.electron.dbRemoveRecentItem(contentId, playlistId); return true; @@ -844,6 +904,10 @@ export class DatabaseService { if (items.length === 0) { return true; } + if (typeof window.electron?.dbRemoveRecentItemsBatch !== 'function') { + return false; + } + try { await window.electron.dbRemoveRecentItemsBatch(items); return true; @@ -861,6 +925,10 @@ export class DatabaseService { playlistId: string, contentType?: 'live' | 'movie' | 'series' ): Promise { + if (typeof window.electron?.dbGetContentByXtreamId !== 'function') { + return null; + } + try { return await window.electron.dbGetContentByXtreamId( xtreamId, diff --git a/libs/shared/database/src/lib/connection.spec.ts b/libs/shared/database/src/lib/connection.spec.ts index 5f60cd3fc..8dcf6dfca 100644 --- a/libs/shared/database/src/lib/connection.spec.ts +++ b/libs/shared/database/src/lib/connection.spec.ts @@ -22,6 +22,7 @@ describe('database schema statements', () => { createTableStatements, columnMigrationStatements, indexMigrationStatements, + normalizeXtreamContentAddedEpochs, } = __databaseConnectionTestHooks; it('defines the core fresh-install tables, indexes, and FTS triggers', () => { @@ -117,4 +118,56 @@ describe('database schema statements', () => { expect(objectNames).toHaveLength(new Set(objectNames).size); }); + + it('runs the legacy millisecond Xtream timestamp normalization once', () => { + const selectGet = jest.fn().mockReturnValue(undefined); + const updateRun = jest.fn(); + const stateRun = jest.fn(); + const prepare = jest.fn((statement: string) => { + if (statement.includes('SELECT value FROM app_state')) { + return { get: selectGet }; + } + if (statement.includes('UPDATE content')) { + return { run: updateRun }; + } + if (statement.includes('INSERT INTO app_state')) { + return { run: stateRun }; + } + + throw new Error(`Unexpected statement: ${compactSql(statement)}`); + }); + const transaction = jest.fn((callback: () => void) => callback); + const sqlite = { + prepare, + transaction, + } as unknown as Parameters[0]; + + normalizeXtreamContentAddedEpochs(sqlite); + + expect(transaction).toHaveBeenCalledTimes(1); + expect(updateRun).toHaveBeenCalledWith(10_000_000_000, 10_000_000_000); + expect(stateRun).toHaveBeenCalledWith( + 'migration:xtream-content-added-epoch-seconds:v1' + ); + }); + + it('skips legacy Xtream timestamp normalization after it has run', () => { + const selectGet = jest.fn().mockReturnValue({ value: 'done' }); + const prepare = jest.fn((statement: string) => { + if (statement.includes('SELECT value FROM app_state')) { + return { get: selectGet }; + } + + throw new Error(`Unexpected statement: ${compactSql(statement)}`); + }); + const transaction = jest.fn((callback: () => void) => callback); + const sqlite = { + prepare, + transaction, + } as unknown as Parameters[0]; + + normalizeXtreamContentAddedEpochs(sqlite); + + expect(transaction).not.toHaveBeenCalled(); + }); }); diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index 6f1e08763..ae2649101 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -23,6 +23,10 @@ let db: DatabaseInstance | null = null; let sqlite: Database.Database | null = null; let initPromise: Promise | null = null; +const XTREAM_ADDED_EPOCH_MILLISECONDS_THRESHOLD = 10_000_000_000; +const XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY = + 'migration:xtream-content-added-epoch-seconds:v1'; + function readTraceFlag(name: string): boolean { const value = process.env[name]?.trim().toLowerCase(); return value ? TRACE_ENV_TRUE_VALUES.has(value) : false; @@ -298,6 +302,7 @@ export const __databaseConnectionTestHooks = { createTableStatements: CREATE_TABLE_STATEMENTS, columnMigrationStatements: COLUMN_MIGRATION_STATEMENTS, indexMigrationStatements: INDEX_MIGRATION_STATEMENTS, + normalizeXtreamContentAddedEpochs, } as const; /** @@ -437,7 +442,9 @@ function deduplicateXtreamCache(sqliteDb: Database.Database): void { const deleteRecentlyViewed = sqliteDb.prepare( `DELETE FROM recently_viewed WHERE content_id = ?` ); - const deleteContent = sqliteDb.prepare(`DELETE FROM content WHERE id = ?`); + const deleteContent = sqliteDb.prepare( + `DELETE FROM content WHERE id = ?` + ); for (const group of duplicateContentGroups) { const candidates = selectContentCandidates.all( @@ -464,6 +471,58 @@ function deduplicateXtreamCache(sqliteDb: Database.Database): void { executeCleanup(); } +function normalizeXtreamContentAddedEpochs(sqliteDb: Database.Database): void { + try { + const migrationState = sqliteDb + .prepare(`SELECT value FROM app_state WHERE key = ?`) + .get(XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY) as + | { value?: unknown } + | undefined; + + if (migrationState?.value === 'done') { + return; + } + + const executeMigration = sqliteDb.transaction(() => { + sqliteDb + .prepare( + `UPDATE content + SET added = CAST(CAST(added AS INTEGER) / 1000 AS TEXT) + WHERE added IS NOT NULL + AND added <> '' + AND added NOT GLOB '*[^0-9]*' + AND CAST(added AS INTEGER) >= ? + AND CAST(added AS INTEGER) / 1000 < ?` + ) + .run( + XTREAM_ADDED_EPOCH_MILLISECONDS_THRESHOLD, + XTREAM_ADDED_EPOCH_MILLISECONDS_THRESHOLD + ); + + sqliteDb + .prepare( + `INSERT INTO app_state (key, value, updated_at) + VALUES (?, 'done', datetime('now')) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at` + ) + .run(XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY); + }); + + executeMigration(); + } catch (error) { + const message = + typeof error === 'object' && error !== null && 'message' in error + ? String((error as { message?: unknown }).message ?? error) + : String(error); + + console.warn( + `Xtream added timestamp normalization failed (continuing): ${message}` + ); + } +} + function runMigrationStatements( sqliteDb: Database.Database, statements: string[] @@ -498,6 +557,7 @@ function runMigrationStatements( function runMigrations(sqliteDb: Database.Database): void { runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS); deduplicateXtreamCache(sqliteDb); + normalizeXtreamContentAddedEpochs(sqliteDb); runMigrationStatements(sqliteDb, INDEX_MIGRATION_STATEMENTS); } diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 93670032f..747be3c26 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -36,6 +36,7 @@ export * from './lib/xtream-item.interface'; export * from './lib/xtream-live-stream.interface'; export * from './lib/xtream-response.interface'; export * from './lib/xtream-restore-state.util'; +export * from './lib/xtream-recently-added.utils'; export * from './lib/xtream-serie-details.interface'; export * from './lib/xtream-serie-item.interface'; export * from './lib/xtream-vod-details.interface'; diff --git a/libs/shared/interfaces/src/lib/xtream-recently-added.utils.spec.ts b/libs/shared/interfaces/src/lib/xtream-recently-added.utils.spec.ts new file mode 100644 index 000000000..2ca8e62d2 --- /dev/null +++ b/libs/shared/interfaces/src/lib/xtream-recently-added.utils.spec.ts @@ -0,0 +1,28 @@ +import { + getXtreamRecentlyAddedMaxEpochSeconds, + toXtreamRecentlyAddedTimestamp, +} from './xtream-recently-added.utils'; + +describe('Xtream recently-added timestamp helpers', () => { + const now = Date.parse('2026-05-19T00:00:00.000Z'); + + it('treats far-future provider timestamps as invalid for recent ranking', () => { + expect(toXtreamRecentlyAddedTimestamp('1893456000', now)).toBe(0); + }); + + it('normalizes valid epoch seconds to milliseconds', () => { + expect(toXtreamRecentlyAddedTimestamp('1779062400', now)).toBe( + Date.parse('2026-05-18T00:00:00.000Z') + ); + }); + + it('treats the epoch unit threshold itself as milliseconds', () => { + expect(toXtreamRecentlyAddedTimestamp('10000000000', now)).toBe( + 10_000_000_000 + ); + }); + + it('builds the query upper-bound with the same future grace window', () => { + expect(getXtreamRecentlyAddedMaxEpochSeconds(now)).toBe('1779235200'); + }); +}); diff --git a/libs/shared/interfaces/src/lib/xtream-recently-added.utils.ts b/libs/shared/interfaces/src/lib/xtream-recently-added.utils.ts new file mode 100644 index 000000000..2e001f989 --- /dev/null +++ b/libs/shared/interfaces/src/lib/xtream-recently-added.utils.ts @@ -0,0 +1,60 @@ +const EPOCH_MILLISECONDS_THRESHOLD = 10_000_000_000; + +export const XTREAM_RECENTLY_ADDED_FUTURE_GRACE_MS = 24 * 60 * 60 * 1000; + +function normalizeEpochNumber(value: number): number { + return value >= EPOCH_MILLISECONDS_THRESHOLD ? value : value * 1000; +} + +function parseTimestampMs(value: unknown): number { + if (typeof value === 'number') { + return normalizeEpochNumber(value); + } + + if (typeof value !== 'string') { + return 0; + } + + const trimmed = value.trim(); + if (!trimmed) { + return 0; + } + + if (/^\d+$/.test(trimmed)) { + return normalizeEpochNumber(Number(trimmed)); + } + + return 0; +} + +export function toXtreamRecentlyAddedTimestamp( + value: unknown, + nowMs = Date.now() +): number { + const timestampMs = parseTimestampMs(value); + if (!Number.isFinite(timestampMs) || timestampMs <= 0) { + return 0; + } + + if (timestampMs > nowMs + XTREAM_RECENTLY_ADDED_FUTURE_GRACE_MS) { + return 0; + } + + return timestampMs; +} + +export function toXtreamRecentlyAddedEpochSeconds( + value: unknown, + nowMs = Date.now() +): string { + const timestampMs = toXtreamRecentlyAddedTimestamp(value, nowMs); + return timestampMs > 0 ? String(Math.floor(timestampMs / 1000)) : ''; +} + +export function getXtreamRecentlyAddedMaxEpochSeconds( + nowMs = Date.now() +): string { + return String( + Math.floor((nowMs + XTREAM_RECENTLY_ADDED_FUTURE_GRACE_MS) / 1000) + ); +} diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 09f2453dc..8bb5317d0 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -2,7 +2,10 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state'; +import { + selectAllPlaylistsMeta, + selectPlaylistsLoadingFlag, +} from '@iptvnator/m3u-state'; import { of } from 'rxjs'; import { DatabaseService, PlaylistsService } from '@iptvnator/services'; import { @@ -11,12 +14,31 @@ import { PlaylistMeta, } from '@iptvnator/shared/interfaces'; import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util'; -import { DashboardDataService } from './dashboard-data.service'; +import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access'; +import { + DashboardDataService, + DashboardFavoriteItem, + GlobalRecentItem, +} from './dashboard-data.service'; describe('DashboardDataService', () => { let service: DashboardDataService; const playlistsLoadedSignal = signal(true); + const createPendingItems = () => { + let resolvePending: (items: T[]) => void = () => { + throw new Error('Pending item promise resolved before init'); + }; + const promise = new Promise((resolve) => { + resolvePending = resolve; + }); + + return { + promise, + resolve: resolvePending, + }; + }; + const createDefaultPlaylists = (): PlaylistMeta[] => [ { _id: 'm3u-1', @@ -73,6 +95,12 @@ describe('DashboardDataService', () => { removeFromFavorites: jest.fn().mockResolvedValue(undefined), removeRecentItem: jest.fn().mockResolvedValue(undefined), }; + const xtreamDataSourceMock = { + getFavorites: jest.fn().mockResolvedValue([]), + getRecentItems: jest.fn().mockResolvedValue([]), + removeFavorite: jest.fn().mockResolvedValue(undefined), + removeRecentItem: jest.fn().mockResolvedValue(undefined), + }; const playlistMock: Playlist = { _id: 'm3u-1', title: 'M3U Playlist', @@ -153,6 +181,14 @@ describe('DashboardDataService', () => { dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([]); dbServiceMock.removeFromFavorites.mockClear(); dbServiceMock.removeRecentItem.mockClear(); + xtreamDataSourceMock.getFavorites.mockClear(); + xtreamDataSourceMock.getFavorites.mockResolvedValue([]); + xtreamDataSourceMock.getRecentItems.mockClear(); + xtreamDataSourceMock.getRecentItems.mockResolvedValue([]); + xtreamDataSourceMock.removeFavorite.mockClear(); + xtreamDataSourceMock.removeFavorite.mockResolvedValue(undefined); + xtreamDataSourceMock.removeRecentItem.mockClear(); + xtreamDataSourceMock.removeRecentItem.mockResolvedValue(undefined); storeMock.dispatch.mockClear(); playbackPositionsMock.getAllPlaybackPositions.mockClear(); @@ -163,6 +199,10 @@ describe('DashboardDataService', () => { DashboardDataService, { provide: Store, useValue: storeMock }, { provide: DatabaseService, useValue: dbServiceMock }, + { + provide: XTREAM_DATA_SOURCE, + useValue: xtreamDataSourceMock, + }, { provide: PlaylistsService, useValue: playlistsServiceMock, @@ -348,6 +388,139 @@ describe('DashboardDataService', () => { ).toHaveLength(1); }); + it('includes PWA Xtream favorites from the active data source', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + xtreamDataSourceMock.getFavorites.mockResolvedValue([ + { + id: 51, + category_id: 12, + title: 'Action Movie', + rating: '8.0', + added_at: '2026-02-02T10:00:00.000Z', + poster_url: 'https://example.com/movie.png', + xtream_id: 5001, + type: 'movie', + }, + ]); + + await service.reloadGlobalFavorites(); + + expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith( + 'xtream-1' + ); + expect(dbServiceMock.getAllGlobalFavorites).not.toHaveBeenCalled(); + expect(service.globalFavoriteItems()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 51, + title: 'Action Movie', + type: 'movie', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + source: 'xtream', + }), + ]) + ); + }); + + it('loads PWA Xtream favorites for multiple playlists in parallel', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + playlistsSignal.set([ + { + _id: 'xtream-1', + title: 'Xtream One', + serverUrl: 'https://one.example.com', + }, + { + _id: 'xtream-2', + title: 'Xtream Two', + serverUrl: 'https://two.example.com', + }, + ]); + const firstFavorites = createPendingItems(); + xtreamDataSourceMock.getFavorites.mockImplementation((playlistId) => + playlistId === 'xtream-1' + ? firstFavorites.promise + : Promise.resolve([]) + ); + + const reload = service.reloadGlobalFavorites(); + await Promise.resolve(); + + expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith( + 'xtream-1' + ); + expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith( + 'xtream-2' + ); + + firstFavorites.resolve([]); + await reload; + }); + + it('uses the epoch fallback for PWA Xtream favorites with empty added dates', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + xtreamDataSourceMock.getFavorites.mockResolvedValue([ + { + id: 52, + category_id: 12, + title: 'Undated Movie', + rating: '8.0', + added: '', + poster_url: 'https://example.com/movie.png', + xtream_id: 5002, + type: 'movie', + }, + ]); + + await service.reloadGlobalFavorites(); + + expect(service.globalFavoriteItems()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 52, + added_at: new Date(0).toISOString(), + }), + ]) + ); + }); + + it('does not load Stalker playlists through the PWA Xtream favorites path', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + playlistsSignal.set([ + { + _id: 'xtream-1', + title: 'Xtream Playlist', + serverUrl: 'https://xtream.example.com', + }, + { + _id: 'stalker-1', + title: 'Stalker Playlist', + serverUrl: 'https://stalker.example.com', + macAddress: '00:11:22:33:44:55', + }, + ]); + + await service.reloadGlobalFavorites(); + + expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledTimes(1); + expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith( + 'xtream-1' + ); + }); + it('builds the M3U favorites route', async () => { await service.reloadGlobalFavorites(); const m3uItem = service @@ -428,6 +601,35 @@ describe('DashboardDataService', () => { ); }); + it('removes PWA Xtream favorites through the active data source', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + const item = { + id: 51, + category_id: 12, + title: 'Action Movie', + poster_url: 'https://example.com/movie.png', + xtream_id: 5001, + type: 'movie', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + source: 'xtream', + } satisfies DashboardFavoriteItem; + + await service.removeGlobalFavorite(item); + + expect(xtreamDataSourceMock.removeFavorite).toHaveBeenCalledWith( + 51, + 'xtream-1' + ); + expect(dbServiceMock.removeFromFavorites).not.toHaveBeenCalled(); + expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith( + 'xtream-1' + ); + }); + it('includes M3U recently viewed items in global recent items', () => { expect(service.globalRecentItems()).toEqual( expect.arrayContaining([ @@ -910,6 +1112,110 @@ describe('DashboardDataService', () => { ).toEqual(['xtream-1', 'stalker-1', 'm3u-1']); }); + it('includes PWA Xtream recent items from the active data source', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + xtreamDataSourceMock.getRecentItems.mockResolvedValue([ + { + id: 91, + category_id: 18, + title: 'Recent Movie', + rating: '7.8', + viewed_at: '2026-04-21T10:00:00.000Z', + poster_url: 'https://example.com/recent-movie.png', + xtream_id: 7001, + type: 'movie', + }, + ]); + + await service.reloadGlobalRecentItems(); + + expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + expect(dbServiceMock.getGlobalRecentlyViewed).not.toHaveBeenCalled(); + expect(service.globalRecentItems()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 91, + title: 'Recent Movie', + type: 'movie', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + source: 'xtream', + viewed_at: '2026-04-21T10:00:00.000Z', + }), + ]) + ); + }); + + it('loads PWA Xtream recent items for multiple playlists in parallel', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + playlistsSignal.set([ + { + _id: 'xtream-1', + title: 'Xtream One', + serverUrl: 'https://one.example.com', + }, + { + _id: 'xtream-2', + title: 'Xtream Two', + serverUrl: 'https://two.example.com', + }, + ]); + const firstRecentItems = createPendingItems(); + xtreamDataSourceMock.getRecentItems.mockImplementation((playlistId) => + playlistId === 'xtream-1' + ? firstRecentItems.promise + : Promise.resolve([]) + ); + + const reload = service.reloadGlobalRecentItems(); + await Promise.resolve(); + + expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith( + 'xtream-2' + ); + + firstRecentItems.resolve([]); + await reload; + }); + + it('does not load Stalker playlists through the PWA Xtream recent path', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + playlistsSignal.set([ + { + _id: 'xtream-1', + title: 'Xtream Playlist', + serverUrl: 'https://xtream.example.com', + }, + { + _id: 'stalker-1', + title: 'Stalker Playlist', + serverUrl: 'https://stalker.example.com', + macAddress: '00:11:22:33:44:55', + }, + ]); + + await service.reloadGlobalRecentItems(); + + expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledTimes(1); + expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + }); + it('falls back to playlist metadata ordering when sources have no recent activity', async () => { playlistsSignal.set([ { @@ -1118,4 +1424,34 @@ describe('DashboardDataService', () => { playlistsServiceMock.removeFromM3uRecentlyViewed ).toHaveBeenCalledWith('m3u-1', 'https://example.com/stream-1.m3u8'); }); + + it('removes PWA Xtream recently viewed through the active data source', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + const item = { + id: 91, + category_id: 18, + title: 'Recent Movie', + viewed_at: '2026-04-21T10:00:00.000Z', + poster_url: 'https://example.com/recent-movie.png', + xtream_id: 7001, + type: 'movie', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + source: 'xtream', + } satisfies GlobalRecentItem; + + await service.removeGlobalRecentItem(item); + + expect(xtreamDataSourceMock.removeRecentItem).toHaveBeenCalledWith( + 91, + 'xtream-1' + ); + expect(dbServiceMock.removeRecentItem).not.toHaveBeenCalled(); + expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + }); }); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 1cf7d2962..261f291dc 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -20,6 +20,10 @@ import { GlobalRecentlyAddedKind, PlaylistsService, } from '@iptvnator/services'; +import { + XTREAM_DATA_SOURCE, + XtreamContentItem, +} from '@iptvnator/portal/xtream/data-access'; import { buildPlaylistRecentItems, Channel, @@ -99,6 +103,7 @@ export type DashboardRecentlyAddedFilterKind = GlobalRecentlyAddedKind; export class DashboardDataService { private readonly store = inject(Store); private readonly dbService = inject(DatabaseService); + private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE); private readonly playlistsService = inject(PlaylistsService); private readonly ngZone = inject(NgZone); private readonly translate = inject(TranslateService); @@ -475,7 +480,10 @@ export class DashboardDataService { } if (!window.electron) { - this.xtreamGlobalRecentItems.set([]); + const recentItems = await this.loadPwaXtreamGlobalRecentItems(); + this.ngZone.run(() => + this.xtreamGlobalRecentItems.set(recentItems) + ); this.globalRecentDbLoadedState.set(true); this.finishInitialGlobalRecentLoadIfReady(); return; @@ -577,7 +585,8 @@ export class DashboardDataService { private async reloadXtreamGlobalFavorites(): Promise { if (!window.electron) { - this.xtreamGlobalFavorites.set([]); + const favorites = await this.loadPwaXtreamGlobalFavorites(); + this.ngZone.run(() => this.xtreamGlobalFavorites.set(favorites)); this.finishInitialGlobalFavoritesLoadIfReady(); return; } @@ -599,6 +608,95 @@ export class DashboardDataService { } } + private async loadPwaXtreamGlobalRecentItems(): Promise< + GlobalRecentItem[] + > { + const nested = await Promise.all( + this.getXtreamPlaylists().map(async (playlist) => { + const rows = await this.xtreamDataSource.getRecentItems( + playlist._id + ); + return rows.map((item) => + this.mapPwaXtreamRecentItem(item, playlist) + ); + }) + ); + return nested.reduce( + (items, playlistItems) => items.concat(playlistItems), + [] + ); + } + + private async loadPwaXtreamGlobalFavorites(): Promise< + DashboardFavoriteItem[] + > { + const nested = await Promise.all( + this.getXtreamPlaylists().map(async (playlist) => { + const rows = await this.xtreamDataSource.getFavorites( + playlist._id + ); + return rows.map((item) => + this.mapPwaXtreamFavoriteItem(item, playlist) + ); + }) + ); + return nested.reduce( + (items, playlistItems) => items.concat(playlistItems), + [] + ); + } + + private getXtreamPlaylists(): PlaylistMeta[] { + return this.playlists().filter( + (playlist) => !!playlist.serverUrl && !playlist.macAddress + ); + } + + private mapPwaXtreamRecentItem( + item: XtreamContentItem, + playlist: PlaylistMeta + ): GlobalRecentItem { + return { + id: item.id, + title: item.title, + type: this.normalizeXtreamActivityType(item.type), + playlist_id: playlist._id, + playlist_name: playlist.title || 'Xtream', + viewed_at: item.viewed_at ?? '', + category_id: item.category_id, + xtream_id: item.xtream_id, + poster_url: item.poster_url, + backdrop_url: item.backdrop_url ?? undefined, + source: 'xtream', + }; + } + + private mapPwaXtreamFavoriteItem( + item: XtreamContentItem, + playlist: PlaylistMeta + ): DashboardFavoriteItem { + return { + id: item.id, + title: item.title, + type: this.normalizeXtreamActivityType(item.type), + playlist_id: playlist._id, + playlist_name: playlist.title || 'Xtream', + added_at: item.added_at || item.added || new Date(0).toISOString(), + category_id: item.category_id, + xtream_id: item.xtream_id, + poster_url: item.poster_url, + backdrop_url: item.backdrop_url ?? undefined, + source: 'xtream', + }; + } + + private normalizeXtreamActivityType(type: string): PortalActivityType { + if (type === 'live' || type === 'series') { + return type; + } + return 'movie'; + } + private async refreshPlaylistBackedGlobalFavorites(): Promise { await this.reloadM3uGlobalFavorites(); this.finishInitialGlobalFavoritesLoadIfReady(); @@ -762,10 +860,17 @@ export class DashboardDataService { async removeGlobalRecentItem(item: GlobalRecentItem): Promise { if (item.source === 'xtream') { - await this.dbService.removeRecentItem( - item.id as number, - item.playlist_id - ); + if (window.electron) { + await this.dbService.removeRecentItem( + item.id as number, + item.playlist_id + ); + } else { + await this.xtreamDataSource.removeRecentItem( + item.id as number, + item.playlist_id + ); + } await this.reloadGlobalRecentItems(); return; } @@ -916,10 +1021,17 @@ export class DashboardDataService { async removeGlobalFavorite(item: DashboardFavoriteItem): Promise { if (item.source === 'xtream') { - await this.dbService.removeFromFavorites( - item.id as number, - item.playlist_id - ); + if (window.electron) { + await this.dbService.removeFromFavorites( + item.id as number, + item.playlist_id + ); + } else { + await this.xtreamDataSource.removeFavorite( + item.id as number, + item.playlist_id + ); + } await this.reloadGlobalFavorites(); return; } @@ -1052,8 +1164,7 @@ export class DashboardDataService { const matchedFavoriteId = channelIdFavoritePosition !== undefined && (channelUrlFavoritePosition === undefined || - channelIdFavoritePosition <= - channelUrlFavoritePosition) + channelIdFavoritePosition <= channelUrlFavoritePosition) ? channelId : channelUrlFavoritePosition !== undefined ? channelUrl diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.html b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.html new file mode 100644 index 000000000..bb6925e26 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.html @@ -0,0 +1,105 @@ +

+
+
+ +
+

+ {{ 'WORKSPACE.SHORTCUTS.TITLE' | translate }} +

+

{{ 'WORKSPACE.SHORTCUTS.SUBTITLE' | translate }}

+
+
+
+ + + {{ data.platformLabelKey | translate }} + + +
+
+ + +
+ @for (group of groups; track group.id) { +
+
+ +

{{ group.labelKey | translate }}

+
+ +
+ @for (item of group.items; track item.id) { +
+ + + {{ item.labelKey | translate }} + + + @for ( + chord of item.chords; + track chord.id + ) { + + @for ( + key of chord.keys; + track key.id + ) { + + {{ key.label }} + + @if (!$last) { + + } + } + + } + +
+ } +
+
+ } +
+
+ + + + +
diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.scss b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.scss new file mode 100644 index 000000000..84943135c --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.scss @@ -0,0 +1,342 @@ +:host { + display: block; + app-region: no-drag; +} + +.shortcuts-dialog { + --shortcut-surface: var(--app-widget-bg, #1e2330); + --shortcut-surface-raised: var(--app-widget-header-bg, #232840); + --shortcut-text: var(--app-heading-color, #d8dce8); + --shortcut-muted: var(--app-body-color, #8b93a8); + --shortcut-border: var(--app-separator, rgba(255, 255, 255, 0.14)); + --shortcut-accent: var(--app-selection-color, #78adff); + --shortcut-panel-bg: color-mix( + in srgb, + var(--shortcut-surface) 86%, + var(--shortcut-surface-raised) 14% + ); + --shortcut-panel-hover: color-mix( + in srgb, + var(--shortcut-accent) 10%, + var(--shortcut-surface-raised) 90% + ); + --shortcut-key-bg: color-mix( + in srgb, + var(--shortcut-surface-raised) 88%, + white 12% + ); + --shortcut-key-shadow: color-mix(in srgb, black 24%, transparent); + + color: var(--shortcut-text); + background: + linear-gradient( + 145deg, + color-mix( + in srgb, + var(--shortcut-accent) 8%, + var(--shortcut-surface) 92% + ), + var(--shortcut-surface) 42% + ), + var(--shortcut-surface); + border: 1px solid var(--shortcut-border); + border-radius: 16px; + box-shadow: 0 22px 70px color-mix(in srgb, black 34%, transparent); + overflow: hidden; +} + +.shortcuts-dialog__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 20px 22px 16px; + border-bottom: 1px solid var(--shortcut-border); +} + +.shortcuts-dialog__title-block { + display: flex; + align-items: center; + min-width: 0; + gap: 14px; +} + +.shortcuts-dialog__hero-icon { + display: inline-grid; + flex: 0 0 auto; + place-items: center; + width: 46px; + height: 46px; + color: var(--shortcut-accent); + background: color-mix(in srgb, var(--shortcut-accent) 14%, transparent); + border: 1px solid + color-mix(in srgb, var(--shortcut-accent) 28%, transparent); + border-radius: 8px; + + mat-icon { + width: 24px; + height: 24px; + font-size: 24px; + } +} + +.shortcuts-dialog__header h2 { + margin: 0; + padding: 0; + font-size: 1.08rem; + font-weight: 700; + line-height: 1.25; +} + +.shortcuts-dialog__header p { + margin: 5px 0 0; + color: var(--shortcut-muted); + font-size: 0.86rem; + line-height: 1.4; +} + +.shortcuts-dialog__header-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; +} + +.shortcuts-dialog__platform { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 32px; + padding: 0 10px; + color: var(--shortcut-muted); + background: color-mix( + in srgb, + var(--shortcut-surface-raised) 82%, + var(--shortcut-surface) 18% + ); + border: 1px solid var(--shortcut-border); + border-radius: 8px; + font-size: 0.76rem; + font-weight: 650; + line-height: 1; + white-space: nowrap; + + mat-icon { + width: 17px; + height: 17px; + font-size: 17px; + } +} + +.shortcuts-dialog__close { + flex: 0 0 auto; +} + +.shortcuts-dialog__content { + max-height: min(68vh, 670px); + padding: 12px; +} + +.shortcuts-dialog__groups { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.shortcuts-dialog__group { + min-width: 0; + background: var(--shortcut-panel-bg); + border: 1px solid var(--shortcut-border); + border-radius: 8px; + overflow: hidden; +} + +.shortcuts-dialog__group-header { + display: flex; + align-items: center; + gap: 9px; + min-height: 38px; + padding: 8px 12px; + border-bottom: 1px solid + color-mix(in srgb, var(--shortcut-border) 72%, transparent); +} + +.shortcuts-dialog__group-icon, +.shortcuts-dialog__action-icon { + display: inline-grid; + flex: 0 0 auto; + place-items: center; + border-radius: 7px; +} + +.shortcuts-dialog__group-icon { + width: 28px; + height: 28px; + color: var(--shortcut-accent); + background: color-mix(in srgb, var(--shortcut-accent) 12%, transparent); + + mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + } +} + +.shortcuts-dialog__group h3 { + margin: 0; + color: var(--shortcut-text); + font-size: 0.74rem; + font-weight: 750; + line-height: 1.2; + text-transform: uppercase; +} + +.shortcuts-dialog__rows { + display: grid; + gap: 1px; +} + +.shortcuts-dialog__row { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) max-content; + gap: 10px; + align-items: center; + min-height: 42px; + padding: 7px 10px; + font-size: 0.88rem; + line-height: 1.35; + transition: + background-color 140ms ease, + transform 140ms ease; + + &:hover { + background: var(--shortcut-panel-hover); + } +} + +.shortcuts-dialog__action-icon { + width: 28px; + height: 28px; + color: var(--shortcut-muted); + background: color-mix( + in srgb, + var(--shortcut-surface-raised) 78%, + var(--shortcut-surface) 22% + ); + + mat-icon { + width: 17px; + height: 17px; + font-size: 17px; + } +} + +.shortcuts-dialog__label { + min-width: 0; + color: var(--shortcut-text); + font-size: 0.85rem; +} + +.shortcuts-dialog__chords { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px 8px; + min-width: 0; +} + +.shortcuts-dialog__chord { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.shortcuts-dialog__joiner { + color: var(--shortcut-muted); + font-size: 0.68rem; + font-weight: 700; +} + +.shortcuts-dialog__chord kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + min-height: 27px; + padding: 2px 8px 3px; + color: var(--shortcut-text); + background: var(--shortcut-key-bg); + border: 1px solid var(--shortcut-border); + border-radius: 6px; + box-shadow: + inset 0 -2px 0 var(--shortcut-key-shadow), + 0 1px 0 color-mix(in srgb, white 28%, transparent); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; + font-weight: 750; + line-height: 1; + white-space: nowrap; +} + +.shortcuts-dialog__chord kbd.shortcuts-dialog__keycap--modifier { + min-width: 38px; + color: var(--shortcut-accent); + border-color: color-mix( + in srgb, + var(--shortcut-accent) 34%, + var(--shortcut-border) + ); +} + +.shortcuts-dialog__actions { + display: flex; + align-items: center; + justify-content: flex-end; + padding: 9px 14px 14px; + border-top: 1px solid var(--shortcut-border); +} + +@media (prefers-reduced-motion: no-preference) { + .shortcuts-dialog { + animation: shortcuts-dialog-enter 160ms ease-out; + } +} + +@media (max-width: 640px) { + .shortcuts-dialog__header { + align-items: flex-start; + flex-direction: column; + padding: 16px 16px 12px; + } + + .shortcuts-dialog__header-actions { + justify-content: space-between; + width: 100%; + } + + .shortcuts-dialog__groups { + grid-template-columns: 1fr; + } + + .shortcuts-dialog__row { + grid-template-columns: 28px minmax(0, 1fr); + gap: 8px 10px; + } + + .shortcuts-dialog__chords { + grid-column: 2; + justify-content: flex-start; + } +} + +@keyframes shortcuts-dialog-enter { + from { + opacity: 0; + transform: translateY(8px) scale(0.985); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.spec.ts new file mode 100644 index 000000000..da2a93f18 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.spec.ts @@ -0,0 +1,138 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { WorkspaceKeyboardShortcutsDialogComponent } from './workspace-keyboard-shortcuts-dialog.component'; + +describe('WorkspaceKeyboardShortcutsDialogComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [WorkspaceKeyboardShortcutsDialogComponent], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: { + groups: [ + { + id: 'global', + labelKey: 'WORKSPACE.SHORTCUTS.GROUPS.GLOBAL', + icon: 'keyboard', + items: [ + { + id: 'open-command-palette', + labelKey: + 'WORKSPACE.SHORTCUTS.ITEMS.OPEN_COMMAND_PALETTE', + icon: 'terminal', + chords: [ + { + id: 'Cmd+K', + ariaLabel: 'Command + K', + keys: [ + { + id: 'cmd', + label: 'Cmd', + ariaLabel: 'Command', + isModifier: true, + }, + { + id: 'k', + label: 'K', + ariaLabel: 'K', + isModifier: false, + }, + ], + }, + ], + }, + { + id: 'play-pause', + labelKey: + 'WORKSPACE.SHORTCUTS.ITEMS.PLAY_PAUSE', + icon: 'play_arrow', + chords: [ + { + id: 'Space', + ariaLabel: 'Space', + keys: [ + { + id: 'space', + label: 'Space', + ariaLabel: 'Space', + isModifier: false, + }, + ], + }, + { + id: 'K', + ariaLabel: 'K', + keys: [ + { + id: 'k', + label: 'K', + ariaLabel: 'K', + isModifier: false, + }, + ], + }, + ], + }, + ], + }, + ], + platformIcon: 'laptop_mac', + platformLabelKey: 'WORKSPACE.SHORTCUTS.PLATFORM.MAC', + }, + }, + { + provide: MatDialogRef, + useValue: { close: jest.fn() }, + }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + onLangChange: of(null), + onTranslationChange: of(null), + onDefaultLangChange: of(null), + currentLang: 'en', + defaultLang: 'en', + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent( + WorkspaceKeyboardShortcutsDialogComponent + ); + fixture.detectChanges(); + }); + + it('renders the dialog title and groups', () => { + const text = fixture.nativeElement.textContent; + + expect(text).toContain('WORKSPACE.SHORTCUTS.TITLE'); + expect(text).toContain('WORKSPACE.SHORTCUTS.GROUPS.GLOBAL'); + }); + + it('renders shortcut labels and multiple keys', () => { + const text = fixture.nativeElement.textContent; + + expect(text).toContain( + 'WORKSPACE.SHORTCUTS.ITEMS.OPEN_COMMAND_PALETTE' + ); + expect(text).toContain('WORKSPACE.SHORTCUTS.ITEMS.PLAY_PAUSE'); + expect(text).toContain('Cmd'); + expect(text).toContain('Space'); + expect(text).toContain('K'); + }); + + it('renders the detected platform label', () => { + expect(fixture.nativeElement.textContent).toContain( + 'WORKSPACE.SHORTCUTS.PLATFORM.MAC' + ); + }); +}); diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts new file mode 100644 index 000000000..1496f4c33 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts @@ -0,0 +1,36 @@ +import { Component, inject } from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import { KeyboardShortcutDisplayGroup } from '@iptvnator/portal/shared/util'; + +export interface WorkspaceKeyboardShortcutsDialogData { + groups: readonly KeyboardShortcutDisplayGroup[]; + platformIcon: string; + platformLabelKey: string; +} + +@Component({ + selector: 'app-workspace-keyboard-shortcuts-dialog', + imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslatePipe], + templateUrl: './workspace-keyboard-shortcuts-dialog.component.html', + styleUrl: './workspace-keyboard-shortcuts-dialog.component.scss', +}) +export class WorkspaceKeyboardShortcutsDialogComponent { + private readonly dialogRef = inject( + MatDialogRef + ); + readonly data = + inject(MAT_DIALOG_DATA); + + readonly groups = this.data.groups; + + close(): void { + this.dialogRef.close(); + } +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts new file mode 100644 index 000000000..5b1fdf24f --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts @@ -0,0 +1,96 @@ +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { Subject } from 'rxjs'; +import { WorkspaceKeyboardShortcutsService } from './workspace-keyboard-shortcuts.service'; + +describe('WorkspaceKeyboardShortcutsService', () => { + let afterClosed$: Subject; + let dialog: { open: jest.Mock }; + let service: WorkspaceKeyboardShortcutsService; + + beforeEach(() => { + afterClosed$ = new Subject(); + dialog = { + open: jest.fn().mockReturnValue({ + afterClosed: () => afterClosed$.asObservable(), + }), + }; + + TestBed.configureTestingModule({ + providers: [ + WorkspaceKeyboardShortcutsService, + { provide: MatDialog, useValue: dialog }, + ], + }); + + service = TestBed.inject(WorkspaceKeyboardShortcutsService); + }); + + afterEach(() => { + Object.defineProperty(navigator, 'userAgentData', { + configurable: true, + value: undefined, + }); + TestBed.resetTestingModule(); + }); + + it('opens the shortcuts dialog when question mark is pressed', () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: '?' })); + + expect(dialog.open).toHaveBeenCalledTimes(1); + }); + + it('opens the shortcuts dialog for Shift+/', () => { + document.dispatchEvent( + new KeyboardEvent('keydown', { key: '/', shiftKey: true }) + ); + + expect(dialog.open).toHaveBeenCalledTimes(1); + }); + + it('does not open while typing in an input', () => { + const input = document.createElement('input'); + document.body.appendChild(input); + + input.dispatchEvent( + new KeyboardEvent('keydown', { + key: '?', + bubbles: true, + }) + ); + + expect(dialog.open).not.toHaveBeenCalled(); + input.remove(); + }); + + it('does not open duplicate dialogs while one is active', () => { + service.openShortcutsDialog(); + service.openShortcutsDialog(); + + expect(dialog.open).toHaveBeenCalledTimes(1); + + afterClosed$.next(); + service.openShortcutsDialog(); + + expect(dialog.open).toHaveBeenCalledTimes(2); + }); + + it('uses userAgentData platform when resolving shortcut modifier labels', () => { + Object.defineProperty(navigator, 'userAgentData', { + configurable: true, + value: { platform: 'Windows' }, + }); + + service.openShortcutsDialog(); + + const dialogData = dialog.open.mock.calls[0][1].data; + const commandPaletteShortcut = dialogData.groups + .flatMap((group) => group.items) + .find((item) => item.id === 'open-command-palette'); + + expect(dialogData.platformLabelKey).toBe( + 'WORKSPACE.SHORTCUTS.PLATFORM.OTHER' + ); + expect(commandPaletteShortcut?.chords[0].keys[0].label).toBe('Ctrl'); + }); +}); diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts new file mode 100644 index 000000000..2b6f38daa --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts @@ -0,0 +1,99 @@ +import { DestroyRef, Injectable, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { + getKeyboardShortcutGroups, + isKeyboardShortcutHelpTrigger, + isTypingInInput, +} from '@iptvnator/portal/shared/util'; +import { + WorkspaceKeyboardShortcutsDialogComponent, + WorkspaceKeyboardShortcutsDialogData, +} from './workspace-keyboard-shortcuts-dialog.component'; + +@Injectable() +export class WorkspaceKeyboardShortcutsService { + private readonly dialog = inject(MatDialog); + private readonly destroyRef = inject(DestroyRef); + private readonly onDocumentKeydown = (event: KeyboardEvent): void => + this.handleKeydown(event); + + private dialogRef: MatDialogRef< + WorkspaceKeyboardShortcutsDialogComponent, + unknown + > | null = null; + + constructor() { + if (typeof document !== 'undefined') { + document.addEventListener('keydown', this.onDocumentKeydown); + this.destroyRef.onDestroy(() => { + document.removeEventListener('keydown', this.onDocumentKeydown); + }); + } + } + + openShortcutsDialog(): void { + if (this.dialogRef) { + return; + } + + const platform = this.getShortcutPlatform(); + const dialogRef = this.dialog.open< + WorkspaceKeyboardShortcutsDialogComponent, + WorkspaceKeyboardShortcutsDialogData + >(WorkspaceKeyboardShortcutsDialogComponent, { + width: 'min(960px, 94vw)', + maxWidth: '94vw', + panelClass: 'workspace-shortcuts-overlay', + autoFocus: false, + data: { + groups: getKeyboardShortcutGroups({ + isMac: platform === 'mac', + isElectron: this.isElectron(), + }), + platformIcon: + platform === 'mac' ? 'laptop_mac' : 'desktop_windows', + platformLabelKey: + platform === 'mac' + ? 'WORKSPACE.SHORTCUTS.PLATFORM.MAC' + : 'WORKSPACE.SHORTCUTS.PLATFORM.OTHER', + }, + }); + + this.dialogRef = dialogRef; + dialogRef + .afterClosed() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.dialogRef = null; + }); + } + + private handleKeydown(event: KeyboardEvent): void { + if (isTypingInInput(event) || !isKeyboardShortcutHelpTrigger(event)) { + return; + } + + event.preventDefault(); + this.openShortcutsDialog(); + } + + private getShortcutPlatform(): 'mac' | 'other' { + if (typeof navigator === 'undefined') { + return 'other'; + } + + const navigatorWithUserAgentData = navigator as Navigator & { + userAgentData?: { platform?: string }; + }; + const platform = + navigatorWithUserAgentData.userAgentData?.platform || + navigator.userAgent; + + return /Mac|iPhone|iPad|iPod/i.test(platform) ? 'mac' : 'other'; + } + + private isElectron(): boolean { + return typeof window !== 'undefined' && !!window.electron; + } +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html index 5624c29bc..1554666b5 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html @@ -65,6 +65,15 @@ importing their first source). Only the per-playlist shortcut and bulk actions are hidden on Settings. -->
+