Merge origin/master into PR 955

This commit is contained in:
4gray committed 2026-05-22 01:34:56 +03:00
commit 2a769ad44e
89 files changed
+6476 -465

No files matched your search

+40
View File
@@ -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
+109 -35
View File
@@ -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 <<TAGS
4gray/iptvnator:latest
4gray/iptvnator:${version}-pwa
4gray/iptvnator:${version}-pwa-${short_sha}
4gray/iptvnator:sha-${short_sha}
TAGS
)
elif [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == refs/tags/v* ]]; then
publish="true"
platforms="linux/amd64,linux/arm64"
tag_version="${GITHUB_REF_NAME#v}"
tags=$(cat <<TAGS
4gray/iptvnator:${tag_version}
4gray/iptvnator:v${tag_version}
TAGS
)
if [[ "${tag_version}" != *-* ]]; then
tags="${tags}"$'\n'"4gray/iptvnator:stable"
fi
elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${{ inputs.push }}" == "true" ]]; then
publish="true"
platforms="linux/amd64,linux/arm64"
tags=$(cat <<TAGS
4gray/iptvnator:manual-${short_sha}
4gray/iptvnator:sha-${short_sha}
TAGS
)
fi
{
echo "publish=${publish}"
echo "platforms=${platforms}"
echo "tags<<EOF"
echo "${tags}"
echo "EOF"
} >> "${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
+38 -1
View File
@@ -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 <http://localhost:4333>. See
[`docker/docker-compose.yml`](./docker/docker-compose.yml) for the ready-to-run
compose file and [`docker/README.md`](./docker/README.md) for environment
variables, reverse proxy notes, and build details.
## Download
Download the latest version of the application for macOS, Windows, and Linux from the [release page](https://github.com/4gray/iptvnator/releases).
@@ -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();
}
});
});
@@ -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<string, unknown> & {
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),
];
+18
View File
@@ -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();
});
+89
View File
@@ -51,6 +51,27 @@ async function addXtreamPortal(page: Page): Promise<void> {
await page.waitForURL(/xtreams.*vod/);
}
async function addM3uPlaylist(page: Page): Promise<void> {
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<void> {
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<void> {
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');
});
+35
View File
@@ -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": "البث المباشر من جميع قوائم التشغيل",
+35
View File
@@ -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": "البث المباشر من جميع قوائم التشغيل",
+35
View File
@@ -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": "Прамы эфір з усіх плэйлістоў",
+35
View File
@@ -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",
+35
View File
@@ -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 από όλες τις λίστες αναπαραγωγής",
+35
View File
@@ -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",
+35
View File
@@ -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",
+35
View File
@@ -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",
+35
View File
@@ -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",
+35
View File
@@ -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",
+35
View File
@@ -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",
+35
View File
@@ -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",
+35
View File
@@ -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",
+35
View File
@@ -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",
+35
View File
@@ -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 из всех плейлистов",
+35
View File
@@ -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",
+35
View File
@@ -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": "来自所有播放列表的直播电视",
+35
View File
@@ -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": "來自所有播放清單的直播電視",
+14
View File
@@ -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));
}
+28
View File
@@ -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.
+7
View File
@@ -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": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

@@ -0,0 +1,27 @@
<section class="mt-16 border-t border-dashed border-surface-700/60 pt-10" aria-labelledby="blog-comments-heading">
<div class="mb-6">
<h2 id="blog-comments-heading" class="font-display text-2xl font-semibold tracking-tight text-surface-100">
Comments
</h2>
</div>
<div class="giscus min-h-40"></div>
<script
is:inline
src="https://giscus.app/client.js"
data-repo="4gray/iptvnator"
data-repo-id="MDEwOlJlcG9zaXRvcnkyMTMxOTQ3Mzg="
data-category="Blog comments"
data-category-id="DIC_kwDODLUX8s4C9eBJ"
data-mapping="pathname"
data-strict="1"
data-reactions-enabled="1"
data-emit-metadata="0"
data-input-position="bottom"
data-theme="transparent_dark"
data-lang="en"
crossorigin="anonymous"
loading="lazy"
async
></script>
</section>
@@ -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.
<BlogImageSlider
images={[
{
src: '/iptvnator/screenshots/external-player-support-mpv.webp',
alt: 'IPTVnator settings showing external MPV player support',
caption:
'External MPV, VLC, and IINA support exists for streams that need a stronger native playback engine.',
},
{
src: '/iptvnator/blog/external-players/playback-diagnostics-v0-22.jpg',
alt: 'IPTVnator playback diagnostics explaining an unsupported codec and offering MPV or VLC actions',
caption:
'Starting with IPTVnator 0.22, playback diagnostics explain likely browser-player failures and offer quick actions for MPV or VLC.',
},
]}
/>
<Alert type="info" title="IPTVnator is a player, not a content provider">
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.
</Alert>
## 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.
<BlogImageSlider
images={[
{
src: '/iptvnator/blog/external-players/diagnostics-anatomy-v0-22.jpg',
alt: 'IPTVnator diagnostic anatomy showing a plain-language reason, technical details, and quick actions',
caption:
'Playback diagnostics turn a generic media error into a plain-language reason, useful technical details, and clear next steps.',
},
]}
/>
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.
<BlogImageSlider
images={[
{
src: '/iptvnator/blog/external-players/external-player-handoff.jpg',
alt: 'IPTVnator external player handoff showing a stream opened in MPV, VLC, or IINA',
caption:
'With one click, IPTVnator can hand the same stream URL to MPV, VLC, or IINA so a native playback engine can handle it.',
},
]}
/>
## 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.
<Alert type="warning" title="Do not post private streams publicly">
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.
</Alert>
## Useful links
<LinkCards
links={[
{
label: 'Download IPTVnator',
href: 'https://github.com/4gray/iptvnator/releases',
hint: 'Get the latest desktop builds for macOS, Windows, and Linux.',
icon: 'download',
},
{
label: 'GitHub Sponsors',
href: 'https://github.com/sponsors/4gray',
hint: 'Support continued IPTVnator development.',
icon: 'sponsor',
},
{
label: 'Ko-fi',
href: 'https://ko-fi.com/4gray',
hint: 'Send a one-time tip if IPTVnator is useful to you.',
icon: 'kofi',
},
{
label: 'Report a playback issue',
href: 'https://github.com/4gray/iptvnator/issues',
hint: 'Include OS, IPTVnator version, selected player, and stream type.',
icon: 'github',
},
{
label: 'Telegram community',
href: 'https://t.me/iptvnator',
hint: 'Ask quick questions and compare player settings with other users.',
icon: 'telegram',
},
]}
/>
## 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.
+3
View File
@@ -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 = {
>
<slot />
</div>
<GiscusComments />
</div>
</article>
</BaseLayout>
+33 -8
View File
@@ -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 */
});
+25 -17
View File
@@ -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"]
+104 -11
View File
@@ -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 <http://localhost:4333>. 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. |
| `<version>-pwa` | `master` pushes | Latest PWA image for the current `package.json` version. |
| `<version>-pwa-<sha>` | `master` pushes | Immutable PWA image for a specific merged commit within a version. |
| `sha-<sha>` | `master` pushes | Commit-addressable image, useful for rollback and support diagnostics. |
| `<version>` / `v<version>` | `v*` release tags | Release image aligned with a repository release tag. |
| `stable` | Stable `v*` release tags | Most recent non-prerelease tagged release image. |
| `manual-<sha>` | Manual runs with `push` | Explicit maintainer-triggered rebuilds outside normal publish events. |
Use `latest` for the simplest self-hosted setup. Pin `sha-<sha>` or
`<version>-pwa-<sha>` 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
```
+15 -12
View File
@@ -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
+109
View File
@@ -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
+25 -10
View File
@@ -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;
}
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;
}
}
+17 -5
View File
@@ -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.
+150
View File
@@ -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": "<provider-url>" }`
- `GET /parse?targetId=<id>`
- `GET /xtream?targetId=<id>&username=<u>&password=<p>&action=<action>`
- `GET /stalker?targetId=<id>&macAddress=<mac>&action=<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.
+21 -1
View File
@@ -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.
+10
View File
@@ -95,6 +95,16 @@ Response: `{ payload: <data>, action: <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
```
@@ -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<PlaylistMeta>,
])
);
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<PlaylistMeta>[])
);
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',
@@ -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<void> {
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<void> {
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<UnifiedCollectionItem[]> {
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<UnifiedCollectionItem[]> {
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<number | null> {
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<UnifiedCollectionItem[]> {
const allMeta = await this.getAllMeta();
const results: UnifiedCollectionItem[] = [];
@@ -726,6 +826,12 @@ export class UnifiedFavoritesDataService {
}, new Map<string, UnifiedCollectionItem[]>());
}
private isXtreamPlaylist(
playlist: Pick<PlaylistMeta, 'serverUrl' | 'macAddress'>
): boolean {
return Boolean(playlist.serverUrl) && !playlist.macAddress;
}
private getStalkerFavoriteId(
favorite:
| Pick<UnifiedCollectionItem, 'stalkerId' | 'uid'>
@@ -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<PlaylistMeta>,
])
);
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<PlaylistMeta>[])
);
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<PlaylistMeta>,
])
);
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<Playlist>[])
);
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([
@@ -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<unknown>[] = [];
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<void> {
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<UnifiedCollectionItem[]> {
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<UnifiedCollectionItem[]> {
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<number | null> {
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<UnifiedCollectionItem[]> {
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<PlaylistMeta, 'serverUrl' | 'macAddress'>
): boolean {
return Boolean(playlist.serverUrl) && !playlist.macAddress;
}
private isPlaylistBackedRecentPlaylist(
playlist: Pick<PlaylistMeta, 'serverUrl' | 'macAddress'>
): boolean {
return Boolean(playlist.macAddress) || this.isM3uPlaylist(playlist);
}
}
+1
View File
@@ -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';
@@ -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,
},
];
@@ -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<typeof getKeyboardShortcutGroups>,
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)) ?? []
);
}
@@ -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<string, string>([
['ArrowLeft', '←'],
['ArrowRight', '→'],
['ArrowUp', '↑'],
['ArrowDown', '↓'],
['Escape', 'Esc'],
]);
const KEY_ARIA_LABELS = new Map<string, string>([
['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;
}
@@ -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', () => {
@@ -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',
@@ -452,6 +452,18 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
);
}
async setContentBackdropIfMissing(
contentId: number,
playlistId: string,
backdropUrl: string
): Promise<void> {
void playlistId;
await this.dbService.setContentBackdropIfMissing(
contentId,
backdropUrl
);
}
// =========================================================================
// Playback Position Operations
// =========================================================================
@@ -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<Record<string, unknown>>;
const vod = (await dataSource.getContent(
'playlist-1',
credentials,
'movie'
)) as Array<Record<string, unknown>>;
const series = (await dataSource.getContent(
'playlist-1',
credentials,
'series'
)) as Array<Record<string, unknown>>;
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<Record<string, unknown>>;
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<Record<string, unknown>>;
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();
});
});
@@ -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<XtreamPlaylistData, 'password'> & {
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<string, XtreamCategory[]>();
private contentCache = new Map<string, XtreamCachedContentItem[]>();
private playlistPasswords = new Map<string, string>();
// =========================================================================
// Playlist Operations (localStorage)
@@ -74,10 +90,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
async getPlaylist(playlistId: string): Promise<XtreamPlaylistData | null> {
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<void> {
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<XtreamPlaylistData>
): Promise<void> {
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<XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[]> {
): 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<number> {
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<XtreamContentItem[]> {
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<void> {
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<void> {
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<boolean> {
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<string, number[]> {
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<XtreamContentItem[]> {
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<void> {
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<void> {
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<string, StoredRecentItem[]> {
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<string, number[]> {
if (!value || typeof value !== 'object') {
return {};
}
const normalized: Record<string, number[]> = {};
Object.entries(value as Record<string, unknown>).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<string, StoredRecentItem[]> {
if (!value || typeof value !== 'object') {
return {};
}
const normalized: Record<string, StoredRecentItem[]> = {};
Object.entries(value as Record<string, unknown>).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<StoredRecentItem, 'backdropUrl'> | Record<string, never> {
const value = item.backdropUrl ?? item.backdrop_url;
if (typeof value !== 'string') {
return {};
}
const backdropUrl = value.trim();
return backdropUrl ? { backdropUrl } : {};
}
private getCollectionItemsFromStorage(): Record<
string,
Record<string, XtreamContentItem>
> {
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<string, Record<string, XtreamContentItem>>;
} catch {
return {};
}
}
private saveCollectionItemsToStorage(
items: Record<string, Record<string, XtreamContentItem>>
): 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<number, XtreamContentItem> {
const idSet = new Set(ids);
const results = new Map<number, XtreamContentItem>();
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<Map<number, XtreamContentItem>> {
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<void> {
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<void> {
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;
});
@@ -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<XtreamContentItem | null>;
/**
* Persist a backdrop URL for an already-known content item without changing
* favorites or recent ordering.
*/
setContentBackdropIfMissing(
contentId: number,
playlistId: string,
backdropUrl: string
): Promise<void>;
// =========================================================================
// Playback Position Operations
// =========================================================================
@@ -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');
});
});
@@ -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<void> {
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<void> {
await this.dbService.removeFromFavorites(contentId, playlistId);
await this.dataSource.removeFavorite(contentId, playlistId);
}
async isFavorite(contentId: number, playlistId: string): Promise<boolean> {
return await this.dbService.isFavorite(contentId, playlistId);
return await this.dataSource.isFavorite(contentId, playlistId);
}
getFavorites(playlistId: string): Observable<FavoriteItem[]> {
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,
}))
)
@@ -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<typeof TestFavoritesStore>;
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);
});
});
@@ -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 });
},
}))
);
};
@@ -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<typeof TestRecentItemsStore>;
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',
}),
]);
});
});
@@ -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<void> {
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) {
@@ -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')"
/>
}
@@ -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> = {}
): 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<TestRecentlyAddedItem[]>([]);
readonly vodStreams = signal<TestRecentlyAddedItem[]>([]);
readonly serialStreams = signal<TestRecentlyAddedItem[]>([]);
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<number, []>;
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'));
});
});
@@ -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) {
@@ -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();
});
});
@@ -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<boolean> {
@@ -496,6 +496,10 @@ export class DatabaseService {
playlistId: string,
type: 'live' | 'movie' | 'series'
): Promise<boolean> {
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<string | null> {
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<boolean> {
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<GlobalRecentItem[]> {
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<GlobalFavoriteItem[]> {
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<GlobalFavoriteItem[]> {
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<void> {
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<boolean> {
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<boolean> {
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<boolean> {
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<XtreamContent[]> {
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<XtreamContent[]> {
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<boolean> {
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<boolean> {
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<boolean> {
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<XtreamContent | null> {
if (typeof window.electron?.dbGetContentByXtreamId !== 'function') {
return null;
}
try {
return await window.electron.dbGetContentByXtreamId(
xtreamId,
@@ -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<typeof normalizeXtreamContentAddedEpochs>[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<typeof normalizeXtreamContentAddedEpochs>[0];
normalizeXtreamContentAddedEpochs(sqlite);
expect(transaction).not.toHaveBeenCalled();
});
});
+61 -1
View File
@@ -23,6 +23,10 @@ let db: DatabaseInstance | null = null;
let sqlite: Database.Database | null = null;
let initPromise: Promise<DatabaseInstance> | 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);
}
+1
View File
@@ -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';
@@ -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');
});
});
@@ -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)
);
}
@@ -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 = <T = never>() => {
let resolvePending: (items: T[]) => void = () => {
throw new Error('Pending item promise resolved before init');
};
const promise = new Promise<T[]>((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'
);
});
});
@@ -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<void> {
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<GlobalRecentItem[]>(
(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<DashboardFavoriteItem[]>(
(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<void> {
await this.reloadM3uGlobalFavorites();
this.finishInitialGlobalFavoritesLoadIfReady();
@@ -762,10 +860,17 @@ export class DashboardDataService {
async removeGlobalRecentItem(item: GlobalRecentItem): Promise<void> {
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<void> {
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
@@ -0,0 +1,105 @@
<section class="shortcuts-dialog">
<header class="shortcuts-dialog__header">
<div class="shortcuts-dialog__title-block">
<span class="shortcuts-dialog__hero-icon" aria-hidden="true">
<mat-icon>keyboard</mat-icon>
</span>
<div>
<h2 mat-dialog-title>
{{ 'WORKSPACE.SHORTCUTS.TITLE' | translate }}
</h2>
<p>{{ 'WORKSPACE.SHORTCUTS.SUBTITLE' | translate }}</p>
</div>
</div>
<div class="shortcuts-dialog__header-actions">
<span class="shortcuts-dialog__platform">
<mat-icon aria-hidden="true">{{ data.platformIcon }}</mat-icon>
{{ data.platformLabelKey | translate }}
</span>
<button
type="button"
mat-icon-button
class="shortcuts-dialog__close"
[attr.aria-label]="'CLOSE' | translate"
(click)="close()"
>
<mat-icon>close</mat-icon>
</button>
</div>
</header>
<mat-dialog-content class="shortcuts-dialog__content">
<div class="shortcuts-dialog__groups">
@for (group of groups; track group.id) {
<section class="shortcuts-dialog__group">
<header class="shortcuts-dialog__group-header">
<span
class="shortcuts-dialog__group-icon"
aria-hidden="true"
>
<mat-icon>{{ group.icon }}</mat-icon>
</span>
<h3>{{ group.labelKey | translate }}</h3>
</header>
<div class="shortcuts-dialog__rows">
@for (item of group.items; track item.id) {
<article class="shortcuts-dialog__row">
<span
class="shortcuts-dialog__action-icon"
aria-hidden="true"
>
<mat-icon>{{ item.icon }}</mat-icon>
</span>
<span class="shortcuts-dialog__label">
{{ item.labelKey | translate }}
</span>
<span class="shortcuts-dialog__chords">
@for (
chord of item.chords;
track chord.id
) {
<span
class="shortcuts-dialog__chord"
[attr.aria-label]="chord.ariaLabel"
>
@for (
key of chord.keys;
track key.id
) {
<kbd
[attr.aria-label]="
key.ariaLabel
"
[class.shortcuts-dialog__keycap--modifier]="
key.isModifier
"
>
{{ key.label }}
</kbd>
@if (!$last) {
<span
class="shortcuts-dialog__joiner"
aria-hidden="true"
>
+
</span>
}
}
</span>
}
</span>
</article>
}
</div>
</section>
}
</div>
</mat-dialog-content>
<mat-dialog-actions align="end" class="shortcuts-dialog__actions">
<button type="button" mat-button (click)="close()">
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
</section>
@@ -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);
}
}
@@ -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<WorkspaceKeyboardShortcutsDialogComponent>;
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'
);
});
});
@@ -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<WorkspaceKeyboardShortcutsDialogComponent>
);
readonly data =
inject<WorkspaceKeyboardShortcutsDialogData>(MAT_DIALOG_DATA);
readonly groups = this.data.groups;
close(): void {
this.dialogRef.close();
}
}
@@ -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<void>;
let dialog: { open: jest.Mock };
let service: WorkspaceKeyboardShortcutsService;
beforeEach(() => {
afterClosed$ = new Subject<void>();
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');
});
});
@@ -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;
}
}
@@ -65,6 +65,15 @@
importing their first source). Only the per-playlist shortcut
and bulk actions are hidden on Settings. -->
<div class="header-actions header-actions--leading">
<button
type="button"
mat-icon-button
(click)="onShortcutsRequested()"
[matTooltip]="'WORKSPACE.SHORTCUTS.OPEN_TOOLTIP' | translate"
[attr.aria-label]="'WORKSPACE.SHORTCUTS.OPEN_ARIA' | translate"
>
<mat-icon>keyboard</mat-icon>
</button>
<button
type="button"
mat-icon-button
@@ -105,6 +105,18 @@ describe('WorkspaceShellHeaderComponent', () => {
expect(requested).toHaveBeenCalledTimes(1);
});
it('emits keyboard shortcuts requests from the help button', () => {
const requested = jest.fn();
component.shortcutsRequested.subscribe(requested);
const button: HTMLButtonElement = fixture.nativeElement.querySelector(
'button[aria-label="WORKSPACE.SHORTCUTS.OPEN_ARIA"]'
);
button.click();
expect(requested).toHaveBeenCalledTimes(1);
});
it('does not render the removed global favorites shortcut', () => {
const button: HTMLButtonElement | null =
fixture.nativeElement.querySelector(
@@ -61,6 +61,7 @@ export class WorkspaceShellHeaderComponent {
readonly searchChanged = output<string>();
readonly searchSubmitted = output<string>();
readonly commandPaletteRequested = output<void>();
readonly shortcutsRequested = output<void>();
readonly addPlaylistRequested = output<void>();
readonly headerShortcutRequested = output<void>();
readonly headerBulkActionRequested = output<void>();
@@ -95,6 +96,10 @@ export class WorkspaceShellHeaderComponent {
this.commandPaletteRequested.emit();
}
onShortcutsRequested(): void {
this.shortcutsRequested.emit();
}
onHeaderShortcutRequested(): void {
this.headerShortcutRequested.emit();
}
@@ -44,6 +44,7 @@
(searchChanged)="facade.onSearchInput($event)"
(searchSubmitted)="facade.onSearchEnter($event)"
(commandPaletteRequested)="facade.openCommandPalette()"
(shortcutsRequested)="keyboardShortcuts.openShortcutsDialog()"
(addPlaylistRequested)="facade.openAddPlaylistDialog()"
(headerShortcutRequested)="facade.runHeaderShortcut()"
(headerBulkActionRequested)="facade.runHeaderBulkAction()"
@@ -7,6 +7,7 @@ import {
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { RouterOutlet, provideRouter } from '@angular/router';
import { By } from '@angular/platform-browser';
import {
WorkspacePortalContext,
WorkspaceShellContextPanel,
@@ -16,6 +17,7 @@ import {
WorkspaceHeaderBulkAction,
WorkspaceShellFacade,
} from './services/workspace-shell.facade';
import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service';
@Component({
selector: 'app-workspace-shell-rail',
@@ -62,6 +64,7 @@ class MockWorkspaceShellHeaderComponent {
readonly searchChanged = output<string>();
readonly searchSubmitted = output<string>();
readonly commandPaletteRequested = output<void>();
readonly shortcutsRequested = output<void>();
readonly addPlaylistRequested = output<void>();
readonly headerShortcutRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
@@ -118,6 +121,10 @@ class MockPlaylistDropZoneDirective {
})
class MockWorkspaceShellImportOverlayComponent {}
class MockWorkspaceKeyboardShortcutsService {
openShortcutsDialog = jest.fn();
}
class MockWorkspaceShellFacade {
readonly brandLink = signal('/workspace/dashboard');
readonly brandTooltipKey = signal('WORKSPACE.SHELL.RAIL_DASHBOARD');
@@ -216,6 +223,10 @@ describe('WorkspaceShellComponent', () => {
provide: WorkspaceShellFacade,
useValue: facade,
},
{
provide: WorkspaceKeyboardShortcutsService,
useClass: MockWorkspaceKeyboardShortcutsService,
},
],
},
})
@@ -265,6 +276,10 @@ describe('WorkspaceShellComponent', () => {
provide: WorkspaceShellFacade,
useValue: facade,
},
{
provide: WorkspaceKeyboardShortcutsService,
useClass: MockWorkspaceKeyboardShortcutsService,
},
],
},
})
@@ -288,4 +303,51 @@ describe('WorkspaceShellComponent', () => {
)
).not.toBeNull();
});
it('opens keyboard shortcuts when the header requests them', async () => {
const facade = new MockWorkspaceShellFacade();
await TestBed.configureTestingModule({
imports: [WorkspaceShellComponent],
providers: [provideRouter([])],
})
.overrideComponent(WorkspaceShellComponent, {
set: {
imports: [
RouterOutlet,
MockExternalPlaybackDockComponent,
MockPlaylistDropOverlayComponent,
MockPlaylistDropZoneDirective,
MockWorkspaceShellContextSidebarComponent,
MockWorkspaceShellHeaderComponent,
MockWorkspaceShellImportOverlayComponent,
MockWorkspaceShellRailComponent,
],
providers: [
{
provide: WorkspaceShellFacade,
useValue: facade,
},
{
provide: WorkspaceKeyboardShortcutsService,
useClass: MockWorkspaceKeyboardShortcutsService,
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(WorkspaceShellComponent);
fixture.detectChanges();
const shortcutsService = fixture.debugElement.injector.get(
WorkspaceKeyboardShortcutsService
) as unknown as MockWorkspaceKeyboardShortcutsService;
const header = fixture.debugElement.query(
By.directive(MockWorkspaceShellHeaderComponent)
).componentInstance as MockWorkspaceShellHeaderComponent;
header.shortcutsRequested.emit();
expect(shortcutsService.openShortcutsDialog).toHaveBeenCalledTimes(1);
});
});
@@ -12,6 +12,7 @@ import { WorkspaceShellRailComponent } from './components/workspace-shell-rail/w
import { WorkspaceShellFacade } from './services/workspace-shell.facade';
import { WorkspaceShellXtreamImportService } from './services/workspace-shell-xtream-import.service';
import { WorkspaceShellCommandPaletteService } from './services/workspace-shell-command-palette.service';
import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service';
@Component({
selector: 'app-workspace-shell',
@@ -31,8 +32,10 @@ import { WorkspaceShellCommandPaletteService } from './services/workspace-shell-
WorkspaceShellFacade,
WorkspaceShellXtreamImportService,
WorkspaceShellCommandPaletteService,
WorkspaceKeyboardShortcutsService,
],
})
export class WorkspaceShellComponent {
readonly facade = inject(WorkspaceShellFacade);
readonly keyboardShortcuts = inject(WorkspaceKeyboardShortcutsService);
}
@@ -0,0 +1,36 @@
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
import assert from 'node:assert/strict';
const postHtmlPath = new URL('../../dist/apps/website/blog/why-external-players-help/index.html', import.meta.url);
const expectedGiscusAttributes = {
src: 'https://giscus.app/client.js',
'data-repo': '4gray/iptvnator',
'data-repo-id': 'MDEwOlJlcG9zaXRvcnkyMTMxOTQ3Mzg=',
'data-category': 'Blog comments',
'data-category-id': 'DIC_kwDODLUX8s4C9eBJ',
'data-mapping': 'pathname',
'data-strict': '1',
'data-reactions-enabled': '1',
'data-emit-metadata': '0',
'data-input-position': 'bottom',
'data-theme': 'transparent_dark',
'data-lang': 'en',
};
test('published blog posts include the Giscus comments embed configuration', async () => {
const html = await readFile(postHtmlPath, 'utf8');
const scriptMatch = html.match(/<script\b[^>]*giscus\.app\/client\.js[^>]*>/);
assert.ok(scriptMatch, 'Expected the blog post HTML to include the Giscus client script.');
const scriptTag = scriptMatch[0];
for (const [name, value] of Object.entries(expectedGiscusAttributes)) {
assert.match(scriptTag, new RegExp(`${name}="${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`));
}
assert.match(scriptTag, /\bloading="lazy"/);
assert.match(scriptTag, /\bcrossorigin="anonymous"/);
assert.match(scriptTag, /\basync/);
});