Proyecto App Windows
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code session-start"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code session-end"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code user-prompt-submit"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code stop"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code pre-task"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code post-task"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "TodoWrite",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code post-todo"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"deny": [
|
||||
"Read(./.entire/metadata/**)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(tree:*)",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:www.electronjs.org)",
|
||||
"Bash(npm run build:frontend:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(npx asar:*)",
|
||||
"Bash(npm run package:app:*)",
|
||||
"Bash(npm run build:backend:*)",
|
||||
"Bash(pnpm add:*)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:www.npmjs.com)",
|
||||
"Skill(example-skills:frontend-design)",
|
||||
"Bash(npm run test:frontend:*)",
|
||||
"Bash(nx lint:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(xargs sed:*)",
|
||||
"Skill(frontend-design)",
|
||||
"mcp__angular-cli__get_best_practices",
|
||||
"mcp__nx-mcp__nx_workspace",
|
||||
"Bash(grep:*)",
|
||||
"Bash(npx nx lint:*)",
|
||||
"Bash(npx nx build:*)",
|
||||
"Bash(npx eslint:*)",
|
||||
"Bash(npx nx run:*)",
|
||||
"Bash(lsof:*)",
|
||||
"Bash(xargs kill:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(npx:*)",
|
||||
"Bash(wc:*)",
|
||||
"Bash(nx build:*)",
|
||||
"Bash(agent-browser:*)",
|
||||
"Bash(sqlite3:*)",
|
||||
"Bash(agent-browser --cdp 9222 eval \"window.ng?.getComponent\\(document.querySelector\\(''app-unified-collection-page''\\)\\)?.playlistId\\(\\)\")",
|
||||
"Bash(npx --yes agent-browser --cdp 9222 eval \"document.querySelector\\(''.nav-item.category-item''\\)?.click\\(\\)\")",
|
||||
"Bash(agent-browser --cdp 9222 eval \"document.querySelector\\(''.playlist-switcher-trigger''\\).click\\(\\)\")",
|
||||
"Bash(agent-browser --cdp 9222 eval \"document.querySelector\\(''''.playlist-switcher-trigger''''\\).click\\(\\)\")",
|
||||
"Bash(npx agent-browser:*)",
|
||||
"Bash(agent-browser --cdp 9222 eval \"fetch\\(''http://localhost:9222/json''\\).then\\(r=>r.json\\(\\)\\).then\\(t=>t[0].webSocketDebuggerUrl\\)\")",
|
||||
"Bash(npx --yes agent-browser --cdp 9222 eval 'document.querySelector\\(\"\".mat-mdc-paginator-range-label\"\"\\)?.textContent?.trim\\(\\)')",
|
||||
"Skill(electron)",
|
||||
"Bash(node:*)",
|
||||
"Bash(pkill -f \"chrome-cdp-debug\")"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": [
|
||||
"nx-mcp"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
|
||||
language: 'en-US'
|
||||
|
||||
reviews:
|
||||
profile: 'chill'
|
||||
high_level_summary: true
|
||||
auto_review:
|
||||
enabled: true
|
||||
drafts: false
|
||||
|
||||
issue_enrichment:
|
||||
labeling:
|
||||
auto_apply_labels: true
|
||||
labeling_instructions:
|
||||
- label: bug
|
||||
instructions: Issues reporting bugs, errors, crashes, incorrect behavior, or unexpected results. This includes runtime errors, logic errors, broken functionality, regressions, and any deviation from expected or documented behavior.
|
||||
- label: enhancement
|
||||
instructions: Feature requests, improvements to existing functionality, performance optimizations, refactoring suggestions, UI/UX enhancements, and any suggestions to make the project better or add new capabilities.
|
||||
- label: documentation
|
||||
instructions: Documentation updates, additions, corrections, or clarifications needed. This includes missing docs, outdated information, unclear instructions, API documentation, code examples, README improvements, and any requests for better explanations or guides.
|
||||
@@ -0,0 +1,19 @@
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
.vscode
|
||||
.editorconfig
|
||||
.dockerignore
|
||||
.angular
|
||||
.electron
|
||||
.husky
|
||||
Dockerfile
|
||||
README.md
|
||||
dist
|
||||
e2e
|
||||
node_modules
|
||||
screenshots
|
||||
test-results
|
||||
coverage
|
||||
build
|
||||
release
|
||||
@@ -0,0 +1,4 @@
|
||||
tmp/
|
||||
settings.local.json
|
||||
metadata/
|
||||
logs/
|
||||
@@ -0,0 +1,12 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [4gray]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom: []
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: "\U0001F41E Bug report"
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
|
||||
- PWA or Electron/Tauri application
|
||||
- OS: [e.g. Linux]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 0.16.0]
|
||||
- Feature: [e.g. M3U playlist, Xtream Code, Stalker Portal]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: "\U0001F680 Feature request"
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,420 @@
|
||||
name: Build and Make Electron App
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build on ${{ matrix.os }} ${{ matrix.arch }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
# macOS builds - separate runners to avoid native module conflicts
|
||||
- os: macos
|
||||
runner: macos-15-intel
|
||||
arch: x64
|
||||
- os: macos
|
||||
runner: macos-latest
|
||||
arch: arm64
|
||||
# Linux and Windows
|
||||
- os: linux
|
||||
runner: ubuntu-22.04
|
||||
linux_profile: standard
|
||||
- os: linux
|
||||
runner: ubuntu-24.04
|
||||
linux_profile: flatpak
|
||||
- os: windows
|
||||
runner: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Linux system dependencies
|
||||
if: matrix.os == 'linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends -y rpm libarchive-tools flatpak flatpak-builder appstream
|
||||
|
||||
# Configure Flatpak
|
||||
# 1. Add the Flathub repository (source of runtimes)
|
||||
flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
# 2. Install the standard Freedesktop Platform and SDK (required by electron-builder)
|
||||
# We install version 24.08 as a safe default, electron-builder might pick what it needs
|
||||
flatpak install --user -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08
|
||||
|
||||
- name: Select Linux packaging targets for CI profile
|
||||
if: matrix.os == 'linux'
|
||||
run: |
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = 'electron-builder.json';
|
||||
const config = JSON.parse(fs.readFileSync(path, 'utf8'));
|
||||
const targets = Array.isArray(config.linux?.target) ? config.linux.target : [];
|
||||
const profile = '${{ matrix.linux_profile }}';
|
||||
|
||||
if (profile === 'standard') {
|
||||
config.linux.target = targets.filter((entry) => String(entry.target).toLowerCase() !== 'flatpak');
|
||||
} else if (profile === 'flatpak') {
|
||||
config.linux.target = targets.filter((entry) => String(entry.target).toLowerCase() === 'flatpak');
|
||||
}
|
||||
|
||||
fs.writeFileSync(path, JSON.stringify(config, null, 4) + '\n');
|
||||
"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm nx build web --skip-nx-cache
|
||||
|
||||
- name: Build backend
|
||||
run: pnpm run build:backend
|
||||
|
||||
- name: Validate AppStream metadata
|
||||
if: matrix.os == 'linux'
|
||||
run: appstreamcli validate --pedantic --no-net apps/electron-backend/linux/com.fourgray.iptvnator.metainfo.xml
|
||||
|
||||
- name: Build website
|
||||
if: matrix.os == 'linux'
|
||||
run: pnpm nx build website --skip-nx-cache
|
||||
|
||||
- name: Verify AppStream website assets
|
||||
if: matrix.os == 'linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
for screenshot in player playlists epg settings; do
|
||||
if ! find dist/apps/website -path "*/appstream/${screenshot}.png" -print -quit | grep -q .; then
|
||||
echo "::error::Missing AppStream website asset for ${screenshot}.png"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Override macOS arch in electron-builder.json
|
||||
if: matrix.os == 'macos'
|
||||
run: |
|
||||
# Replace the mac arch array with just the target architecture
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('electron-builder.json', 'utf8'));
|
||||
pkg.mac.target.arch = ['${{ matrix.arch }}'];
|
||||
fs.writeFileSync('electron-builder.json', JSON.stringify(pkg, null, 4));
|
||||
"
|
||||
|
||||
- name: Validate macOS signing configuration
|
||||
if: matrix.os == 'macos' && github.event_name != 'pull_request'
|
||||
shell: bash
|
||||
env:
|
||||
CSC_NAME: ${{ vars.CSC_NAME }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
if [ -z "${CSC_NAME}" ]; then
|
||||
echo "::error::Missing CSC_NAME repository variable for deterministic macOS code signing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${CSC_LINK}" ] || [ -z "${CSC_KEY_PASSWORD}" ]; then
|
||||
echo "::error::Missing CSC_LINK or CSC_KEY_PASSWORD secret for macOS code signing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
has_api_key_credentials=false
|
||||
if [ -n "${APPLE_API_KEY_CONTENT}" ] || [ -n "${APPLE_API_KEY_ID}" ] || [ -n "${APPLE_API_ISSUER}" ]; then
|
||||
if [ -z "${APPLE_API_KEY_CONTENT}" ] || [ -z "${APPLE_API_KEY_ID}" ] || [ -z "${APPLE_API_ISSUER}" ]; then
|
||||
echo "::error::APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER must all be set for App Store Connect API key notarization."
|
||||
exit 1
|
||||
fi
|
||||
has_api_key_credentials=true
|
||||
fi
|
||||
|
||||
has_apple_id_credentials=false
|
||||
if [ -n "${APPLE_ID}" ] || [ -n "${APPLE_APP_SPECIFIC_PASSWORD}" ] || [ -n "${APPLE_TEAM_ID}" ]; then
|
||||
if [ -z "${APPLE_ID}" ] || [ -z "${APPLE_APP_SPECIFIC_PASSWORD}" ] || [ -z "${APPLE_TEAM_ID}" ]; then
|
||||
echo "::error::APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, and APPLE_TEAM_ID must all be set for Apple ID notarization."
|
||||
exit 1
|
||||
fi
|
||||
has_apple_id_credentials=true
|
||||
fi
|
||||
|
||||
if [ "${has_api_key_credentials}" = false ] && [ "${has_apple_id_credentials}" = false ]; then
|
||||
echo "::error::Missing notarization credentials. Configure either APPLE_API_KEY + APPLE_API_KEY_ID + APPLE_API_ISSUER, or APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Prepare macOS notarization credentials
|
||||
if: matrix.os == 'macos' && github.event_name != 'pull_request'
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
if [ -n "${APPLE_API_KEY_CONTENT}" ]; then
|
||||
APPLE_API_KEY_PATH="${RUNNER_TEMP}/AuthKey_${APPLE_API_KEY_ID}.p8"
|
||||
printf '%s' "${APPLE_API_KEY_CONTENT}" > "${APPLE_API_KEY_PATH}"
|
||||
chmod 600 "${APPLE_API_KEY_PATH}"
|
||||
echo "APPLE_API_KEY=${APPLE_API_KEY_PATH}" >> "${GITHUB_ENV}"
|
||||
echo "APPLE_API_KEY_ID=${APPLE_API_KEY_ID}" >> "${GITHUB_ENV}"
|
||||
echo "APPLE_API_ISSUER=${APPLE_API_ISSUER}" >> "${GITHUB_ENV}"
|
||||
echo "APPLE_ID=" >> "${GITHUB_ENV}"
|
||||
echo "APPLE_APP_SPECIFIC_PASSWORD=" >> "${GITHUB_ENV}"
|
||||
echo "APPLE_TEAM_ID=" >> "${GITHUB_ENV}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "${APPLE_ID}" ]; then
|
||||
echo "APPLE_API_KEY=" >> "${GITHUB_ENV}"
|
||||
echo "APPLE_API_KEY_ID=" >> "${GITHUB_ENV}"
|
||||
echo "APPLE_API_ISSUER=" >> "${GITHUB_ENV}"
|
||||
echo "APPLE_ID=${APPLE_ID}" >> "${GITHUB_ENV}"
|
||||
echo "APPLE_APP_SPECIFIC_PASSWORD=${APPLE_APP_SPECIFIC_PASSWORD}" >> "${GITHUB_ENV}"
|
||||
echo "APPLE_TEAM_ID=${APPLE_TEAM_ID}" >> "${GITHUB_ENV}"
|
||||
fi
|
||||
|
||||
- name: Make Electron app (macOS)
|
||||
if: matrix.os == 'macos' && github.event_name != 'pull_request'
|
||||
env:
|
||||
CSC_NAME: ${{ vars.CSC_NAME }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
DEBUG: electron-builder,electron-notarize*
|
||||
run: pnpm run make:app
|
||||
|
||||
- name: Verify signed macOS app
|
||||
if: matrix.os == 'macos' && github.event_name != 'pull_request'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
case "${{ matrix.arch }}" in
|
||||
x64)
|
||||
APP_PATH="dist/executables/mac/IPTVnator.app"
|
||||
;;
|
||||
arm64)
|
||||
APP_PATH="dist/executables/mac-arm64/IPTVnator.app"
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unsupported macOS arch: ${{ matrix.arch }}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
print_debug_attrs() {
|
||||
echo "::group::Extended attributes"
|
||||
xattr -lr "${APP_PATH}" | sed -n '1,120p' || true
|
||||
echo "::endgroup::"
|
||||
}
|
||||
|
||||
trap print_debug_attrs ERR
|
||||
|
||||
if [ ! -d "${APP_PATH}" ]; then
|
||||
echo "::error::Expected app bundle not found at ${APP_PATH}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SIGNATURE_INFO="$(codesign -dv --verbose=4 "${APP_PATH}" 2>&1)"
|
||||
printf '%s\n' "${SIGNATURE_INFO}"
|
||||
|
||||
if printf '%s\n' "${SIGNATURE_INFO}" | grep -q 'Signature=adhoc'; then
|
||||
echo "::error::macOS app is still ad-hoc signed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if printf '%s\n' "${SIGNATURE_INFO}" | grep -q 'TeamIdentifier=not set'; then
|
||||
echo "::error::macOS app is missing a TeamIdentifier."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
codesign --verify --deep --strict --verbose=4 "${APP_PATH}"
|
||||
spctl -a -vvv --type execute "${APP_PATH}"
|
||||
xcrun stapler validate "${APP_PATH}"
|
||||
|
||||
- name: Make Electron app
|
||||
if: matrix.os != 'macos' || github.event_name == 'pull_request'
|
||||
run: pnpm run make:app
|
||||
|
||||
- name: Verify packaged worker layout
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_OS: ${{ matrix.os }}
|
||||
PACKAGE_ARCH: ${{ matrix.arch || '' }}
|
||||
run: pnpm run verify:package-layout -- "$PACKAGE_OS" "$PACKAGE_ARCH"
|
||||
|
||||
- name: Smoke test packaged Flatpak launcher
|
||||
if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
FLATPAK_BUNDLE="$(find dist/executables -maxdepth 1 -name '*.flatpak' | head -n 1)"
|
||||
if [ -z "${FLATPAK_BUNDLE}" ]; then
|
||||
echo "::error::Flatpak bundle not found in dist/executables"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
flatpak install --user --noninteractive -y "${FLATPAK_BUNDLE}"
|
||||
flatpak run --command=sh com.fourgray.iptvnator -c '
|
||||
set -euo pipefail
|
||||
|
||||
test -f /app/share/metainfo/com.fourgray.iptvnator.metainfo.xml
|
||||
test -L /app/bin/iptvnator
|
||||
|
||||
LAUNCHER_PATH="$(readlink -f /app/bin/iptvnator)"
|
||||
test -f "${LAUNCHER_PATH}"
|
||||
test -f "${LAUNCHER_PATH}.bin"
|
||||
grep -q '\''readlink -f "$SCRIPT_PATH"'\'' "${LAUNCHER_PATH}"
|
||||
grep -q '\''exec "$SCRIPT_DIR/iptvnator.bin"'\'' "${LAUNCHER_PATH}"
|
||||
'
|
||||
|
||||
- name: Upload artifacts (macOS)
|
||||
if: matrix.os == 'macos'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-${{ matrix.arch }}-artifacts
|
||||
path: |
|
||||
dist/executables/**/*.dmg
|
||||
dist/executables/**/*.zip
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload artifacts (Linux)
|
||||
if: matrix.os == 'linux' && matrix.linux_profile == 'standard'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-artifacts
|
||||
path: |
|
||||
dist/executables/**/*.deb
|
||||
dist/executables/**/*.rpm
|
||||
dist/executables/**/*.snap
|
||||
dist/executables/**/*.AppImage
|
||||
dist/executables/**/*.tar.gz
|
||||
dist/executables/**/*.pacman
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload artifacts (Flatpak)
|
||||
if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-flatpak-artifacts
|
||||
path: |
|
||||
dist/executables/**/*.flatpak
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload artifacts (Windows)
|
||||
if: matrix.os == 'windows'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-artifacts
|
||||
path: |
|
||||
dist/executables/**/*.exe
|
||||
dist/executables/**/*.msi
|
||||
dist/executables/**/*.zip
|
||||
retention-days: 7
|
||||
|
||||
create-release:
|
||||
name: Create Draft Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R artifacts
|
||||
|
||||
- name: Get version from package.json
|
||||
id: package-version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Draft Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
draft: true
|
||||
prerelease: ${{ github.event_name == 'pull_request' }}
|
||||
name: Release v${{ steps.package-version.outputs.version }}
|
||||
tag_name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || format('test-{0}', github.sha) }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/macos-x64-artifacts/*-x64.dmg
|
||||
artifacts/macos-x64-artifacts/*-x64.zip
|
||||
artifacts/macos-arm64-artifacts/*-arm64.dmg
|
||||
artifacts/macos-arm64-artifacts/*-arm64.zip
|
||||
artifacts/linux-artifacts/*.AppImage
|
||||
artifacts/linux-artifacts/*.deb
|
||||
artifacts/linux-artifacts/*.rpm
|
||||
artifacts/linux-artifacts/*.snap
|
||||
artifacts/linux-artifacts/*.tar.gz
|
||||
artifacts/linux-artifacts/*.pacman
|
||||
artifacts/linux-flatpak-artifacts/*.flatpak
|
||||
artifacts/windows-artifacts/*-setup.exe
|
||||
artifacts/windows-artifacts/*.msi
|
||||
artifacts/windows-artifacts/*.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
publish-snap:
|
||||
name: Publish to Snapcraft Store
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }}
|
||||
|
||||
steps:
|
||||
- name: Download snap artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: linux-artifacts
|
||||
path: artifacts
|
||||
|
||||
- name: Setup Snapcraft
|
||||
uses: samuelmeuli/action-snapcraft@v3
|
||||
|
||||
- name: Publish all snaps to edge channel
|
||||
run: |
|
||||
# Find and publish all snap files
|
||||
for SNAP_FILE in artifacts/*.snap; do
|
||||
if [ -f "$SNAP_FILE" ]; then
|
||||
echo "Publishing: $SNAP_FILE"
|
||||
snapcraft upload --release=edge "$SNAP_FILE"
|
||||
fi
|
||||
done
|
||||
@@ -0,0 +1,41 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
unit-and-typecheck:
|
||||
name: Unit Tests and Typechecks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck web and Electron entry points
|
||||
run: pnpm run typecheck:ci
|
||||
|
||||
- name: Run curated Nx unit test suite
|
||||
run: pnpm run test:unit:ci
|
||||
env:
|
||||
CI: true
|
||||
NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false
|
||||
@@ -0,0 +1,71 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [master]
|
||||
schedule:
|
||||
- cron: '0 20 * * 3'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Override automatic language detection by changing the below list
|
||||
# Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
|
||||
language: ['javascript']
|
||||
# Learn more...
|
||||
# https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
fetch-depth: 2
|
||||
|
||||
# If this run was triggered by a pull request event, then checkout
|
||||
# the head of the pull request instead of the merge commit.
|
||||
- run: git checkout HEAD^2
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Deploy Website to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- '.github/workflows/deploy-website.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: 'pages'
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Website
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build website
|
||||
run: pnpm nx build website
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: dist/apps/website
|
||||
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -0,0 +1,39 @@
|
||||
name: docker-build
|
||||
|
||||
on: [workflow_dispatch]
|
||||
|
||||
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
|
||||
@@ -0,0 +1,98 @@
|
||||
name: 'Cross-Platform E2E Tests'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
electron-e2e-tests:
|
||||
name: Electron E2E on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
NX_SKIP_NX_CACHE: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Backend
|
||||
run: pnpm nx build electron-backend
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
|
||||
- name: Run Electron E2E Tests (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" pnpm nx run electron-backend-e2e:e2e
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Run Electron E2E Tests (Windows/Mac)
|
||||
if: runner.os != 'Linux'
|
||||
run: pnpm nx run electron-backend-e2e:e2e
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Upload Electron Test Results
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-electron-${{ matrix.os }}
|
||||
path: dist/playwright-report/electron-backend-e2e/
|
||||
retention-days: 7
|
||||
|
||||
web-e2e-tests:
|
||||
name: Web E2E on Ubuntu Chromium
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NX_SKIP_NX_CACHE: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Chromium
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Run Web E2E Tests
|
||||
run: pnpm nx run web-e2e:e2e -- --project=chromium
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Upload Web Test Results
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-web-ubuntu
|
||||
path: dist/.playwright/apps/web-e2e/playwright-report/
|
||||
retention-days: 7
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
#compiled output
|
||||
/dist
|
||||
/build
|
||||
/tmp
|
||||
/out-tsc
|
||||
/app-builds
|
||||
/release
|
||||
/e2e/tracing
|
||||
/e2e/screenshots
|
||||
/test-results
|
||||
api.js
|
||||
epg-worker.js
|
||||
main.js
|
||||
menu.js
|
||||
src/**/*.js
|
||||
!src/karma.conf.js
|
||||
*.js.map
|
||||
data.db
|
||||
preload.js
|
||||
shared/*.js
|
||||
playwright-report
|
||||
.nx
|
||||
server.js
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
package-lock.json
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# VLC portable for Windows NSIS bundle (downloaded by tools/packaging/download-vlc-windows.mjs)
|
||||
/tools/packaging/bundled-vlc/win64/
|
||||
|
||||
# misc
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
testem.log
|
||||
/typings
|
||||
.angular/*
|
||||
|
||||
# e2e
|
||||
/e2e/*.js
|
||||
!/e2e/protractor.conf.js
|
||||
/e2e/*.map
|
||||
/e2e/db
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.angular
|
||||
.astro
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"nx-mcp": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["nx", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
*.yml
|
||||
dist
|
||||
build
|
||||
coverage
|
||||
release
|
||||
.angular
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"tabWidth": 4,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 80
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"extends": "stylelint-config-standard",
|
||||
"rules": {
|
||||
"indentation": 4,
|
||||
"selector-type-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignoreTypes": [
|
||||
"/^mat-/"
|
||||
]
|
||||
}
|
||||
],
|
||||
"selector-pseudo-element-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignorePseudoElements": ["ng-deep"]
|
||||
}
|
||||
],
|
||||
"at-rule-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignoreAtRules": ["function", "if", "each", "include", "mixin"]
|
||||
}
|
||||
],
|
||||
"media-feature-name-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignoreMediaFeatureNames": ["display-mode"]
|
||||
}
|
||||
],
|
||||
"value-keyword-case": null,
|
||||
"string-quotes": "single",
|
||||
"value-no-vendor-prefix": null,
|
||||
"property-no-vendor-prefix": null
|
||||
},
|
||||
"plugins": ["stylelint-scss"],
|
||||
"syntax": "scss"
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["ms-playwright.playwright", "esbenp.prettier-vscode"]
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to coding agents working in this repository.
|
||||
|
||||
## Plan Mode
|
||||
|
||||
- When an agent is in Plan Mode and produces a final `<proposed_plan>`, it must also save that finalized plan as a Markdown file in the repo-root `.plans/` directory.
|
||||
- Save only finalized plans. Do not write interim exploration, questions, or draft revisions to `.plans/`.
|
||||
- Use the filename pattern `YYYY-MM-DD-short-topic.md` such as `.plans/2026-03-12-channel-filtering.md`.
|
||||
- If the intended filename already exists, append a numeric suffix such as `-2`, `-3`, and so on.
|
||||
|
||||
## Documentation After Changes
|
||||
|
||||
- After implementing a meaningful change, agents must assess whether canonical repo docs need updates before considering the task complete.
|
||||
- Meaningful changes include new or changed user-visible behavior, architecture or data-flow changes, non-obvious maintenance workflows, new setup/debugging steps, and new subsystem contracts or boundaries.
|
||||
- Skip doc updates for trivial refactors with unchanged behavior, formatting-only edits, and isolated test-only changes.
|
||||
- Prefer updating an existing authoritative doc before creating a new one:
|
||||
1. `README.md` for top-level developer or user workflows
|
||||
2. `docs/architecture/` for architecture, ownership, and behavior contracts
|
||||
3. the nearest module `README.md` for local usage or behavior
|
||||
- Repo docs are canonical even when they were originally drafted by an LLM. External wiki pages are derivative or synthesis content unless explicitly promoted back into the repo.
|
||||
- The external wiki sync is one-way by default: repo docs -> external wiki `_repo-context/`.
|
||||
- If repo docs changed and `IPTVNATOR_WIKI_VAULT` is configured, run `pnpm wiki:export --mode changed` after the doc update.
|
||||
- The wiki exporter only owns `_repo-context/` in the external vault. It must never overwrite repo docs or maintained wiki pages outside that folder.
|
||||
- Final task summaries should state whether docs were updated, which doc changed, and whether wiki export ran, was skipped, or failed.
|
||||
|
||||
## Electron Debugging (CDP)
|
||||
|
||||
- Start the Electron development app with: `nx serve electron-backend`
|
||||
- Package-script equivalent: `pnpm run serve:backend`
|
||||
- Electron is configured to start with: `--remote-debugging-port=9222`
|
||||
- Connect Chrome DevTools Protocol tools to: `127.0.0.1:9222`
|
||||
- For Electron automation/debugging tasks, use the `electron` skill
|
||||
- Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`.
|
||||
- If DevTools is open, `agent-browser --cdp 9222 ...` may attach to the DevTools page instead of the IPTVnator window. Symptoms: `tab list` shows `about:blank`, snapshots are empty, and screenshots are black.
|
||||
- If that happens, inspect targets with `curl http://127.0.0.1:9222/json/list` and connect directly to the IPTVnator page websocket from the `webSocketDebuggerUrl` field.
|
||||
|
||||
### Trace / Debug Startup
|
||||
|
||||
- Full startup tracing:
|
||||
|
||||
```bash
|
||||
IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend
|
||||
```
|
||||
|
||||
- Narrower trace flags:
|
||||
- `IPTVNATOR_TRACE_IPC=1` traces renderer `window.electron.*` bridge calls
|
||||
- `IPTVNATOR_TRACE_DB=1` traces DB worker requests and request-scoped DB events
|
||||
- `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in the main process and DB worker
|
||||
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events
|
||||
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal
|
||||
|
||||
- GPU/compositor debugging:
|
||||
|
||||
```bash
|
||||
IPTVNATOR_DISABLE_HARDWARE_ACCELERATION=1 nx serve electron-backend
|
||||
```
|
||||
|
||||
- If local Nx state gets weird before a rerun:
|
||||
|
||||
```bash
|
||||
pnpm nx reset
|
||||
```
|
||||
|
||||
### agent-browser (global install)
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 tab list
|
||||
agent-browser --cdp 9222 tab 1
|
||||
agent-browser --cdp 9222 snapshot -i -c -d 4
|
||||
agent-browser --cdp 9222 screenshot /tmp/iptvnator-cdp.png
|
||||
```
|
||||
|
||||
### Fallback
|
||||
|
||||
```bash
|
||||
npx --yes agent-browser --cdp 9222 tab list
|
||||
```
|
||||
|
||||
### DevTools Workaround
|
||||
|
||||
```bash
|
||||
ELECTRON_OPEN_DEVTOOLS=1 nx serve electron-backend
|
||||
curl http://127.0.0.1:9222/json/list
|
||||
agent-browser connect ws://127.0.0.1:9222/devtools/page/<iptvnator-page-id>
|
||||
agent-browser screenshot /tmp/iptvnator-cdp.png
|
||||
```
|
||||
|
||||
## Radio / Audio Player
|
||||
|
||||
M3U playlists can contain radio channels identified by the `radio="true"` attribute on `#EXTINF` lines. When a radio channel is selected:
|
||||
|
||||
- The dedicated `AudioPlayerComponent` (`libs/ui/playback/src/lib/audio-player/`) renders instead of a video player
|
||||
- The audio player always uses the built-in inline player — external player settings (MPV/VLC) are ignored
|
||||
- The EPG panel is hidden (radio streams have no EPG data)
|
||||
- The layout uses a cinematic hero pattern: the station logo is blurred as a full-area backdrop with a vignette overlay, and the artwork card + controls float above it
|
||||
- Volume is shared with the video player via `localStorage` key `'volume'`
|
||||
- Keyboard shortcuts: ArrowUp/ArrowDown (volume +/-5%), M (mute toggle)
|
||||
- Radio detection in the video player template: `activeChannel.radio === 'true'` — this is a string comparison, not boolean
|
||||
|
||||
Key files:
|
||||
|
||||
- `libs/ui/playback/src/lib/audio-player/audio-player.component.ts` — the audio player component
|
||||
- `libs/ui/playback/src/lib/audio-player/audio-player.component.scss` — cinematic hero styling
|
||||
- `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html` — template conditionals for radio vs video
|
||||
- `libs/shared/interfaces/src/lib/channel.interface.ts` — `radio: string` field on Channel interface
|
||||
|
||||
## Repo Skills
|
||||
|
||||
- `iptvnator-ui-design`
|
||||
Repository-specific UI design guidance for IPTVnator.
|
||||
Use when working on channel rows, EPG views, settings surfaces, shared selection styles, or light/dark theme consistency.
|
||||
File: `.codex/skills/iptvnator-ui-design/SKILL.md`
|
||||
|
||||
- `iptvnator-theme-style`
|
||||
Theme architecture, design token reference, shared SCSS library, portal header pattern, Electron drag region, and common styling mistakes.
|
||||
Use when adding/changing CSS tokens, styling portal headers or sidebars, using shared SCSS mixins (`portal-layout`, `content-grid`, `portal-sidebar`), or auditing cross-portal visual consistency.
|
||||
File: `.codex/skills/iptvnator-theme-style/SKILL.md`
|
||||
|
||||
- `iptvnator-nx-architecture`
|
||||
Repository-specific Nx monorepo structure, library placement rules, path alias guidance, and migration guardrails for portal/workspace/app code.
|
||||
Use when deciding where code belongs, extracting code into libs, choosing imports, or refactoring Xtream/Stalker/Workspace boundaries.
|
||||
File: `.codex/skills/iptvnator-nx-architecture/SKILL.md`
|
||||
|
||||
- `iptvnator-sqlite-db-worker`
|
||||
Repository-specific guidance for the Electron non-EPG SQLite worker, including worker boundaries, request-scoped DB progress events, and validation steps for slow DB operations.
|
||||
Use when moving heavy database work off the main thread, adding worker-backed SQLite operations, or wiring loading/progress UI for Xtream and playlist DB flows.
|
||||
File: `.codex/skills/iptvnator-sqlite-db-worker/SKILL.md`
|
||||
|
||||
- `xtream-electron`
|
||||
Repository-specific guidance for IPTVnator's Electron-first Xtream implementation, including feature/data-access boundaries, worker-backed DB flows, and Xtream loading/progress UX expectations.
|
||||
Use when working on Xtream routes, store/data-source logic, or Electron-backed Xtream import/search/delete behavior.
|
||||
File: `.codex/skills/xtream-electron/SKILL.md`
|
||||
|
||||
<!-- nx configuration start-->
|
||||
<!-- Leave the start & end comments to automatically receive updates. -->
|
||||
|
||||
## General Guidelines for working with Nx
|
||||
|
||||
- For navigating/exploring the workspace, invoke the `nx-workspace` skill first - it has patterns for querying projects, targets, and dependencies
|
||||
- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
|
||||
- Prefix nx commands with the workspace's package manager (e.g., `pnpm nx build`, `npm exec nx test`) - avoids using globally installed CLI
|
||||
- You have access to the Nx MCP server and its tools, use them to help the user
|
||||
- For Nx plugin best practices, check `node_modules/@nx/<plugin>/PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable.
|
||||
- NEVER guess CLI flags - always check nx_docs or `--help` first when unsure
|
||||
|
||||
## Scaffolding & Generators
|
||||
|
||||
- For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the `nx-generate` skill FIRST before exploring or calling MCP tools
|
||||
|
||||
## When to use nx_docs
|
||||
|
||||
- USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases
|
||||
- DON'T USE for: basic generator syntax (`nx g @nx/react:app`), standard commands, things you already know
|
||||
- The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax
|
||||
|
||||
<!-- nx configuration end-->
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
# [0.12.0](https://github.com/4gray/iptvnator/compare/v0.11.1...v0.12.0) (2023-03-11)
|
||||
|
||||
|
||||
### Important change
|
||||
|
||||
The storage location of playlists has been changed, now everything is stored in IndexedDB. So after the update you will see a panel offering to migrate playlists.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* macos related window management improvements ([b336680](https://github.com/4gray/iptvnator/commit/**b336680ec93b6c2a78af08bf1847f6e133895719**))
|
||||
* set epgSource as not required field ([049ed6b](https://github.com/4gray/iptvnator/commit/049ed6be519df602fd8eb5071fb17efe1a850000)), closes [#175](https://github.com/4gray/iptvnator/issues/175)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add cmd+q hotkey to close the app (macOS) ([f3e00e7](https://github.com/4gray/iptvnator/commit/f3e00e78aa65b64d058c27b616b0d11d1a374015)), closes [#181](https://github.com/4gray/iptvnator/issues/181)
|
||||
* add dockerfile and docker-compose ([4b97e3d](https://github.com/4gray/iptvnator/commit/4b97e3d4b3b84a57c5c09cc25c0c362de341e2ba))
|
||||
* add italian language ([3e3f18c](https://github.com/4gray/iptvnator/commit/3e3f18cabf3784bcfee17e54771ca0a5dbcbbf33))
|
||||
* draggable channels in favorites list ([ba41c8d](https://github.com/4gray/iptvnator/commit/ba41c8dae5e82bd1f39fb9a6cd8518e25dcdb894))
|
||||
* export playlist as m3u ([7e4d6b1](https://github.com/4gray/iptvnator/commit/7e4d6b171fa87ffb7084344a10d653dd2cb30ea2))
|
||||
* persist window size ([2ce60e0](https://github.com/4gray/iptvnator/commit/2ce60e0a205dd8034626f35bfea1632fcac56529)), closes [#205](https://github.com/4gray/iptvnator/issues/205)
|
||||
* **pwa:** load a m3u playlist as a URL parameter [#176](https://github.com/4gray/iptvnator/issues/176) ([344bd75](https://github.com/4gray/iptvnator/commit/344bd75c876ff3d26e5721e64e7b35cf7547950a))
|
||||
|
||||
|
||||
|
||||
## [0.11.1](https://github.com/4gray/iptvnator/compare/v0.11.0...v0.11.1) (2022-10-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow file:// protocol for epg source ([c4e1076](https://github.com/4gray/iptvnator/commit/c4e107681e55ac168a86b57cf0c6bfd5a2b35c5c))
|
||||
* disable service worker in electron app ([df4e99a](https://github.com/4gray/iptvnator/commit/df4e99a4ae7186322eddcab8b6a81a04bd4c2e1a))
|
||||
* red screen in PIP window ([9756a76](https://github.com/4gray/iptvnator/commit/9756a76a90c05c008cd84c690c82b04c7ec56c87))
|
||||
* service worker injection ([ba3dd88](https://github.com/4gray/iptvnator/commit/ba3dd88dde1129ac21ec6056b81f7ff8b569f5d6))
|
||||
|
||||
|
||||
### Reverts
|
||||
|
||||
* Revert "Update release.yml" ([5ac1992](https://github.com/4gray/iptvnator/commit/5ac199281d0fa24998cf01dff148316f7684e46a))
|
||||
|
||||
|
||||
|
||||
# [0.11.0](https://github.com/4gray/iptvnator/compare/v0.10.0...v0.11.0) (2022-09-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* set default value for video player ([ab1d0bf](https://github.com/4gray/iptvnator/commit/ab1d0bfec85dd8a43606c02a215c6654b809bb15))
|
||||
* show notification after playlist refresh (PWA) ([b18e537](https://github.com/4gray/iptvnator/commit/b18e537ccb25c49002577a7aedd810c81eb981ea))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* check for available updates in PWA ([17b265f](https://github.com/4gray/iptvnator/commit/17b265f5a6bc4cae5c66532ccda3af580b52779c))
|
||||
* implement multi epg view ([b4db751](https://github.com/4gray/iptvnator/commit/b4db751fb66353700dbc9e2285232da28ee655cb))
|
||||
* import playlist as text ([6676fa0](https://github.com/4gray/iptvnator/commit/6676fa0a4267a5cb56697b47f24954581873f3e1))
|
||||
* support multiple epg sources ([f8c6874](https://github.com/4gray/iptvnator/commit/f8c6874ad3734ef74e5de25e83626c7e8e77c55a))
|
||||
|
||||
|
||||
|
||||
# [0.10.0](https://github.com/4gray/iptvnator/compare/v0.9.0...v0.10.0) (2022-04-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* capitalize app name [#117](https://github.com/4gray/iptvnator/issues/117) ([36d3eaa](https://github.com/4gray/iptvnator/commit/36d3eaa54d546de64a9522583772cf411a2866c4))
|
||||
* epg function to compare channel ids ([c7de39e](https://github.com/4gray/iptvnator/commit/c7de39e7c4fa46d9d09adb34a88a060fa9570ea6))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add context menu with default actions ([44e76e0](https://github.com/4gray/iptvnator/commit/44e76e0e35eccc085c9b35ddd3a112b6c1aa8e09)), closes [#96](https://github.com/4gray/iptvnator/issues/96)
|
||||
* add option to change aspect ratio ([b8a3f76](https://github.com/4gray/iptvnator/commit/b8a3f76c40f44416eb69b7ab0d99bf6ad3b5307b)), closes [#80](https://github.com/4gray/iptvnator/issues/80)
|
||||
* add option to select stream resolution ([c23fe3a](https://github.com/4gray/iptvnator/commit/c23fe3a923ff5b60930f9968ec9cfb611b552858)), closes [#93](https://github.com/4gray/iptvnator/issues/93)
|
||||
* add pwa support ([5a5085d](https://github.com/4gray/iptvnator/commit/5a5085dcf7e7ba956c02db539160bb06ebce5e80))
|
||||
* auto-detect if OS is in dark mode ([ad26588](https://github.com/4gray/iptvnator/commit/ad265884e6976ad4ddc636434bf97bc3e02c7613))
|
||||
* generate global playlist with all favorites ([764201a](https://github.com/4gray/iptvnator/commit/764201a0afa03b0ed3c075e3c3cfcf6fba5c105a)), closes [#97](https://github.com/4gray/iptvnator/issues/97)
|
||||
* integrate french localization ([ab75a2f](https://github.com/4gray/iptvnator/commit/ab75a2f83752ccebd07ca0dd13e9ded1e58e0efc))
|
||||
* **pwa:** auto-generate playlist with global favorites ([98ff7f4](https://github.com/4gray/iptvnator/commit/98ff7f4fa4576e00d3de0afd05395f673622ddd8))
|
||||
* switch playlists from the sidebar ([0bc71d4](https://github.com/4gray/iptvnator/commit/0bc71d47911c9b87cbe92f20f7d024c0027349e1))
|
||||
|
||||
|
||||
|
||||
# [0.9.0](https://github.com/4gray/iptvnator/compare/v0.8.0...v0.9.0) (2021-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* search feature should not affect favorites list ([ef52f77](https://github.com/4gray/iptvnator/commit/ef52f77c117c644c2173d4b82783028a19f25011)), closes [#71](https://github.com/4gray/iptvnator/issues/71)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add chinese translation ([a497f05](https://github.com/4gray/iptvnator/commit/a497f0570175618d7053b53fd47aa907e6361f17))
|
||||
* global subtitle display setting ([4d2e175](https://github.com/4gray/iptvnator/commit/4d2e17565d247c2a6bc9ae3d23ab37ff52033478))
|
||||
* rearrange the display order of playlists ([757c739](https://github.com/4gray/iptvnator/commit/757c739d92d0a646f1927a4c3f2d3eb8425876df)), closes [#77](https://github.com/4gray/iptvnator/issues/77)
|
||||
|
||||
|
||||
|
||||
# [0.8.0](https://github.com/4gray/iptvnator/compare/v0.7.0...v0.8.0) (2021-07-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add info popup with epg info for active channel ([0eecfd1](https://github.com/4gray/iptvnator/commit/0eecfd1163a36019a7b600290fe5f01ee3bb9677)), closes [#51](https://github.com/4gray/iptvnator/issues/51)
|
||||
* add support of specific user-agent and referer on channel level ([a55f741](https://github.com/4gray/iptvnator/commit/a55f741a320db430987352289fd7847e050dcafd)), closes [#57](https://github.com/4gray/iptvnator/issues/57)
|
||||
|
||||
|
||||
|
||||
# [0.7.0](https://github.com/4gray/iptvnator/compare/v0.6.0...v0.7.0) (2021-05-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* promise issue after channel switch [#29](https://github.com/4gray/iptvnator/issues/29) ([d1f194a](https://github.com/4gray/iptvnator/commit/d1f194a25e231fd39f73aae8da7fccf60e7d4826))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* auto-refresh playlists on app startup ([e8ab576](https://github.com/4gray/iptvnator/commit/e8ab576d8b797a39eb1206e80f19e04abe88bdb4))
|
||||
* refresh playlist from file system or imported url ([57cf247](https://github.com/4gray/iptvnator/commit/57cf2477d9f3d423eb4ebbd983488b4ade275411))
|
||||
* support of timeshift and catchup attributes in playlists ([ced16a8](https://github.com/4gray/iptvnator/commit/ced16a88b25c9cb139d3a70ed1194a977cfb07f1))
|
||||
|
||||
|
||||
|
||||
# [0.6.0](https://github.com/4gray/iptvnator/compare/v0.5.0...v0.6.0) (2021-04-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* quit application via window close icon ([24d5584](https://github.com/4gray/iptvnator/commit/24d558470251d479b611a22bfa7f1b7ba0c70a45)), closes [#33](https://github.com/4gray/iptvnator/issues/33)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add "what is new" dialog ([cc375a4](https://github.com/4gray/iptvnator/commit/cc375a4d4e068ec6cd23deeb83135fe8b773e517))
|
||||
* add dark theme ([0cf010a](https://github.com/4gray/iptvnator/commit/0cf010aba31b9e7b8a3344787b7c18bb67405ab7))
|
||||
* add dialog with detailed epg description ([96e93c5](https://github.com/4gray/iptvnator/commit/96e93c5b0cf1b8d9e703d93c66a1ad552ab44ed8))
|
||||
* set custom user agent for a playlist ([a8167c4](https://github.com/4gray/iptvnator/commit/a8167c4b2ae625f9714c8bbe5cd6ffa3fcfa0140)), closes [#26](https://github.com/4gray/iptvnator/issues/26)
|
||||
* show channel logos in the list ([41998ff](https://github.com/4gray/iptvnator/commit/41998ff7a0800368ef64ba184e4bab1b02f509c0)), closes [#28](https://github.com/4gray/iptvnator/issues/28)
|
||||
|
||||
|
||||
|
||||
# [0.5.0](https://github.com/4gray/iptvnator/compare/v0.4.0...v0.5.0) (2021-02-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* less strict url validation of playlist URL ([4f366d9](https://github.com/4gray/iptvnator/commit/4f366d91fd5664787b2258f30a25cb0d3d58b30e)), closes [#22](https://github.com/4gray/iptvnator/issues/22)
|
||||
* macOS corrupt icon fix, closes [#21](https://github.com/4gray/iptvnator/issues/21) ([955bb27](https://github.com/4gray/iptvnator/commit/955bb27eec4267a954246415fa1234dd4cd2b08c))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add dialog about playlist details ([9f951fa](https://github.com/4gray/iptvnator/commit/9f951fa0174e4548c57f7a5392d4d74587ac2023))
|
||||
* add german language (i18n) ([7888e85](https://github.com/4gray/iptvnator/commit/7888e85ff106176fff3951ec9e9a056e787d28e5))
|
||||
* add internationalization ([0f8ca2a](https://github.com/4gray/iptvnator/commit/0f8ca2ab1a618dfc3e273ddadfdb51c251912642))
|
||||
* add russian language (i18n) ([a93af69](https://github.com/4gray/iptvnator/commit/a93af690eea075092ae9a987531af01cd3d9a415))
|
||||
|
||||
|
||||
|
||||
# [0.4.0](https://github.com/4gray/iptvnator/compare/v0.3.0...v0.4.0) (2021-02-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* default html5 player initialization fix ([4a6b976](https://github.com/4gray/iptvnator/commit/4a6b9761902fb694677980874ad3dc4a985e4c90))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add `ctrl+f` as hotkey to focus search field ([cad162d](https://github.com/4gray/iptvnator/commit/cad162dc6700a9543571ef051b24d123d41fe9f9))
|
||||
* add epg worker with new ipc commands ([418566f](https://github.com/4gray/iptvnator/commit/418566f6317960e83edff117a036b54df2eac07b))
|
||||
* add moment.js based date pipe ([4881ba8](https://github.com/4gray/iptvnator/commit/4881ba8fe073fd425942773e04bbc98bb7d02aaa))
|
||||
* check for available update ([68ccabb](https://github.com/4gray/iptvnator/commit/68ccabbcb110295aa46a88b1c6c70d057ffaef66))
|
||||
* epg integration ([2e861b6](https://github.com/4gray/iptvnator/commit/2e861b6a1f2c0744bc1eaae632a79087d1721f41))
|
||||
* **epg:** epg program list with date navigation ([fdbe02b](https://github.com/4gray/iptvnator/commit/fdbe02b47e400cdad0c6e0294579150590ac5c14))
|
||||
* favorite channels list ([f02bbe3](https://github.com/4gray/iptvnator/commit/f02bbe39a66a12fd1d3b12863aa2b32048b7691f))
|
||||
* integrate epg feature ([e896af0](https://github.com/4gray/iptvnator/commit/e896af037303990c95d95efac7296365e8c714ee))
|
||||
* integrate epg worker & refactor app menu ([3b97d74](https://github.com/4gray/iptvnator/commit/3b97d74e0b39b20d62f4b2911fe0af1a7c70891b))
|
||||
* sort playlists by import date ([d967b12](https://github.com/4gray/iptvnator/commit/d967b121008075751f47bbd4898894571ef38152))
|
||||
* validation of playlist url ([da2fe5e](https://github.com/4gray/iptvnator/commit/da2fe5e6bfdf09b1feaf7aa4db6240f962555870))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* destroy hls instance after view change ([2e3681c](https://github.com/4gray/iptvnator/commit/2e3681c36edb9e95509d510ab445ab3d95bb4328))
|
||||
|
||||
|
||||
|
||||
# [0.2.0](https://github.com/4gray/iptvnator/compare/v0.1.0...v0.2.0) (2020-09-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add new video.js based player ([1e852e3](https://github.com/4gray/iptvnator/commit/1e852e389931e18ccfaf78f21c86df5dfe81ad6d))
|
||||
* add settings page ([1a44ecd](https://github.com/4gray/iptvnator/commit/1a44ecd995d212e9597c44353fa049e4f07f0ab7))
|
||||
* video player configuration in app settings ([05c0c25](https://github.com/4gray/iptvnator/commit/05c0c251cf92ad17788628f5c8d8d8107e935d94))
|
||||
|
||||
|
||||
|
||||
# [0.1.0](https://github.com/4gray/iptvnator/compare/v0.0.2...v0.1.0) (2020-09-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* playlist upload for mac os [#6](https://github.com/4gray/iptvnator/issues/6) ([78ca56f](https://github.com/4gray/iptvnator/commit/78ca56f2b64ca61d7acdce0038d1210e99b07ffc))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add new application icon ([0ce0b1f](https://github.com/4gray/iptvnator/commit/0ce0b1f1b5222470c79e666f6be92f507dc2f68d))
|
||||
* open playlist from file system ([861e480](https://github.com/4gray/iptvnator/commit/861e480b7076fe0e02f96908f4f30ac626722a9a))
|
||||
|
||||
|
||||
|
||||
## [0.0.2](https://github.com/4gray/iptvnator/compare/v0.0.1...v0.0.2) (2020-09-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* define path to userData folder as db store ([8e8c107](https://github.com/4gray/iptvnator/commit/8e8c107ca78d30bc6e90c6894fc021b1ee83d5ea))
|
||||
* enable copy-paste hotkeys for mac os ([4357c17](https://github.com/4gray/iptvnator/commit/4357c172932231d50c33ee8bc7decfb5a73d9419))
|
||||
|
||||
|
||||
|
||||
## [0.0.1](https://github.com/4gray/iptvnator/compare/2f1701a3db04beb2fc6aca1e3a05f04c0a04b8af...v0.0.1) (2020-09-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* full screen permissions ([fff2aaa](https://github.com/4gray/iptvnator/commit/fff2aaa5416cb29b12c60ff0cbe615e409689808))
|
||||
* full screen permissions ([f0d0fb1](https://github.com/4gray/iptvnator/commit/f0d0fb1c31651106ae5a2e1e1920b0d6efdfb489))
|
||||
* start_url fix in manifest file ([e05b7eb](https://github.com/4gray/iptvnator/commit/e05b7ebdfe58fe8b4bb8a169eaa8c0ab62947a32))
|
||||
* start_url fix in manifest file ([3929655](https://github.com/4gray/iptvnator/commit/392965595581b38229975475562c839e30146920))
|
||||
* update paths to make app installable ([fd9fc53](https://github.com/4gray/iptvnator/commit/fd9fc5360692030b4ce70eb9cdc44b79239a3fa8))
|
||||
* update paths to make app installable ([4b7d1b5](https://github.com/4gray/iptvnator/commit/4b7d1b561ca04a8c0ed32a0b6a4ce48e695b310f))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add channel search function ([d1b9461](https://github.com/4gray/iptvnator/commit/d1b94615f0d1a420d320babfc3e866dd2520cf72))
|
||||
* add channel search function ([7a039af](https://github.com/4gray/iptvnator/commit/7a039af3f1e0619ce7a97622668705698380609a))
|
||||
* add favorites and tab based navigation ([8d333d9](https://github.com/4gray/iptvnator/commit/8d333d95203ec4344f44df0678c70d6840ae1bb0))
|
||||
* add favorites and tab based navigation ([c6e3e1f](https://github.com/4gray/iptvnator/commit/c6e3e1f02550b83914e284b95943bc05a53ad509))
|
||||
* add icon, about dialog and menu ([bcf49b8](https://github.com/4gray/iptvnator/commit/bcf49b84c7626c3e87bbdab8ad382df4546f42e9))
|
||||
* change sidebar mode ([22f2bc1](https://github.com/4gray/iptvnator/commit/22f2bc1df9a588d38e507c1a2b6a5b0a5aadd830))
|
||||
* change sidebar mode ([ca9f7a2](https://github.com/4gray/iptvnator/commit/ca9f7a254b2551592cc543698b25d622bc262df6))
|
||||
* electron based app ([0feb657](https://github.com/4gray/iptvnator/commit/0feb65786ab3caf7775195cc266a2789eb3093ed))
|
||||
* electron based app ([998e7e9](https://github.com/4gray/iptvnator/commit/998e7e92557aff5cf86bd362abdde051275797b0))
|
||||
* implement channel groups view ([20e0a40](https://github.com/4gray/iptvnator/commit/20e0a4019c6a3db09fa54a3118c67eb348787a18))
|
||||
* implement channel groups view ([773e1ed](https://github.com/4gray/iptvnator/commit/773e1edf63df735d517fd11dda6dfd6b3181258f))
|
||||
* initial commit ([79332aa](https://github.com/4gray/iptvnator/commit/79332aa6de5dd2a10fad187e809057e2cbac4abd))
|
||||
* initial commit ([2f1701a](https://github.com/4gray/iptvnator/commit/2f1701a3db04beb2fc6aca1e3a05f04c0a04b8af))
|
||||
* list with recent playlists ([1735026](https://github.com/4gray/iptvnator/commit/1735026c201591c4192317f3a5371c8548f8fa1c))
|
||||
* list with recent playlists ([8ee96ee](https://github.com/4gray/iptvnator/commit/8ee96eeebe0ca422d7bc183296f896c49e827b19))
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Plan Mode
|
||||
|
||||
- When Claude Code is in Plan Mode and produces a final `<proposed_plan>`, it must also save that finalized plan as a Markdown file in the repo-root `.plans/` directory.
|
||||
- Save only finalized plans. Do not write interim exploration, question turns, or draft revisions to `.plans/`.
|
||||
- Use the filename pattern `YYYY-MM-DD-short-topic.md` such as `.plans/2026-03-12-channel-filtering.md`.
|
||||
- If the intended filename already exists, append a numeric suffix such as `-2`, `-3`, and so on.
|
||||
|
||||
## Documentation After Changes
|
||||
|
||||
- After implementing a meaningful change, Claude Code must assess whether canonical repo docs need updates before considering the task complete.
|
||||
- Meaningful changes include new or changed user-visible behavior, architecture or data-flow changes, non-obvious maintenance workflows, new setup/debugging steps, and new subsystem contracts or boundaries.
|
||||
- Skip doc updates for trivial refactors with unchanged behavior, formatting-only edits, and isolated test-only changes.
|
||||
- Prefer updating an existing authoritative doc before creating a new one:
|
||||
1. `README.md` for top-level developer or user workflows
|
||||
2. `docs/architecture/` for architecture, ownership, and behavior contracts
|
||||
3. the nearest module `README.md` for local usage or behavior
|
||||
- Repo docs are canonical even when they were originally drafted by an LLM. External wiki pages are derivative or synthesis content unless explicitly promoted back into the repo.
|
||||
- The external wiki sync is one-way by default: repo docs -> external wiki `_repo-context/`.
|
||||
- If repo docs changed and `IPTVNATOR_WIKI_VAULT` is configured, run `pnpm wiki:export --mode changed` after the doc update.
|
||||
- The wiki exporter only owns `_repo-context/` in the external vault. It must never overwrite repo docs or maintained wiki pages outside that folder.
|
||||
- Final task summaries should state whether docs were updated, which doc changed, and whether wiki export ran, was skipped, or failed.
|
||||
|
||||
## Project Overview
|
||||
|
||||
IPTVnator is a cross-platform IPTV player application built with Angular and Electron, supporting M3U/M3U8 playlists, Xtream Codes API, and Stalker portals.
|
||||
|
||||
**Dual Environment Support**: The application is designed to work in both Electron and as a Progressive Web App (PWA). The architecture uses a factory pattern to inject environment-specific services at runtime, ensuring the same codebase works in both contexts.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Building and Serving
|
||||
|
||||
```bash
|
||||
# Serve the Angular web app only (development mode, baseHref="/")
|
||||
pnpm run serve:frontend
|
||||
# or
|
||||
nx serve web
|
||||
|
||||
# Serve with PWA configuration (optimized, baseHref="/")
|
||||
pnpm run serve:frontend:pwa
|
||||
# or
|
||||
nx serve web --configuration=pwa
|
||||
|
||||
# Serve the Electron app (starts both frontend and backend)
|
||||
pnpm run serve:backend
|
||||
# or
|
||||
nx serve electron-backend
|
||||
|
||||
# Build frontend for Electron (baseHref="./")
|
||||
pnpm run build:frontend
|
||||
# or
|
||||
nx build web
|
||||
|
||||
# Build frontend for PWA deployment (baseHref="/")
|
||||
pnpm run build:frontend:pwa
|
||||
# or
|
||||
nx build web --configuration=pwa
|
||||
|
||||
# Build backend (Electron)
|
||||
pnpm run build:backend
|
||||
# or
|
||||
nx build electron-backend
|
||||
|
||||
# Package the app (creates distributable without installers)
|
||||
pnpm run package:app
|
||||
# or
|
||||
nx run electron-backend:package
|
||||
|
||||
# Create installers/executables
|
||||
pnpm run make:app
|
||||
# or
|
||||
nx run electron-backend:make
|
||||
```
|
||||
|
||||
### Electron CDP Debugging
|
||||
|
||||
- Start Electron in dev mode with: `nx serve electron-backend`
|
||||
- Package-script equivalent: `pnpm run serve:backend`
|
||||
- The workspace is configured to always launch Electron with: `--remote-debugging-port=9222`
|
||||
- Use CDP clients (Chrome DevTools Protocol tools) against: `127.0.0.1:9222`
|
||||
- When the task is Electron automation/debugging, use the `electron` skill
|
||||
|
||||
For startup tracing or white-screen debugging:
|
||||
|
||||
```bash
|
||||
IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend
|
||||
```
|
||||
|
||||
Useful narrower flags:
|
||||
|
||||
- `IPTVNATOR_TRACE_IPC=1` traces renderer `window.electron.*` bridge calls
|
||||
- `IPTVNATOR_TRACE_DB=1` traces DB worker requests and DB progress events
|
||||
- `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in both main and worker connections
|
||||
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle
|
||||
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal
|
||||
|
||||
For GPU/compositor debugging:
|
||||
|
||||
```bash
|
||||
IPTVNATOR_DISABLE_HARDWARE_ACCELERATION=1 nx serve electron-backend
|
||||
```
|
||||
|
||||
If the Nx daemon gets into a bad state before rerunning Electron:
|
||||
|
||||
```bash
|
||||
pnpm nx reset
|
||||
```
|
||||
|
||||
Use global `agent-browser` (preferred):
|
||||
|
||||
```bash
|
||||
# Verify CDP targets
|
||||
agent-browser --cdp 9222 tab list
|
||||
|
||||
# Switch to the app tab and inspect interactive elements
|
||||
agent-browser --cdp 9222 tab 1
|
||||
agent-browser --cdp 9222 snapshot -i -c -d 4
|
||||
|
||||
# Capture debug artifacts
|
||||
agent-browser --cdp 9222 screenshot /tmp/iptvnator-cdp.png
|
||||
agent-browser --cdp 9222 trace start /tmp/iptvnator.trace.zip
|
||||
agent-browser --cdp 9222 wait 1500
|
||||
agent-browser --cdp 9222 trace stop /tmp/iptvnator.trace.zip
|
||||
```
|
||||
|
||||
If `agent-browser` is not in PATH, use:
|
||||
|
||||
```bash
|
||||
npx --yes agent-browser --cdp 9222 tab list
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run frontend tests
|
||||
pnpm run test:frontend
|
||||
# or
|
||||
nx test web
|
||||
|
||||
# Run backend tests
|
||||
pnpm run test:backend
|
||||
# or
|
||||
nx test electron-backend
|
||||
|
||||
# Run e2e tests (Playwright)
|
||||
nx e2e web-e2e
|
||||
|
||||
# Run tests with coverage
|
||||
nx test web --configuration=ci
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
```bash
|
||||
# Lint frontend
|
||||
nx lint web
|
||||
|
||||
# Lint backend
|
||||
nx lint electron-backend
|
||||
```
|
||||
|
||||
### Documentation And Wiki Export
|
||||
|
||||
```bash
|
||||
# Export a full canonical-doc snapshot into the external Obsidian vault
|
||||
pnpm wiki:export --mode full
|
||||
|
||||
# Export only the latest repo context into the external Obsidian vault
|
||||
pnpm wiki:export --mode changed
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure (Nx Workspace)
|
||||
|
||||
This is an Nx monorepo with the following structure:
|
||||
|
||||
- **apps/web** - Angular application (frontend)
|
||||
- **apps/electron-backend** - Electron main process
|
||||
- **apps/web-e2e** - Playwright end-to-end tests
|
||||
- **libs/** - Shared libraries:
|
||||
- **m3u-state** - NgRx state management for playlists
|
||||
- **services** - Abstract DataService and implementations
|
||||
- **shared/interfaces** - TypeScript interfaces and types
|
||||
- **shared/m3u-utils** - M3U playlist utilities
|
||||
- **ui/components** - Reusable UI components
|
||||
- **ui/pipes** - Angular pipes
|
||||
- **ui/shared-portals** - Portal-related UI components
|
||||
|
||||
### Frontend Architecture (Angular)
|
||||
|
||||
**State Management**: Uses NgRx for playlist state management:
|
||||
|
||||
- Store configuration in `apps/web/src/app/app.config.ts`
|
||||
- Playlist state, actions, effects, and reducers in `libs/m3u-state/`
|
||||
- Entity adapter pattern for managing playlists collection
|
||||
- Router store integration for route-based state
|
||||
|
||||
**XtreamStore Architecture** (Signal Store with Feature Composition):
|
||||
|
||||
The Xtream Codes module uses NgRx Signal Store with a layered architecture:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ PRESENTATION LAYER │
|
||||
│ Components use XtreamStore (facade) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FACADE LAYER │
|
||||
│ XtreamStore │
|
||||
│ (Composes feature stores, unified API) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────┬────────────┼────────────┬────────────┐
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
|
||||
│ withPortal│ │withContent │ │withSelection│ │ withSearch │ │ withPlayer │
|
||||
└────────────┘ └────────────┘ └────────────┘ └────────────┘ └────────────┘
|
||||
│ │ │
|
||||
└───────────────────────────┼──────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ DATA SOURCE LAYER │
|
||||
│ IXtreamDataSource │
|
||||
│ ┌───────────────────┬───────────────────┐ │
|
||||
│ ▼ ▼ │
|
||||
│ ElectronDataSource PwaDataSource │
|
||||
│ (DB-first + API) (API-only) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
File structure:
|
||||
|
||||
```
|
||||
apps/web/src/app/xtream-electron/
|
||||
├── stores/
|
||||
│ ├── features/
|
||||
│ │ ├── with-portal.feature.ts # Playlist & portal status
|
||||
│ │ ├── with-content.feature.ts # Categories & streams
|
||||
│ │ ├── with-selection.feature.ts # UI selection & pagination
|
||||
│ │ ├── with-search.feature.ts # Search functionality
|
||||
│ │ ├── with-epg.feature.ts # EPG data
|
||||
│ │ ├── with-player.feature.ts # Stream URLs & player
|
||||
│ │ └── index.ts
|
||||
│ ├── xtream.store.ts # Facade composing all features
|
||||
│ └── index.ts
|
||||
├── services/
|
||||
│ ├── xtream-api.service.ts # Xtream Codes API calls
|
||||
│ ├── xtream-url.service.ts # Stream URL construction
|
||||
│ └── index.ts
|
||||
├── data-sources/
|
||||
│ ├── xtream-data-source.interface.ts # Abstract interface + types
|
||||
│ ├── electron-xtream-data-source.ts # DB-first implementation
|
||||
│ ├── pwa-xtream-data-source.ts # API-only implementation
|
||||
│ └── index.ts # Factory provider
|
||||
└── with-favorites.feature.ts # Favorites (existing)
|
||||
└── with-recent-items.ts # Recently viewed (existing)
|
||||
```
|
||||
|
||||
Key patterns:
|
||||
|
||||
- **Feature stores**: Each `with*.feature.ts` uses `signalStoreFeature()` for focused functionality
|
||||
- **Facade pattern**: `XtreamStore` composes all features, maintaining backward compatibility
|
||||
- **Data source abstraction**: `IXtreamDataSource` interface with environment-specific implementations
|
||||
- **Factory injection**: `provideXtreamDataSource()` selects Electron or PWA implementation at runtime
|
||||
|
||||
Data strategies by environment:
|
||||
| Environment | Strategy |
|
||||
|-------------|----------|
|
||||
| **Electron** | DB-first: Check DB → fetch API if missing → cache to DB |
|
||||
| **PWA** | API-only: Always fetch from API, store in memory |
|
||||
|
||||
**M3U Playlist Module Architecture**:
|
||||
|
||||
The M3U playlist module handles traditional M3U/M3U8 playlists with support for 90,000+ channels.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ VIDEO PLAYER PAGE │
|
||||
│ apps/web/src/app/home/video-player/ │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────┐ ┌──────────────────────┐ ┌────────────────────┐ │
|
||||
│ │ Sidebar │ │ Video Player │ │ EPG List │ │
|
||||
│ │ │ │ (ArtPlayer/Video.js)│ │ (Right drawer) │ │
|
||||
│ │ ┌─────────┐ │ │ │ │ │ │
|
||||
│ │ │Channel │ │ │ │ │ │ │
|
||||
│ │ │List │ │ │ │ │ │ │
|
||||
│ │ │Container│ │ │ │ │ │ │
|
||||
│ │ └─────────┘ │ │ │ │ │ │
|
||||
│ └─────────────┘ └──────────────────────┘ └────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Radio Channel Layout** (when `channel.radio === 'true'`):
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ ┌─────────────┐ ┌────────────────────────────────────────────────┐│
|
||||
│ │ Sidebar │ │ Blurred backdrop (station logo) ││
|
||||
│ │ │ │ ┌──────────┐ ││
|
||||
│ │ │ │ │ Artwork │ ← cinematic hero layout ││
|
||||
│ │ │ │ └──────────┘ ││
|
||||
│ │ │ │ Station Name ││
|
||||
│ │ │ │ [LIVE] badge ││
|
||||
│ │ │ │ ⏮ ▶/⏸ ⏭ ← transport controls ││
|
||||
│ │ │ │ 🔊 ━━━━━━━━━ ← volume slider ││
|
||||
│ │ │ │ (no EPG panel) ││
|
||||
│ └─────────────┘ └────────────────────────────────────────────────┘│
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Key radio behavior:
|
||||
|
||||
- Detection: `channel.radio === 'true'` (string from M3U `radio` attribute)
|
||||
- The audio player always renders inline — `shouldShowInlinePlayer` is bypassed for radio
|
||||
- EPG panel is conditionally hidden in the template when radio is active
|
||||
- Volume is shared with video player via `localStorage` key `'volume'`
|
||||
- Keyboard: ArrowUp/Down adjusts volume by 5%, M toggles mute
|
||||
- Component: `libs/ui/playback/src/lib/audio-player/audio-player.component.ts`
|
||||
|
||||
Channel List Component Structure (parent coordinator pattern):
|
||||
|
||||
```
|
||||
libs/ui/components/src/lib/channel-list-container/
|
||||
├── channel-list-container.component.ts # Parent - shared state coordinator
|
||||
├── all-channels-tab/ # Virtual scroll + debounced search
|
||||
├── groups-tab/ # Expansion panels + infinite scroll
|
||||
├── favorites-tab/ # CDK drag-drop reordering
|
||||
└── channel-list-item/ # Individual channel display
|
||||
```
|
||||
|
||||
Key patterns:
|
||||
|
||||
- **EnrichedChannel**: Pre-computed EPG data attached to channels for performance
|
||||
- **Parent coordinator**: Manages shared signals (`channelEpgMap`, `progressTick`, `favoriteIds`)
|
||||
- **Virtual scrolling**: CDK virtual scroll for 90,000+ channel lists
|
||||
- **Infinite scroll**: IntersectionObserver in groups tab loads 50 items at a time
|
||||
- **Global progress tick**: Single 30s interval instead of per-item intervals
|
||||
|
||||
State management via NgRx (`libs/m3u-state/`):
|
||||
|
||||
- `PlaylistActions`: loadPlaylists, addPlaylist, removePlaylist, parsePlaylist
|
||||
- `ChannelActions`: setChannels, setActiveChannel, setAdjacentChannelAsActive
|
||||
- `EpgActions`: setActiveEpgProgram, setCurrentEpgProgram, setEpgAvailableFlag
|
||||
- `FavoritesActions`: updateFavorites, setFavorites
|
||||
|
||||
See `docs/architecture/m3u-playlist-module.md` for complete documentation.
|
||||
|
||||
**Routing**: Lazy-loaded routes in `apps/web/src/app/app.routes.ts`
|
||||
|
||||
- Home/playlists overview: `/`
|
||||
- Video player: `/playlists/:id` or `/iptv`
|
||||
- Xtream Codes: `/xtreams/:id` (different routes for Electron vs web)
|
||||
- Stalker portal: `/portals/:id`
|
||||
- Settings: `/settings`
|
||||
|
||||
**Service Architecture** (Factory Pattern):
|
||||
|
||||
- Abstract `DataService` class in `libs/services/src/lib/data.service.ts` defines the contract
|
||||
- Two environment-specific implementations:
|
||||
- `ElectronService` (`apps/web/src/app/services/electron.service.ts`) - Uses IPC to communicate with Electron backend
|
||||
- `PwaService` (`apps/web/src/app/services/pwa.service.ts`) - Uses HTTP API and IndexedDB for standalone web version
|
||||
- Factory function `DataFactory()` in `apps/web/src/app/app.config.ts` determines which implementation to inject:
|
||||
```typescript
|
||||
if (window.electron) {
|
||||
return new ElectronService();
|
||||
}
|
||||
return new PwaService();
|
||||
```
|
||||
|
||||
**Data Storage (Environment-Specific)**:
|
||||
|
||||
- **Electron**: libSQL/SQLite database via Drizzle ORM
|
||||
- Location: `~/.iptvnator/databases/iptvnator.db`
|
||||
- Full-featured relational database with foreign keys and indexes
|
||||
- Supports local file or remote Turso instance via env vars
|
||||
- **PWA (Web)**: IndexedDB via `ngx-indexed-db`
|
||||
- Browser-based NoSQL storage
|
||||
- Same schema structure but implemented in IndexedDB
|
||||
- Limited by browser storage quotas
|
||||
|
||||
**TypeScript File Size Rule**:
|
||||
|
||||
Keep TypeScript files under **300 lines**. Hard maximum is **350–400 lines**.
|
||||
|
||||
- When creating new files, design them to stay within this limit from the start.
|
||||
- When adding a feature to an existing file that would push it past 350 lines, **refactor first**: extract helpers, sub-services, or feature modules before adding the new code.
|
||||
- When you notice a file already exceeds 350 lines, **proactively suggest a refactoring** (or perform it if the change is straightforward) — even if the immediate task is small.
|
||||
|
||||
Typical split strategies:
|
||||
|
||||
- Angular components: extract child components, move logic to a dedicated service or store feature
|
||||
- Signal store features: split into smaller `with*` feature functions in separate files
|
||||
- Services: split by responsibility (e.g. separate API, transformation, and state concerns)
|
||||
- Utility files: group by domain and export from a barrel `index.ts`
|
||||
|
||||
This rule exists to keep the codebase navigable and reviewable. A 150-line file is always preferable to a 500-line file.
|
||||
|
||||
---
|
||||
|
||||
**Angular Coding Standards**:
|
||||
|
||||
This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use the following:
|
||||
|
||||
- **Component Queries**: Use `viewChild()`, `viewChildren()`, `contentChild()`, `contentChildren()` instead of `@ViewChild`, `@ViewChildren`, `@ContentChild`, `@ContentChildren` decorators
|
||||
|
||||
```typescript
|
||||
// ✅ Correct - Signal-based
|
||||
readonly menu = viewChild.required<MatMenu>('menuRef');
|
||||
readonly items = viewChildren<ElementRef>('item');
|
||||
|
||||
// ❌ Incorrect - Old decorator syntax
|
||||
@ViewChild('menuRef') menu!: MatMenu;
|
||||
@ViewChildren('item') items!: QueryList<ElementRef>;
|
||||
```
|
||||
|
||||
**Important**: When using signals in templates with properties that expect non-signal values, unwrap the signal by calling it:
|
||||
|
||||
```html
|
||||
<!-- ✅ Correct - Unwrap the signal -->
|
||||
<button [matMenuTriggerFor]="menu()">Open Menu</button>
|
||||
|
||||
<!-- ❌ Incorrect - Signal not unwrapped -->
|
||||
<button [matMenuTriggerFor]="menu">Open Menu</button>
|
||||
```
|
||||
|
||||
- **Component Inputs/Outputs**: Use `input()` and `output()` functions instead of `@Input()` and `@Output()` decorators
|
||||
|
||||
```typescript
|
||||
// ✅ Correct - Signal-based
|
||||
readonly title = input.required<string>();
|
||||
readonly size = input<number>(10); // with default value
|
||||
readonly clicked = output<string>();
|
||||
|
||||
// ❌ Incorrect - Old decorator syntax
|
||||
@Input({ required: true }) title!: string;
|
||||
@Input() size = 10;
|
||||
@Output() clicked = new EventEmitter<string>();
|
||||
```
|
||||
|
||||
- **Reactive State**: Use signal primitives for reactive state management
|
||||
|
||||
```typescript
|
||||
// ✅ Use signal(), computed(), effect(), linkedSignal()
|
||||
readonly count = signal(0);
|
||||
readonly doubled = computed(() => this.count() * 2);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
console.log('Count changed:', this.count());
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- **Host Bindings**: Use `@HostBinding()` and `@HostListener()` decorators (these don't have signal equivalents yet)
|
||||
|
||||
```typescript
|
||||
@HostBinding('class.active') get isActive() { return this.active(); }
|
||||
@HostListener('click') onClick() { /* ... */ }
|
||||
```
|
||||
|
||||
- **Control Flow**: Use `@if`, `@for`, `@switch` instead of `*ngIf`, `*ngFor`, `*ngSwitch`
|
||||
|
||||
```typescript
|
||||
// ✅ Correct - Modern syntax
|
||||
@if (isLoggedIn()) {
|
||||
<p>Welcome!</p>
|
||||
}
|
||||
|
||||
@for (item of items(); track item.id) {
|
||||
<li>{{ item.name }}</li>
|
||||
}
|
||||
|
||||
// ❌ Incorrect - Old syntax
|
||||
<p *ngIf="isLoggedIn">Welcome!</p>
|
||||
<li *ngFor="let item of items; trackBy: trackById">{{ item.name }}</li>
|
||||
```
|
||||
|
||||
### Backend Architecture (Electron)
|
||||
|
||||
**Main Entry**: `apps/electron-backend/src/main.ts`
|
||||
|
||||
- Bootstraps Electron app and initializes database
|
||||
- Registers event handlers for IPC communication
|
||||
|
||||
**Database**:
|
||||
|
||||
- **ORM**: Drizzle ORM with libSQL (local SQLite file or remote Turso)
|
||||
- **Location**: `~/.iptvnator/databases/iptvnator.db` (avoids spaces in path)
|
||||
- **Schema** (`apps/electron-backend/src/app/database/schema.ts`):
|
||||
- `playlists` - Playlist metadata (M3U, Xtream, Stalker)
|
||||
- `categories` - Content categories (live, movies, series)
|
||||
- `content` - Streams/VOD/series items
|
||||
- `favorites` - User favorites
|
||||
- `recentlyViewed` - Watch history
|
||||
- **Connection**: `apps/electron-backend/src/app/database/connection.ts`
|
||||
- Auto-creates tables on init
|
||||
- Supports local file or remote via env vars (`LIBSQL_URL`, `LIBSQL_AUTH_TOKEN`)
|
||||
|
||||
**IPC Communication**:
|
||||
|
||||
- **Preload script**: `apps/electron-backend/src/app/api/main.preload.ts`
|
||||
- Exposes `window.electron` API via `contextBridge`
|
||||
- All IPC channels defined here (playlist operations, EPG, database CRUD, external players, etc.)
|
||||
- **Event handlers**: `apps/electron-backend/src/app/events/`
|
||||
- `database.events.ts` - Database CRUD operations
|
||||
- `playlist.events.ts` - Playlist import/update
|
||||
- `epg.events.ts` - EPG fetch and parsing (uses worker)
|
||||
- `xtream.events.ts` - Xtream Codes API
|
||||
- `stalker.events.ts` - Stalker portal API
|
||||
- `player.events.ts` - External player (MPV, VLC) integration
|
||||
- `settings.events.ts` - App settings
|
||||
- `electron.events.ts` - App version, etc.
|
||||
|
||||
**Workers**:
|
||||
|
||||
- EPG parsing runs in worker thread: `apps/electron-backend/src/app/workers/epg-parser.worker.ts`
|
||||
|
||||
### Key Features
|
||||
|
||||
**Playlist Support**:
|
||||
|
||||
- M3U/M3U8 files (local or URL)
|
||||
- Xtream Codes API (`username`, `password`, `serverUrl`)
|
||||
- Stalker portal (`macAddress`, `url`)
|
||||
|
||||
**Video Players**:
|
||||
|
||||
- Built-in HTML5 player with HLS.js or Video.js
|
||||
- External players: MPV, VLC (via IPC to Electron backend)
|
||||
|
||||
**Radio Player**:
|
||||
|
||||
- Dedicated audio player for channels with `radio="true"` M3U attribute
|
||||
- Cinematic layout: blurred station logo as backdrop, floating artwork card, transport controls
|
||||
- Always uses the built-in inline player — external player settings (MPV/VLC) are ignored for radio
|
||||
- EPG panel is hidden for radio channels (radio streams have no EPG data)
|
||||
- Volume synced with video player via shared `localStorage` key `'volume'`
|
||||
- Keyboard shortcuts: ArrowUp/ArrowDown (volume), M (mute)
|
||||
- Component: `libs/ui/playback/src/lib/audio-player/audio-player.component.ts`
|
||||
|
||||
**EPG (Electronic Program Guide)**:
|
||||
|
||||
- XMLTV format support
|
||||
- Background parsing in worker thread
|
||||
- Stored in database for quick lookup
|
||||
|
||||
**Favorites and Recently Viewed**:
|
||||
|
||||
- Per-playlist favorites and global favorites
|
||||
- Recently viewed tracks watch history
|
||||
|
||||
**Internationalization**:
|
||||
|
||||
- Uses `@ngx-translate` with 16 language files in `apps/web/src/assets/i18n/`
|
||||
|
||||
## Development Notes
|
||||
|
||||
### Environment Detection and Dual-Mode Architecture
|
||||
|
||||
The app determines whether it's running in Electron or as a PWA by checking:
|
||||
|
||||
```typescript
|
||||
window.electron; // truthy in Electron, undefined in browser
|
||||
```
|
||||
|
||||
**Why Dual Mode?**
|
||||
IPTVnator supports both Electron (desktop app) and PWA (web browser) to provide flexibility:
|
||||
|
||||
- **Electron**: Full-featured desktop experience with local database, external player support (MPV/VLC), and native file system access
|
||||
- **PWA**: Lightweight web version that runs in any browser without installation
|
||||
|
||||
**Environment-Specific Behavior**:
|
||||
|
||||
- `app.config.ts` - `DataFactory()` selects DataService implementation based on environment
|
||||
- `app.routes.ts` - Different routes for Xtream portals (Electron uses Tauri-based routes, PWA uses standard routes)
|
||||
- Storage layer switches automatically:
|
||||
- Electron → libSQL/Drizzle ORM → `~/.iptvnator/databases/iptvnator.db`
|
||||
- PWA → IndexedDB → Browser storage
|
||||
- External player support (MPV/VLC) only available in Electron
|
||||
- File system operations only available in Electron (uploading playlists from disk)
|
||||
|
||||
**Base Href Configuration**:
|
||||
The app uses different base href values depending on the build target:
|
||||
|
||||
- **Development & PWA**: `baseHref="/"` (from `index.html`)
|
||||
- Used by: `pnpm run serve:frontend`, `pnpm run build:frontend:pwa`
|
||||
- For web servers with proper routing
|
||||
- **Electron Production**: `baseHref="./"` (overridden in build config)
|
||||
- Used by: `pnpm run build:backend`, `pnpm run make:app`
|
||||
- Required for `file://` protocol in Electron
|
||||
|
||||
Build configurations in `apps/web/project.json`:
|
||||
|
||||
- `production`: Electron build with `baseHref="./"`
|
||||
- `pwa`: Web deployment with `baseHref="/"`
|
||||
- `development`: Dev mode with `baseHref="/"` from index.html
|
||||
|
||||
**Factory Pattern Implementation**:
|
||||
The factory pattern ensures a single codebase works in both environments without conditional checks scattered throughout the application. All environment-specific logic is encapsulated in the service implementations.
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
- **Unit tests**: Jest with `jest-preset-angular` and `ng-mocks`
|
||||
- **E2E tests**: Playwright testing the web app
|
||||
- Backend tests use standard Jest
|
||||
|
||||
### Nx Commands
|
||||
|
||||
Use `nx` CLI for better performance:
|
||||
|
||||
```bash
|
||||
nx run <project>:<target>
|
||||
# Example: nx run web:build
|
||||
# Example: nx run electron-backend:serve
|
||||
```
|
||||
|
||||
To run multiple projects:
|
||||
|
||||
```bash
|
||||
nx run-many --target=test --all
|
||||
```
|
||||
|
||||
### Electron Build Process
|
||||
|
||||
The Electron backend depends on the web app being built first:
|
||||
|
||||
- `electron-backend:build` depends on `web:build`
|
||||
- Output goes to `dist/apps/electron-backend` (backend) and `dist/apps/web` (frontend)
|
||||
- Packaging combines both into distributable
|
||||
|
||||
### Database Migrations
|
||||
|
||||
No formal migration system yet. Schema changes are applied via raw SQL in `connection.ts` `createTables()` function using `CREATE TABLE IF NOT EXISTS`.
|
||||
|
||||
### Common Patterns
|
||||
|
||||
**IPC Communication**:
|
||||
|
||||
1. Define handler in appropriate events file (e.g., `database.events.ts`)
|
||||
2. Register with `ipcMain.handle()` in the event bootstrap function
|
||||
3. Expose in preload script via `contextBridge.exposeInMainWorld()`
|
||||
4. Call from Angular via `window.electron.<methodName>()`
|
||||
|
||||
**Adding New Playlist Source**:
|
||||
|
||||
1. Add type to `libs/shared/interfaces/src/lib/playlist.interface.ts`
|
||||
2. Create event handler in `apps/electron-backend/src/app/events/`
|
||||
3. Add UI in `apps/web/src/app/home/`
|
||||
4. Update database schema if needed
|
||||
|
||||
**State Management**:
|
||||
|
||||
- Use NgRx for global application state (playlists)
|
||||
- Use component stores (`@ngrx/component-store`) for feature-specific state
|
||||
- Use NgRx signals for reactive data streams
|
||||
|
||||
<!-- nx configuration start-->
|
||||
<!-- Leave the start & end comments to automatically receive updates. -->
|
||||
|
||||
## General Guidelines for working with Nx
|
||||
|
||||
- For navigating/exploring the workspace, invoke the `nx-workspace` skill first - it has patterns for querying projects, targets, and dependencies
|
||||
- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
|
||||
- Prefix nx commands with the workspace's package manager (e.g., `pnpm nx build`, `npm exec nx test`) - avoids using globally installed CLI
|
||||
- You have access to the Nx MCP server and its tools, use them to help the user
|
||||
- For Nx plugin best practices, check `node_modules/@nx/<plugin>/PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable.
|
||||
- NEVER guess CLI flags - always check nx_docs or `--help` first when unsure
|
||||
|
||||
## Scaffolding & Generators
|
||||
|
||||
- For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the `nx-generate` skill FIRST before exploring or calling MCP tools
|
||||
|
||||
## When to use nx_docs
|
||||
|
||||
- USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases
|
||||
- DON'T USE for: basic generator syntax (`nx g @nx/react:app`), standard commands, things you already know
|
||||
- The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax
|
||||
|
||||
<!-- nx configuration end-->
|
||||
@@ -0,0 +1,7 @@
|
||||
Copyright 2020-2021
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,275 @@
|
||||
# IPTVnator - IPTV Player Application
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/4gray/iptvnator/electron/src/assets/icons/favicon.256x256.png" alt="IPTVnator icon" title="Free IPTV player application" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/4gray/iptvnator/releases"><img src="https://img.shields.io/github/release/4gray/iptvnator.svg?style=for-the-badge&logo=github" alt="Release"></a>
|
||||
<a href="https://github.com/4gray/iptvnator/releases"><img src="https://img.shields.io/github/v/release/4gray/iptvnator?include_prereleases&label=pre-release&logo=github&style=for-the-badge" /></a>
|
||||
<img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/4gray/iptvnator/build-and-test.yaml?style=for-the-badge&logo=github"> <a href="https://github.com/4gray/iptvnator/releases"><img src="https://img.shields.io/github/downloads/4gray/iptvnator/total?style=for-the-badge&logo=github" alt="Releases"></a> <a href="https://codecov.io/gh/4gray/iptvnator"><img alt="Codecov" src="https://img.shields.io/codecov/c/github/4gray/iptvnator?style=for-the-badge"></a> <a href="https://t.me/iptvnator"><img src="https://img.shields.io/badge/telegram-iptvnator-blue?logo=telegram&style=for-the-badge" alt="Telegram"></a> <a href="https://bsky.app/profile/iptvnator.bsky.social"><img src="https://img.shields.io/badge/bluesky-iptvnator-darkblue?logo=bluesky&style=for-the-badge" alt="Bluesky"></a>
|
||||
</p>
|
||||
|
||||
🌐 **[Website](https://4gray.github.io/iptvnator/)** | <a href="https://t.me/iptvnator">Telegram channel for discussions</a> | <a href="https://ko-fi.com/4gray" target="_blank">Buy me a coffee</a> | <a href="https://github.com/sponsors/4gray">GitHub Sponsors</a>
|
||||
|
||||
**IPTVnator** is a video player application that provides support for IPTV playlist playback (m3u, m3u8). The application allows users to import playlists using remote URLs or by uploading files from the local file system. Additionally, it supports EPG information in XMLTV format which can be provided via URL.
|
||||
|
||||
The application is a cross-platform, open-source project built with Electron and Angular.
|
||||
|
||||
⚠️ Note: IPTVnator does not provide any playlists or other digital content. The channels and pictures in the screenshots are for demonstration purposes only.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- M3u and M3u8 playlist support 📺
|
||||
- Radio playlist support with dedicated audio player 📻
|
||||
- Xtream Code (XC) and Stalker portal (STB) support
|
||||
- External player support - MPV, VLC
|
||||
- Add playlists from the file system or remote URLs 📂
|
||||
- Automatic playlist updates on application startup
|
||||
- Channel search functionality 🔍
|
||||
- EPG support (TV Guide) with detailed information
|
||||
- TV archive/catchup/timeshift functionality
|
||||
- Group-based channel list
|
||||
- Read-only M3U channel details from the channel context menu
|
||||
- Favorite channels management
|
||||
- Global favorites aggregated from all playlists
|
||||
- HTML video player with HLS.js support or Video.js-based player
|
||||
- Internationalization with support for 16 languages:
|
||||
- Arabic
|
||||
- Moroccan arabic
|
||||
- English
|
||||
- Russian
|
||||
- German
|
||||
- Korean
|
||||
- Spanish
|
||||
- Chinese
|
||||
- Traditional chinese
|
||||
- French
|
||||
- Italian
|
||||
- Turkish
|
||||
- Japanese
|
||||
- Dutch
|
||||
- Belarusian
|
||||
- Polish
|
||||
- Custom "User Agent" header configuration for playlists
|
||||
- Light and Dark themes
|
||||
- Docker version available for self-hosting
|
||||
|
||||
## Screenshots:
|
||||
|
||||
| Welcome screen: Playlists overview | Main player interface with channels sidebar and video player |
|
||||
| :----------------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
|  |  |
|
||||
| Welcome screen: Add playlist via file upload | Welcome screen: Add playlist via URL |
|
||||
|  |  |
|
||||
| EPG Sidebar: TV guide on the right side | General application settings |
|
||||
|  |  |
|
||||
| Playlist settings |
|
||||
|  | |
|
||||
|
||||
_Note: First version of the application which was developed as a PWA is available in an extra git branch._
|
||||
|
||||
## Download
|
||||
|
||||
Download the latest version of the application for macOS, Windows, and Linux from the [release page](https://github.com/4gray/iptvnator/releases).
|
||||
|
||||
Alternatively, you can install the application using one of the following package managers:
|
||||
|
||||
### Homebrew
|
||||
|
||||
```shell
|
||||
$ brew install iptvnator
|
||||
```
|
||||
|
||||
### Snap
|
||||
|
||||
```shell
|
||||
$ sudo snap install iptvnator
|
||||
```
|
||||
|
||||
### Arch
|
||||
|
||||
Also available as an Arch PKG, [iptvnator-bin](https://aur.archlinux.org/packages/iptvnator-bin/), in the AUR (using your favourite AUR-helper, .e.g. `yay`)
|
||||
|
||||
```shell
|
||||
$ yay -S iptvnator-bin
|
||||
```
|
||||
|
||||
### Gentoo
|
||||
|
||||
You can install IPTVnator from the [gentoo-zh overlay](https://github.com/microcai/gentoo-zh)
|
||||
|
||||
```shell
|
||||
sudo eselect repository enable gentoo-zh
|
||||
sudo emerge --sync gentoo-zh
|
||||
sudo emerge iptvnator-bin
|
||||
```
|
||||
|
||||
[](https://snapcraft.io/iptvnator)
|
||||
|
||||
<a href="https://github.com/sponsors/4gray" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/default-green.png" alt="Buy Me A Coffee" width="185"></a>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### macOS: "App is damaged and can't be opened"
|
||||
|
||||
Older unsigned macOS builds may require removing the quarantine flag from the downloaded application:
|
||||
|
||||
```bash
|
||||
xattr -c /Applications/IPTVnator.app
|
||||
```
|
||||
|
||||
Alternatively, if the app is located in a different directory:
|
||||
|
||||
```bash
|
||||
xattr -c ~/Downloads/IPTVnator.app
|
||||
```
|
||||
|
||||
### Linux: chrome-sandbox Issues
|
||||
|
||||
If you encounter the following error when launching IPTVnator:
|
||||
|
||||
```
|
||||
The SUID sandbox helper binary was found, but is not configured correctly.
|
||||
Rather than run without sandboxing I'm aborting now.
|
||||
You need to make sure that chrome-sandbox is owned by root and has mode 4755.
|
||||
```
|
||||
|
||||
**Solution 1: Fix chrome-sandbox permissions (Recommended for .deb/.rpm installations)**
|
||||
|
||||
Navigate to the IPTVnator installation directory and run:
|
||||
|
||||
```bash
|
||||
sudo chown root:root chrome-sandbox
|
||||
sudo chmod 4755 chrome-sandbox
|
||||
```
|
||||
|
||||
**Solution 2: Launch with --no-sandbox flag**
|
||||
|
||||
Edit the desktop launcher file to add the `--no-sandbox` flag:
|
||||
|
||||
1. Find your desktop file location:
|
||||
- **Ubuntu/Debian**: `~/.local/share/applications/iptvnator.desktop`
|
||||
- **System-wide**: `/usr/share/applications/iptvnator.desktop`
|
||||
|
||||
2. Edit the file and modify the `Exec` line:
|
||||
|
||||
```
|
||||
Exec=iptvnator --no-sandbox %U
|
||||
```
|
||||
|
||||
3. Save the file and relaunch the application from your application menu.
|
||||
|
||||
Alternatively, you can launch IPTVnator from the terminal with the flag:
|
||||
|
||||
```bash
|
||||
iptvnator --no-sandbox
|
||||
```
|
||||
|
||||
### GNU/Linux: Wayland startup failure
|
||||
|
||||
If IPTVnator exits on GNU/Linux with errors about failing to connect to
|
||||
Wayland or initialize the Ozone platform, force X11/XWayland instead:
|
||||
|
||||
```bash
|
||||
iptvnator --ozone-platform=x11
|
||||
```
|
||||
|
||||
This workaround is mainly for older or problematic Linux graphics stacks. The
|
||||
Snap package already includes this X11 override by default. For AppImage,
|
||||
direct binaries, and other Linux package formats, pass the flag manually when
|
||||
needed.
|
||||
|
||||
## How to Build and Develop
|
||||
|
||||
Requirements:
|
||||
|
||||
- Node.js with pnpm (via Corepack)
|
||||
|
||||
1. Clone this repository and install project dependencies:
|
||||
|
||||
```
|
||||
$ corepack enable
|
||||
$ pnpm install
|
||||
```
|
||||
|
||||
2. Start the application:
|
||||
```
|
||||
$ pnpm run serve:backend
|
||||
```
|
||||
|
||||
This will open the Electron app in a separate window, while the Angular dev server will run at http://localhost:4200.
|
||||
|
||||
The equivalent Nx command is:
|
||||
|
||||
```
|
||||
$ nx serve electron-backend
|
||||
```
|
||||
|
||||
To start Electron with an empty, isolated data directory instead of your normal
|
||||
`~/.iptvnator` folder, set `IPTVNATOR_E2E_DATA_DIR` for that run:
|
||||
|
||||
```
|
||||
$ rm -rf .tmp/iptvnator-empty && mkdir -p .tmp/iptvnator-empty
|
||||
$ IPTVNATOR_E2E_DATA_DIR="$PWD/.tmp/iptvnator-empty" pnpm run serve:backend
|
||||
```
|
||||
|
||||
This redirects the SQLite database, Electron user data, and local config under
|
||||
the given directory. Delete that directory whenever you want a fresh empty
|
||||
state.
|
||||
|
||||
If you need to debug renderer freezes or GPU/compositor issues in Electron, you
|
||||
can disable hardware acceleration for a run:
|
||||
|
||||
```
|
||||
$ IPTVNATOR_DISABLE_HARDWARE_ACCELERATION=1 pnpm run serve:backend
|
||||
```
|
||||
|
||||
If you need startup diagnostics for a white screen or a frozen route, you can
|
||||
also turn on opt-in Electron tracing. These logs are written to the Electron
|
||||
terminal output so they still help when the renderer DevTools never open:
|
||||
|
||||
```
|
||||
$ IPTVNATOR_TRACE_STARTUP=1 pnpm run serve:backend
|
||||
```
|
||||
|
||||
Nx equivalent:
|
||||
|
||||
```
|
||||
$ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend
|
||||
```
|
||||
|
||||
Useful narrower flags:
|
||||
|
||||
- `IPTVNATOR_TRACE_IPC=1` logs renderer `window.electron.*` calls reaching the
|
||||
Electron bridge
|
||||
- `IPTVNATOR_TRACE_DB=1` logs DB worker requests and request-scoped DB events
|
||||
- `IPTVNATOR_TRACE_SQL=1` logs SQLite statements in both the main connection and
|
||||
DB worker connection
|
||||
- `IPTVNATOR_TRACE_WINDOW=1` logs BrowserWindow load, navigation, and
|
||||
unresponsive events
|
||||
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console messages into
|
||||
the Electron terminal output
|
||||
|
||||
If the local Nx daemon gets into a bad state before rerunning Electron, reset it:
|
||||
|
||||
```
|
||||
$ pnpm nx reset
|
||||
```
|
||||
|
||||
To run only the Angular app without Electron, use:
|
||||
|
||||
```
|
||||
$ pnpm run serve:frontend
|
||||
```
|
||||
|
||||
## Disclaimer
|
||||
|
||||
**IPTVnator doesn't provide any playlists or other digital content.**
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
|
||||
[](#contributors)
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
@@ -0,0 +1,12 @@
|
||||
import playwright from 'eslint-plugin-playwright';
|
||||
import baseConfig from '../../eslint.config.mjs';
|
||||
|
||||
export default [
|
||||
playwright.configs['flat/recommended'],
|
||||
...baseConfig,
|
||||
{
|
||||
files: ['**/*.ts', '**/*.js'],
|
||||
// Override or add rules here
|
||||
rules: {},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,70 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
import { workspaceRoot } from '@nx/devkit';
|
||||
|
||||
/**
|
||||
* Playwright configuration for Electron e2e tests.
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './src',
|
||||
/* Match .e2e.ts files */
|
||||
testMatch: '**/*.e2e.ts',
|
||||
/* Run tests sequentially since we're testing an Electron app */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code */
|
||||
forbidOnly: !!process.env['CI'],
|
||||
/* Retry on CI only */
|
||||
retries: process.env['CI'] ? 2 : 0,
|
||||
/* Single worker since we're testing an Electron app */
|
||||
workers: 1,
|
||||
/* Reporter to use */
|
||||
reporter: [
|
||||
['list'],
|
||||
[
|
||||
'html',
|
||||
{
|
||||
outputFolder:
|
||||
'../../dist/playwright-report/electron-backend-e2e',
|
||||
},
|
||||
],
|
||||
],
|
||||
/* Shared settings for all the projects below */
|
||||
use: {
|
||||
/* Align with the app's existing test ids */
|
||||
testIdAttribute: 'data-test-id',
|
||||
/* Collect trace when retrying the failed test */
|
||||
trace: 'on-first-retry',
|
||||
/* Screenshots on failure */
|
||||
screenshot: 'on',
|
||||
/* Video on failure */
|
||||
video: 'on-first-retry',
|
||||
},
|
||||
webServer: [
|
||||
{
|
||||
command: 'pnpm nx run stalker-mock-server:serve',
|
||||
url: `http://localhost:${process.env['MOCK_PORT'] ?? '3210'}/health`,
|
||||
reuseExistingServer: !process.env['CI'],
|
||||
cwd: workspaceRoot,
|
||||
},
|
||||
{
|
||||
command: 'pnpm nx run xtream-mock-server:serve',
|
||||
url: `http://localhost:${process.env['XTREAM_MOCK_PORT'] ?? '3211'}/health`,
|
||||
reuseExistingServer: !process.env['CI'],
|
||||
cwd: workspaceRoot,
|
||||
},
|
||||
],
|
||||
/* Output folder for test artifacts */
|
||||
outputDir: '../../dist/test-results/electron-backend-e2e',
|
||||
/* Timeout for each test */
|
||||
timeout: 60000,
|
||||
/* Timeout for expect() assertions */
|
||||
expect: {
|
||||
timeout: 10000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'electron',
|
||||
use: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "electron-backend-e2e",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"sourceRoot": "apps/electron-backend-e2e/src",
|
||||
"implicitDependencies": [
|
||||
"electron-backend",
|
||||
"stalker-mock-server",
|
||||
"xtream-mock-server"
|
||||
],
|
||||
"targets": {
|
||||
"e2e": {
|
||||
"dependsOn": ["electron-backend:build-e2e"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import {
|
||||
addStalkerPortal,
|
||||
addXtreamPortal,
|
||||
clickCategoryByNameExact,
|
||||
clickFirstGridListCard,
|
||||
closeElectronApp,
|
||||
defaultXtreamPassword,
|
||||
defaultXtreamUsername,
|
||||
expect,
|
||||
expectPathname,
|
||||
launchElectronApp,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForStalkerCatalog,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
fetchStalkerCategoryFixture,
|
||||
fetchXtreamLiveFixture,
|
||||
fetchXtreamSeriesFixture,
|
||||
fetchXtreamVodFixture,
|
||||
getXtreamDateValue,
|
||||
getXtreamTitle,
|
||||
} from './portal-mock-fixtures';
|
||||
|
||||
test.describe('Electron Catalog Sorting', () => {
|
||||
test('sorts Xtream live channels by server order and name, with persistence after revisit', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const fixture = await fetchXtreamLiveFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const expectedServerOrder = fixture.items
|
||||
.map((item) => getXtreamTitle(item))
|
||||
.filter((title, index, titles) => titles.indexOf(title) === index)
|
||||
.slice(0, 5);
|
||||
const expectedAscending = fixture.items
|
||||
.map((item) => getXtreamTitle(item))
|
||||
.filter((title, index, titles) => titles.indexOf(title) === index)
|
||||
.sort(collator.compare)
|
||||
.slice(0, 5);
|
||||
const expectedDescending = fixture.items
|
||||
.map((item) => getXtreamTitle(item))
|
||||
.filter((title, index, titles) => titles.indexOf(title) === index)
|
||||
.sort(collator.compare)
|
||||
.reverse()
|
||||
.slice(0, 5);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow);
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
fixture.categoryName
|
||||
);
|
||||
|
||||
await expectVisibleChannelTitles(
|
||||
app.mainWindow,
|
||||
expectedServerOrder
|
||||
);
|
||||
|
||||
await setLiveSortMode(app.mainWindow, 'Name A-Z');
|
||||
await expectVisibleChannelTitles(app.mainWindow, expectedAscending);
|
||||
|
||||
await setLiveSortMode(app.mainWindow, 'Name Z-A');
|
||||
await expectVisibleChannelTitles(
|
||||
app.mainWindow,
|
||||
expectedDescending
|
||||
);
|
||||
|
||||
await setLiveSortMode(app.mainWindow, 'Name A-Z');
|
||||
await openWorkspaceSection(app.mainWindow, 'Movies');
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
fixture.categoryName
|
||||
);
|
||||
await expectVisibleChannelTitles(app.mainWindow, expectedAscending);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('sorts Xtream VOD and series content, and keeps the chosen sort after revisiting the section', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const vodFixture = await fetchXtreamVodFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const seriesFixture = await fetchXtreamSeriesFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const expectedVodDateDesc = [...vodFixture.items]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
getXtreamDateValue(right) - getXtreamDateValue(left)
|
||||
)
|
||||
.map((item) => getXtreamTitle(item))
|
||||
.slice(0, visibleComparisonSize);
|
||||
const expectedVodDateAsc = [...vodFixture.items]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
getXtreamDateValue(left) - getXtreamDateValue(right)
|
||||
)
|
||||
.map((item) => getXtreamTitle(item))
|
||||
.slice(0, visibleComparisonSize);
|
||||
const expectedVodNameAsc = [...vodFixture.items]
|
||||
.map((item) => getXtreamTitle(item))
|
||||
.sort(collator.compare)
|
||||
.slice(0, visibleComparisonSize);
|
||||
const expectedVodNameDesc = [...vodFixture.items]
|
||||
.map((item) => getXtreamTitle(item))
|
||||
.sort(collator.compare)
|
||||
.reverse()
|
||||
.slice(0, visibleComparisonSize);
|
||||
const expectedSeriesNameAsc = [...seriesFixture.items]
|
||||
.map((item) => getXtreamTitle(item))
|
||||
.sort(collator.compare)
|
||||
.slice(0, visibleComparisonSize);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow);
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'Movies');
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
vodFixture.categoryName
|
||||
);
|
||||
await expectVisibleGridTitles(app.mainWindow, expectedVodDateDesc);
|
||||
|
||||
await setContentSortMode(
|
||||
app.mainWindow,
|
||||
'Date Added (Oldest First)'
|
||||
);
|
||||
await expectVisibleGridTitles(app.mainWindow, expectedVodDateAsc);
|
||||
|
||||
await setContentSortMode(app.mainWindow, 'Name A-Z');
|
||||
await expectVisibleGridTitles(app.mainWindow, expectedVodNameAsc);
|
||||
|
||||
await setContentSortMode(app.mainWindow, 'Name Z-A');
|
||||
await expectVisibleGridTitles(app.mainWindow, expectedVodNameDesc);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'Series');
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
seriesFixture.categoryName
|
||||
);
|
||||
await setContentSortMode(app.mainWindow, 'Name A-Z');
|
||||
await expectVisibleGridTitles(
|
||||
app.mainWindow,
|
||||
expectedSeriesNameAsc
|
||||
);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await openWorkspaceSection(app.mainWindow, 'Series');
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
seriesFixture.categoryName
|
||||
);
|
||||
await expectVisibleGridTitles(
|
||||
app.mainWindow,
|
||||
expectedSeriesNameAsc
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not expose local sorting controls on Stalker category routes', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['stalker']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addStalkerPortal(app.mainWindow);
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
|
||||
await expect(
|
||||
app.mainWindow.getByRole('button', { name: 'Sort content' })
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
app.mainWindow.getByRole('button', { name: 'Sort channels' })
|
||||
).toHaveCount(0);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'Movies');
|
||||
await expect(
|
||||
app.mainWindow.getByRole('button', { name: 'Sort content' })
|
||||
).toHaveCount(0);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'Series');
|
||||
await expect(
|
||||
app.mainWindow.getByRole('button', { name: 'Sort content' })
|
||||
).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps Xtream catalog page after opening VOD and series details, and resets grid scroll on page changes', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const vodFixture = await fetchXtreamVodFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const seriesFixture = await fetchXtreamSeriesFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow);
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'Movies');
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
vodFixture.categoryName
|
||||
);
|
||||
await expectCatalogGridReady(app.mainWindow);
|
||||
await expectCatalogScrollResetAfterNextPage(app.mainWindow);
|
||||
await clickFirstGridListCard(app.mainWindow);
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/
|
||||
);
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
await expectCatalogPageQuery(app.mainWindow, '2');
|
||||
await expectCatalogGridReady(app.mainWindow);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'Series');
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
seriesFixture.categoryName
|
||||
);
|
||||
await expectCatalogGridReady(app.mainWindow);
|
||||
await expectCatalogScrollResetAfterNextPage(app.mainWindow);
|
||||
await clickFirstGridListCard(app.mainWindow);
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/
|
||||
);
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
await expectCatalogPageQuery(app.mainWindow, '2');
|
||||
await expectCatalogGridReady(app.mainWindow);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('resets Stalker VOD and series grid scroll when changing pages', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['stalker']);
|
||||
const vodFixture = await fetchStalkerCategoryFixture(request, 'vod');
|
||||
const seriesFixture = await fetchStalkerCategoryFixture(
|
||||
request,
|
||||
'series'
|
||||
);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addStalkerPortal(app.mainWindow);
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'Movies');
|
||||
await clickCategoryByVisibleName(
|
||||
app.mainWindow,
|
||||
vodFixture.categoryName
|
||||
);
|
||||
await expectCatalogGridReady(app.mainWindow);
|
||||
await expectCatalogScrollResetAfterNextPage(app.mainWindow, {
|
||||
expectContentChange: false,
|
||||
});
|
||||
await expectStalkerDetailBackPreservesCatalogPage(app.mainWindow);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'Series');
|
||||
await clickCategoryByVisibleName(
|
||||
app.mainWindow,
|
||||
seriesFixture.categoryName
|
||||
);
|
||||
await expectCatalogGridReady(app.mainWindow);
|
||||
await expectCatalogScrollResetAfterNextPage(app.mainWindow, {
|
||||
expectContentChange: false,
|
||||
});
|
||||
await expectStalkerDetailBackPreservesCatalogPage(app.mainWindow);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const collator = new Intl.Collator(undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
const visibleComparisonSize = 8;
|
||||
const xtreamCredentials = {
|
||||
username: defaultXtreamUsername,
|
||||
password: defaultXtreamPassword,
|
||||
};
|
||||
|
||||
async function setLiveSortMode(
|
||||
page: Page,
|
||||
label: 'Server Order' | 'Name A-Z' | 'Name Z-A'
|
||||
): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Sort channels' }).click();
|
||||
await page.getByRole('menuitem', { name: label, exact: true }).click();
|
||||
}
|
||||
|
||||
async function setContentSortMode(
|
||||
page: Page,
|
||||
label:
|
||||
| 'Date Added (Latest First)'
|
||||
| 'Date Added (Oldest First)'
|
||||
| 'Name A-Z'
|
||||
| 'Name Z-A'
|
||||
): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Sort content' }).click();
|
||||
await page.getByRole('menuitem', { name: label, exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectVisibleChannelTitles(
|
||||
page: Page,
|
||||
expectedTitles: string[]
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const titles = await visibleChannelTitles(page);
|
||||
return titles.slice(0, expectedTitles.length);
|
||||
})
|
||||
.toEqual(expectedTitles);
|
||||
}
|
||||
|
||||
async function expectVisibleGridTitles(
|
||||
page: Page,
|
||||
expectedTitles: string[]
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const titles = await visibleGridTitles(page);
|
||||
return titles.slice(0, expectedTitles.length);
|
||||
})
|
||||
.toEqual(expectedTitles);
|
||||
}
|
||||
|
||||
async function expectCatalogGridReady(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.locator('.category-content-layout mat-card').first()
|
||||
).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectCatalogScrollResetAfterNextPage(
|
||||
page: Page,
|
||||
options: { expectContentChange?: boolean } = {}
|
||||
): Promise<string[]> {
|
||||
const grid = catalogGrid(page);
|
||||
|
||||
await expect(grid).toBeVisible({ timeout: 20000 });
|
||||
await ensureCatalogCanGoNext(page);
|
||||
const rangeBefore = await catalogRangeText(page);
|
||||
const titlesBefore = await visibleGridTitles(page);
|
||||
await grid.evaluate((element: HTMLElement) => {
|
||||
element.scrollTo({ top: element.scrollHeight });
|
||||
});
|
||||
await expect.poll(() => getCatalogGridScrollTop(page)).toBeGreaterThan(0);
|
||||
|
||||
await page
|
||||
.locator('.category-content-header')
|
||||
.getByRole('button', { name: 'Next page' })
|
||||
.click();
|
||||
await expectCatalogPageQuery(page, '2');
|
||||
await expect.poll(() => catalogRangeText(page)).not.toBe(rangeBefore);
|
||||
if (options.expectContentChange !== false) {
|
||||
await expect
|
||||
.poll(() => visibleGridTitles(page))
|
||||
.not.toEqual(titlesBefore);
|
||||
}
|
||||
await expect.poll(() => getCatalogGridScrollTop(page)).toBeLessThan(2);
|
||||
return visibleGridTitles(page);
|
||||
}
|
||||
|
||||
async function expectStalkerDetailBackPreservesCatalogPage(
|
||||
page: Page
|
||||
): Promise<void> {
|
||||
const titlesOnPage = await visibleGridTitles(page);
|
||||
|
||||
await clickFirstGridListCard(page);
|
||||
await goBackFromDetail(page);
|
||||
|
||||
await expectCatalogPageQuery(page, '2');
|
||||
await expectCatalogGridReady(page);
|
||||
await expect.poll(() => visibleGridTitles(page)).toEqual(titlesOnPage);
|
||||
}
|
||||
|
||||
async function expectCatalogPageQuery(
|
||||
page: Page,
|
||||
expectedPage: string
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get('page'))
|
||||
.toBe(expectedPage);
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
const backButton = page
|
||||
.locator('app-content-hero .hero__back-button')
|
||||
.first();
|
||||
|
||||
await expect(backButton).toBeVisible({ timeout: 20000 });
|
||||
try {
|
||||
await backButton.click({ timeout: 5000 });
|
||||
} catch {
|
||||
await backButton.evaluate((button: HTMLButtonElement) =>
|
||||
button.click()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function clickCategoryByVisibleName(
|
||||
page: Page,
|
||||
categoryName: string
|
||||
): Promise<void> {
|
||||
const category = page
|
||||
.locator('app-workspace-context-panel .category-item:visible')
|
||||
.filter({
|
||||
has: page.locator('.nav-item-label', {
|
||||
hasText: new RegExp(`^\\s*${escapeRegex(categoryName)}\\s*$`),
|
||||
}),
|
||||
})
|
||||
.first();
|
||||
|
||||
await expect(category).toBeVisible({ timeout: 20000 });
|
||||
await category.click();
|
||||
await expect.poll(() => category.getAttribute('aria-current')).toBe('true');
|
||||
}
|
||||
|
||||
function catalogGrid(page: Page) {
|
||||
return page.locator('app-category-content-view app-grid-list').first();
|
||||
}
|
||||
|
||||
async function ensureCatalogCanGoNext(page: Page): Promise<void> {
|
||||
const header = page.locator('.category-content-header');
|
||||
const nextButton = header.getByRole('button', { name: 'Next page' });
|
||||
|
||||
await expect(nextButton).toBeVisible({ timeout: 20000 });
|
||||
|
||||
if (await nextButton.isDisabled()) {
|
||||
await header.getByRole('button', { name: 'Previous page' }).click();
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get('page'))
|
||||
.toBe(null);
|
||||
}
|
||||
|
||||
await expect(nextButton).toBeEnabled({ timeout: 20000 });
|
||||
}
|
||||
|
||||
async function catalogRangeText(page: Page): Promise<string> {
|
||||
return (
|
||||
(await page
|
||||
.locator('.category-content-header .mat-mdc-paginator-range-label')
|
||||
.first()
|
||||
.textContent()) ?? ''
|
||||
).trim();
|
||||
}
|
||||
|
||||
async function getCatalogGridScrollTop(page: Page): Promise<number> {
|
||||
return catalogGrid(page).evaluate((element: HTMLElement) =>
|
||||
Math.round(element.scrollTop)
|
||||
);
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
async function visibleChannelTitles(page: Page): Promise<string[]> {
|
||||
return page
|
||||
.locator('[data-test-id="channel-item"] .channel-name')
|
||||
.allInnerTexts()
|
||||
.then((titles: string[]) =>
|
||||
uniqueTitles(titles.map((title) => title.trim()))
|
||||
);
|
||||
}
|
||||
|
||||
async function visibleGridTitles(page: Page): Promise<string[]> {
|
||||
return page
|
||||
.locator('.category-content-layout mat-card .title')
|
||||
.allInnerTexts()
|
||||
.then((titles: string[]) =>
|
||||
uniqueTitles(titles.map((title) => title.trim()))
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueTitles(titles: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return titles.filter((title) => {
|
||||
if (seen.has(title)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(title);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
clickCategoryById,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openSources,
|
||||
openWorkspaceSection,
|
||||
refreshSource,
|
||||
resetMockServers,
|
||||
restartElectronApp,
|
||||
sourceRowByTitle,
|
||||
test,
|
||||
waitForSourceRowIdle,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
test.describe('Electron Xtream Category Management', () => {
|
||||
test('hides and restores categories, supports search and bulk actions, and persists hidden selections after refresh', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const portalName = 'Category Managed Xtream';
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: portalName,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
// Wait for the route to settle on the live TV section so that
|
||||
// the sidebar shows live categories (not VOD/series from a
|
||||
// previous section) before we read from it.
|
||||
await app.mainWindow.waitForURL(
|
||||
/\/workspace\/xtreams\/[^/]+\/live/
|
||||
);
|
||||
|
||||
const targetCategory = await pickSidebarCategory(app.mainWindow);
|
||||
|
||||
let dialog = await openManageCategoriesDialog(app.mainWindow);
|
||||
|
||||
await dialog
|
||||
.getByRole('button', {
|
||||
name: 'Deselect All',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await expect(
|
||||
dialog.locator('.category-item mat-checkbox input:checked')
|
||||
).toHaveCount(0);
|
||||
|
||||
await dialog
|
||||
.getByRole('button', {
|
||||
name: 'Select All',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await expect(
|
||||
dialog
|
||||
.locator('.category-item mat-checkbox input:checked')
|
||||
.first()
|
||||
).toBeVisible();
|
||||
|
||||
await dialog
|
||||
.locator('input[type="search"]')
|
||||
.fill(targetCategory.name);
|
||||
await toggleManagedCategory(dialog, targetCategory, false);
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save', exact: true })
|
||||
.click();
|
||||
await app.mainWindow.waitForSelector('mat-dialog-container', {
|
||||
state: 'detached',
|
||||
});
|
||||
|
||||
await expect(
|
||||
sidebarCategoryById(app.mainWindow, targetCategory.id)
|
||||
).toHaveCount(0);
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await refreshSource(app.mainWindow, portalName, { confirm: true });
|
||||
await openSources(app.mainWindow);
|
||||
await waitForSourceRowIdle(app.mainWindow, portalName);
|
||||
|
||||
const restarted = await restartElectronApp(app, dataDir);
|
||||
app.electronApp = restarted.electronApp;
|
||||
app.mainWindow = restarted.mainWindow;
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await sourceRowByTitle(app.mainWindow, portalName).first().click();
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await app.mainWindow.waitForURL(
|
||||
/\/workspace\/xtreams\/[^/]+\/live/
|
||||
);
|
||||
await expect(
|
||||
sidebarCategoryById(app.mainWindow, targetCategory.id)
|
||||
).toHaveCount(0);
|
||||
|
||||
dialog = await openManageCategoriesDialog(app.mainWindow);
|
||||
await dialog
|
||||
.locator('input[type="search"]')
|
||||
.fill(targetCategory.name);
|
||||
await toggleManagedCategory(dialog, targetCategory, true);
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save', exact: true })
|
||||
.click();
|
||||
await app.mainWindow.waitForSelector('mat-dialog-container', {
|
||||
state: 'detached',
|
||||
});
|
||||
|
||||
dialog = await openManageCategoriesDialog(app.mainWindow);
|
||||
await dialog
|
||||
.locator('input[type="search"]')
|
||||
.fill(targetCategory.name);
|
||||
const restoredRows = dialog.locator('.category-item');
|
||||
await expect(restoredRows).toHaveCount(1, { timeout: 15000 });
|
||||
await expect(
|
||||
restoredRows.first().locator('mat-checkbox input')
|
||||
).toBeChecked();
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.click();
|
||||
await app.mainWindow.waitForSelector('mat-dialog-container', {
|
||||
state: 'detached',
|
||||
});
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('persists hidden selections after refreshing from the workspace header', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const portalName = 'Header Refresh Xtream';
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: portalName,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await app.mainWindow.waitForURL(
|
||||
/\/workspace\/xtreams\/[^/]+\/live/
|
||||
);
|
||||
|
||||
const targetCategory = await pickSidebarCategory(app.mainWindow);
|
||||
const dialog = await openManageCategoriesDialog(app.mainWindow);
|
||||
|
||||
await dialog
|
||||
.getByRole('button', {
|
||||
name: 'Deselect All',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await expect(
|
||||
dialog.locator('.category-item mat-checkbox input:checked')
|
||||
).toHaveCount(0);
|
||||
|
||||
await dialog
|
||||
.locator('input[type="search"]')
|
||||
.fill(targetCategory.name);
|
||||
await toggleManagedCategory(dialog, targetCategory, true);
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save', exact: true })
|
||||
.click();
|
||||
await app.mainWindow.waitForSelector('mat-dialog-container', {
|
||||
state: 'detached',
|
||||
});
|
||||
|
||||
await expect(
|
||||
sidebarCategoryById(app.mainWindow, targetCategory.id)
|
||||
).toBeVisible();
|
||||
|
||||
await refreshFromWorkspaceHeader(app.mainWindow);
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await app.mainWindow.waitForURL(
|
||||
/\/workspace\/xtreams\/[^/]+\/live/
|
||||
);
|
||||
await expectVisibleSidebarCategoryNames(app.mainWindow, [
|
||||
targetCategory.name,
|
||||
]);
|
||||
|
||||
const restarted = await restartElectronApp(app, dataDir);
|
||||
app.electronApp = restarted.electronApp;
|
||||
app.mainWindow = restarted.mainWindow;
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await sourceRowByTitle(app.mainWindow, portalName).first().click();
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await app.mainWindow.waitForURL(
|
||||
/\/workspace\/xtreams\/[^/]+\/live/
|
||||
);
|
||||
await expectVisibleSidebarCategoryNames(app.mainWindow, [
|
||||
targetCategory.name,
|
||||
]);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function openManageCategoriesDialog(page: Page) {
|
||||
await page.getByRole('button', { name: 'Manage categories' }).click();
|
||||
const dialog = page.locator('mat-dialog-container').last();
|
||||
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Select All', exact: true })
|
||||
).toBeVisible();
|
||||
// Wait for the category list to render — Angular needs a CD cycle after
|
||||
// isLoading() flips to false before the @for items are painted.
|
||||
await expect(dialog.locator('.category-item').first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function refreshFromWorkspaceHeader(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByRole('button', { name: 'Refresh playlist', exact: true })
|
||||
.click();
|
||||
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Yes', exact: true }).click();
|
||||
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
|
||||
}
|
||||
|
||||
function sidebarCategoryById(page: Page, categoryId: string): Locator {
|
||||
return page.locator(
|
||||
`app-workspace-context-panel .category-item[data-category-id="${categoryId}"]`
|
||||
);
|
||||
}
|
||||
|
||||
async function expectVisibleSidebarCategoryIds(
|
||||
page: Page,
|
||||
expectedIds: string[]
|
||||
): Promise<void> {
|
||||
const categories = page.locator(
|
||||
'app-workspace-context-panel .category-item:visible'
|
||||
);
|
||||
const actualIds: string[] = [];
|
||||
const count = await categories.count();
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const categoryId = await categories
|
||||
.nth(index)
|
||||
.getAttribute('data-category-id');
|
||||
|
||||
if (categoryId) {
|
||||
actualIds.push(categoryId.trim());
|
||||
}
|
||||
}
|
||||
|
||||
expect(actualIds).toEqual(expectedIds);
|
||||
}
|
||||
|
||||
async function expectVisibleSidebarCategoryNames(
|
||||
page: Page,
|
||||
expectedNames: string[]
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const categories = page.locator(
|
||||
'app-workspace-context-panel .category-item:visible'
|
||||
);
|
||||
const actualNames: string[] = [];
|
||||
const count = await categories.count();
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const categoryName =
|
||||
(
|
||||
await categories
|
||||
.nth(index)
|
||||
.locator('.nav-item-label')
|
||||
.textContent()
|
||||
)?.trim() ?? '';
|
||||
|
||||
if (categoryName) {
|
||||
actualNames.push(categoryName);
|
||||
}
|
||||
}
|
||||
|
||||
return actualNames;
|
||||
})
|
||||
.toEqual(expectedNames);
|
||||
}
|
||||
|
||||
async function pickSidebarCategory(
|
||||
page: Page
|
||||
): Promise<{ id: string; itemCount: number; name: string }> {
|
||||
let preferredCandidate:
|
||||
| { id: string; itemCount: number; name: string }
|
||||
| null = null;
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const categories = page.locator(
|
||||
'app-workspace-context-panel .category-item:visible'
|
||||
);
|
||||
const count = await categories.count();
|
||||
const candidates: Array<{
|
||||
id: string;
|
||||
itemCount: number;
|
||||
name: string;
|
||||
}> = [];
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const category = categories.nth(index);
|
||||
const id =
|
||||
(await category.getAttribute('data-category-id'))?.trim() ??
|
||||
'';
|
||||
const name =
|
||||
(
|
||||
await category.locator('.nav-item-label').textContent()
|
||||
)?.trim() ?? '';
|
||||
const countText =
|
||||
(
|
||||
await category.locator('.item-count').textContent()
|
||||
)?.trim() ?? '';
|
||||
const itemCount = Number.parseInt(countText, 10) || 0;
|
||||
|
||||
if (id && name && itemCount > 0) {
|
||||
candidates.push({ id, itemCount, name });
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
preferredCandidate = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
const nameCounts = new Map<string, number>();
|
||||
for (const candidate of candidates) {
|
||||
nameCounts.set(
|
||||
candidate.name,
|
||||
(nameCounts.get(candidate.name) ?? 0) + 1
|
||||
);
|
||||
}
|
||||
|
||||
preferredCandidate =
|
||||
candidates.find(
|
||||
(candidate) => nameCounts.get(candidate.name) === 1
|
||||
) ?? candidates[0];
|
||||
|
||||
return preferredCandidate !== null;
|
||||
})
|
||||
.toBe(true, {
|
||||
message: 'No visible Xtream category with content was found.',
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
return preferredCandidate!;
|
||||
}
|
||||
|
||||
async function toggleManagedCategory(
|
||||
dialog: Locator,
|
||||
targetCategory: {
|
||||
id: string;
|
||||
itemCount: number;
|
||||
name: string;
|
||||
},
|
||||
shouldBeSelected: boolean
|
||||
): Promise<void> {
|
||||
void targetCategory.id;
|
||||
const categoryRows = dialog.locator('.category-item');
|
||||
await expect(categoryRows).toHaveCount(1, { timeout: 15000 });
|
||||
const categoryRow = categoryRows.first();
|
||||
const checkbox = categoryRow.locator('mat-checkbox input');
|
||||
|
||||
await expect(categoryRow).toBeVisible({ timeout: 15000 });
|
||||
if (shouldBeSelected) {
|
||||
await checkbox.check();
|
||||
await expect(checkbox).toBeChecked();
|
||||
return;
|
||||
}
|
||||
|
||||
await checkbox.uncheck();
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
clickCategoryByNameExact,
|
||||
clickFirstGridListCard,
|
||||
closeElectronApp,
|
||||
defaultXtreamPassword,
|
||||
defaultXtreamUsername,
|
||||
expectPathname,
|
||||
expect,
|
||||
goToDashboard,
|
||||
launchElectronApp,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
fetchXtreamLiveFixture,
|
||||
fetchXtreamSeriesFixture,
|
||||
fetchXtreamVodFixture,
|
||||
getXtreamTitle,
|
||||
pickDistinctTitles,
|
||||
} from './portal-mock-fixtures';
|
||||
|
||||
test.describe('Dashboard Activation', () => {
|
||||
test('opens live favorites in the collection route and movies/series in global collection detail views from the dashboard', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const liveFixture = await fetchXtreamLiveFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const vodFixture = await fetchXtreamVodFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const seriesFixture = await fetchXtreamSeriesFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const [liveTitle] = pickDistinctTitles(
|
||||
liveFixture.items,
|
||||
getXtreamTitle
|
||||
);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: 'Xtream Dashboard Activation Source',
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
liveFixture.categoryName
|
||||
);
|
||||
await toggleFavoriteForChannel(app.mainWindow, liveTitle);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Movies', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
vodFixture.categoryName
|
||||
);
|
||||
const movieTitle = await clickFirstGridListCard(app.mainWindow);
|
||||
await addCurrentDetailToFavorites(app.mainWindow);
|
||||
await playCurrentDetail(app.mainWindow);
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Series', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
seriesFixture.categoryName
|
||||
);
|
||||
const seriesTitle = await clickFirstGridListCard(app.mainWindow);
|
||||
await playFirstSeriesEpisode(app.mainWindow);
|
||||
|
||||
await goToDashboard(app.mainWindow);
|
||||
|
||||
await expectDashboardRail(app.mainWindow, 'dashboard-global-favorites-rail');
|
||||
await dashboardRailCardByTitle(
|
||||
app.mainWindow,
|
||||
'dashboard-global-favorites-rail',
|
||||
liveTitle
|
||||
).click();
|
||||
await app.mainWindow.waitForURL(
|
||||
/\/workspace\/xtreams\/[^/]+\/favorites$/
|
||||
);
|
||||
await expect(
|
||||
app.mainWindow.locator('.player-toolbar .close-btn').first()
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await app.mainWindow.goBack();
|
||||
await app.mainWindow.waitForURL(/\/workspace\/dashboard$/);
|
||||
|
||||
await dashboardRailCardByTitle(
|
||||
app.mainWindow,
|
||||
'dashboard-global-favorites-rail',
|
||||
movieTitle
|
||||
).click();
|
||||
await expectInlineCollectionDetail(app.mainWindow, {
|
||||
pathname: /\/workspace\/global-favorites$/,
|
||||
title: movieTitle,
|
||||
});
|
||||
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
|
||||
|
||||
await dashboardRailCardByTitle(
|
||||
app.mainWindow,
|
||||
'dashboard-recently-watched-rail',
|
||||
movieTitle
|
||||
).click();
|
||||
await expectInlineCollectionDetail(app.mainWindow, {
|
||||
pathname: /\/workspace\/global-recent$/,
|
||||
title: movieTitle,
|
||||
});
|
||||
|
||||
await app.mainWindow.goBack();
|
||||
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
|
||||
|
||||
await dashboardRailCardByTitle(
|
||||
app.mainWindow,
|
||||
'dashboard-recently-watched-rail',
|
||||
seriesTitle
|
||||
).click();
|
||||
await expectInlineCollectionDetail(app.mainWindow, {
|
||||
pathname: /\/workspace\/global-recent$/,
|
||||
title: seriesTitle,
|
||||
});
|
||||
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const xtreamCredentials = {
|
||||
username: defaultXtreamUsername,
|
||||
password: defaultXtreamPassword,
|
||||
};
|
||||
|
||||
async function expectDashboardRail(page: Page, railTestId: string): Promise<void> {
|
||||
await expect(page.locator(`[data-test-id="${railTestId}"]`)).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
function dashboardRailCardByTitle(
|
||||
page: Page,
|
||||
railTestId: string,
|
||||
title: string
|
||||
) {
|
||||
return page.locator(`[data-test-id="${railTestId}-card"]`).filter({
|
||||
hasText: title,
|
||||
}).first();
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
const backButton = page
|
||||
.locator('app-content-hero .hero__back-button')
|
||||
.first();
|
||||
|
||||
await expect(backButton).toBeVisible({ timeout: 20000 });
|
||||
try {
|
||||
await backButton.click({ timeout: 5000 });
|
||||
} catch {
|
||||
await backButton.evaluate((button: HTMLButtonElement) =>
|
||||
button.click()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function addCurrentDetailToFavorites(page: Page): Promise<void> {
|
||||
const addButton = page.locator('button.favorite-btn').first();
|
||||
|
||||
await expect(addButton).toBeVisible({ timeout: 20000 });
|
||||
await addButton.click();
|
||||
await expect(
|
||||
page.locator('button.favorite-btn--active').first()
|
||||
).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectInlineCollectionDetail(
|
||||
page: Page,
|
||||
params: {
|
||||
pathname: RegExp;
|
||||
title: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
await expectPathname(page, params.pathname);
|
||||
await expect(page.locator('app-workspace-context-panel')).toHaveCount(0);
|
||||
await expect(page.locator('app-content-hero')).toContainText(params.title);
|
||||
await expect(
|
||||
page.locator('app-content-hero .hero__back-button').first()
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
}
|
||||
|
||||
async function playCurrentDetail(page: Page): Promise<void> {
|
||||
const playButton = page.locator('button.play-btn').first();
|
||||
|
||||
await expect(playButton).toBeVisible({ timeout: 20000 });
|
||||
await playButton.click();
|
||||
}
|
||||
|
||||
async function playFirstSeriesEpisode(page: Page): Promise<void> {
|
||||
const seasonCard = page.locator('.season-card').first();
|
||||
const episodeCard = page
|
||||
.locator('.episode-card, .episode-list-item')
|
||||
.first();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await seasonCard.count()) + (await episodeCard.count()),
|
||||
{ timeout: 20000 }
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
if ((await seasonCard.count()) > 0) {
|
||||
await seasonCard.scrollIntoViewIfNeeded();
|
||||
await expect(seasonCard).toBeVisible({ timeout: 20000 });
|
||||
await seasonCard.click();
|
||||
}
|
||||
|
||||
await episodeCard.scrollIntoViewIfNeeded();
|
||||
await expect(episodeCard).toBeVisible({ timeout: 20000 });
|
||||
await episodeCard.click();
|
||||
}
|
||||
|
||||
async function toggleFavoriteForChannel(
|
||||
page: Page,
|
||||
title: string
|
||||
): Promise<void> {
|
||||
const item = page
|
||||
.locator('[data-test-id="channel-item"]')
|
||||
.filter({ hasText: title })
|
||||
.first();
|
||||
|
||||
await expect(item).toBeVisible({ timeout: 20000 });
|
||||
await item.hover();
|
||||
await item.locator('.favorite-button').first().click();
|
||||
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
|
||||
/star/
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,173 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import {
|
||||
buildM3uContent,
|
||||
channelItemByTitle,
|
||||
closeElectronApp,
|
||||
createMutableTextServer,
|
||||
expect,
|
||||
importM3uPlaylistFromUrl,
|
||||
launchElectronApp,
|
||||
openWorkspaceSection,
|
||||
openSettings,
|
||||
test,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
const fallbackLogoSvg = Buffer.from(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><rect width="96" height="96" rx="20" fill="#16436b"/><text x="48" y="58" text-anchor="middle" font-size="28" font-family="Arial" fill="#f5f7fb">GN</text></svg>`
|
||||
).toString('base64');
|
||||
const fallbackLogoDataUrl = `data:image/svg+xml;base64,${fallbackLogoSvg}`;
|
||||
const epgFixtureXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tv>
|
||||
<channel id="guide-news">
|
||||
<display-name>Guide News</display-name>
|
||||
</channel>
|
||||
<programme start="20260328070000 +0000" stop="20260328080000 +0000" channel="guide-news">
|
||||
<title>Guide Bulletin</title>
|
||||
<desc>EPG refresh smoke test.</desc>
|
||||
</programme>
|
||||
</tv>
|
||||
`;
|
||||
|
||||
test.describe('Electron EPG', () => {
|
||||
test('adds an EPG source, fetches guide data, removes the source row, and clears stored EPG data', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const epgServer = await createMutableTextServer(epgFixtureXml, {
|
||||
contentType: 'application/xml; charset=utf-8',
|
||||
resourcePath: '/guide.xml',
|
||||
});
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Add EPG source' })
|
||||
.click();
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row input')
|
||||
.first()
|
||||
.fill(epgServer.resourceUrl);
|
||||
|
||||
await app.mainWindow.locator('.epg-source-row button').first().click();
|
||||
await expect(
|
||||
app.mainWindow.locator('.epg-progress-panel')
|
||||
).toBeVisible();
|
||||
await expect
|
||||
.poll(() => getEpgChannelCount(app.mainWindow), {
|
||||
timeout: 30000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
await expect(
|
||||
app.mainWindow.locator('.epg-progress-panel .stat-badge').first()
|
||||
).toBeVisible();
|
||||
|
||||
await app.mainWindow.locator('.epg-source-row button').nth(1).click();
|
||||
await expect(app.mainWindow.locator('.epg-source-row')).toHaveCount(0);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Clear EPG data' })
|
||||
.click();
|
||||
const dialog = app.mainWindow.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Yes', exact: true }).click();
|
||||
await app.mainWindow.waitForSelector('mat-dialog-container', {
|
||||
state: 'detached',
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => getEpgChannelCount(app.mainWindow), {
|
||||
timeout: 20000,
|
||||
})
|
||||
.toBe(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await epgServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('uses the XMLTV channel icon as a fallback when the playlist has no tvg-logo', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const playlistServer = await createMutableTextServer(
|
||||
buildM3uContent([
|
||||
{
|
||||
name: 'Guide News Live',
|
||||
tvgId: 'guide-news',
|
||||
url: 'https://example.com/live/guide-news.m3u8',
|
||||
},
|
||||
]),
|
||||
{
|
||||
contentType: 'application/x-mpegurl; charset=utf-8',
|
||||
resourcePath: '/playlist.m3u',
|
||||
}
|
||||
);
|
||||
const epgServer = await createMutableTextServer(
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tv>
|
||||
<channel id="guide-news">
|
||||
<display-name>Guide News Live</display-name>
|
||||
<icon src="${fallbackLogoDataUrl}"/>
|
||||
</channel>
|
||||
<programme start="20260328070000 +0000" stop="20260328080000 +0000" channel="guide-news">
|
||||
<title>Guide Bulletin</title>
|
||||
<desc>EPG logo fallback test.</desc>
|
||||
</programme>
|
||||
</tv>
|
||||
`,
|
||||
{
|
||||
contentType: 'application/xml; charset=utf-8',
|
||||
resourcePath: '/guide.xml',
|
||||
}
|
||||
);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await importM3uPlaylistFromUrl(
|
||||
app.mainWindow,
|
||||
playlistServer.resourceUrl
|
||||
);
|
||||
|
||||
await openSettings(app.mainWindow);
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Add EPG source' })
|
||||
.click();
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row input')
|
||||
.first()
|
||||
.fill(epgServer.resourceUrl);
|
||||
await app.mainWindow.locator('.epg-source-row button').first().click();
|
||||
|
||||
await expect
|
||||
.poll(() => getEpgChannelCount(app.mainWindow), {
|
||||
timeout: 30000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'All channels');
|
||||
|
||||
const channelItem = channelItemByTitle(
|
||||
app.mainWindow,
|
||||
'Guide News Live'
|
||||
);
|
||||
await expect(channelItem).toBeVisible();
|
||||
await expect(channelItem.locator('.channel-logo')).toHaveAttribute(
|
||||
'src',
|
||||
fallbackLogoDataUrl
|
||||
);
|
||||
await expect(
|
||||
channelItem.locator('.channel-logo-fallback')
|
||||
).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await playlistServer.close();
|
||||
await epgServer.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function getEpgChannelCount(page: Page): Promise<number> {
|
||||
return page.evaluate(async () => {
|
||||
const channels = await window.electron?.getEpgChannelsByRange?.(0, 20);
|
||||
return Array.isArray(channels) ? channels.length : 0;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import {
|
||||
addStalkerPortal,
|
||||
addXtreamPortal,
|
||||
channelItemByTitle,
|
||||
clearCurrentUnifiedCollection,
|
||||
clickCategoryById,
|
||||
clickGridListCardByTitle,
|
||||
clickCategoryByNameExact,
|
||||
clickFirstGridListCard,
|
||||
closeElectronApp,
|
||||
contentCardByTitle,
|
||||
defaultXtreamPassword,
|
||||
defaultXtreamUsername,
|
||||
expectPathname,
|
||||
expectVisibleContentCardTitle,
|
||||
expect,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
launchElectronApp,
|
||||
openWorkspaceSection,
|
||||
openPlaylistFavorites,
|
||||
openSources,
|
||||
resetMockServers,
|
||||
restartElectronApp,
|
||||
sourceRowByTitle,
|
||||
switchUnifiedCollectionContent,
|
||||
switchUnifiedCollectionScope,
|
||||
test,
|
||||
playlistSwitcherTitle,
|
||||
waitForM3uCatalog,
|
||||
waitForStalkerCatalog,
|
||||
waitForXtreamWorkspaceReady,
|
||||
writeTemporaryM3uFile,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
fetchStalkerCategoryFixture,
|
||||
fetchXtreamLiveFixture,
|
||||
fetchXtreamSeriesFixture,
|
||||
fetchXtreamVodFixture,
|
||||
getStalkerTitle,
|
||||
getXtreamTitle,
|
||||
pickDistinctTitles,
|
||||
} from './portal-mock-fixtures';
|
||||
|
||||
test.describe('Electron Favorites', () => {
|
||||
test('shows M3U favorites in playlist and all-playlists scope, and preserves them after restart', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const playlistTitle = 'm3u-favorites-source.m3u';
|
||||
const playlistSourceTitle = 'm3u-favorites-source';
|
||||
const filePath = writeTemporaryM3uFile(dataDir, playlistTitle, [
|
||||
{
|
||||
groupTitle: 'News',
|
||||
name: 'M3U Favorite One',
|
||||
url: 'https://streams.example.test/m3u-favorite-one.m3u8',
|
||||
},
|
||||
{
|
||||
groupTitle: 'News',
|
||||
name: 'M3U Favorite Two',
|
||||
url: 'https://streams.example.test/m3u-favorite-two.m3u8',
|
||||
},
|
||||
]);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await importM3uPlaylistFromNativeDialog(app, filePath);
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
await toggleFavoriteForChannel(app.mainWindow, 'M3U Favorite One');
|
||||
await toggleFavoriteForChannel(app.mainWindow, 'M3U Favorite Two');
|
||||
await openPlaylistFavorites(app.mainWindow);
|
||||
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, 'M3U Favorite One')
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, 'M3U Favorite Two')
|
||||
).toHaveCount(1);
|
||||
|
||||
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, 'M3U Favorite One')
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, 'M3U Favorite Two')
|
||||
).toHaveCount(1);
|
||||
|
||||
const restarted = await restartElectronApp(app, dataDir);
|
||||
app.electronApp = restarted.electronApp;
|
||||
app.mainWindow = restarted.mainWindow;
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await sourceRowByTitle(app.mainWindow, playlistSourceTitle)
|
||||
.first()
|
||||
.click();
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
await openPlaylistFavorites(app.mainWindow);
|
||||
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, 'M3U Favorite One')
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, 'M3U Favorite Two')
|
||||
).toHaveCount(1);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps playlist M3U favorites cleared after navigating away and back', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const playlistTitle = 'm3u-clear-favorites-source.m3u';
|
||||
const playlistSourceTitle = 'm3u-clear-favorites-source';
|
||||
const filePath = writeTemporaryM3uFile(dataDir, playlistTitle, [
|
||||
{
|
||||
groupTitle: 'News',
|
||||
name: 'M3U Clear Favorite One',
|
||||
url: 'https://streams.example.test/m3u-clear-favorite-one.m3u8',
|
||||
},
|
||||
{
|
||||
groupTitle: 'News',
|
||||
name: 'M3U Clear Favorite Two',
|
||||
url: 'https://streams.example.test/m3u-clear-favorite-two.m3u8',
|
||||
},
|
||||
]);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await importM3uPlaylistFromNativeDialog(app, filePath);
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
await toggleFavoriteForChannel(
|
||||
app.mainWindow,
|
||||
'M3U Clear Favorite One'
|
||||
);
|
||||
await toggleFavoriteForChannel(
|
||||
app.mainWindow,
|
||||
'M3U Clear Favorite Two'
|
||||
);
|
||||
await openPlaylistFavorites(app.mainWindow);
|
||||
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, 'M3U Clear Favorite One')
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, 'M3U Clear Favorite Two')
|
||||
).toHaveCount(1);
|
||||
|
||||
await clearCurrentUnifiedCollection(app.mainWindow);
|
||||
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, 'M3U Clear Favorite One')
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, 'M3U Clear Favorite Two')
|
||||
).toHaveCount(0);
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await sourceRowByTitle(app.mainWindow, playlistSourceTitle)
|
||||
.first()
|
||||
.click();
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
await openPlaylistFavorites(app.mainWindow);
|
||||
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, 'M3U Clear Favorite One')
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, 'M3U Clear Favorite Two')
|
||||
).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('shows Xtream live, movie, and series favorites in playlist and all-playlists scope, and preserves them after restart', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const liveFixture = await fetchXtreamLiveFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const vodFixture = await fetchXtreamVodFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const seriesFixture = await fetchXtreamSeriesFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const [liveTitle] = pickDistinctTitles(
|
||||
liveFixture.items,
|
||||
getXtreamTitle
|
||||
);
|
||||
const portalTitle = 'Xtream Favorites Source';
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: portalTitle,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
liveFixture.categoryName
|
||||
);
|
||||
await toggleFavoriteForChannel(app.mainWindow, liveTitle);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Movies', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
vodFixture.categoryName
|
||||
);
|
||||
// Use clickFirstGridListCard: grid sorts by date-desc so fixture order ≠
|
||||
// display order; this atomically reads the first card's title and clicks
|
||||
// it, avoiding a race where the grid re-renders between the title read
|
||||
// and a separate search-by-title click.
|
||||
const movieTitle = await clickFirstGridListCard(app.mainWindow);
|
||||
await addCurrentDetailToFavorites(app.mainWindow);
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Series', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
seriesFixture.categoryName
|
||||
);
|
||||
const seriesTitle = await clickFirstGridListCard(app.mainWindow);
|
||||
await addCurrentDetailToFavorites(app.mainWindow);
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
|
||||
await openPlaylistFavorites(app.mainWindow);
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, liveTitle)
|
||||
).toHaveCount(1);
|
||||
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
||||
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
||||
|
||||
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Live TV');
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, liveTitle)
|
||||
).toHaveCount(1);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
||||
|
||||
const restarted = await restartElectronApp(app, dataDir);
|
||||
app.electronApp = restarted.electronApp;
|
||||
app.mainWindow = restarted.mainWindow;
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await sourceRowByTitle(app.mainWindow, portalTitle).first().click();
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openPlaylistFavorites(app.mainWindow);
|
||||
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Live TV');
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, liveTitle)
|
||||
).toHaveCount(1);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('opens Xtream favorite movies and series inline from favorites without switching the playlist or showing the sidebar', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const vodFixture = await fetchXtreamVodFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const seriesFixture = await fetchXtreamSeriesFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const hostPortalTitle = 'Xtream Favorite Detail Host';
|
||||
const sourcePortalTitle = 'Xtream Favorite Detail Source';
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: hostPortalTitle,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: sourcePortalTitle,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Movies', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
vodFixture.categoryName
|
||||
);
|
||||
const movieTitle = await clickFirstGridListCard(app.mainWindow);
|
||||
await addCurrentDetailToFavorites(app.mainWindow);
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Series', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
seriesFixture.categoryName
|
||||
);
|
||||
const seriesTitle = await clickFirstGridListCard(app.mainWindow);
|
||||
await addCurrentDetailToFavorites(app.mainWindow);
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await sourceRowByTitle(app.mainWindow, hostPortalTitle)
|
||||
.first()
|
||||
.click();
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openPlaylistFavorites(app.mainWindow);
|
||||
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
|
||||
hostPortalTitle
|
||||
);
|
||||
|
||||
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
||||
await contentCardByTitle(app.mainWindow, movieTitle)
|
||||
.first()
|
||||
.click();
|
||||
await expectInlineCollectionDetail(app.mainWindow, {
|
||||
pathname: /\/workspace\/global-favorites$/,
|
||||
title: movieTitle,
|
||||
playlistTitle: hostPortalTitle,
|
||||
});
|
||||
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
|
||||
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
||||
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
||||
await contentCardByTitle(app.mainWindow, seriesTitle)
|
||||
.first()
|
||||
.click();
|
||||
await expectInlineCollectionDetail(app.mainWindow, {
|
||||
pathname: /\/workspace\/global-favorites$/,
|
||||
title: seriesTitle,
|
||||
playlistTitle: hostPortalTitle,
|
||||
});
|
||||
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('shows Stalker live, movie, and series favorites in playlist and all-playlists scope, and preserves them after restart', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['stalker']);
|
||||
const liveFixture = await fetchStalkerCategoryFixture(request, 'itv');
|
||||
const vodFixture = await fetchStalkerCategoryFixture(request, 'vod');
|
||||
const seriesFixture = await fetchStalkerCategoryFixture(
|
||||
request,
|
||||
'series'
|
||||
);
|
||||
const [liveTitle] = pickDistinctTitles(
|
||||
liveFixture.items,
|
||||
getStalkerTitle
|
||||
);
|
||||
const [movieTitle] = pickDistinctTitles(
|
||||
vodFixture.items,
|
||||
getStalkerTitle
|
||||
);
|
||||
const [seriesTitle] = pickDistinctTitles(
|
||||
seriesFixture.items,
|
||||
getStalkerTitle
|
||||
);
|
||||
const portalTitle = 'Stalker Favorites Source';
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addStalkerPortal(app.mainWindow, {
|
||||
name: portalTitle,
|
||||
});
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Live TV', exact: true })
|
||||
.click();
|
||||
await clickCategoryById(app.mainWindow, liveFixture.categoryId);
|
||||
await toggleFavoriteForChannel(app.mainWindow, liveTitle);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Movies', exact: true })
|
||||
.click();
|
||||
await clickCategoryById(app.mainWindow, vodFixture.categoryId);
|
||||
await clickGridListCardByTitle(app.mainWindow, movieTitle);
|
||||
await addCurrentDetailToFavorites(app.mainWindow);
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Series', exact: true })
|
||||
.click();
|
||||
await clickCategoryById(app.mainWindow, seriesFixture.categoryId);
|
||||
await clickGridListCardByTitle(app.mainWindow, seriesTitle);
|
||||
await addCurrentDetailToFavorites(app.mainWindow);
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
|
||||
await openPlaylistFavorites(app.mainWindow);
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, liveTitle)
|
||||
).toHaveCount(1);
|
||||
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, movieTitle)
|
||||
).toHaveCount(1);
|
||||
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, seriesTitle)
|
||||
).toHaveCount(1);
|
||||
|
||||
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Live TV');
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, liveTitle)
|
||||
).toHaveCount(1);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, movieTitle)
|
||||
).toHaveCount(1);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, seriesTitle)
|
||||
).toHaveCount(1);
|
||||
|
||||
const restarted = await restartElectronApp(app, dataDir);
|
||||
app.electronApp = restarted.electronApp;
|
||||
app.mainWindow = restarted.mainWindow;
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await sourceRowByTitle(app.mainWindow, portalTitle).first().click();
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
await openPlaylistFavorites(app.mainWindow);
|
||||
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Live TV');
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, liveTitle)
|
||||
).toHaveCount(1);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, movieTitle)
|
||||
).toHaveCount(1);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, seriesTitle)
|
||||
).toHaveCount(1);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('opens Stalker favorite movies and series inline from favorites without showing the sidebar', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['stalker']);
|
||||
const vodFixture = await fetchStalkerCategoryFixture(request, 'vod');
|
||||
const seriesFixture = await fetchStalkerCategoryFixture(
|
||||
request,
|
||||
'series'
|
||||
);
|
||||
const [movieTitle] = pickDistinctTitles(
|
||||
vodFixture.items,
|
||||
getStalkerTitle
|
||||
);
|
||||
const [seriesTitle] = pickDistinctTitles(
|
||||
seriesFixture.items,
|
||||
getStalkerTitle
|
||||
);
|
||||
const portalTitle = 'Stalker Favorite Detail Source';
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addStalkerPortal(app.mainWindow, {
|
||||
name: portalTitle,
|
||||
});
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Movies', exact: true })
|
||||
.click();
|
||||
await clickCategoryById(app.mainWindow, vodFixture.categoryId);
|
||||
await clickGridListCardByTitle(app.mainWindow, movieTitle);
|
||||
await addCurrentDetailToFavorites(app.mainWindow);
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Series', exact: true })
|
||||
.click();
|
||||
await clickCategoryById(app.mainWindow, seriesFixture.categoryId);
|
||||
await clickGridListCardByTitle(app.mainWindow, seriesTitle);
|
||||
await addCurrentDetailToFavorites(app.mainWindow);
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
|
||||
await openPlaylistFavorites(app.mainWindow);
|
||||
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
|
||||
portalTitle
|
||||
);
|
||||
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
||||
await contentCardByTitle(app.mainWindow, movieTitle)
|
||||
.first()
|
||||
.click();
|
||||
await expectInlineCollectionDetail(app.mainWindow, {
|
||||
pathname: /\/workspace\/global-favorites$/,
|
||||
title: movieTitle,
|
||||
playlistTitle: portalTitle,
|
||||
});
|
||||
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
|
||||
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
||||
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
||||
await contentCardByTitle(app.mainWindow, seriesTitle)
|
||||
.first()
|
||||
.click();
|
||||
await expectInlineCollectionDetail(app.mainWindow, {
|
||||
pathname: /\/workspace\/global-favorites$/,
|
||||
title: seriesTitle,
|
||||
playlistTitle: portalTitle,
|
||||
});
|
||||
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const xtreamCredentials = {
|
||||
username: defaultXtreamUsername,
|
||||
password: defaultXtreamPassword,
|
||||
};
|
||||
|
||||
async function addCurrentDetailToFavorites(page: Page): Promise<void> {
|
||||
const addButton = page.locator('button.favorite-btn').first();
|
||||
|
||||
await expect(addButton).toBeVisible({ timeout: 20000 });
|
||||
await addButton.click();
|
||||
await expect(
|
||||
page.locator('button.favorite-btn--active').first()
|
||||
).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
const backButton = page
|
||||
.locator('app-content-hero .hero__back-button')
|
||||
.first();
|
||||
|
||||
await expect(backButton).toBeVisible({ timeout: 20000 });
|
||||
try {
|
||||
await backButton.click({ timeout: 5000 });
|
||||
} catch {
|
||||
await backButton.evaluate((button: HTMLButtonElement) =>
|
||||
button.click()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectInlineCollectionDetail(
|
||||
page: Page,
|
||||
params: {
|
||||
pathname: RegExp;
|
||||
title: string;
|
||||
playlistTitle: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
await expectPathname(page, params.pathname);
|
||||
await expect(playlistSwitcherTitle(page)).toContainText(
|
||||
params.playlistTitle
|
||||
);
|
||||
await expect(page.locator('app-workspace-context-panel')).toHaveCount(0);
|
||||
await expect(page.locator('app-content-hero')).toContainText(params.title);
|
||||
await expect(
|
||||
page.locator('app-content-hero .hero__back-button').first()
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
}
|
||||
|
||||
async function toggleFavoriteForChannel(
|
||||
page: Page,
|
||||
title: string
|
||||
): Promise<void> {
|
||||
const item = channelItemByTitle(page, title).first();
|
||||
|
||||
await expect(item).toBeVisible({ timeout: 20000 });
|
||||
await item.hover();
|
||||
await item.locator('.favorite-button').first().click();
|
||||
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
|
||||
/star/
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
closeElectronApp,
|
||||
expect,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
launchElectronApp,
|
||||
m3uFixturePath,
|
||||
test,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
test.describe('Electron Native Playlist Import', () => {
|
||||
test('imports an M3U playlist via the native file picker path', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await importM3uPlaylistFromNativeDialog(app, m3uFixturePath);
|
||||
|
||||
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('channel-item')
|
||||
).toHaveCount(4);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,504 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import {
|
||||
channelItemByTitle,
|
||||
addStalkerPortal,
|
||||
addXtreamPortal,
|
||||
closeElectronApp,
|
||||
defaultXtreamPassword,
|
||||
defaultXtreamUsername,
|
||||
expect,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
launchElectronApp,
|
||||
openPlaylistFavorites,
|
||||
openPlaylistRecent,
|
||||
openSettings,
|
||||
openSourceEditor,
|
||||
openSources,
|
||||
openWorkspaceSection,
|
||||
playlistSwitcherTitle,
|
||||
resetMockServers,
|
||||
restartElectronApp,
|
||||
saveSettings,
|
||||
saveSourceDialog,
|
||||
sourceRowByTitle,
|
||||
test,
|
||||
updateSourceDialog,
|
||||
waitForDbOperationEvent,
|
||||
waitForM3uCatalog,
|
||||
waitForStalkerCatalog,
|
||||
waitForXtreamCatalog,
|
||||
writeTemporaryM3uFile,
|
||||
xtreamMockServer,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
const xtreamStressUsername = 'stress';
|
||||
const xtreamStressPassword = 'stress';
|
||||
const dbWorkerBatchDelayMs = '20';
|
||||
|
||||
const localAFileName = 'switcher-local-a.m3u';
|
||||
const localADisplayName = 'switcher-local-a';
|
||||
const localBFileName = 'switcher-local-b.m3u';
|
||||
const localBDisplayName = 'switcher-local-b';
|
||||
const localAChannelName = 'Switcher Favorite A';
|
||||
const localBChannelName = 'Switcher Favorite B';
|
||||
const offlineLocalFileName = 'offline-local-source.m3u';
|
||||
const offlineLocalDisplayName = 'offline-local-source';
|
||||
const xtreamAName = 'Switcher Xtream A';
|
||||
const xtreamBName = 'Switcher Xtream B';
|
||||
const stalkerAName = 'Switcher Stalker A';
|
||||
const stalkerBName = 'Switcher Stalker B';
|
||||
const offlineXtreamName = 'Offline Cached Xtream';
|
||||
const importingXtreamName = 'Importing Xtream Portal';
|
||||
|
||||
test.describe('Electron Playlist Switcher', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
test.setTimeout(120000);
|
||||
|
||||
test('preserves same-type sections and cross-type mappings when switching from the workspace header', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream', 'stalker']);
|
||||
const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [
|
||||
{
|
||||
groupTitle: 'News',
|
||||
name: 'Switcher Local A',
|
||||
url: 'https://streams.example.test/switcher-local-a.m3u8',
|
||||
},
|
||||
]);
|
||||
const localBPath = writeTemporaryM3uFile(dataDir, localBFileName, [
|
||||
{
|
||||
groupTitle: 'Sports',
|
||||
name: 'Switcher Local B',
|
||||
url: 'https://streams.example.test/switcher-local-b.m3u8',
|
||||
},
|
||||
]);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await importM3uPlaylistFromNativeDialog(app, localAPath);
|
||||
await importM3uPlaylistFromNativeDialog(app, localBPath);
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: xtreamAName,
|
||||
});
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: xtreamBName,
|
||||
});
|
||||
await addStalkerPortal(app.mainWindow, {
|
||||
name: stalkerAName,
|
||||
});
|
||||
await addStalkerPortal(app.mainWindow, {
|
||||
name: stalkerBName,
|
||||
});
|
||||
await openSources(app.mainWindow);
|
||||
|
||||
await sourceRowByTitle(app.mainWindow, localADisplayName)
|
||||
.first()
|
||||
.click();
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
await openPlaylistRecent(app.mainWindow);
|
||||
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
|
||||
|
||||
await switchPlaylistFromHeader(app.mainWindow, localBDisplayName);
|
||||
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
|
||||
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
|
||||
localBDisplayName
|
||||
);
|
||||
|
||||
await switchPlaylistFromHeader(app.mainWindow, xtreamAName);
|
||||
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
|
||||
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
|
||||
xtreamAName
|
||||
);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await waitForXtreamCatalog(app.mainWindow);
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/xtreams\/[^/]+\/live$/
|
||||
);
|
||||
|
||||
await switchPlaylistFromHeader(app.mainWindow, xtreamBName);
|
||||
await waitForXtreamCatalog(app.mainWindow);
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/xtreams\/[^/]+\/live$/
|
||||
);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'Recently added');
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/xtreams\/[^/]+\/recently-added$/
|
||||
);
|
||||
|
||||
await switchPlaylistFromHeader(app.mainWindow, stalkerAName);
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/stalker\/[^/]+\/recent$/
|
||||
);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/stalker\/[^/]+\/itv$/
|
||||
);
|
||||
|
||||
await switchPlaylistFromHeader(app.mainWindow, stalkerBName);
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/stalker\/[^/]+\/itv$/
|
||||
);
|
||||
|
||||
await switchPlaylistFromHeader(app.mainWindow, xtreamAName);
|
||||
await waitForXtreamCatalog(app.mainWindow);
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/xtreams\/[^/]+\/live$/
|
||||
);
|
||||
|
||||
await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/playlists\/[^/]+\/all$/
|
||||
);
|
||||
|
||||
await openPlaylistFavorites(app.mainWindow);
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/global-favorites$/
|
||||
);
|
||||
|
||||
await switchPlaylistFromHeader(app.mainWindow, stalkerBName);
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/global-favorites$/
|
||||
);
|
||||
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
|
||||
stalkerBName
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('refreshes M3U unified collections immediately when switching same-type playlists from the header', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [
|
||||
{
|
||||
groupTitle: 'News',
|
||||
name: localAChannelName,
|
||||
url: 'https://streams.example.test/switcher-favorite-a.m3u8',
|
||||
},
|
||||
]);
|
||||
const localBPath = writeTemporaryM3uFile(dataDir, localBFileName, [
|
||||
{
|
||||
groupTitle: 'Sports',
|
||||
name: localBChannelName,
|
||||
url: 'https://streams.example.test/switcher-favorite-b.m3u8',
|
||||
},
|
||||
]);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await importM3uPlaylistFromNativeDialog(app, localAPath);
|
||||
await importM3uPlaylistFromNativeDialog(app, localBPath);
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await sourceRowByTitle(app.mainWindow, localADisplayName)
|
||||
.first()
|
||||
.click();
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
await toggleFavoriteForChannel(app.mainWindow, localAChannelName);
|
||||
await channelItemByTitle(app.mainWindow, localAChannelName)
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await sourceRowByTitle(app.mainWindow, localBDisplayName)
|
||||
.first()
|
||||
.click();
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
await toggleFavoriteForChannel(app.mainWindow, localBChannelName);
|
||||
await channelItemByTitle(app.mainWindow, localBChannelName)
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await sourceRowByTitle(app.mainWindow, localADisplayName)
|
||||
.first()
|
||||
.click();
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
|
||||
await openPlaylistFavorites(app.mainWindow);
|
||||
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
|
||||
1
|
||||
);
|
||||
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
|
||||
0
|
||||
);
|
||||
|
||||
await switchPlaylistFromHeader(app.mainWindow, localBDisplayName);
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/global-favorites$/
|
||||
);
|
||||
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
|
||||
1
|
||||
);
|
||||
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
|
||||
0
|
||||
);
|
||||
|
||||
await openPlaylistRecent(app.mainWindow);
|
||||
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
|
||||
1
|
||||
);
|
||||
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
|
||||
0
|
||||
);
|
||||
|
||||
await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/global-recent$/
|
||||
);
|
||||
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
|
||||
1
|
||||
);
|
||||
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('switches away from importing Xtream playlists, returns before completion, and avoids redundant reimport after caching', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [
|
||||
{
|
||||
groupTitle: 'News',
|
||||
name: 'Import Switch Local',
|
||||
url: 'https://streams.example.test/import-switch-local.m3u8',
|
||||
},
|
||||
]);
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
env: {
|
||||
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await importM3uPlaylistFromNativeDialog(app, localAPath);
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: importingXtreamName,
|
||||
password: xtreamStressPassword,
|
||||
username: xtreamStressUsername,
|
||||
});
|
||||
|
||||
const overlay = app.mainWindow.locator('.workspace-loading-overlay');
|
||||
await expect(overlay).toBeVisible({ timeout: 15000 });
|
||||
await waitForDbOperationEvent(app.mainWindow, {
|
||||
operation: 'save-content',
|
||||
predicate: (event) =>
|
||||
event.status === 'progress' &&
|
||||
(event.current ?? 0) > 0 &&
|
||||
(event.total ?? 0) > 0,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
await expect(overlay).toHaveCount(0);
|
||||
|
||||
await switchPlaylistFromHeader(app.mainWindow, importingXtreamName);
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/xtreams\/[^/]+\/vod$/
|
||||
);
|
||||
await Promise.race([
|
||||
overlay.waitFor({ state: 'visible', timeout: 15000 }),
|
||||
waitForXtreamCatalog(app.mainWindow),
|
||||
]);
|
||||
|
||||
await waitForXtreamCatalog(app.mainWindow);
|
||||
|
||||
await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
|
||||
const startedBeforeReturn = await countDbEvents(
|
||||
app.mainWindow,
|
||||
'save-content',
|
||||
'started'
|
||||
);
|
||||
|
||||
await switchPlaylistFromHeader(app.mainWindow, importingXtreamName);
|
||||
await waitForXtreamCatalog(app.mainWindow);
|
||||
await expect(overlay).toHaveCount(0);
|
||||
|
||||
await app.mainWindow.waitForTimeout(1200);
|
||||
|
||||
const startedAfterReturn = await countDbEvents(
|
||||
app.mainWindow,
|
||||
'save-content',
|
||||
'started'
|
||||
);
|
||||
expect(startedAfterReturn).toBe(startedBeforeReturn);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps cached Xtream content browseable with an unavailable warning after the portal goes offline and after restart', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const offlineLocalPath = writeTemporaryM3uFile(
|
||||
dataDir,
|
||||
offlineLocalFileName,
|
||||
[
|
||||
{
|
||||
groupTitle: 'News',
|
||||
name: 'Offline Local Channel',
|
||||
url: 'https://streams.example.test/offline-local.m3u8',
|
||||
},
|
||||
]
|
||||
);
|
||||
let app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await selectSettingsOption(
|
||||
app.mainWindow,
|
||||
'select-startup-behavior',
|
||||
'restore-last-view'
|
||||
);
|
||||
await saveSettings(app.mainWindow);
|
||||
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: offlineXtreamName,
|
||||
password: defaultXtreamPassword,
|
||||
username: defaultXtreamUsername,
|
||||
});
|
||||
await openWorkspaceSection(app.mainWindow, 'Movies');
|
||||
await waitForXtreamCatalog(app.mainWindow);
|
||||
|
||||
await importM3uPlaylistFromNativeDialog(app, offlineLocalPath);
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
const dialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
offlineXtreamName
|
||||
);
|
||||
await updateSourceDialog(dialog, {
|
||||
serverUrl: xtreamMockServer.replace(/:\d+$/, ':65530'),
|
||||
});
|
||||
await saveSourceDialog(app.mainWindow, dialog);
|
||||
|
||||
await sourceRowByTitle(app.mainWindow, offlineLocalDisplayName)
|
||||
.first()
|
||||
.click();
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
|
||||
await switchPlaylistFromHeader(app.mainWindow, offlineXtreamName);
|
||||
await waitForXtreamCatalog(app.mainWindow);
|
||||
await expectXtreamUnavailableWarning(app.mainWindow);
|
||||
await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount(
|
||||
0
|
||||
);
|
||||
|
||||
app = await restartElectronApp(app, dataDir);
|
||||
|
||||
await waitForXtreamCatalog(app.mainWindow);
|
||||
await expectXtreamUnavailableWarning(app.mainWindow);
|
||||
await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount(
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function openPlaylistSwitcher(page: Page): Promise<void> {
|
||||
await page
|
||||
.locator('app-playlist-switcher .playlist-switcher-trigger')
|
||||
.click();
|
||||
await expect(
|
||||
page.locator('.cdk-overlay-pane .playlist-item').first()
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function switchPlaylistFromHeader(
|
||||
page: Page,
|
||||
title: string
|
||||
): Promise<void> {
|
||||
await openPlaylistSwitcher(page);
|
||||
const item = page
|
||||
.locator('.cdk-overlay-pane .playlist-item')
|
||||
.filter({ hasText: title })
|
||||
.first();
|
||||
|
||||
await expect(item).toBeVisible();
|
||||
await item.click();
|
||||
await expect(page.locator('.cdk-overlay-pane .playlist-item')).toHaveCount(
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
async function expectPathname(page: Page, pattern: RegExp): Promise<void> {
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).pathname, { timeout: 20000 })
|
||||
.toMatch(pattern);
|
||||
}
|
||||
|
||||
async function countDbEvents(
|
||||
page: Page,
|
||||
operation: string,
|
||||
status: string
|
||||
): Promise<number> {
|
||||
return page.evaluate(
|
||||
({ operation, status: desiredStatus }) =>
|
||||
(window.__dbOperationEvents ?? []).filter(
|
||||
(event) =>
|
||||
event.operation === operation &&
|
||||
event.status === desiredStatus
|
||||
).length,
|
||||
{ operation, status }
|
||||
);
|
||||
}
|
||||
|
||||
async function selectSettingsOption(
|
||||
page: Page,
|
||||
selectTestId: string,
|
||||
optionTestId: string
|
||||
): Promise<void> {
|
||||
await page.getByTestId(selectTestId).click();
|
||||
await page.getByTestId(optionTestId).click();
|
||||
}
|
||||
|
||||
async function expectXtreamUnavailableWarning(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId('xtream-offline-warning')
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
}
|
||||
|
||||
async function toggleFavoriteForChannel(page: Page, title: string): Promise<void> {
|
||||
const item = channelItemByTitle(page, title).first();
|
||||
|
||||
await expect(item).toBeVisible({ timeout: 20000 });
|
||||
await item.hover();
|
||||
await item.locator('.favorite-button').first().click();
|
||||
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(/star/);
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import { APIRequestContext } from '@playwright/test';
|
||||
import {
|
||||
defaultStalkerMacAddress,
|
||||
expect,
|
||||
stalkerMockServer,
|
||||
xtreamMockServer,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
export type XtreamCategory = {
|
||||
category_id: string;
|
||||
category_name: string;
|
||||
};
|
||||
|
||||
export type XtreamLiveStream = {
|
||||
added?: string;
|
||||
category_id?: string;
|
||||
epg_channel_id?: string;
|
||||
name?: string;
|
||||
stream_id?: number | string;
|
||||
stream_type?: string;
|
||||
tv_archive?: number | string;
|
||||
tv_archive_duration?: number | string;
|
||||
};
|
||||
|
||||
export type XtreamVodStream = {
|
||||
added?: string;
|
||||
category_id?: string;
|
||||
name?: string;
|
||||
stream_id?: number | string;
|
||||
};
|
||||
|
||||
export type XtreamSeriesItem = {
|
||||
added?: string;
|
||||
category_id?: number | string;
|
||||
last_modified?: string;
|
||||
name?: string;
|
||||
releaseDate?: string;
|
||||
series_id?: number | string;
|
||||
};
|
||||
|
||||
export type XtreamCategoryFixture<T> = {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
items: T[];
|
||||
};
|
||||
|
||||
export type XtreamRawEpgListing = {
|
||||
channel_id?: string;
|
||||
description?: string;
|
||||
end?: string;
|
||||
epg_id?: string;
|
||||
id?: string;
|
||||
lang?: string;
|
||||
start?: string;
|
||||
start_timestamp?: string;
|
||||
stop_timestamp?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type XtreamNormalizedEpgListing = {
|
||||
channelId: string;
|
||||
description: string;
|
||||
end: string;
|
||||
id: string;
|
||||
rawDescription: string;
|
||||
rawEnd: string;
|
||||
rawStart: string;
|
||||
rawTitle: string;
|
||||
start: string;
|
||||
startTimestamp: number;
|
||||
stopTimestamp: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type XtreamEpgFixture = {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
fullEpg: XtreamNormalizedEpgListing[];
|
||||
rawFullEpg: XtreamRawEpgListing[];
|
||||
rawShortEpg: XtreamRawEpgListing[];
|
||||
shortEpg: XtreamNormalizedEpgListing[];
|
||||
stream: XtreamLiveStream;
|
||||
};
|
||||
|
||||
type StalkerCategory = {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type StalkerProxyPayload<T> = {
|
||||
payload: {
|
||||
js: T;
|
||||
};
|
||||
};
|
||||
|
||||
type StalkerOrderedList<T> = {
|
||||
data: T[];
|
||||
};
|
||||
|
||||
type StalkerSeasonLike = {
|
||||
id?: number | string;
|
||||
};
|
||||
|
||||
export type StalkerContentItem = {
|
||||
id?: number | string;
|
||||
name?: string;
|
||||
o_name?: string;
|
||||
};
|
||||
|
||||
export type StalkerCategoryFixture = {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
items: StalkerContentItem[];
|
||||
};
|
||||
|
||||
export async function fetchXtreamLiveFixture(
|
||||
request: APIRequestContext,
|
||||
credentials = { username: 'minimal', password: 'minimal' }
|
||||
): Promise<XtreamCategoryFixture<XtreamLiveStream>> {
|
||||
return fetchXtreamFixture<XtreamLiveStream>(request, {
|
||||
categoriesAction: 'get_live_categories',
|
||||
itemsAction: 'get_live_streams',
|
||||
...credentials,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchXtreamVodFixture(
|
||||
request: APIRequestContext,
|
||||
credentials = { username: 'minimal', password: 'minimal' }
|
||||
): Promise<XtreamCategoryFixture<XtreamVodStream>> {
|
||||
return fetchXtreamFixture<XtreamVodStream>(request, {
|
||||
categoriesAction: 'get_vod_categories',
|
||||
itemsAction: 'get_vod_streams',
|
||||
...credentials,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchXtreamSeriesFixture(
|
||||
request: APIRequestContext,
|
||||
credentials = { username: 'minimal', password: 'minimal' }
|
||||
): Promise<XtreamCategoryFixture<XtreamSeriesItem>> {
|
||||
return fetchXtreamFixture<XtreamSeriesItem>(request, {
|
||||
categoriesAction: 'get_series_categories',
|
||||
itemsAction: 'get_series',
|
||||
...credentials,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchXtreamEpgFixture(
|
||||
request: APIRequestContext,
|
||||
credentials = { username: 'epg', password: 'epg' }
|
||||
): Promise<XtreamEpgFixture> {
|
||||
const liveFixture = await fetchXtreamLiveFixture(request, credentials);
|
||||
const stream = liveFixture.items[0];
|
||||
if (!stream) {
|
||||
throw new Error('Xtream EPG fixture returned no live stream.');
|
||||
}
|
||||
const streamId = Number(stream.stream_id);
|
||||
|
||||
if (!Number.isFinite(streamId) || streamId <= 0) {
|
||||
throw new Error('Xtream EPG fixture returned an invalid stream id.');
|
||||
}
|
||||
|
||||
const rawShortResponse = await fetchJson<{
|
||||
epg_listings?: XtreamRawEpgListing[];
|
||||
}>(
|
||||
request,
|
||||
`${xtreamMockServer}/player_api.php?action=get_short_epg&username=${encodeURIComponent(
|
||||
credentials.username
|
||||
)}&password=${encodeURIComponent(
|
||||
credentials.password
|
||||
)}&stream_id=${streamId}&limit=3`
|
||||
);
|
||||
const rawFullResponse = await fetchJson<{
|
||||
epg_listings?: XtreamRawEpgListing[];
|
||||
}>(
|
||||
request,
|
||||
`${xtreamMockServer}/player_api.php?action=get_simple_data_table&username=${encodeURIComponent(
|
||||
credentials.username
|
||||
)}&password=${encodeURIComponent(
|
||||
credentials.password
|
||||
)}&stream_id=${streamId}`
|
||||
);
|
||||
|
||||
const rawShortEpg = rawShortResponse.epg_listings ?? [];
|
||||
const rawFullEpg = rawFullResponse.epg_listings ?? [];
|
||||
|
||||
if (rawShortEpg.length === 0 || rawFullEpg.length === 0) {
|
||||
throw new Error('Xtream EPG fixture returned no schedule data.');
|
||||
}
|
||||
|
||||
return {
|
||||
categoryId: liveFixture.categoryId,
|
||||
categoryName: liveFixture.categoryName,
|
||||
stream,
|
||||
rawShortEpg,
|
||||
rawFullEpg,
|
||||
shortEpg: normalizeXtreamEpgListings(rawShortEpg),
|
||||
fullEpg: normalizeXtreamEpgListings(rawFullEpg),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchStalkerCategoryFixture(
|
||||
request: APIRequestContext,
|
||||
type: 'itv' | 'series' | 'vod'
|
||||
): Promise<StalkerCategoryFixture> {
|
||||
const categoriesResponse = await fetchJson<
|
||||
StalkerProxyPayload<StalkerCategory[]>
|
||||
>(
|
||||
request,
|
||||
buildStalkerProxyUrl('get_categories', {
|
||||
type,
|
||||
})
|
||||
);
|
||||
const categories = categoriesResponse.payload.js ?? [];
|
||||
|
||||
for (const category of categories) {
|
||||
const itemsResponse = await fetchJson<
|
||||
StalkerProxyPayload<StalkerOrderedList<StalkerContentItem>>
|
||||
>(
|
||||
request,
|
||||
buildStalkerProxyUrl('get_ordered_list', {
|
||||
category: String(category.id),
|
||||
p: '1',
|
||||
type,
|
||||
})
|
||||
);
|
||||
const items = itemsResponse.payload.js.data ?? [];
|
||||
|
||||
if (items.length > 0) {
|
||||
if (type === 'series') {
|
||||
const playableSeries = await findPlayableStalkerSeriesItem(
|
||||
request,
|
||||
items
|
||||
);
|
||||
|
||||
if (!playableSeries) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
categoryId: String(category.id),
|
||||
categoryName: category.title,
|
||||
items: [
|
||||
playableSeries,
|
||||
...items.filter(
|
||||
(item) =>
|
||||
String(item.id ?? '') !==
|
||||
String(playableSeries.id ?? '')
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
categoryId: String(category.id),
|
||||
categoryName: category.title,
|
||||
items,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Stalker mock server returned no ${type} items.`);
|
||||
}
|
||||
|
||||
export function getXtreamTitle(
|
||||
item: XtreamLiveStream | XtreamVodStream | XtreamSeriesItem
|
||||
): string {
|
||||
return `${item.name ?? ''}`.trim();
|
||||
}
|
||||
|
||||
export function getXtreamDateValue(
|
||||
item: XtreamVodStream | XtreamSeriesItem
|
||||
): number {
|
||||
if ('last_modified' in item && item.last_modified) {
|
||||
return Number.parseInt(item.last_modified, 10) || 0;
|
||||
}
|
||||
|
||||
return Number.parseInt(item.added ?? '', 10) || 0;
|
||||
}
|
||||
|
||||
export function getStalkerTitle(item: StalkerContentItem): string {
|
||||
return `${item.o_name ?? item.name ?? ''}`.trim();
|
||||
}
|
||||
|
||||
export function pickDistinctTitles<T>(
|
||||
items: T[],
|
||||
getTitle: (item: T) => string
|
||||
): [string, string] {
|
||||
const titles = items
|
||||
.map((item) => getTitle(item))
|
||||
.map((title) => title.trim())
|
||||
.filter((title, index, allTitles) => {
|
||||
return (
|
||||
title.length > 0 &&
|
||||
allTitles.findIndex((candidate) => candidate === title) ===
|
||||
index
|
||||
);
|
||||
});
|
||||
|
||||
if (titles.length < 2) {
|
||||
throw new Error('Expected at least two distinct item titles.');
|
||||
}
|
||||
|
||||
return [titles[0], titles[1]];
|
||||
}
|
||||
|
||||
async function fetchXtreamFixture<T extends { category_id?: string | number }>(
|
||||
request: APIRequestContext,
|
||||
options: {
|
||||
categoriesAction: string;
|
||||
itemsAction: string;
|
||||
password: string;
|
||||
username: string;
|
||||
}
|
||||
): Promise<XtreamCategoryFixture<T>> {
|
||||
const { categoriesAction, itemsAction, password, username } = options;
|
||||
const categories = await fetchJson<XtreamCategory[]>(
|
||||
request,
|
||||
`${xtreamMockServer}/player_api.php?action=${encodeURIComponent(
|
||||
categoriesAction
|
||||
)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(
|
||||
password
|
||||
)}`
|
||||
);
|
||||
|
||||
for (const category of categories) {
|
||||
const items = await fetchJson<T[]>(
|
||||
request,
|
||||
`${xtreamMockServer}/player_api.php?action=${encodeURIComponent(
|
||||
itemsAction
|
||||
)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(
|
||||
password
|
||||
)}&category_id=${encodeURIComponent(category.category_id)}`
|
||||
);
|
||||
|
||||
if (items.length > 0) {
|
||||
return {
|
||||
categoryId: String(category.category_id),
|
||||
categoryName: category.category_name,
|
||||
items,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Xtream mock server returned no items for ${itemsAction}.`);
|
||||
}
|
||||
|
||||
async function fetchJson<T>(
|
||||
request: APIRequestContext,
|
||||
url: string
|
||||
): Promise<T> {
|
||||
const response = await request.get(url);
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
function normalizeXtreamEpgListings(
|
||||
listings: XtreamRawEpgListing[]
|
||||
): XtreamNormalizedEpgListing[] {
|
||||
return listings
|
||||
.map((listing, index) => {
|
||||
const startTimestamp = Number.parseInt(
|
||||
String(listing.start_timestamp ?? ''),
|
||||
10
|
||||
);
|
||||
const stopTimestamp = Number.parseInt(
|
||||
String(listing.stop_timestamp ?? ''),
|
||||
10
|
||||
);
|
||||
|
||||
return {
|
||||
id: String(listing.id ?? index),
|
||||
title: decodeXtreamText(String(listing.title ?? '')),
|
||||
description: decodeXtreamText(
|
||||
String(listing.description ?? '')
|
||||
),
|
||||
rawTitle: String(listing.title ?? ''),
|
||||
rawDescription: String(listing.description ?? ''),
|
||||
rawStart: String(listing.start ?? ''),
|
||||
rawEnd: String(listing.end ?? ''),
|
||||
start: new Date(startTimestamp * 1000).toISOString(),
|
||||
end: new Date(stopTimestamp * 1000).toISOString(),
|
||||
startTimestamp,
|
||||
stopTimestamp,
|
||||
channelId: String(listing.channel_id ?? listing.epg_id ?? ''),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.startTimestamp - right.startTimestamp);
|
||||
}
|
||||
|
||||
function decodeXtreamText(value: string): string {
|
||||
return Buffer.from(value, 'base64').toString('utf-8');
|
||||
}
|
||||
|
||||
function buildStalkerProxyUrl(
|
||||
action: string,
|
||||
params: Record<string, string>
|
||||
): string {
|
||||
const searchParams = new URLSearchParams({
|
||||
action,
|
||||
macAddress: defaultStalkerMacAddress,
|
||||
url: `${stalkerMockServer}/portal.php`,
|
||||
...params,
|
||||
});
|
||||
|
||||
return `${stalkerMockServer}/stalker?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
async function findPlayableStalkerSeriesItem(
|
||||
request: APIRequestContext,
|
||||
items: StalkerContentItem[]
|
||||
): Promise<StalkerContentItem | null> {
|
||||
for (const item of items) {
|
||||
const itemId = String(item.id ?? '').trim();
|
||||
|
||||
if (!itemId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const seasonsResponse = await fetchJson<
|
||||
StalkerProxyPayload<
|
||||
StalkerOrderedList<StalkerSeasonLike> | StalkerSeasonLike[]
|
||||
>
|
||||
>(
|
||||
request,
|
||||
buildStalkerProxyUrl('get_ordered_list', {
|
||||
movie_id: itemId,
|
||||
p: '1',
|
||||
type: 'series',
|
||||
})
|
||||
);
|
||||
const seasons = extractStalkerSeriesItems(seasonsResponse.payload.js);
|
||||
|
||||
if (seasons.length > 0) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractStalkerSeriesItems<T>(
|
||||
payload: StalkerOrderedList<T> | T[] | undefined
|
||||
): T[] {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
return payload?.data ?? [];
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
addStalkerPortal,
|
||||
addXtreamPortal,
|
||||
closeElectronApp,
|
||||
defaultStalkerPortalName,
|
||||
defaultXtreamPortalName,
|
||||
expect,
|
||||
expectPortalDebugSuccess,
|
||||
goToDashboard,
|
||||
launchElectronApp,
|
||||
resetMockServers,
|
||||
stalkerMockServer,
|
||||
test,
|
||||
waitForStalkerCatalog,
|
||||
waitForXtreamCatalog,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
test.describe('Electron Provider Smoke Tests', () => {
|
||||
test('loads Xtream content through the Electron IPC path', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow);
|
||||
await waitForXtreamCatalog(app.mainWindow);
|
||||
await expectPortalDebugSuccess(app.mainWindow, 'xtream');
|
||||
|
||||
await goToDashboard(app.mainWindow);
|
||||
await expectRecentSourceCard(
|
||||
app.mainWindow,
|
||||
defaultXtreamPortalName
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('loads Stalker content through the Electron IPC path', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['stalker']);
|
||||
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addStalkerPortal(app.mainWindow, {
|
||||
portalUrl: `${stalkerMockServer}/portal.php`,
|
||||
});
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
await expectPortalDebugSuccess(app.mainWindow, 'stalker');
|
||||
|
||||
await goToDashboard(app.mainWindow);
|
||||
await expectRecentSourceCard(
|
||||
app.mainWindow,
|
||||
defaultStalkerPortalName
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function expectRecentSourceCard(
|
||||
page: Page,
|
||||
title: string
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId('dashboard-recent-sources-rail')
|
||||
).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByTestId('dashboard-recent-sources-rail-card').filter({
|
||||
hasText: title,
|
||||
}).first()
|
||||
).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import {
|
||||
addStalkerPortal,
|
||||
addXtreamPortal,
|
||||
channelItemByTitle,
|
||||
clickCategoryById,
|
||||
clickGridListCardByTitle,
|
||||
clickCategoryByNameExact,
|
||||
clickFirstGridListCard,
|
||||
closeElectronApp,
|
||||
contentCardByTitle,
|
||||
defaultXtreamPassword,
|
||||
defaultXtreamUsername,
|
||||
expectPathname,
|
||||
expectVisibleContentCardTitle,
|
||||
expect,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
launchElectronApp,
|
||||
openPlaylistFavorites,
|
||||
openPlaylistRecent,
|
||||
openWorkspaceSection,
|
||||
openSources,
|
||||
resetMockServers,
|
||||
restartElectronApp,
|
||||
sourceRowByTitle,
|
||||
switchUnifiedCollectionContent,
|
||||
switchUnifiedCollectionScope,
|
||||
test,
|
||||
playlistSwitcherTitle,
|
||||
waitForM3uCatalog,
|
||||
waitForStalkerCatalog,
|
||||
waitForXtreamWorkspaceReady,
|
||||
writeTemporaryM3uFile,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
fetchStalkerCategoryFixture,
|
||||
fetchXtreamLiveFixture,
|
||||
fetchXtreamSeriesFixture,
|
||||
fetchXtreamVodFixture,
|
||||
getStalkerTitle,
|
||||
getXtreamTitle,
|
||||
pickDistinctTitles,
|
||||
} from './portal-mock-fixtures';
|
||||
|
||||
test.describe('Electron Recently Viewed', () => {
|
||||
test('keeps unified live detail open when re-clicking the active M3U recent item', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const filePath = writeTemporaryM3uFile(
|
||||
dataDir,
|
||||
'm3u-live-reclick.m3u',
|
||||
[
|
||||
{
|
||||
groupTitle: 'Live',
|
||||
name: 'Stable Recent Channel',
|
||||
url: 'https://streams.example.test/stable-recent.m3u8',
|
||||
},
|
||||
]
|
||||
);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await importM3uPlaylistFromNativeDialog(app, filePath);
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
|
||||
await channelItemByTitle(app.mainWindow, 'Stable Recent Channel')
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await openPlaylistRecent(app.mainWindow);
|
||||
|
||||
const item = channelItemByTitle(
|
||||
app.mainWindow,
|
||||
'Stable Recent Channel'
|
||||
).first();
|
||||
const closeButton = app.mainWindow
|
||||
.locator('.player-toolbar .close-btn')
|
||||
.first();
|
||||
|
||||
await item.click();
|
||||
await expect(closeButton).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await item.click();
|
||||
await expect(closeButton).toBeVisible({ timeout: 20000 });
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('tracks M3U recent channels in newest-first order, supports all-playlists scope, and persists removals after restart', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const playlistTitle = 'm3u-recent-source.m3u';
|
||||
const playlistSourceTitle = 'm3u-recent-source';
|
||||
const filePath = writeTemporaryM3uFile(dataDir, playlistTitle, [
|
||||
{
|
||||
groupTitle: 'Live',
|
||||
name: 'Recent Channel One',
|
||||
url: 'https://streams.example.test/recent-one.m3u8',
|
||||
},
|
||||
{
|
||||
groupTitle: 'Live',
|
||||
name: 'Recent Channel Two',
|
||||
url: 'https://streams.example.test/recent-two.m3u8',
|
||||
},
|
||||
]);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await importM3uPlaylistFromNativeDialog(app, filePath);
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
|
||||
await channelItemByTitle(app.mainWindow, 'Recent Channel One')
|
||||
.first()
|
||||
.click();
|
||||
await channelItemByTitle(app.mainWindow, 'Recent Channel Two')
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await openPlaylistRecent(app.mainWindow);
|
||||
await expect
|
||||
.poll(() => visibleLiveTitles(app.mainWindow))
|
||||
.toEqual(['Recent Channel Two', 'Recent Channel One']);
|
||||
|
||||
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
||||
await expect
|
||||
.poll(() => visibleLiveTitles(app.mainWindow))
|
||||
.toEqual(['Recent Channel Two', 'Recent Channel One']);
|
||||
|
||||
await removeLiveRecentItem(app.mainWindow, 'Recent Channel Two');
|
||||
await expect
|
||||
.poll(() => visibleLiveTitles(app.mainWindow))
|
||||
.toEqual(['Recent Channel One']);
|
||||
|
||||
const restarted = await restartElectronApp(app, dataDir);
|
||||
app.electronApp = restarted.electronApp;
|
||||
app.mainWindow = restarted.mainWindow;
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await sourceRowByTitle(app.mainWindow, playlistSourceTitle)
|
||||
.first()
|
||||
.click();
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
await openPlaylistRecent(app.mainWindow);
|
||||
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
||||
await expect
|
||||
.poll(() => visibleLiveTitles(app.mainWindow))
|
||||
.toEqual(['Recent Channel One']);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('tracks Xtream live, movie, and series history across playlist and all-playlists scope, persists after restart, and supports clearing', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const liveFixture = await fetchXtreamLiveFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const vodFixture = await fetchXtreamVodFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const seriesFixture = await fetchXtreamSeriesFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const [liveTitle] = pickDistinctTitles(
|
||||
liveFixture.items,
|
||||
getXtreamTitle
|
||||
);
|
||||
const portalTitle = 'Xtream Recent Source';
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: portalTitle,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
liveFixture.categoryName
|
||||
);
|
||||
await toggleFavoriteForChannel(app.mainWindow, liveTitle);
|
||||
await openPlaylistFavorites(app.mainWindow);
|
||||
await channelItemByTitle(app.mainWindow, liveTitle).first().click();
|
||||
await closeUnifiedLiveDetail(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Movies', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
vodFixture.categoryName
|
||||
);
|
||||
// Pick from the displayed grid: fixture order ≠ display order (date-desc
|
||||
// sort + pagination), so the first fixture item may not be on page 1.
|
||||
// Use clickFirstGridListCard to atomically read + click the first visible
|
||||
// card, avoiding a race where the grid re-renders between title read and
|
||||
// a separate search-by-title click.
|
||||
const movieTitle = await clickFirstGridListCard(app.mainWindow);
|
||||
await playCurrentDetail(app.mainWindow);
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Series', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
seriesFixture.categoryName
|
||||
);
|
||||
const seriesTitle = await clickFirstGridListCard(app.mainWindow);
|
||||
await playFirstSeriesEpisode(app.mainWindow);
|
||||
|
||||
await openPlaylistRecent(app.mainWindow);
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, liveTitle)
|
||||
).toHaveCount(1);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
||||
|
||||
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Live TV');
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, liveTitle)
|
||||
).toHaveCount(1);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
||||
|
||||
const restarted = await restartElectronApp(app, dataDir);
|
||||
app.electronApp = restarted.electronApp;
|
||||
app.mainWindow = restarted.mainWindow;
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await sourceRowByTitle(app.mainWindow, portalTitle).first().click();
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openPlaylistRecent(app.mainWindow);
|
||||
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Live TV');
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, liveTitle)
|
||||
).toHaveCount(1);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
||||
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Live TV');
|
||||
await clearRecentItems(app.mainWindow, 'Live TV');
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, liveTitle)
|
||||
).toHaveCount(0);
|
||||
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
||||
await clearRecentItems(app.mainWindow, 'Movies');
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, movieTitle)
|
||||
).toHaveCount(0);
|
||||
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
||||
await clearRecentItems(app.mainWindow, 'Series');
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, seriesTitle)
|
||||
).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('opens Xtream recent movies and series inline from recent without switching the playlist or showing the sidebar', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const vodFixture = await fetchXtreamVodFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const seriesFixture = await fetchXtreamSeriesFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const portalTitle = 'Xtream Recent Detail Source';
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: portalTitle,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Movies', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
vodFixture.categoryName
|
||||
);
|
||||
const movieTitle = await clickFirstGridListCard(app.mainWindow);
|
||||
await playCurrentDetail(app.mainWindow);
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Series', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
seriesFixture.categoryName
|
||||
);
|
||||
const seriesTitle = await clickFirstGridListCard(app.mainWindow);
|
||||
await playFirstSeriesEpisode(app.mainWindow);
|
||||
|
||||
await openPlaylistRecent(app.mainWindow);
|
||||
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
|
||||
portalTitle
|
||||
);
|
||||
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
||||
await contentCardByTitle(app.mainWindow, movieTitle)
|
||||
.first()
|
||||
.click();
|
||||
await expectInlineCollectionDetail(app.mainWindow, {
|
||||
pathname: /\/workspace\/global-recent$/,
|
||||
title: movieTitle,
|
||||
playlistTitle: portalTitle,
|
||||
});
|
||||
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
|
||||
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
||||
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
||||
await contentCardByTitle(app.mainWindow, seriesTitle)
|
||||
.first()
|
||||
.click();
|
||||
await expectInlineCollectionDetail(app.mainWindow, {
|
||||
pathname: /\/workspace\/global-recent$/,
|
||||
title: seriesTitle,
|
||||
playlistTitle: portalTitle,
|
||||
});
|
||||
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('tracks Stalker live, movie, and series history across playlist and all-playlists scope, and preserves it after restart', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['stalker']);
|
||||
const liveFixture = await fetchStalkerCategoryFixture(request, 'itv');
|
||||
const vodFixture = await fetchStalkerCategoryFixture(request, 'vod');
|
||||
const seriesFixture = await fetchStalkerCategoryFixture(
|
||||
request,
|
||||
'series'
|
||||
);
|
||||
const [liveTitle] = pickDistinctTitles(
|
||||
liveFixture.items,
|
||||
getStalkerTitle
|
||||
);
|
||||
const [movieTitle] = pickDistinctTitles(
|
||||
vodFixture.items,
|
||||
getStalkerTitle
|
||||
);
|
||||
const [seriesTitle] = pickDistinctTitles(
|
||||
seriesFixture.items,
|
||||
getStalkerTitle
|
||||
);
|
||||
const portalTitle = 'Stalker Recent Source';
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addStalkerPortal(app.mainWindow, {
|
||||
name: portalTitle,
|
||||
});
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Live TV', exact: true })
|
||||
.click();
|
||||
await clickCategoryById(app.mainWindow, liveFixture.categoryId);
|
||||
await toggleFavoriteForChannel(app.mainWindow, liveTitle);
|
||||
await openPlaylistFavorites(app.mainWindow);
|
||||
await channelItemByTitle(app.mainWindow, liveTitle).first().click();
|
||||
await closeUnifiedLiveDetail(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Movies', exact: true })
|
||||
.click();
|
||||
await clickCategoryById(app.mainWindow, vodFixture.categoryId);
|
||||
await clickGridListCardByTitle(app.mainWindow, movieTitle);
|
||||
await playCurrentDetail(app.mainWindow);
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Series', exact: true })
|
||||
.click();
|
||||
await clickCategoryById(app.mainWindow, seriesFixture.categoryId);
|
||||
await clickGridListCardByTitle(app.mainWindow, seriesTitle);
|
||||
await playFirstSeriesEpisode(app.mainWindow);
|
||||
|
||||
await openPlaylistRecent(app.mainWindow);
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, liveTitle)
|
||||
).toHaveCount(1);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, movieTitle)
|
||||
).toHaveCount(1);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, seriesTitle)
|
||||
).toHaveCount(1);
|
||||
|
||||
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Live TV');
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, liveTitle)
|
||||
).toHaveCount(1);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, movieTitle)
|
||||
).toHaveCount(1);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, seriesTitle)
|
||||
).toHaveCount(1);
|
||||
|
||||
const restarted = await restartElectronApp(app, dataDir);
|
||||
app.electronApp = restarted.electronApp;
|
||||
app.mainWindow = restarted.mainWindow;
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await sourceRowByTitle(app.mainWindow, portalTitle).first().click();
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
await openPlaylistRecent(app.mainWindow);
|
||||
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Live TV');
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, liveTitle)
|
||||
).toHaveCount(1);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, movieTitle)
|
||||
).toHaveCount(1);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, seriesTitle)
|
||||
).toHaveCount(1);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('opens Stalker recent movies and series inline from recent without showing the sidebar', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['stalker']);
|
||||
const vodFixture = await fetchStalkerCategoryFixture(request, 'vod');
|
||||
const seriesFixture = await fetchStalkerCategoryFixture(
|
||||
request,
|
||||
'series'
|
||||
);
|
||||
const [movieTitle] = pickDistinctTitles(
|
||||
vodFixture.items,
|
||||
getStalkerTitle
|
||||
);
|
||||
const [seriesTitle] = pickDistinctTitles(
|
||||
seriesFixture.items,
|
||||
getStalkerTitle
|
||||
);
|
||||
const portalTitle = 'Stalker Recent Detail Source';
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addStalkerPortal(app.mainWindow, {
|
||||
name: portalTitle,
|
||||
});
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Movies', exact: true })
|
||||
.click();
|
||||
await clickCategoryById(app.mainWindow, vodFixture.categoryId);
|
||||
await clickGridListCardByTitle(app.mainWindow, movieTitle);
|
||||
await playCurrentDetail(app.mainWindow);
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Series', exact: true })
|
||||
.click();
|
||||
await clickCategoryById(app.mainWindow, seriesFixture.categoryId);
|
||||
await clickGridListCardByTitle(app.mainWindow, seriesTitle);
|
||||
await playFirstSeriesEpisode(app.mainWindow);
|
||||
|
||||
await openPlaylistRecent(app.mainWindow);
|
||||
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
|
||||
portalTitle
|
||||
);
|
||||
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
||||
await contentCardByTitle(app.mainWindow, movieTitle)
|
||||
.first()
|
||||
.click();
|
||||
await expectInlineCollectionDetail(app.mainWindow, {
|
||||
pathname: /\/workspace\/global-recent$/,
|
||||
title: movieTitle,
|
||||
playlistTitle: portalTitle,
|
||||
});
|
||||
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
|
||||
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
||||
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
||||
await contentCardByTitle(app.mainWindow, seriesTitle)
|
||||
.first()
|
||||
.click();
|
||||
await expectInlineCollectionDetail(app.mainWindow, {
|
||||
pathname: /\/workspace\/global-recent$/,
|
||||
title: seriesTitle,
|
||||
playlistTitle: portalTitle,
|
||||
});
|
||||
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const xtreamCredentials = {
|
||||
username: defaultXtreamUsername,
|
||||
password: defaultXtreamPassword,
|
||||
};
|
||||
|
||||
async function clearRecentItems(page: Page, typeLabel: string): Promise<void> {
|
||||
await page
|
||||
.getByRole('button', { name: `Clear recently viewed ${typeLabel}` })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Yes' }).click();
|
||||
}
|
||||
|
||||
async function closeUnifiedLiveDetail(page: Page): Promise<void> {
|
||||
const closeButton = page.locator('.player-toolbar .close-btn').first();
|
||||
|
||||
await expect(closeButton).toBeVisible({ timeout: 20000 });
|
||||
await closeButton.click();
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
const backButton = page
|
||||
.locator('app-content-hero .hero__back-button')
|
||||
.first();
|
||||
|
||||
await expect(backButton).toBeVisible({ timeout: 20000 });
|
||||
try {
|
||||
await backButton.click({ timeout: 5000 });
|
||||
} catch {
|
||||
await backButton.evaluate((button: HTMLButtonElement) =>
|
||||
button.click()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectInlineCollectionDetail(
|
||||
page: Page,
|
||||
params: {
|
||||
pathname: RegExp;
|
||||
title: string;
|
||||
playlistTitle: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
await expectPathname(page, params.pathname);
|
||||
await expect(playlistSwitcherTitle(page)).toContainText(
|
||||
params.playlistTitle
|
||||
);
|
||||
await expect(page.locator('app-workspace-context-panel')).toHaveCount(0);
|
||||
await expect(page.locator('app-content-hero')).toContainText(params.title);
|
||||
await expect(
|
||||
page.locator('app-content-hero .hero__back-button').first()
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
}
|
||||
|
||||
async function playCurrentDetail(page: Page): Promise<void> {
|
||||
const playButton = page.locator('button.play-btn').first();
|
||||
|
||||
await expect(playButton).toBeVisible({ timeout: 20000 });
|
||||
await playButton.click();
|
||||
}
|
||||
|
||||
async function playFirstSeriesEpisode(page: Page): Promise<void> {
|
||||
const seasonCard = page.locator('.season-card').first();
|
||||
const episodeCard = page
|
||||
.locator('.episode-card, .episode-list-item')
|
||||
.first();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await seasonCard.count()) + (await episodeCard.count()),
|
||||
{ timeout: 20000 }
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
if ((await seasonCard.count()) > 0) {
|
||||
await seasonCard.scrollIntoViewIfNeeded();
|
||||
await expect(seasonCard).toBeVisible({ timeout: 20000 });
|
||||
await seasonCard.click();
|
||||
}
|
||||
|
||||
await episodeCard.scrollIntoViewIfNeeded();
|
||||
await expect(episodeCard).toBeVisible({ timeout: 20000 });
|
||||
await episodeCard.click();
|
||||
}
|
||||
|
||||
async function removeLiveRecentItem(page: Page, title: string): Promise<void> {
|
||||
const item = channelItemByTitle(page, title).first();
|
||||
|
||||
await expect(item).toBeVisible({ timeout: 20000 });
|
||||
await item.hover();
|
||||
await item.locator('.favorite-button').first().click();
|
||||
}
|
||||
|
||||
async function toggleFavoriteForChannel(
|
||||
page: Page,
|
||||
title: string
|
||||
): Promise<void> {
|
||||
const item = channelItemByTitle(page, title).first();
|
||||
|
||||
await expect(item).toBeVisible({ timeout: 20000 });
|
||||
await item.hover();
|
||||
await item.locator('.favorite-button').first().click();
|
||||
}
|
||||
|
||||
async function visibleLiveTitles(page: Page): Promise<string[]> {
|
||||
return page
|
||||
.locator('[data-test-id="channel-item"] .channel-name')
|
||||
.allInnerTexts()
|
||||
.then((titles) => titles.map((title) => title.trim()));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
clickCategoryByNameExact,
|
||||
clickFirstGridListCard,
|
||||
closeElectronApp,
|
||||
createMutableTextServer,
|
||||
enableRemoteControl,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openGlobalRecent,
|
||||
openSettings,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
restartElectronApp,
|
||||
saveSettings,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
defaultXtreamPassword,
|
||||
defaultXtreamUsername,
|
||||
} from './electron-test-fixtures';
|
||||
import { fetchXtreamVodFixture } from './portal-mock-fixtures';
|
||||
|
||||
const epgFixtureXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tv>
|
||||
<channel id="news-1">
|
||||
<display-name>News One</display-name>
|
||||
</channel>
|
||||
<programme start="20260328070000 +0000" stop="20260328080000 +0000" channel="news-1">
|
||||
<title>Morning Bulletin</title>
|
||||
<desc>Daily morning news.</desc>
|
||||
</programme>
|
||||
</tv>
|
||||
`;
|
||||
|
||||
test.describe('Electron Settings', () => {
|
||||
test('persists changed desktop settings across app restart', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const epgServer = await createMutableTextServer(epgFixtureXml, {
|
||||
contentType: 'application/xml; charset=utf-8',
|
||||
resourcePath: '/settings-guide.xml',
|
||||
});
|
||||
const firstLaunch = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await openSettings(firstLaunch.mainWindow);
|
||||
await selectSettingsOption(
|
||||
firstLaunch.mainWindow,
|
||||
'select-language',
|
||||
'de'
|
||||
);
|
||||
await firstLaunch.mainWindow
|
||||
.locator('[data-test-id="DARK_THEME"]')
|
||||
.click();
|
||||
await selectSettingsOption(
|
||||
firstLaunch.mainWindow,
|
||||
'select-video-player',
|
||||
'html5'
|
||||
);
|
||||
await selectSettingsOption(
|
||||
firstLaunch.mainWindow,
|
||||
'select-stream-format',
|
||||
'ts'
|
||||
);
|
||||
await firstLaunch.mainWindow
|
||||
.locator(
|
||||
'mat-checkbox[formcontrolname="showExternalPlaybackBar"] input[type="checkbox"]'
|
||||
)
|
||||
.uncheck();
|
||||
await enableRemoteControl(firstLaunch.mainWindow, 8877);
|
||||
await firstLaunch.mainWindow
|
||||
.getByRole('button', { name: 'Add EPG source' })
|
||||
.click();
|
||||
await firstLaunch.mainWindow
|
||||
.locator('.epg-source-row input')
|
||||
.first()
|
||||
.fill(epgServer.resourceUrl);
|
||||
await saveSettings(firstLaunch.mainWindow);
|
||||
} finally {
|
||||
await closeElectronApp(firstLaunch);
|
||||
}
|
||||
|
||||
const secondLaunch = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await openSettings(secondLaunch.mainWindow);
|
||||
|
||||
await expect(
|
||||
secondLaunch.mainWindow.getByTestId('select-language')
|
||||
).toContainText('Deutsch');
|
||||
await expect(
|
||||
secondLaunch.mainWindow.locator('[data-test-id="DARK_THEME"]')
|
||||
).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(
|
||||
secondLaunch.mainWindow.getByTestId('select-video-player')
|
||||
).toContainText(/HTML5/i);
|
||||
await expect(
|
||||
secondLaunch.mainWindow.getByTestId('select-stream-format')
|
||||
).toContainText('ts');
|
||||
await expect(
|
||||
secondLaunch.mainWindow.locator(
|
||||
'mat-checkbox[formcontrolname="showExternalPlaybackBar"] input[type="checkbox"]'
|
||||
)
|
||||
).not.toBeChecked();
|
||||
await expect(
|
||||
secondLaunch.mainWindow.locator(
|
||||
'mat-checkbox[formcontrolname="remoteControl"] input[type="checkbox"]'
|
||||
)
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
secondLaunch.mainWindow.locator('#remoteControlPort')
|
||||
).toHaveValue('8877');
|
||||
await expect(
|
||||
secondLaunch.mainWindow.locator('.epg-source-row input').first()
|
||||
).toHaveValue(epgServer.resourceUrl);
|
||||
} finally {
|
||||
await closeElectronApp(secondLaunch);
|
||||
await epgServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('starts on sources when dashboard is disabled', async ({ dataDir }) => {
|
||||
const firstLaunch = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await openSettings(firstLaunch.mainWindow);
|
||||
await firstLaunch.mainWindow
|
||||
.locator(
|
||||
'mat-checkbox[formcontrolname="showDashboard"] input[type="checkbox"]'
|
||||
)
|
||||
.uncheck();
|
||||
await saveSettings(firstLaunch.mainWindow);
|
||||
} finally {
|
||||
await closeElectronApp(firstLaunch);
|
||||
}
|
||||
|
||||
const secondLaunch = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await secondLaunch.mainWindow.waitForURL(/\/workspace\/sources$/);
|
||||
await expect(
|
||||
secondLaunch.mainWindow.getByRole('link', {
|
||||
name: 'Dashboard',
|
||||
exact: true,
|
||||
})
|
||||
).toHaveCount(0);
|
||||
await expect(secondLaunch.mainWindow.locator('a.brand')).toHaveAttribute(
|
||||
'href',
|
||||
/\/workspace\/sources$/
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(secondLaunch);
|
||||
}
|
||||
});
|
||||
|
||||
test('restores the last section-level view across restart when configured', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
let app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await selectSettingsOption(
|
||||
app.mainWindow,
|
||||
'select-startup-behavior',
|
||||
'restore-last-view'
|
||||
);
|
||||
await saveSettings(app.mainWindow);
|
||||
|
||||
await openGlobalRecent(app.mainWindow);
|
||||
} finally {
|
||||
app = await restartElectronApp(app, dataDir);
|
||||
}
|
||||
|
||||
try {
|
||||
await app.mainWindow.waitForURL(/\/workspace\/global-recent$/);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('ignores settings and restores only the section root after a detail route', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const vodFixture = await fetchXtreamVodFixture(request, {
|
||||
password: defaultXtreamPassword,
|
||||
username: defaultXtreamUsername,
|
||||
});
|
||||
let app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await selectSettingsOption(
|
||||
app.mainWindow,
|
||||
'select-startup-behavior',
|
||||
'restore-last-view'
|
||||
);
|
||||
await saveSettings(app.mainWindow);
|
||||
|
||||
await addXtreamPortal(app.mainWindow);
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'Movies');
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
vodFixture.categoryName
|
||||
);
|
||||
await clickFirstGridListCard(app.mainWindow);
|
||||
await app.mainWindow.waitForURL(
|
||||
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/
|
||||
);
|
||||
|
||||
await openSettings(app.mainWindow);
|
||||
} finally {
|
||||
app = await restartElectronApp(app, dataDir);
|
||||
}
|
||||
|
||||
try {
|
||||
await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod$/);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function selectSettingsOption(
|
||||
page: Page,
|
||||
selectTestId: string,
|
||||
optionTestId: string
|
||||
): Promise<void> {
|
||||
await page.getByTestId(selectTestId).click();
|
||||
await page.getByTestId(optionTestId).click();
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
test,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
test.describe('Electron App Smoke Test', () => {
|
||||
test('app should start and display the dashboard', async ({ dataDir }) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await expect
|
||||
.poll(async () => app.mainWindow.title())
|
||||
.toContain('IPTVnator');
|
||||
await expect(
|
||||
app.mainWindow.getByRole('link', {
|
||||
name: 'Dashboard',
|
||||
exact: true,
|
||||
})
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
app.mainWindow.getByRole('link', { name: 'Open settings' })
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('app should expose the expected main window properties', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
const isVisible = await app.electronApp.evaluate(
|
||||
async ({ BrowserWindow }) => {
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0];
|
||||
return mainWindow ? mainWindow.isVisible() : false;
|
||||
}
|
||||
);
|
||||
|
||||
expect(isVisible).toBe(true);
|
||||
|
||||
const bounds = await app.electronApp.evaluate(
|
||||
async ({ BrowserWindow }) => {
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0];
|
||||
return mainWindow ? mainWindow.getBounds() : null;
|
||||
}
|
||||
);
|
||||
|
||||
expect(bounds).not.toBeNull();
|
||||
expect(bounds?.width).toBeGreaterThan(800);
|
||||
expect(bounds?.height).toBeGreaterThan(600);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('app should render workspace content', async ({ dataDir }) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
app.mainWindow.getByRole('button', { name: 'Add playlist' })
|
||||
).toBeVisible();
|
||||
|
||||
const modifier =
|
||||
process.platform === 'darwin' ? 'Meta' : 'Control';
|
||||
await app.mainWindow.locator('body').focus();
|
||||
await app.mainWindow.keyboard.press(`${modifier}+K`);
|
||||
await expect(
|
||||
app.mainWindow.locator(
|
||||
'mat-dialog-container app-workspace-command-palette'
|
||||
)
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,505 @@
|
||||
import {
|
||||
addStalkerPortal,
|
||||
addXtreamPortal,
|
||||
closeElectronApp,
|
||||
createMutableTextServer,
|
||||
deleteSource,
|
||||
dragSourceBefore,
|
||||
expect,
|
||||
expectPlaylistUpdatedToast,
|
||||
expectSourceDialogValues,
|
||||
getVisibleSourceTitles,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
importM3uPlaylistFromUrl,
|
||||
launchElectronApp,
|
||||
openSources,
|
||||
openSourceEditor,
|
||||
refreshSource,
|
||||
resetMockServers,
|
||||
restartElectronApp,
|
||||
saveSourceDialog,
|
||||
selectSourceSort,
|
||||
selectSourceTypeFilter,
|
||||
sourceRowByTitle,
|
||||
test,
|
||||
updateSourceDialog,
|
||||
waitForM3uCatalog,
|
||||
waitForPortalDebugEvent,
|
||||
waitForXtreamCatalog,
|
||||
waitForSourceRowIdle,
|
||||
writeTemporaryM3uFile,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
const localSourceFileName = 'alpha-local-source.m3u';
|
||||
const localSourceDisplayName = 'alpha-local-source';
|
||||
const urlSourceFileName = 'omega-url-source.m3u';
|
||||
const xtreamSourceName = 'Charlie Xtream Portal';
|
||||
const stalkerSourceName = 'Bravo Stalker Portal';
|
||||
const editableLocalSourceDisplayName = 'editable-local-source';
|
||||
const deletableLocalSourceDisplayName = 'deletable-local-source';
|
||||
const refreshLocalSourceDisplayName = 'refresh-local-source';
|
||||
|
||||
test.describe('Electron Sources View', () => {
|
||||
test('filters and sorts sources, including persisted custom order', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream', 'stalker']);
|
||||
const localFilePath = writeTemporaryM3uFile(
|
||||
dataDir,
|
||||
localSourceFileName,
|
||||
[
|
||||
{
|
||||
groupTitle: 'News',
|
||||
name: 'Alpha Local News',
|
||||
url: 'https://streams.example.test/local-alpha.m3u8',
|
||||
},
|
||||
]
|
||||
);
|
||||
const urlServer = await createMutableTextServer(
|
||||
`#EXTM3U
|
||||
#EXTINF:-1 group-title="Sports",Omega URL Sports
|
||||
https://streams.example.test/url-omega.m3u8
|
||||
`,
|
||||
{
|
||||
contentType: 'application/vnd.apple.mpegurl',
|
||||
resourcePath: `/${urlSourceFileName}`,
|
||||
}
|
||||
);
|
||||
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await importM3uPlaylistFromNativeDialog(app, localFilePath);
|
||||
await importM3uPlaylistFromUrl(
|
||||
app.mainWindow,
|
||||
urlServer.resourceUrl
|
||||
);
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: xtreamSourceName,
|
||||
});
|
||||
await addStalkerPortal(app.mainWindow, {
|
||||
name: stalkerSourceName,
|
||||
});
|
||||
await openSources(app.mainWindow);
|
||||
|
||||
await expect
|
||||
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
||||
.toHaveLength(4);
|
||||
|
||||
await selectSourceTypeFilter(app.mainWindow, 'M3U');
|
||||
await expect
|
||||
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
||||
.toEqual([urlSourceFileName, localSourceDisplayName]);
|
||||
|
||||
await selectSourceTypeFilter(app.mainWindow, 'Xtream');
|
||||
await expect
|
||||
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
||||
.toEqual([xtreamSourceName]);
|
||||
|
||||
await selectSourceTypeFilter(app.mainWindow, 'Stalker');
|
||||
await expect
|
||||
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
||||
.toEqual([stalkerSourceName]);
|
||||
|
||||
await selectSourceTypeFilter(app.mainWindow, 'All');
|
||||
|
||||
await selectSourceSort(app.mainWindow, 'Date added (Oldest first)');
|
||||
await expect
|
||||
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
||||
.toEqual([
|
||||
localSourceDisplayName,
|
||||
urlSourceFileName,
|
||||
xtreamSourceName,
|
||||
stalkerSourceName,
|
||||
]);
|
||||
|
||||
await selectSourceSort(app.mainWindow, 'Name (A-Z)');
|
||||
await expect
|
||||
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
||||
.toEqual([
|
||||
localSourceDisplayName,
|
||||
stalkerSourceName,
|
||||
xtreamSourceName,
|
||||
urlSourceFileName,
|
||||
]);
|
||||
|
||||
await selectSourceSort(app.mainWindow, 'Name (Z-A)');
|
||||
await expect
|
||||
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
||||
.toEqual([
|
||||
urlSourceFileName,
|
||||
xtreamSourceName,
|
||||
stalkerSourceName,
|
||||
localSourceDisplayName,
|
||||
]);
|
||||
|
||||
await selectSourceSort(app.mainWindow, 'Date added (Newest first)');
|
||||
await expect
|
||||
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
||||
.toEqual([
|
||||
stalkerSourceName,
|
||||
xtreamSourceName,
|
||||
urlSourceFileName,
|
||||
localSourceDisplayName,
|
||||
]);
|
||||
|
||||
await selectSourceSort(app.mainWindow, 'Custom order');
|
||||
await dragSourceBefore(
|
||||
app.mainWindow,
|
||||
xtreamSourceName,
|
||||
localSourceDisplayName
|
||||
);
|
||||
await expect
|
||||
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
||||
.toEqual([
|
||||
xtreamSourceName,
|
||||
localSourceDisplayName,
|
||||
urlSourceFileName,
|
||||
stalkerSourceName,
|
||||
]);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Dashboard', exact: true })
|
||||
.click();
|
||||
await openSources(app.mainWindow);
|
||||
await expect
|
||||
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
||||
.toEqual([
|
||||
xtreamSourceName,
|
||||
localSourceDisplayName,
|
||||
urlSourceFileName,
|
||||
stalkerSourceName,
|
||||
]);
|
||||
|
||||
const restarted = await restartElectronApp(app, dataDir);
|
||||
app.electronApp = restarted.electronApp;
|
||||
app.mainWindow = restarted.mainWindow;
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await expect
|
||||
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
||||
.toEqual([
|
||||
xtreamSourceName,
|
||||
localSourceDisplayName,
|
||||
urlSourceFileName,
|
||||
stalkerSourceName,
|
||||
]);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await urlServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('edits M3U, Xtream, and Stalker source details and keeps them after restart', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream', 'stalker']);
|
||||
const localFilePath = writeTemporaryM3uFile(
|
||||
dataDir,
|
||||
'editable-local-source.m3u',
|
||||
[
|
||||
{
|
||||
groupTitle: 'Movies',
|
||||
name: 'Editable Local Channel',
|
||||
url: 'https://streams.example.test/editable-local.m3u8',
|
||||
},
|
||||
]
|
||||
);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await importM3uPlaylistFromNativeDialog(app, localFilePath);
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: 'Editable Xtream Source',
|
||||
});
|
||||
await addStalkerPortal(app.mainWindow, {
|
||||
name: 'Editable Stalker Source',
|
||||
});
|
||||
await openSources(app.mainWindow);
|
||||
|
||||
let dialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
editableLocalSourceDisplayName
|
||||
);
|
||||
await updateSourceDialog(dialog, {
|
||||
autoRefresh: true,
|
||||
title: 'Edited Local Source',
|
||||
userAgent: 'Electron E2E Local/1.0',
|
||||
});
|
||||
await saveSourceDialog(app.mainWindow, dialog);
|
||||
|
||||
dialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
'Editable Xtream Source'
|
||||
);
|
||||
await updateSourceDialog(dialog, {
|
||||
password: 'pass1',
|
||||
serverUrl: 'http://127.0.0.1:3211',
|
||||
title: 'Edited Xtream Source',
|
||||
username: 'user1',
|
||||
});
|
||||
await saveSourceDialog(app.mainWindow, dialog);
|
||||
|
||||
dialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
'Editable Stalker Source'
|
||||
);
|
||||
await updateSourceDialog(dialog, {
|
||||
macAddress: '00:1A:79:00:00:99',
|
||||
portalUrl: 'http://127.0.0.1:3210/portal.php',
|
||||
title: 'Edited Stalker Source',
|
||||
});
|
||||
await saveSourceDialog(app.mainWindow, dialog);
|
||||
|
||||
dialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
'Edited Local Source'
|
||||
);
|
||||
await expectSourceDialogValues(dialog, {
|
||||
autoRefresh: true,
|
||||
title: 'Edited Local Source',
|
||||
userAgent: 'Electron E2E Local/1.0',
|
||||
});
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.click();
|
||||
|
||||
dialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
'Edited Xtream Source'
|
||||
);
|
||||
await expectSourceDialogValues(dialog, {
|
||||
password: 'pass1',
|
||||
serverUrl: 'http://127.0.0.1:3211',
|
||||
title: 'Edited Xtream Source',
|
||||
username: 'user1',
|
||||
});
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.click();
|
||||
|
||||
dialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
'Edited Stalker Source'
|
||||
);
|
||||
await expectSourceDialogValues(dialog, {
|
||||
macAddress: '00:1A:79:00:00:99',
|
||||
portalUrl: 'http://127.0.0.1:3210/portal.php',
|
||||
title: 'Edited Stalker Source',
|
||||
});
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.click();
|
||||
|
||||
const restarted = await restartElectronApp(app, dataDir);
|
||||
app.electronApp = restarted.electronApp;
|
||||
app.mainWindow = restarted.mainWindow;
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
|
||||
dialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
'Edited Local Source'
|
||||
);
|
||||
await expectSourceDialogValues(dialog, {
|
||||
autoRefresh: true,
|
||||
title: 'Edited Local Source',
|
||||
userAgent: 'Electron E2E Local/1.0',
|
||||
});
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.click();
|
||||
|
||||
dialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
'Edited Xtream Source'
|
||||
);
|
||||
await expectSourceDialogValues(dialog, {
|
||||
password: 'pass1',
|
||||
serverUrl: 'http://127.0.0.1:3211',
|
||||
title: 'Edited Xtream Source',
|
||||
username: 'user1',
|
||||
});
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.click();
|
||||
|
||||
dialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
'Edited Stalker Source'
|
||||
);
|
||||
await expectSourceDialogValues(dialog, {
|
||||
macAddress: '00:1A:79:00:00:99',
|
||||
portalUrl: 'http://127.0.0.1:3210/portal.php',
|
||||
title: 'Edited Stalker Source',
|
||||
});
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('deletes M3U, Stalker, and Xtream sources from the sources view', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream', 'stalker']);
|
||||
const localFilePath = writeTemporaryM3uFile(
|
||||
dataDir,
|
||||
'deletable-local-source.m3u',
|
||||
[
|
||||
{
|
||||
groupTitle: 'Live',
|
||||
name: 'Delete Me Local',
|
||||
url: 'https://streams.example.test/delete-local.m3u8',
|
||||
},
|
||||
]
|
||||
);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await importM3uPlaylistFromNativeDialog(app, localFilePath);
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: 'Delete Me Xtream',
|
||||
});
|
||||
await addStalkerPortal(app.mainWindow, {
|
||||
name: 'Delete Me Stalker',
|
||||
});
|
||||
await openSources(app.mainWindow);
|
||||
|
||||
await deleteSource(app.mainWindow, deletableLocalSourceDisplayName);
|
||||
await expect(
|
||||
sourceRowByTitle(
|
||||
app.mainWindow,
|
||||
deletableLocalSourceDisplayName
|
||||
)
|
||||
).toHaveCount(0, {
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
await deleteSource(app.mainWindow, 'Delete Me Stalker');
|
||||
await expect(
|
||||
sourceRowByTitle(app.mainWindow, 'Delete Me Stalker')
|
||||
).toHaveCount(0, {
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
await deleteSource(app.mainWindow, 'Delete Me Xtream');
|
||||
await expect(
|
||||
sourceRowByTitle(app.mainWindow, 'Delete Me Xtream')
|
||||
).toHaveCount(0, {
|
||||
timeout: 60000,
|
||||
});
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('refreshes local-file M3U, URL M3U, and Xtream sources from the sources view', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const localFilePath = writeTemporaryM3uFile(
|
||||
dataDir,
|
||||
'refresh-local-source.m3u',
|
||||
[
|
||||
{
|
||||
groupTitle: 'News',
|
||||
name: 'Original Local Channel',
|
||||
url: 'https://streams.example.test/original-local.m3u8',
|
||||
},
|
||||
]
|
||||
);
|
||||
const urlServer = await createMutableTextServer(
|
||||
`#EXTM3U
|
||||
#EXTINF:-1 group-title="News",Original URL Channel
|
||||
https://streams.example.test/original-url.m3u8
|
||||
`,
|
||||
{
|
||||
contentType: 'application/vnd.apple.mpegurl',
|
||||
resourcePath: '/refresh-url-source.m3u',
|
||||
}
|
||||
);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await importM3uPlaylistFromNativeDialog(app, localFilePath);
|
||||
await importM3uPlaylistFromUrl(
|
||||
app.mainWindow,
|
||||
urlServer.resourceUrl
|
||||
);
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: 'Refresh Xtream Source',
|
||||
});
|
||||
await openSources(app.mainWindow);
|
||||
|
||||
writeTemporaryM3uFile(dataDir, 'refresh-local-source.m3u', [
|
||||
{
|
||||
groupTitle: 'News',
|
||||
name: 'Refreshed Local Channel',
|
||||
url: 'https://streams.example.test/refreshed-local.m3u8',
|
||||
},
|
||||
]);
|
||||
await refreshSource(app.mainWindow, refreshLocalSourceDisplayName);
|
||||
await expectPlaylistUpdatedToast(app.mainWindow);
|
||||
await sourceRowByTitle(
|
||||
app.mainWindow,
|
||||
refreshLocalSourceDisplayName
|
||||
)
|
||||
.first()
|
||||
.click();
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('channel-item').filter({
|
||||
hasText: 'Refreshed Local Channel',
|
||||
})
|
||||
).toHaveCount(1);
|
||||
await openSources(app.mainWindow);
|
||||
|
||||
urlServer.setBody(
|
||||
`#EXTM3U
|
||||
#EXTINF:-1 group-title="News",Refreshed URL Channel
|
||||
https://streams.example.test/refreshed-url.m3u8
|
||||
`
|
||||
);
|
||||
await refreshSource(app.mainWindow, 'refresh-url-source.m3u');
|
||||
await expectPlaylistUpdatedToast(app.mainWindow);
|
||||
await waitForSourceRowIdle(
|
||||
app.mainWindow,
|
||||
'refresh-url-source.m3u'
|
||||
);
|
||||
await sourceRowByTitle(app.mainWindow, 'refresh-url-source.m3u')
|
||||
.first()
|
||||
.click();
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('channel-item').filter({
|
||||
hasText: 'Refreshed URL Channel',
|
||||
})
|
||||
).toHaveCount(1);
|
||||
await openSources(app.mainWindow);
|
||||
|
||||
await refreshSource(app.mainWindow, 'Refresh Xtream Source', {
|
||||
confirm: true,
|
||||
});
|
||||
await waitForXtreamCatalog(app.mainWindow);
|
||||
await waitForPortalDebugEvent(app.mainWindow, {
|
||||
operation: 'get_live_categories',
|
||||
provider: 'xtream',
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
await openSources(app.mainWindow);
|
||||
await waitForSourceRowIdle(app.mainWindow, 'Refresh Xtream Source');
|
||||
await expect(
|
||||
sourceRowByTitle(app.mainWindow, 'Refresh Xtream Source')
|
||||
.first()
|
||||
.locator('.meta')
|
||||
).toContainText('Updated:');
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await urlServer.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import {
|
||||
addXtreamPortal,
|
||||
channelItemByTitle,
|
||||
clickCategoryByNameExact,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
fetchXtreamEpgFixture,
|
||||
XtreamEpgFixture,
|
||||
XtreamNormalizedEpgListing,
|
||||
} from './portal-mock-fixtures';
|
||||
|
||||
const epgPortalName = 'Xtream EPG Fixture';
|
||||
const epgCredentials = {
|
||||
username: 'epg',
|
||||
password: 'epg',
|
||||
};
|
||||
|
||||
for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
|
||||
test(`renders Xtream EPG previews and selected-channel schedule in ${timeZone}`, async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const fixture = await fetchXtreamEpgFixture(request, epgCredentials);
|
||||
const currentProgram = fixture.shortEpg[0];
|
||||
if (!currentProgram) {
|
||||
throw new Error('Expected the Xtream EPG fixture to include a current program.');
|
||||
}
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
env: { TZ: timeZone },
|
||||
});
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: `${epgPortalName} ${timeZone}`,
|
||||
username: epgCredentials.username,
|
||||
password: epgCredentials.password,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
|
||||
const channelRow = channelItemByTitle(
|
||||
app.mainWindow,
|
||||
fixture.stream.name ?? ''
|
||||
).first();
|
||||
await expect(channelRow).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await expect
|
||||
.poll(async () =>
|
||||
((await channelRow.locator('.epg-title').textContent()) ?? '').trim()
|
||||
)
|
||||
.toBe(currentProgram.title);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return (await channelRow.locator('.epg-time').allInnerTexts()).map(
|
||||
(value) => value.trim()
|
||||
);
|
||||
})
|
||||
.toEqual([
|
||||
formatTimeInZone(currentProgram.startTimestamp, timeZone),
|
||||
formatTimeInZone(currentProgram.stopTimestamp, timeZone),
|
||||
]);
|
||||
await expect
|
||||
.poll(async () => getProgressWidthPercent(channelRow))
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await channelRow.click();
|
||||
await expect(app.mainWindow.locator('app-epg-list')).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
const currentDateKey = formatDateKeyInZone(Date.now(), timeZone);
|
||||
const currentDayTitles = visibleTitlesForCurrentDate(
|
||||
fixture.fullEpg,
|
||||
currentDateKey,
|
||||
timeZone
|
||||
);
|
||||
|
||||
await expect.poll(() => visibleProgramTitles(app.mainWindow)).toEqual(
|
||||
currentDayTitles
|
||||
);
|
||||
|
||||
const currentProgramRow = app.mainWindow
|
||||
.locator('app-epg-list .program-item.current-program')
|
||||
.first();
|
||||
await expect(currentProgramRow).toBeVisible();
|
||||
await expect(currentProgramRow.locator('.program-title')).toHaveText(
|
||||
currentProgram.title
|
||||
);
|
||||
await expect(currentProgramRow.locator('.time')).toHaveText(
|
||||
`${formatTimeInZone(
|
||||
currentProgram.startTimestamp,
|
||||
timeZone
|
||||
)} - ${formatTimeInZone(currentProgram.stopTimestamp, timeZone)}`
|
||||
);
|
||||
|
||||
const firstFutureDateKey = getFirstFutureDateKey(
|
||||
fixture.fullEpg,
|
||||
currentDateKey,
|
||||
timeZone
|
||||
);
|
||||
expect(firstFutureDateKey).not.toBeNull();
|
||||
|
||||
const futureDayTitles = titlesForDate(
|
||||
fixture.fullEpg,
|
||||
firstFutureDateKey!,
|
||||
timeZone
|
||||
);
|
||||
const nextDayClicks = dayDifference(currentDateKey, firstFutureDateKey!);
|
||||
|
||||
for (let index = 0; index < nextDayClicks; index += 1) {
|
||||
await app.mainWindow.locator('app-epg-list .next-day').click();
|
||||
}
|
||||
|
||||
await expect.poll(() => visibleProgramTitles(app.mainWindow)).toEqual(
|
||||
futureDayTitles
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatTimeInZone(
|
||||
timestampSeconds: number,
|
||||
timeZone: string
|
||||
): string {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(new Date(timestampSeconds * 1000));
|
||||
}
|
||||
|
||||
function formatDateKeyInZone(
|
||||
timestampMilliseconds: number,
|
||||
timeZone: string
|
||||
): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(new Date(timestampMilliseconds));
|
||||
|
||||
const year = parts.find((part) => part.type === 'year')?.value;
|
||||
const month = parts.find((part) => part.type === 'month')?.value;
|
||||
const day = parts.find((part) => part.type === 'day')?.value;
|
||||
|
||||
if (!year || !month || !day) {
|
||||
throw new Error(`Failed to format a date key for timezone ${timeZone}.`);
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function titlesForDate(
|
||||
listings: XtreamNormalizedEpgListing[],
|
||||
dateKey: string,
|
||||
timeZone: string
|
||||
): string[] {
|
||||
return listings
|
||||
.filter(
|
||||
(listing) =>
|
||||
formatDateKeyInZone(listing.startTimestamp * 1000, timeZone) ===
|
||||
dateKey
|
||||
)
|
||||
.map((listing) => listing.title);
|
||||
}
|
||||
|
||||
function visibleTitlesForCurrentDate(
|
||||
listings: XtreamNormalizedEpgListing[],
|
||||
currentDateKey: string,
|
||||
timeZone: string
|
||||
): string[] {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
return listings
|
||||
.filter(
|
||||
(listing) =>
|
||||
formatDateKeyInZone(listing.startTimestamp * 1000, timeZone) ===
|
||||
currentDateKey && listing.stopTimestamp >= nowSeconds
|
||||
)
|
||||
.map((listing) => listing.title);
|
||||
}
|
||||
|
||||
function getFirstFutureDateKey(
|
||||
listings: XtreamNormalizedEpgListing[],
|
||||
currentDateKey: string,
|
||||
timeZone: string
|
||||
): string | null {
|
||||
const uniqueDateKeys = Array.from(
|
||||
new Set(
|
||||
listings.map((listing) =>
|
||||
formatDateKeyInZone(listing.startTimestamp * 1000, timeZone)
|
||||
)
|
||||
)
|
||||
).sort();
|
||||
|
||||
return uniqueDateKeys.find((dateKey) => dateKey > currentDateKey) ?? null;
|
||||
}
|
||||
|
||||
function dayDifference(fromDateKey: string, toDateKey: string): number {
|
||||
const from = Date.parse(`${fromDateKey}T00:00:00.000Z`);
|
||||
const to = Date.parse(`${toDateKey}T00:00:00.000Z`);
|
||||
return Math.round((to - from) / (24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
async function visibleProgramTitles(page: Parameters<typeof channelItemByTitle>[0]) {
|
||||
return page
|
||||
.locator('app-epg-list .program-title')
|
||||
.allInnerTexts()
|
||||
.then((titles) => titles.map((title) => title.trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
async function getProgressWidthPercent(row: ReturnType<typeof channelItemByTitle>) {
|
||||
const style = await row.locator('.epg-progress-fill').getAttribute('style');
|
||||
const width = style?.match(/width:\s*([\d.]+)%/)?.[1] ?? '0';
|
||||
return Number.parseFloat(width);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import {
|
||||
addXtreamPortal,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
expectPortalDebugSuccess,
|
||||
expectRendererFramesAdvance,
|
||||
launchElectronApp,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForDbOperationEvent,
|
||||
waitForXtreamCatalog,
|
||||
xtreamMockServer,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
const stressPortalName = 'Stress Xtream Portal';
|
||||
const stressXtreamUsername = 'stress';
|
||||
const stressXtreamPassword = 'stress';
|
||||
const dbWorkerBatchDelayMs = '20';
|
||||
|
||||
function playlistRowByName(name: string) {
|
||||
return `app-playlist-item:has-text("${name}")`;
|
||||
}
|
||||
|
||||
async function openSources(page: Parameters<typeof addXtreamPortal>[0]) {
|
||||
await page.getByRole('link', { name: 'Sources', exact: true }).click();
|
||||
await page.waitForURL(/\/workspace\/sources$/);
|
||||
}
|
||||
|
||||
function xtreamBlockedStateTitle(page: Parameters<typeof addXtreamPortal>[0]) {
|
||||
return page.locator('.xtream-content-gate .error-view__title');
|
||||
}
|
||||
|
||||
test.describe('Electron Xtream Responsiveness', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
test.setTimeout(120000);
|
||||
|
||||
test('keeps the UI responsive during large Xtream imports', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
env: {
|
||||
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: stressPortalName,
|
||||
password: stressXtreamPassword,
|
||||
username: stressXtreamUsername,
|
||||
});
|
||||
|
||||
const overlay = app.mainWindow.locator(
|
||||
'.workspace-loading-overlay'
|
||||
);
|
||||
await expect(overlay).toBeVisible({ timeout: 15000 });
|
||||
await expect(
|
||||
overlay.getByRole('button', {
|
||||
name: /stop sync|cancel import/i,
|
||||
})
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await waitForDbOperationEvent(app.mainWindow, {
|
||||
operation: 'save-content',
|
||||
predicate: (event) =>
|
||||
event.status === 'progress' &&
|
||||
(event.current ?? 0) > 0 &&
|
||||
(event.total ?? 0) > 0,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await app.mainWindow.evaluate(
|
||||
() =>
|
||||
(window.__dbOperationEvents ?? []).filter(
|
||||
(event) =>
|
||||
event.operation === 'save-content' &&
|
||||
event.status === 'progress'
|
||||
).length
|
||||
)) > 2,
|
||||
{ timeout: 30000 }
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
await expectRendererFramesAdvance(app.mainWindow, {
|
||||
minimumDelta: 5,
|
||||
sampleMs: 400,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await overlay.locator('p').allInnerTexts()).some(
|
||||
(text) =>
|
||||
/(Live TV|Movies|Series).*?\d+\s*\/\s*\d+/.test(
|
||||
text
|
||||
)
|
||||
),
|
||||
{ timeout: 30000 }
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
await waitForXtreamCatalog(app.mainWindow);
|
||||
await expectPortalDebugSuccess(app.mainWindow, 'xtream');
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('stops importing after cancel until the user explicitly retries', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
env: {
|
||||
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: stressPortalName,
|
||||
password: stressXtreamPassword,
|
||||
username: stressXtreamUsername,
|
||||
});
|
||||
|
||||
const overlay = app.mainWindow.locator(
|
||||
'.workspace-loading-overlay'
|
||||
);
|
||||
const cancelButton = overlay.getByRole('button', {
|
||||
name: /stop sync|cancel import/i,
|
||||
});
|
||||
|
||||
await expect(overlay).toBeVisible({ timeout: 15000 });
|
||||
await expect(cancelButton).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await waitForDbOperationEvent(app.mainWindow, {
|
||||
operation: 'save-content',
|
||||
predicate: (event) =>
|
||||
event.status === 'progress' &&
|
||||
(event.current ?? 0) > 0 &&
|
||||
(event.total ?? 0) > 0,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
await cancelButton.click();
|
||||
|
||||
await expect(overlay).toHaveCount(0, { timeout: 20000 });
|
||||
await expect(xtreamBlockedStateTitle(app.mainWindow)).toHaveText(
|
||||
'Import cancelled',
|
||||
{ timeout: 20000 }
|
||||
);
|
||||
|
||||
await app.mainWindow.waitForTimeout(1200);
|
||||
await expect(overlay).toHaveCount(0);
|
||||
|
||||
const retryButton = app.mainWindow.getByRole('button', {
|
||||
name: 'Retry',
|
||||
exact: true,
|
||||
});
|
||||
await retryButton.click();
|
||||
|
||||
await waitForXtreamCatalog(app.mainWindow);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('blocks expired and inactive Xtream accounts before import starts', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: 'Expired Xtream Portal',
|
||||
password: 'expired',
|
||||
username: 'expired',
|
||||
});
|
||||
|
||||
await expect(xtreamBlockedStateTitle(app.mainWindow)).toHaveText(
|
||||
'Account expired',
|
||||
{ timeout: 20000 }
|
||||
);
|
||||
await expect(
|
||||
app.mainWindow.locator('.workspace-loading-overlay')
|
||||
).toHaveCount(0);
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: 'Inactive Xtream Portal',
|
||||
password: 'inactive',
|
||||
username: 'inactive',
|
||||
});
|
||||
|
||||
await expect(xtreamBlockedStateTitle(app.mainWindow)).toHaveText(
|
||||
'Account inactive',
|
||||
{ timeout: 20000 }
|
||||
);
|
||||
await expect(
|
||||
app.mainWindow.locator('.workspace-loading-overlay')
|
||||
).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('blocks unreachable Xtream portals immediately without an import loop', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: 'Unavailable Xtream Portal',
|
||||
password: stressXtreamPassword,
|
||||
serverUrl: xtreamMockServer.replace(/:\d+$/, ':65530'),
|
||||
username: stressXtreamUsername,
|
||||
});
|
||||
|
||||
await expect(xtreamBlockedStateTitle(app.mainWindow)).toHaveText(
|
||||
'Portal unavailable',
|
||||
{ timeout: 20000 }
|
||||
);
|
||||
await expect(
|
||||
app.mainWindow.locator('.workspace-loading-overlay')
|
||||
).toHaveCount(0);
|
||||
await app.mainWindow.waitForTimeout(1200);
|
||||
await expect(
|
||||
app.mainWindow.locator('.workspace-loading-overlay')
|
||||
).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the UI responsive during large Xtream playlist deletion', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
env: {
|
||||
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: stressPortalName,
|
||||
password: stressXtreamPassword,
|
||||
username: stressXtreamUsername,
|
||||
});
|
||||
await waitForXtreamCatalog(app.mainWindow);
|
||||
await openSources(app.mainWindow);
|
||||
|
||||
const playlistRow = app.mainWindow.locator(
|
||||
playlistRowByName(stressPortalName)
|
||||
);
|
||||
await expect(playlistRow).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await playlistRow.locator('.delete-btn').click();
|
||||
|
||||
const confirmDialog = app.mainWindow.locator(
|
||||
'mat-dialog-container'
|
||||
);
|
||||
await expect(confirmDialog).toBeVisible();
|
||||
await confirmDialog
|
||||
.getByRole('button', { name: 'Yes', exact: true })
|
||||
.click();
|
||||
|
||||
await waitForDbOperationEvent(app.mainWindow, {
|
||||
operation: 'delete-playlist',
|
||||
status: 'started',
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
playlistRow.locator('.busy-state__message')
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
await expect(playlistRow.locator('.cancel-btn')).toBeVisible();
|
||||
|
||||
await waitForDbOperationEvent(app.mainWindow, {
|
||||
operation: 'delete-playlist',
|
||||
predicate: (event) =>
|
||||
event.status === 'progress' &&
|
||||
(event.current ?? 0) > 0 &&
|
||||
(event.total ?? 0) > 0,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
await expectRendererFramesAdvance(app.mainWindow, {
|
||||
minimumDelta: 5,
|
||||
sampleMs: 400,
|
||||
});
|
||||
|
||||
await expect(playlistRow).toHaveCount(0, { timeout: 60000 });
|
||||
await expect(
|
||||
app.mainWindow.locator(playlistRowByName(stressPortalName))
|
||||
).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
addXtreamPortal,
|
||||
clickCategoryByNameExact,
|
||||
clickGridListCardByTitle,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
fetchXtreamVodFixture,
|
||||
getXtreamTitle,
|
||||
pickDistinctTitles,
|
||||
} from './portal-mock-fixtures';
|
||||
|
||||
const emptyMetadataCredentials = {
|
||||
password: 'emptyvod',
|
||||
username: 'emptyvod',
|
||||
};
|
||||
|
||||
test.describe('Xtream VOD Details', () => {
|
||||
test('shows a curated fallback when the portal returns empty VOD metadata', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const vodFixture = await fetchXtreamVodFixture(
|
||||
request,
|
||||
emptyMetadataCredentials
|
||||
);
|
||||
const [movieTitle] = pickDistinctTitles(vodFixture.items, getXtreamTitle);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
...emptyMetadataCredentials,
|
||||
name: 'Xtream Empty Metadata',
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Movies', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(app.mainWindow, vodFixture.categoryName);
|
||||
await clickGridListCardByTitle(app.mainWindow, movieTitle);
|
||||
|
||||
await app.mainWindow.waitForURL(
|
||||
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/
|
||||
);
|
||||
await expect(app.mainWindow.locator('app-content-hero')).toContainText(
|
||||
movieTitle
|
||||
);
|
||||
await expect(
|
||||
app.mainWindow.locator('[data-testid="xtream-vod-fallback-status"]')
|
||||
).toContainText('Portal metadata unavailable');
|
||||
await expect(
|
||||
app.mainWindow.locator('[data-testid="xtream-vod-fallback"]')
|
||||
).toContainText(
|
||||
'Extended metadata was not provided by this portal.'
|
||||
);
|
||||
|
||||
await expect(app.mainWindow.locator('button.play-btn')).toHaveCount(0);
|
||||
await expect(app.mainWindow.locator('button.favorite-btn')).toHaveCount(
|
||||
0
|
||||
);
|
||||
await expect(app.mainWindow.locator('button.download-btn')).toHaveCount(
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"sourceMap": false,
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.js",
|
||||
"playwright.config.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.e2e.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
const esbuild = require('esbuild');
|
||||
const path = require('path');
|
||||
|
||||
// Node.js built-in modules that should be externalized
|
||||
const nodeBuiltins = [
|
||||
'assert', 'async_hooks', 'buffer', 'child_process', 'cluster',
|
||||
'console', 'constants', 'crypto', 'dgram', 'dns', 'domain',
|
||||
'events', 'fs', 'http', 'http2', 'https', 'inspector', 'module',
|
||||
'net', 'os', 'path', 'perf_hooks', 'process', 'punycode',
|
||||
'querystring', 'readline', 'repl', 'stream', 'string_decoder',
|
||||
'sys', 'timers', 'tls', 'trace_events', 'tty', 'url', 'util',
|
||||
'v8', 'vm', 'worker_threads', 'zlib'
|
||||
];
|
||||
|
||||
// Native modules that must be externalized (loaded at runtime)
|
||||
const nativeModules = [
|
||||
'better-sqlite3',
|
||||
];
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
async function buildWorker() {
|
||||
try {
|
||||
const workers = [
|
||||
{
|
||||
label: 'EPG parser worker',
|
||||
entry: path.join(
|
||||
__dirname,
|
||||
'src/app/workers/epg-parser.worker.ts'
|
||||
),
|
||||
outfile: path.join(
|
||||
__dirname,
|
||||
'../../dist/apps/electron-backend/workers/epg-parser.worker.js'
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'database worker',
|
||||
entry: path.join(
|
||||
__dirname,
|
||||
'src/app/workers/database.worker.ts'
|
||||
),
|
||||
outfile: path.join(
|
||||
__dirname,
|
||||
'../../dist/apps/electron-backend/workers/database.worker.js'
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'playlist refresh worker',
|
||||
entry: path.join(
|
||||
__dirname,
|
||||
'src/app/workers/playlist-refresh.worker.ts'
|
||||
),
|
||||
outfile: path.join(
|
||||
__dirname,
|
||||
'../../dist/apps/electron-backend/workers/playlist-refresh.worker.js'
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
for (const worker of workers) {
|
||||
console.log(
|
||||
`Building ${worker.label} with esbuild (${isProduction ? 'production' : 'development'})...`
|
||||
);
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [worker.entry],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: worker.outfile,
|
||||
external: [
|
||||
...nodeBuiltins.map((m) => `node:${m}`),
|
||||
...nodeBuiltins,
|
||||
...nativeModules,
|
||||
],
|
||||
sourcemap: !isProduction,
|
||||
minify: isProduction,
|
||||
alias: {
|
||||
'shared-interfaces': path.join(
|
||||
__dirname,
|
||||
'../../libs/shared/interfaces/src/index.ts'
|
||||
),
|
||||
'm3u-utils': path.join(
|
||||
__dirname,
|
||||
'../../libs/shared/m3u-utils/src/index.ts'
|
||||
),
|
||||
'database': path.join(
|
||||
__dirname,
|
||||
'../../libs/shared/database/src/index.ts'
|
||||
),
|
||||
'database-schema': path.join(
|
||||
__dirname,
|
||||
'../../libs/shared/database/src/lib/schema.ts'
|
||||
),
|
||||
'database-path-utils': path.join(
|
||||
__dirname,
|
||||
'../../libs/shared/database/src/lib/path-utils.ts'
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log('✅ Workers built successfully!');
|
||||
} catch (error) {
|
||||
console.error('❌ Worker build failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
buildWorker();
|
||||
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
displayName: 'electron-backend',
|
||||
preset: '../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': [
|
||||
'ts-jest',
|
||||
{ tsconfig: '<rootDir>/tsconfig.spec.json' },
|
||||
],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../coverage/apps/electron-backend',
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>com.fourgray.iptvnator</id>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
<name>IPTVnator</name>
|
||||
<developer_name>4gray</developer_name>
|
||||
<update_contact>fourgray@proton.me</update_contact>
|
||||
<summary>Open-source IPTV player for M3U, Xtream Codes, and Stalker portals</summary>
|
||||
<launchable type="desktop-id">com.fourgray.iptvnator.desktop</launchable>
|
||||
|
||||
<description>
|
||||
<p>IPTVnator is a cross-platform IPTV player for user-supplied playlists and portal connections.</p>
|
||||
<p>It supports M3U and M3U8 playlists, Xtream Codes API, and Stalker portals with EPG, catch-up, favorites, and external player integration.</p>
|
||||
<p>The app does not include channels, playlists, or media catalogs. Any IPTV sources, logos, metadata, and programme information come from services the user configures.</p>
|
||||
</description>
|
||||
|
||||
<categories>
|
||||
<category>AudioVideo</category>
|
||||
<category>Player</category>
|
||||
<category>Video</category>
|
||||
</categories>
|
||||
|
||||
<keywords>
|
||||
<keyword>IPTV</keyword>
|
||||
<keyword>M3U</keyword>
|
||||
<keyword>M3U8</keyword>
|
||||
<keyword>EPG</keyword>
|
||||
<keyword>Xtream</keyword>
|
||||
<keyword>Stalker</keyword>
|
||||
</keywords>
|
||||
|
||||
<url type="homepage">https://4gray.github.io/iptvnator/</url>
|
||||
<url type="bugtracker">https://github.com/4gray/iptvnator/issues</url>
|
||||
<url type="help">https://4gray.github.io/iptvnator/blog/</url>
|
||||
<url type="donation">https://github.com/sponsors/4gray</url>
|
||||
|
||||
<content_rating type="oars-1.0"/>
|
||||
|
||||
<releases>
|
||||
<release version="0.20.0" type="development" date="2026-02-21"/>
|
||||
<release version="0.19.0" type="stable" date="2026-02-11"/>
|
||||
</releases>
|
||||
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Browse channels and watch live IPTV streams</caption>
|
||||
<image type="source">https://4gray.github.io/iptvnator/appstream/player.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Manage playlists and switch between sources</caption>
|
||||
<image type="source">https://4gray.github.io/iptvnator/appstream/playlists.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>View programme schedules in the integrated EPG</caption>
|
||||
<image type="source">https://4gray.github.io/iptvnator/appstream/epg.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Adjust playback, interface, and provider settings</caption>
|
||||
<image type="source">https://4gray.github.io/iptvnator/appstream/settings.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
|
||||
<provides>
|
||||
<binary>iptvnator</binary>
|
||||
</provides>
|
||||
</component>
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>com.kiracloud.kiratv</id>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
<name>KiraTV</name>
|
||||
<developer_name>Kira Cloud</developer_name>
|
||||
<update_contact>jnavajascordobes@gmail.com</update_contact>
|
||||
<summary>Cliente de escritorio IPTV para Kira Cloud (Xtream Codes)</summary>
|
||||
<launchable type="desktop-id">com.kiracloud.kiratv.desktop</launchable>
|
||||
|
||||
<description>
|
||||
<p>KiraTV es un reproductor IPTV para Windows y Linux, orientado a la familia y preconfigurado para Kira Cloud.</p>
|
||||
<p>Incluye soporte para Xtream Codes (TV en directo, VOD y series), EPG y favoritos.</p>
|
||||
<p>La aplicación no incluye canales ni contenido: el usuario inicia sesión con las credenciales de su proveedor.</p>
|
||||
</description>
|
||||
|
||||
<categories>
|
||||
<category>AudioVideo</category>
|
||||
<category>Player</category>
|
||||
<category>Video</category>
|
||||
</categories>
|
||||
|
||||
<keywords>
|
||||
<keyword>IPTV</keyword>
|
||||
<keyword>Xtream</keyword>
|
||||
<keyword>KiraTV</keyword>
|
||||
</keywords>
|
||||
|
||||
<url type="homepage">https://play.kiracloud.es</url>
|
||||
|
||||
<content_rating type="oars-1.0"/>
|
||||
|
||||
<releases>
|
||||
<release version="0.20.0" type="stable" date="2026-05-03"/>
|
||||
</releases>
|
||||
|
||||
<provides>
|
||||
<binary>kiratv</binary>
|
||||
</provides>
|
||||
</component>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"name": "electron-backend",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "apps/electron-backend/src",
|
||||
"projectType": "application",
|
||||
"targets": {
|
||||
"build-worker": {
|
||||
"executor": "nx:run-commands",
|
||||
"outputs": ["{workspaceRoot}/dist/apps/electron-backend/workers"],
|
||||
"options": {
|
||||
"command": "node apps/electron-backend/build-worker.js"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"dependsOn": [
|
||||
"electron-backend:build-worker",
|
||||
{
|
||||
"projects": ["web"],
|
||||
"target": "build",
|
||||
"params": "forward"
|
||||
},
|
||||
{
|
||||
"projects": ["remote-control-web"],
|
||||
"target": "build",
|
||||
"params": "forward"
|
||||
}
|
||||
],
|
||||
"executor": "nx-electron:build",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/apps/electron-backend",
|
||||
"main": "apps/electron-backend/src/main.ts",
|
||||
"tsConfig": "apps/electron-backend/tsconfig.app.json",
|
||||
"assets": ["apps/electron-backend/src/assets"]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"optimization": true,
|
||||
"extractLicenses": true,
|
||||
"inspect": false,
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/electron-backend/src/environments/environment.ts",
|
||||
"with": "apps/electron-backend/src/environments/environment.prod.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
"kiratv": {
|
||||
"optimization": true,
|
||||
"extractLicenses": true,
|
||||
"inspect": false,
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/electron-backend/src/environments/environment.ts",
|
||||
"with": "apps/electron-backend/src/environments/environment.kiratv.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"build-e2e": {
|
||||
"dependsOn": [
|
||||
"electron-backend:build-worker",
|
||||
{
|
||||
"projects": ["web"],
|
||||
"target": "build-e2e"
|
||||
},
|
||||
{
|
||||
"projects": ["remote-control-web"],
|
||||
"target": "build-e2e"
|
||||
}
|
||||
],
|
||||
"executor": "nx-electron:build",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/apps/electron-backend",
|
||||
"main": "apps/electron-backend/src/main.ts",
|
||||
"tsConfig": "apps/electron-backend/tsconfig.app.json",
|
||||
"assets": ["apps/electron-backend/src/assets"]
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "nx run electron-backend:serve-electron"
|
||||
}
|
||||
},
|
||||
"serve-electron": {
|
||||
"executor": "nx-electron:execute",
|
||||
"options": {
|
||||
"buildTarget": "electron-backend:build",
|
||||
"waitUntilTargets": ["web:serve"],
|
||||
"remoteDebuggingPort": 9222
|
||||
}
|
||||
},
|
||||
"package": {
|
||||
"dependsOn": ["electron-backend:build"],
|
||||
"executor": "nx-electron:package",
|
||||
"options": {
|
||||
"name": "electron-backend",
|
||||
"frontendProject": "web",
|
||||
"sourcePath": "dist/apps",
|
||||
"outputPath": "dist/packages",
|
||||
"prepackageOnly": true
|
||||
}
|
||||
},
|
||||
"make": {
|
||||
"dependsOn": ["electron-backend:build"],
|
||||
"executor": "nx-electron:make",
|
||||
"options": {
|
||||
"name": "electron-backend",
|
||||
"frontendProject": "web",
|
||||
"sourcePath": "dist/apps",
|
||||
"outputPath": "dist/executables"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"command": "eslint apps/electron-backend/**/*.ts"
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "apps/electron-backend/jest.config.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import type {
|
||||
ExternalPlayerSession,
|
||||
PlaylistRefreshEvent,
|
||||
PlaylistRefreshPayload,
|
||||
XtreamCategory,
|
||||
} from 'shared-interfaces';
|
||||
import {
|
||||
DEBUG_TRACE_EVENT_CHANNEL,
|
||||
isRendererApiTraceEnabled,
|
||||
roundTraceDuration,
|
||||
summarizeForTrace,
|
||||
} from '../services/debug-trace';
|
||||
|
||||
const PORTAL_DEBUG_EVENT = 'PORTAL_DEBUG_EVENT';
|
||||
const EXTERNAL_PLAYER_SESSION_UPDATE = 'EXTERNAL_PLAYER_SESSION_UPDATE';
|
||||
const DB_OPERATION_EVENT = 'DB_OPERATION_EVENT';
|
||||
const PLAYLIST_REFRESH_EVENT = 'PLAYLIST:REFRESH_EVENT';
|
||||
|
||||
type PortalDebugEvent = {
|
||||
requestId: string;
|
||||
provider: 'xtream' | 'stalker';
|
||||
operation: string;
|
||||
transport: 'electron-main' | 'electron-renderer' | 'pwa-http';
|
||||
startedAt: string;
|
||||
durationMs: number;
|
||||
status: 'success' | 'error';
|
||||
request: unknown;
|
||||
response?: unknown;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
type DbOperationEvent = {
|
||||
operationId?: string;
|
||||
operation: string;
|
||||
playlistId?: string;
|
||||
status: 'started' | 'progress' | 'completed' | 'cancelled' | 'error';
|
||||
phase?: string;
|
||||
current?: number;
|
||||
total?: number;
|
||||
increment?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const dbSaveContentProgressListeners = new Set<
|
||||
(event: Electron.IpcRendererEvent, data: DbOperationEvent) => void
|
||||
>();
|
||||
|
||||
const shouldTraceRendererApi = isRendererApiTraceEnabled();
|
||||
|
||||
function emitRendererTrace(payload: {
|
||||
method: string;
|
||||
phase: 'start' | 'success' | 'error';
|
||||
args?: unknown;
|
||||
durationMs?: number;
|
||||
error?: unknown;
|
||||
result?: unknown;
|
||||
}): void {
|
||||
if (!shouldTraceRendererApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
ipcRenderer.send(DEBUG_TRACE_EVENT_CHANNEL, payload);
|
||||
}
|
||||
|
||||
function wrapElectronApi<T extends Record<string, unknown>>(api: T): T {
|
||||
if (!shouldTraceRendererApi) {
|
||||
return api;
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(api).map(([name, value]) => {
|
||||
if (
|
||||
typeof value !== 'function' ||
|
||||
name.startsWith('on') ||
|
||||
name.startsWith('remove')
|
||||
) {
|
||||
return [name, value];
|
||||
}
|
||||
|
||||
const original = value as (...args: unknown[]) => unknown;
|
||||
|
||||
return [
|
||||
name,
|
||||
(...args: unknown[]) => {
|
||||
const startedAt =
|
||||
globalThis.performance?.now?.() ?? Date.now();
|
||||
emitRendererTrace({
|
||||
args: summarizeForTrace(args),
|
||||
method: name,
|
||||
phase: 'start',
|
||||
});
|
||||
|
||||
try {
|
||||
const result = original(...args);
|
||||
|
||||
if (
|
||||
result &&
|
||||
typeof (result as PromiseLike<unknown>).then ===
|
||||
'function'
|
||||
) {
|
||||
return (result as Promise<unknown>)
|
||||
.then((resolvedValue) => {
|
||||
emitRendererTrace({
|
||||
durationMs: roundTraceDuration(
|
||||
(globalThis.performance?.now?.() ??
|
||||
Date.now()) - startedAt
|
||||
),
|
||||
method: name,
|
||||
phase: 'success',
|
||||
result: summarizeForTrace(
|
||||
resolvedValue
|
||||
),
|
||||
});
|
||||
return resolvedValue;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
emitRendererTrace({
|
||||
durationMs: roundTraceDuration(
|
||||
(globalThis.performance?.now?.() ??
|
||||
Date.now()) - startedAt
|
||||
),
|
||||
error: summarizeForTrace(error),
|
||||
method: name,
|
||||
phase: 'error',
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
emitRendererTrace({
|
||||
durationMs: roundTraceDuration(
|
||||
(globalThis.performance?.now?.() ??
|
||||
Date.now()) - startedAt
|
||||
),
|
||||
method: name,
|
||||
phase: 'success',
|
||||
result: summarizeForTrace(result),
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
emitRendererTrace({
|
||||
durationMs: roundTraceDuration(
|
||||
(globalThis.performance?.now?.() ??
|
||||
Date.now()) - startedAt
|
||||
),
|
||||
error: summarizeForTrace(error),
|
||||
method: name,
|
||||
phase: 'error',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
];
|
||||
})
|
||||
) as T;
|
||||
}
|
||||
|
||||
const electronApi = {
|
||||
// Remote control channel change listener
|
||||
onChannelChange: (
|
||||
callback: (data: { direction: 'up' | 'down' }) => void
|
||||
) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: any) =>
|
||||
callback(data);
|
||||
ipcRenderer.on('CHANNEL_CHANGE', handler);
|
||||
return () => ipcRenderer.off('CHANNEL_CHANGE', handler);
|
||||
},
|
||||
onRemoteControlCommand: (
|
||||
callback: (data: {
|
||||
type:
|
||||
| 'channel-select-number'
|
||||
| 'volume-up'
|
||||
| 'volume-down'
|
||||
| 'volume-toggle-mute';
|
||||
number?: number;
|
||||
}) => void
|
||||
) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: any) =>
|
||||
callback(data);
|
||||
ipcRenderer.on('REMOTE_CONTROL_COMMAND', handler);
|
||||
return () => ipcRenderer.off('REMOTE_CONTROL_COMMAND', handler);
|
||||
},
|
||||
updateRemoteControlStatus: (status: any) => {
|
||||
ipcRenderer.send('REMOTE_CONTROL_STATUS_UPDATE', status);
|
||||
},
|
||||
// Player error listener
|
||||
onPlayerError: (
|
||||
callback: (data: {
|
||||
player: string;
|
||||
error: string;
|
||||
originalError: string;
|
||||
}) => void
|
||||
) => {
|
||||
ipcRenderer.on('player-error', (_event, data) => callback(data));
|
||||
},
|
||||
onPortalDebugEvent: (callback: (data: PortalDebugEvent) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: any) =>
|
||||
callback(data as PortalDebugEvent);
|
||||
ipcRenderer.on(PORTAL_DEBUG_EVENT, handler);
|
||||
return () => ipcRenderer.off(PORTAL_DEBUG_EVENT, handler);
|
||||
},
|
||||
// EPG progress listener
|
||||
onEpgProgress: (
|
||||
callback: (data: {
|
||||
url: string;
|
||||
status: 'loading' | 'complete' | 'error';
|
||||
stats?: { totalChannels: number; totalPrograms: number };
|
||||
error?: string;
|
||||
}) => void
|
||||
) => {
|
||||
ipcRenderer.on('EPG_PROGRESS_UPDATE', (_event, data) => callback(data));
|
||||
},
|
||||
// Playback position update listener - returns unsubscribe function
|
||||
onPlaybackPositionUpdate: (callback: (data: any) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: any) =>
|
||||
callback(data);
|
||||
ipcRenderer.on('playback-position-update', handler);
|
||||
return () => ipcRenderer.off('playback-position-update', handler);
|
||||
},
|
||||
onExternalPlayerSessionUpdate: (
|
||||
callback: (data: ExternalPlayerSession) => void
|
||||
) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
data: ExternalPlayerSession
|
||||
) => callback(data);
|
||||
ipcRenderer.on(EXTERNAL_PLAYER_SESSION_UPDATE, handler);
|
||||
return () => ipcRenderer.off(EXTERNAL_PLAYER_SESSION_UPDATE, handler);
|
||||
},
|
||||
onDbOperationEvent: (callback: (data: DbOperationEvent) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: any) =>
|
||||
callback(data as DbOperationEvent);
|
||||
ipcRenderer.on(DB_OPERATION_EVENT, handler);
|
||||
return () => ipcRenderer.off(DB_OPERATION_EVENT, handler);
|
||||
},
|
||||
onPlaylistRefreshEvent: (
|
||||
callback: (data: PlaylistRefreshEvent) => void
|
||||
) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: any) =>
|
||||
callback(data as PlaylistRefreshEvent);
|
||||
ipcRenderer.on(PLAYLIST_REFRESH_EVENT, handler);
|
||||
return () => ipcRenderer.off(PLAYLIST_REFRESH_EVENT, handler);
|
||||
},
|
||||
// DB save content progress listener
|
||||
onDbSaveContentProgress: (callback: (count: number) => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
data: DbOperationEvent
|
||||
) => {
|
||||
if (
|
||||
data.operation !== 'save-content' ||
|
||||
data.status !== 'progress'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
callback(data.increment ?? data.current ?? 0);
|
||||
};
|
||||
|
||||
dbSaveContentProgressListeners.add(handler);
|
||||
ipcRenderer.on(DB_OPERATION_EVENT, handler);
|
||||
},
|
||||
// Remove DB save content progress listener
|
||||
removeDbSaveContentProgress: () => {
|
||||
dbSaveContentProgressListeners.forEach((handler) => {
|
||||
ipcRenderer.off(DB_OPERATION_EVENT, handler);
|
||||
});
|
||||
dbSaveContentProgressListeners.clear();
|
||||
},
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
platform: process.platform,
|
||||
fetchPlaylistByUrl: (url: string, title?: string) =>
|
||||
ipcRenderer.invoke('fetch-playlist-by-url', url, title),
|
||||
updatePlaylistFromFilePath: (filePath: string, title: string) =>
|
||||
ipcRenderer.invoke('update-playlist-from-file-path', filePath, title),
|
||||
openPlaylistFromFile: () => ipcRenderer.invoke('open-playlist-from-file'),
|
||||
saveFileDialog: (
|
||||
defaultPath: string,
|
||||
filters?: { name: string; extensions: string[] }[]
|
||||
) => ipcRenderer.invoke('save-file-dialog', defaultPath, filters),
|
||||
writeFile: (filePath: string, content: string) =>
|
||||
ipcRenderer.invoke('write-file', filePath, content),
|
||||
setUserAgent: (userAgent: string, referer?: string) =>
|
||||
ipcRenderer.invoke('set-user-agent', userAgent, referer),
|
||||
openInMpv: (
|
||||
url: string,
|
||||
title: string,
|
||||
thumbnail: string,
|
||||
userAgent: string,
|
||||
referer?: string,
|
||||
origin?: string,
|
||||
contentInfo?: any,
|
||||
startTime?: number,
|
||||
headers?: Record<string, string>
|
||||
): Promise<ExternalPlayerSession> =>
|
||||
ipcRenderer.invoke(
|
||||
'OPEN_MPV_PLAYER',
|
||||
url,
|
||||
title,
|
||||
thumbnail,
|
||||
userAgent,
|
||||
referer,
|
||||
origin,
|
||||
contentInfo,
|
||||
startTime,
|
||||
headers
|
||||
),
|
||||
openInVlc: (
|
||||
url: string,
|
||||
title: string,
|
||||
thumbnail: string,
|
||||
userAgent: string,
|
||||
referer?: string,
|
||||
origin?: string,
|
||||
contentInfo?: any,
|
||||
startTime?: number,
|
||||
headers?: Record<string, string>
|
||||
): Promise<ExternalPlayerSession> =>
|
||||
ipcRenderer.invoke(
|
||||
'OPEN_VLC_PLAYER',
|
||||
url,
|
||||
title,
|
||||
thumbnail,
|
||||
userAgent,
|
||||
referer,
|
||||
origin,
|
||||
contentInfo,
|
||||
startTime,
|
||||
headers
|
||||
),
|
||||
closeExternalPlayerSession: (sessionId: string) =>
|
||||
ipcRenderer.invoke('CLOSE_EXTERNAL_PLAYER_SESSION', sessionId),
|
||||
autoUpdatePlaylists: (playlists) =>
|
||||
ipcRenderer.invoke('AUTO_UPDATE', playlists),
|
||||
fetchEpg: (urls: string[]) =>
|
||||
ipcRenderer.invoke('FETCH_EPG', { url: urls }),
|
||||
getChannelPrograms: (channelId: string) =>
|
||||
ipcRenderer.invoke('GET_CHANNEL_PROGRAMS', { channelId }),
|
||||
getEpgChannelMetadata: (channelIds: string[]) =>
|
||||
ipcRenderer.invoke('EPG_GET_CHANNEL_METADATA', { channelIds }),
|
||||
getEpgChannels: () => ipcRenderer.invoke('EPG_GET_CHANNELS'),
|
||||
getEpgChannelsByRange: (skip: number, limit: number) =>
|
||||
ipcRenderer.invoke('EPG_GET_CHANNELS_BY_RANGE', { skip, limit }),
|
||||
forceFetchEpg: (url: string) => ipcRenderer.invoke('EPG_FORCE_FETCH', url),
|
||||
clearEpgData: () => ipcRenderer.invoke('EPG_CLEAR_ALL'),
|
||||
checkEpgFreshness: (urls: string[], maxAgeHours?: number) =>
|
||||
ipcRenderer.invoke('EPG_CHECK_FRESHNESS', { urls, maxAgeHours }),
|
||||
searchEpgPrograms: (searchTerm: string, limit?: number) =>
|
||||
ipcRenderer.invoke('EPG_DB_SEARCH_PROGRAMS', searchTerm, limit),
|
||||
setMpvPlayerPath: (mpvPlayerPath: string) =>
|
||||
ipcRenderer.invoke('SET_MPV_PLAYER_PATH', mpvPlayerPath),
|
||||
setVlcPlayerPath: (vlcPlayerPath: string) =>
|
||||
ipcRenderer.invoke('SET_VLC_PLAYER_PATH', vlcPlayerPath),
|
||||
updateSettings: (settings: any) =>
|
||||
ipcRenderer.invoke('SETTINGS_UPDATE', settings),
|
||||
getAiSettings: () => ipcRenderer.invoke('GET_AI_SETTINGS'),
|
||||
stalkerRequest: (payload: {
|
||||
url: string;
|
||||
macAddress: string;
|
||||
params: Record<string, string>;
|
||||
token?: string;
|
||||
serialNumber?: string;
|
||||
requestId?: string;
|
||||
}) => ipcRenderer.invoke('STALKER_REQUEST', payload),
|
||||
xtreamRequest: (payload: {
|
||||
url: string;
|
||||
params: Record<string, string>;
|
||||
requestId?: string;
|
||||
sessionId?: string;
|
||||
suppressErrorLog?: boolean;
|
||||
}) => ipcRenderer.invoke('XTREAM_REQUEST', payload),
|
||||
xtreamCancelSession: (sessionId: string) =>
|
||||
ipcRenderer.invoke('XTREAM_CANCEL_SESSION', sessionId),
|
||||
xtreamProbeUrl: (url: string, method?: 'GET' | 'HEAD') =>
|
||||
ipcRenderer.invoke('XTREAM_PROBE_URL', { url, method }),
|
||||
refreshPlaylist: (payload: PlaylistRefreshPayload) =>
|
||||
ipcRenderer.invoke('PLAYLIST:REFRESH', payload),
|
||||
cancelPlaylistRefresh: (operationId: string) =>
|
||||
ipcRenderer.invoke('PLAYLIST:CANCEL_REFRESH', operationId),
|
||||
// Database operations
|
||||
dbCreatePlaylist: (playlist: any) =>
|
||||
ipcRenderer.invoke('DB_CREATE_PLAYLIST', playlist),
|
||||
dbGetPlaylist: (playlistId: string) =>
|
||||
ipcRenderer.invoke('DB_GET_PLAYLIST', playlistId),
|
||||
dbUpsertAppPlaylist: (playlist: any) =>
|
||||
ipcRenderer.invoke('DB_UPSERT_APP_PLAYLIST', playlist),
|
||||
dbUpsertAppPlaylists: (playlists: any[]) =>
|
||||
ipcRenderer.invoke('DB_UPSERT_APP_PLAYLISTS', playlists),
|
||||
dbGetAppPlaylists: () => ipcRenderer.invoke('DB_GET_APP_PLAYLISTS'),
|
||||
dbGetAppPlaylist: (playlistId: string) =>
|
||||
ipcRenderer.invoke('DB_GET_APP_PLAYLIST', playlistId),
|
||||
dbUpdatePlaylist: (playlistId: string, updates: any) =>
|
||||
ipcRenderer.invoke('DB_UPDATE_PLAYLIST', playlistId, updates),
|
||||
dbDeletePlaylist: (playlistId: string, operationId?: string) =>
|
||||
ipcRenderer.invoke('DB_DELETE_PLAYLIST', playlistId, operationId),
|
||||
dbDeleteXtreamContent: (playlistId: string, operationId?: string) =>
|
||||
ipcRenderer.invoke('DB_DELETE_XTREAM_CONTENT', playlistId, operationId),
|
||||
dbRestoreXtreamUserData: (
|
||||
playlistId: string,
|
||||
favorites: unknown[],
|
||||
recentlyViewed: unknown[],
|
||||
operationId?: string
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_RESTORE_XTREAM_USER_DATA',
|
||||
playlistId,
|
||||
favorites,
|
||||
recentlyViewed,
|
||||
operationId
|
||||
),
|
||||
dbHasCategories: (playlistId: string, type: string) =>
|
||||
ipcRenderer.invoke('DB_HAS_CATEGORIES', playlistId, type),
|
||||
dbGetCategories: (playlistId: string, type: string) =>
|
||||
ipcRenderer.invoke('DB_GET_CATEGORIES', playlistId, type),
|
||||
dbSaveCategories: (
|
||||
playlistId: string,
|
||||
categories: XtreamCategory[],
|
||||
type: string,
|
||||
hiddenCategoryXtreamIds?: number[]
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_SAVE_CATEGORIES',
|
||||
playlistId,
|
||||
categories,
|
||||
type,
|
||||
hiddenCategoryXtreamIds
|
||||
),
|
||||
dbGetAllCategories: (playlistId: string, type: string) =>
|
||||
ipcRenderer.invoke('DB_GET_ALL_CATEGORIES', playlistId, type),
|
||||
dbUpdateCategoryVisibility: (categoryIds: number[], hidden: boolean) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_UPDATE_CATEGORY_VISIBILITY',
|
||||
categoryIds,
|
||||
hidden
|
||||
),
|
||||
dbHasContent: (playlistId: string, type: string) =>
|
||||
ipcRenderer.invoke('DB_HAS_CONTENT', playlistId, type),
|
||||
dbGetContent: (playlistId: string, type: string) =>
|
||||
ipcRenderer.invoke('DB_GET_CONTENT', playlistId, type),
|
||||
dbSaveContent: (
|
||||
playlistId: string,
|
||||
streams: any[],
|
||||
type: string,
|
||||
operationId?: string
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_SAVE_CONTENT',
|
||||
playlistId,
|
||||
streams,
|
||||
type,
|
||||
operationId
|
||||
),
|
||||
dbClearXtreamImportCache: (
|
||||
playlistId: string,
|
||||
type: 'live' | 'movie' | 'series'
|
||||
) => ipcRenderer.invoke('DB_CLEAR_XTREAM_IMPORT_CACHE', playlistId, type),
|
||||
dbSearchContent: (
|
||||
playlistId: string,
|
||||
searchTerm: string,
|
||||
types: string[],
|
||||
excludeHidden?: boolean
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_SEARCH_CONTENT',
|
||||
playlistId,
|
||||
searchTerm,
|
||||
types,
|
||||
excludeHidden
|
||||
),
|
||||
dbGlobalSearch: (
|
||||
searchTerm: string,
|
||||
types: string[],
|
||||
excludeHidden?: boolean
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_GLOBAL_SEARCH',
|
||||
searchTerm,
|
||||
types,
|
||||
excludeHidden
|
||||
),
|
||||
dbGetGlobalRecentlyAdded: (
|
||||
kind: 'all' | 'vod' | 'series',
|
||||
limit?: number,
|
||||
playlistType?:
|
||||
| 'xtream'
|
||||
| 'stalker'
|
||||
| 'm3u-file'
|
||||
| 'm3u-text'
|
||||
| 'm3u-url'
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_GET_GLOBAL_RECENTLY_ADDED',
|
||||
kind,
|
||||
limit,
|
||||
playlistType
|
||||
),
|
||||
dbGetRecentlyViewed: () => ipcRenderer.invoke('DB_GET_RECENTLY_VIEWED'),
|
||||
dbClearRecentlyViewed: () => ipcRenderer.invoke('DB_CLEAR_RECENTLY_VIEWED'),
|
||||
// Favorites
|
||||
dbAddFavorite: (
|
||||
contentId: number,
|
||||
playlistId: string,
|
||||
backdropUrl?: string
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_ADD_FAVORITE',
|
||||
contentId,
|
||||
playlistId,
|
||||
backdropUrl
|
||||
),
|
||||
dbRemoveFavorite: (contentId: number, playlistId: string) =>
|
||||
ipcRenderer.invoke('DB_REMOVE_FAVORITE', contentId, playlistId),
|
||||
dbIsFavorite: (contentId: number, playlistId: string) =>
|
||||
ipcRenderer.invoke('DB_IS_FAVORITE', contentId, playlistId),
|
||||
dbGetFavorites: (playlistId: string) =>
|
||||
ipcRenderer.invoke('DB_GET_FAVORITES', playlistId),
|
||||
dbGetGlobalFavorites: () => ipcRenderer.invoke('DB_GET_GLOBAL_FAVORITES'),
|
||||
dbGetAllGlobalFavorites: () =>
|
||||
ipcRenderer.invoke('DB_GET_ALL_GLOBAL_FAVORITES'),
|
||||
dbReorderGlobalFavorites: (
|
||||
updates: { content_id: number; position: number }[]
|
||||
) => ipcRenderer.invoke('DB_REORDER_GLOBAL_FAVORITES', updates),
|
||||
// Recently viewed (playlist-specific)
|
||||
dbGetRecentItems: (playlistId: string) =>
|
||||
ipcRenderer.invoke('DB_GET_RECENT_ITEMS', playlistId),
|
||||
dbAddRecentItem: (
|
||||
contentId: number,
|
||||
playlistId: string,
|
||||
backdropUrl?: string
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_ADD_RECENT_ITEM',
|
||||
contentId,
|
||||
playlistId,
|
||||
backdropUrl
|
||||
),
|
||||
dbClearPlaylistRecentItems: (playlistId: string) =>
|
||||
ipcRenderer.invoke('DB_CLEAR_PLAYLIST_RECENT_ITEMS', playlistId),
|
||||
dbRemoveRecentItem: (contentId: number, playlistId: string) =>
|
||||
ipcRenderer.invoke('DB_REMOVE_RECENT_ITEM', contentId, playlistId),
|
||||
dbGetContentByXtreamId: (
|
||||
xtreamId: number,
|
||||
playlistId: string,
|
||||
contentType?: 'live' | 'movie' | 'series'
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_GET_CONTENT_BY_XTREAM_ID',
|
||||
xtreamId,
|
||||
playlistId,
|
||||
contentType
|
||||
),
|
||||
dbSetContentBackdropIfMissing: (
|
||||
contentId: number,
|
||||
backdropUrl?: string
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_SET_CONTENT_BACKDROP_IF_MISSING',
|
||||
contentId,
|
||||
backdropUrl
|
||||
),
|
||||
dbDeleteAllPlaylists: (operationId?: string) =>
|
||||
ipcRenderer.invoke('DB_DELETE_ALL_PLAYLISTS', operationId),
|
||||
dbCancelOperation: (operationId: string) =>
|
||||
ipcRenderer.invoke('DB_CANCEL_OPERATION', operationId),
|
||||
dbGetAppState: (key: string) => ipcRenderer.invoke('DB_GET_APP_STATE', key),
|
||||
dbSetAppState: (key: string, value: string) =>
|
||||
ipcRenderer.invoke('DB_SET_APP_STATE', key, value),
|
||||
// Playback Positions
|
||||
dbSavePlaybackPosition: (playlistId: string, data: any) =>
|
||||
ipcRenderer.invoke('DB_SAVE_PLAYBACK_POSITION', playlistId, data),
|
||||
dbGetPlaybackPosition: (
|
||||
playlistId: string,
|
||||
contentXtreamId: number,
|
||||
contentType: 'vod' | 'episode'
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_GET_PLAYBACK_POSITION',
|
||||
playlistId,
|
||||
contentXtreamId,
|
||||
contentType
|
||||
),
|
||||
dbGetSeriesPlaybackPositions: (
|
||||
playlistId: string,
|
||||
seriesXtreamId: number
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_GET_SERIES_PLAYBACK_POSITIONS',
|
||||
playlistId,
|
||||
seriesXtreamId
|
||||
),
|
||||
dbGetRecentPlaybackPositions: (playlistId: string, limit?: number) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_GET_RECENT_PLAYBACK_POSITIONS',
|
||||
playlistId,
|
||||
limit
|
||||
),
|
||||
dbGetAllPlaybackPositions: (playlistId: string) =>
|
||||
ipcRenderer.invoke('DB_GET_ALL_PLAYBACK_POSITIONS', playlistId),
|
||||
dbClearAllPlaybackPositions: (playlistId: string) =>
|
||||
ipcRenderer.invoke('DB_CLEAR_ALL_PLAYBACK_POSITIONS', playlistId),
|
||||
dbClearPlaybackPosition: (
|
||||
playlistId: string,
|
||||
contentXtreamId: number,
|
||||
contentType: 'vod' | 'episode'
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_CLEAR_PLAYBACK_POSITION',
|
||||
playlistId,
|
||||
contentXtreamId,
|
||||
contentType
|
||||
),
|
||||
getLocalIpAddresses: () => ipcRenderer.invoke('get-local-ip-addresses'),
|
||||
// Downloads
|
||||
downloadsStart: (data: {
|
||||
playlistId: string;
|
||||
xtreamId: number;
|
||||
contentType: 'vod' | 'episode';
|
||||
title: string;
|
||||
url: string;
|
||||
posterUrl?: string;
|
||||
downloadFolder: string;
|
||||
headers?: { userAgent?: string; referer?: string; origin?: string };
|
||||
seriesXtreamId?: number;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
// Playlist info for auto-creation if needed
|
||||
playlistName?: string;
|
||||
playlistType?:
|
||||
| 'xtream'
|
||||
| 'stalker'
|
||||
| 'm3u-file'
|
||||
| 'm3u-text'
|
||||
| 'm3u-url';
|
||||
serverUrl?: string;
|
||||
portalUrl?: string;
|
||||
macAddress?: string;
|
||||
}) => ipcRenderer.invoke('DOWNLOADS_START', data),
|
||||
downloadsCancel: (downloadId: number) =>
|
||||
ipcRenderer.invoke('DOWNLOADS_CANCEL', downloadId),
|
||||
downloadsRetry: (downloadId: number, downloadFolder: string) =>
|
||||
ipcRenderer.invoke('DOWNLOADS_RETRY', downloadId, downloadFolder),
|
||||
downloadsRemove: (downloadId: number) =>
|
||||
ipcRenderer.invoke('DOWNLOADS_REMOVE', downloadId),
|
||||
downloadsGetList: (playlistId?: string) =>
|
||||
ipcRenderer.invoke('DOWNLOADS_GET_LIST', playlistId),
|
||||
downloadsGet: (downloadId: number) =>
|
||||
ipcRenderer.invoke('DOWNLOADS_GET', downloadId),
|
||||
downloadsGetDefaultFolder: () =>
|
||||
ipcRenderer.invoke('DOWNLOADS_GET_DEFAULT_FOLDER'),
|
||||
downloadsSelectFolder: () => ipcRenderer.invoke('DOWNLOADS_SELECT_FOLDER'),
|
||||
downloadsRevealFile: (filePath: string) =>
|
||||
ipcRenderer.invoke('DOWNLOADS_REVEAL_FILE', filePath),
|
||||
downloadsPlayFile: (filePath: string) =>
|
||||
ipcRenderer.invoke('DOWNLOADS_PLAY_FILE', filePath),
|
||||
downloadsClearCompleted: (playlistId?: string) =>
|
||||
ipcRenderer.invoke('DOWNLOADS_CLEAR_COMPLETED', playlistId),
|
||||
onDownloadsUpdate: (callback: () => void) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on('DOWNLOADS_UPDATE_EVENT', handler);
|
||||
return () => ipcRenderer.off('DOWNLOADS_UPDATE_EVENT', handler);
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', wrapElectronApi(electronApi));
|
||||
@@ -0,0 +1,286 @@
|
||||
import { app, BrowserWindow, Menu, screen, shell } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { rendererAppName, rendererAppPort } from './constants';
|
||||
import {
|
||||
isRendererConsoleTraceEnabled,
|
||||
isWindowTraceEnabled,
|
||||
trace,
|
||||
} from './services/debug-trace';
|
||||
import { store, WINDOW_BOUNDS } from './services/store.service';
|
||||
import { environment } from '../environments/environment';
|
||||
|
||||
function attachWindowTrace(mainWindow: Electron.BrowserWindow): void {
|
||||
if (!isWindowTraceEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const webContents = mainWindow.webContents;
|
||||
|
||||
trace('window', 'created', {
|
||||
id: mainWindow.id,
|
||||
});
|
||||
|
||||
mainWindow.on('unresponsive', () => {
|
||||
trace('window', 'unresponsive', {
|
||||
id: mainWindow.id,
|
||||
url: webContents.getURL(),
|
||||
});
|
||||
});
|
||||
mainWindow.on('responsive', () => {
|
||||
trace('window', 'responsive', {
|
||||
id: mainWindow.id,
|
||||
url: webContents.getURL(),
|
||||
});
|
||||
});
|
||||
|
||||
webContents.on('did-start-loading', () => {
|
||||
trace('window', 'did-start-loading', {
|
||||
id: mainWindow.id,
|
||||
url: webContents.getURL(),
|
||||
});
|
||||
});
|
||||
webContents.on('dom-ready', () => {
|
||||
trace('window', 'dom-ready', {
|
||||
id: mainWindow.id,
|
||||
url: webContents.getURL(),
|
||||
});
|
||||
});
|
||||
webContents.on('did-finish-load', () => {
|
||||
trace('window', 'did-finish-load', {
|
||||
id: mainWindow.id,
|
||||
url: webContents.getURL(),
|
||||
});
|
||||
});
|
||||
webContents.on(
|
||||
'did-fail-load',
|
||||
(_event, errorCode, errorDescription, validatedURL) => {
|
||||
trace('window', 'did-fail-load', {
|
||||
errorCode,
|
||||
errorDescription,
|
||||
id: mainWindow.id,
|
||||
validatedURL,
|
||||
});
|
||||
}
|
||||
);
|
||||
webContents.on('did-navigate', (_event, url) => {
|
||||
trace('window', 'did-navigate', {
|
||||
id: mainWindow.id,
|
||||
url,
|
||||
});
|
||||
});
|
||||
webContents.on('render-process-gone', (_event, details) => {
|
||||
trace('window', 'render-process-gone', {
|
||||
details,
|
||||
id: mainWindow.id,
|
||||
url: webContents.getURL(),
|
||||
});
|
||||
});
|
||||
|
||||
if (isRendererConsoleTraceEnabled()) {
|
||||
webContents.on(
|
||||
'console-message',
|
||||
(_event, level, message, line, sourceId) => {
|
||||
trace('renderer-console', 'message', {
|
||||
level,
|
||||
line,
|
||||
message,
|
||||
sourceId,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default class App {
|
||||
// Keep a global reference of the window object, if you don't, the window will
|
||||
// be closed automatically when the JavaScript object is garbage collected.
|
||||
static mainWindow: Electron.BrowserWindow;
|
||||
static application: Electron.App;
|
||||
static BrowserWindow;
|
||||
|
||||
private static shouldOpenDevTools() {
|
||||
return process.env.ELECTRON_OPEN_DEVTOOLS === '1';
|
||||
}
|
||||
|
||||
public static isDevelopmentMode() {
|
||||
// First check ELECTRON_IS_DEV environment variable (used by E2E tests)
|
||||
// This allows E2E tests to run in production mode without packaging
|
||||
if ('ELECTRON_IS_DEV' in process.env) {
|
||||
return parseInt(process.env.ELECTRON_IS_DEV, 10) === 1;
|
||||
}
|
||||
// Fall back to Electron's built-in app.isPackaged
|
||||
// This is the most reliable way to detect if the app is packaged
|
||||
return !app.isPackaged;
|
||||
}
|
||||
|
||||
private static onWindowAllClosed() {
|
||||
if (process.platform !== 'darwin') {
|
||||
App.application.quit();
|
||||
}
|
||||
}
|
||||
|
||||
private static onClose() {
|
||||
// Dereference the window object, usually you would store windows
|
||||
// in an array if your app supports multi windows, this is the time
|
||||
// when you should delete the corresponding element.
|
||||
App.mainWindow = null;
|
||||
}
|
||||
|
||||
private static onRedirect(event: any, url: string) {
|
||||
if (url !== App.mainWindow.webContents.getURL()) {
|
||||
// this is a normal external redirect, open it in a new browser window
|
||||
event.preventDefault();
|
||||
shell.openExternal(url);
|
||||
}
|
||||
}
|
||||
|
||||
private static onReady() {
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
if (rendererAppName) {
|
||||
App.initMainWindow();
|
||||
App.loadMainWindow();
|
||||
}
|
||||
}
|
||||
|
||||
private static onActivate() {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (App.mainWindow === null) {
|
||||
App.onReady();
|
||||
}
|
||||
}
|
||||
|
||||
private static initMainWindow() {
|
||||
const workAreaSize = screen.getPrimaryDisplay().workAreaSize;
|
||||
const width = Math.min(1280, workAreaSize.width || 1280);
|
||||
const height = Math.min(720, workAreaSize.height || 720);
|
||||
|
||||
const savedWindowBounds = store.get(WINDOW_BOUNDS);
|
||||
|
||||
// Create the browser window.
|
||||
App.mainWindow = new BrowserWindow({
|
||||
title: environment.windowTitle,
|
||||
width: width,
|
||||
height: height,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
backgroundThrottling: false,
|
||||
preload: join(__dirname, 'main.preload.js'),
|
||||
},
|
||||
...savedWindowBounds,
|
||||
minHeight: 600,
|
||||
minWidth: 900,
|
||||
...(process.platform === 'darwin'
|
||||
? {
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: true,
|
||||
trafficLightPosition: { x: 16, y: 20 },
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
App.mainWindow.setMenu(null);
|
||||
attachWindowTrace(App.mainWindow);
|
||||
if (!savedWindowBounds) {
|
||||
App.mainWindow.center();
|
||||
}
|
||||
|
||||
// if main window is ready to show, close the splash window and show the main window
|
||||
App.mainWindow.once('ready-to-show', () => {
|
||||
App.mainWindow.show();
|
||||
});
|
||||
|
||||
// Route target="_blank" / window.open() to the OS default browser
|
||||
App.mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (/^https?:\/\//.test(url)) {
|
||||
shell.openExternal(url);
|
||||
}
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
// Emitted when the window is closed.
|
||||
App.mainWindow.on('closed', () => {
|
||||
// Dereference the window object, usually you would store windows
|
||||
// in an array if your app supports multi windows, this is the time
|
||||
// when you should delete the corresponding element.
|
||||
App.mainWindow = null;
|
||||
});
|
||||
|
||||
App.mainWindow.on('close', () => {
|
||||
if (App.mainWindow) {
|
||||
store.set(WINDOW_BOUNDS, App.mainWindow.getNormalBounds());
|
||||
}
|
||||
});
|
||||
|
||||
// Enable context menu for input fields only
|
||||
App.mainWindow.webContents.on('context-menu', (event, params) => {
|
||||
const { isEditable, editFlags } = params;
|
||||
|
||||
// Check if this is an editable field (input, textarea, contenteditable)
|
||||
// editFlags.canPaste is a good indicator of an input field
|
||||
if (isEditable && editFlags.canPaste) {
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Cut',
|
||||
role: 'cut',
|
||||
enabled: editFlags.canCut,
|
||||
},
|
||||
{
|
||||
label: 'Copy',
|
||||
role: 'copy',
|
||||
enabled: editFlags.canCopy,
|
||||
},
|
||||
{
|
||||
label: 'Paste',
|
||||
role: 'paste',
|
||||
enabled: editFlags.canPaste,
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Select All',
|
||||
role: 'selectAll',
|
||||
enabled: editFlags.canSelectAll,
|
||||
},
|
||||
]);
|
||||
|
||||
menu.popup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static loadMainWindow() {
|
||||
// load the index.html of the app.
|
||||
if (App.isDevelopmentMode()) {
|
||||
App.mainWindow.loadURL(`http://localhost:${rendererAppPort}`);
|
||||
if (App.shouldOpenDevTools()) {
|
||||
App.mainWindow.webContents.openDevTools();
|
||||
}
|
||||
} else {
|
||||
App.mainWindow.loadFile(
|
||||
join(__dirname, '..', rendererAppName, 'index.html')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static main(app: Electron.App, browserWindow: typeof BrowserWindow) {
|
||||
// we pass the Electron.App object and the
|
||||
// Electron.BrowserWindow into this function
|
||||
// so this class has no dependencies. This
|
||||
// makes the code easier to write tests for
|
||||
|
||||
App.BrowserWindow = browserWindow;
|
||||
App.application = app;
|
||||
|
||||
App.application.on('window-all-closed', App.onWindowAllClosed); // Quit when all windows are closed.
|
||||
App.application.on('ready', App.onReady); // App is ready to load data
|
||||
App.application.on('activate', App.onActivate); // App is activated
|
||||
App.application.on('before-quit', () => {
|
||||
if (App.mainWindow)
|
||||
store.set(WINDOW_BOUNDS, App.mainWindow.getNormalBounds());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const rendererAppPort = 4200;
|
||||
export const rendererAppName = 'web'; // options.name.split('-')[0] + '-web'
|
||||
export const electronAppName = 'kiratv';
|
||||
export const updateServerUrl = 'https://deployment-server-url.com'; // TODO: insert your update server url here
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Database connection re-export from shared library
|
||||
* This file maintains backwards compatibility for existing imports
|
||||
*/
|
||||
|
||||
export {
|
||||
getDatabase,
|
||||
initDatabase,
|
||||
getDatabasePath,
|
||||
closeDatabase,
|
||||
type DatabaseInstance,
|
||||
type DatabaseOptions,
|
||||
} from 'database';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import * as schema from 'database-schema';
|
||||
|
||||
export type AppDatabase = BetterSQLite3Database<typeof schema>;
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import { saveCategories } from './category.operations';
|
||||
|
||||
function createDbMock(existingCount = 0) {
|
||||
const where = jest.fn().mockResolvedValue([{ count: existingCount }]);
|
||||
const from = jest.fn().mockReturnValue({ where });
|
||||
const select = jest.fn().mockReturnValue({ from });
|
||||
const onConflictDoNothing = jest.fn().mockResolvedValue(undefined);
|
||||
const values = jest.fn().mockReturnValue({ onConflictDoNothing });
|
||||
const insert = jest.fn().mockReturnValue({ values });
|
||||
|
||||
return {
|
||||
db: {
|
||||
select,
|
||||
insert,
|
||||
} as unknown as AppDatabase,
|
||||
insert,
|
||||
values,
|
||||
onConflictDoNothing,
|
||||
select,
|
||||
from,
|
||||
where,
|
||||
};
|
||||
}
|
||||
|
||||
describe('category.operations', () => {
|
||||
it('restores hidden categories when Xtream API category IDs are strings', async () => {
|
||||
const { db, values, insert } = createDbMock();
|
||||
|
||||
await saveCategories(
|
||||
db,
|
||||
'playlist-1',
|
||||
[
|
||||
{ category_name: 'News', category_id: '101' },
|
||||
{ category_name: 'Sports', category_id: '102' },
|
||||
],
|
||||
'live',
|
||||
[102]
|
||||
);
|
||||
|
||||
expect(insert).toHaveBeenCalled();
|
||||
expect(values).toHaveBeenCalledWith([
|
||||
{
|
||||
playlistId: 'playlist-1',
|
||||
name: 'News',
|
||||
type: 'live',
|
||||
xtreamId: 101,
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
playlistId: 'playlist-1',
|
||||
name: 'Sports',
|
||||
type: 'live',
|
||||
xtreamId: 102,
|
||||
hidden: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips categories whose Xtream IDs are not numeric', async () => {
|
||||
const { db, values } = createDbMock();
|
||||
|
||||
await saveCategories(
|
||||
db,
|
||||
'playlist-1',
|
||||
[
|
||||
{ category_name: 'Valid', category_id: '201' },
|
||||
{ category_name: 'Broken', category_id: 'not-a-number' },
|
||||
],
|
||||
'movies',
|
||||
[201]
|
||||
);
|
||||
|
||||
expect(values).toHaveBeenCalledWith([
|
||||
{
|
||||
playlistId: 'playlist-1',
|
||||
name: 'Valid',
|
||||
type: 'movies',
|
||||
xtreamId: 201,
|
||||
hidden: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not insert categories when all Xtream IDs are invalid', async () => {
|
||||
const { db, insert } = createDbMock();
|
||||
|
||||
await saveCategories(
|
||||
db,
|
||||
'playlist-1',
|
||||
[{ category_name: 'Broken', category_id: 'not-a-number' }],
|
||||
'series',
|
||||
[301]
|
||||
);
|
||||
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm';
|
||||
import * as schema from 'database-schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
|
||||
type XtreamCategoryInput = {
|
||||
category_name: string;
|
||||
category_id: string | number;
|
||||
};
|
||||
|
||||
function normalizeXtreamCategoryId(rawCategoryId: string | number): number | null {
|
||||
const xtreamId = Number.parseInt(String(rawCategoryId), 10);
|
||||
|
||||
return Number.isNaN(xtreamId) ? null : xtreamId;
|
||||
}
|
||||
|
||||
export async function hasCategories(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
type: 'live' | 'movies' | 'series'
|
||||
): Promise<boolean> {
|
||||
const result = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.categories)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.categories.playlistId, playlistId),
|
||||
eq(schema.categories.type, type)
|
||||
)
|
||||
);
|
||||
|
||||
return result[0].count > 0;
|
||||
}
|
||||
|
||||
export async function getCategories(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
type: 'live' | 'movies' | 'series'
|
||||
) {
|
||||
return db
|
||||
.select()
|
||||
.from(schema.categories)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.categories.playlistId, playlistId),
|
||||
eq(schema.categories.type, type),
|
||||
eq(schema.categories.hidden, false)
|
||||
)
|
||||
)
|
||||
.orderBy(sql`name COLLATE NOCASE`);
|
||||
}
|
||||
|
||||
export async function saveCategories(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
categories: XtreamCategoryInput[],
|
||||
type: 'live' | 'movies' | 'series',
|
||||
hiddenCategoryXtreamIds?: number[]
|
||||
): Promise<{ success: boolean }> {
|
||||
if (!categories || categories.length === 0) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const existingCategories = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.categories)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.categories.playlistId, playlistId),
|
||||
eq(schema.categories.type, type)
|
||||
)
|
||||
);
|
||||
|
||||
if ((existingCategories[0]?.count ?? 0) > 0) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const hiddenSet = new Set(hiddenCategoryXtreamIds || []);
|
||||
const values = categories.flatMap((category) => {
|
||||
const xtreamId = normalizeXtreamCategoryId(category.category_id);
|
||||
|
||||
if (xtreamId === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
playlistId,
|
||||
name: category.category_name,
|
||||
type,
|
||||
xtreamId,
|
||||
hidden: hiddenSet.has(xtreamId),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
if (values.length === 0) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(schema.categories)
|
||||
.values(values)
|
||||
.onConflictDoNothing({
|
||||
target: [
|
||||
schema.categories.playlistId,
|
||||
schema.categories.type,
|
||||
schema.categories.xtreamId,
|
||||
],
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getAllCategories(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
type: 'live' | 'movies' | 'series'
|
||||
) {
|
||||
return db
|
||||
.select()
|
||||
.from(schema.categories)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.categories.playlistId, playlistId),
|
||||
eq(schema.categories.type, type)
|
||||
)
|
||||
)
|
||||
.orderBy(sql`name COLLATE NOCASE`);
|
||||
}
|
||||
|
||||
export async function updateCategoryVisibility(
|
||||
db: AppDatabase,
|
||||
categoryIds: number[],
|
||||
hidden: boolean
|
||||
): Promise<{ success: boolean }> {
|
||||
if (categoryIds.length === 0) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
await db
|
||||
.update(schema.categories)
|
||||
.set({ hidden })
|
||||
.where(inArray(schema.categories.id, categoryIds));
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
const andMock = jest.fn((...conditions: unknown[]) => ({
|
||||
kind: 'and',
|
||||
conditions,
|
||||
}));
|
||||
const eqMock = jest.fn((left: unknown, right: unknown) => ({
|
||||
kind: 'eq',
|
||||
left,
|
||||
right,
|
||||
}));
|
||||
const sqlMock = jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
|
||||
kind: 'sql',
|
||||
strings,
|
||||
values,
|
||||
}));
|
||||
|
||||
jest.mock('drizzle-orm', () => ({
|
||||
and: (...conditions: unknown[]) => andMock(...conditions),
|
||||
eq: (left: unknown, right: unknown) => eqMock(left, right),
|
||||
sql: (strings: TemplateStringsArray, ...values: unknown[]) =>
|
||||
sqlMock(strings, ...values),
|
||||
}));
|
||||
|
||||
import * as schema from 'database-schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import { setContentBackdropIfMissing } from './content-backdrop.operations';
|
||||
|
||||
function createDbMock() {
|
||||
const where = jest.fn().mockResolvedValue(undefined);
|
||||
const set = jest.fn().mockReturnValue({ where });
|
||||
const update = jest.fn().mockReturnValue({ set });
|
||||
|
||||
return {
|
||||
db: {
|
||||
update,
|
||||
} as unknown as AppDatabase,
|
||||
set,
|
||||
update,
|
||||
where,
|
||||
};
|
||||
}
|
||||
|
||||
describe('content-backdrop.operations', () => {
|
||||
beforeEach(() => {
|
||||
andMock.mockClear();
|
||||
eqMock.mockClear();
|
||||
sqlMock.mockClear();
|
||||
});
|
||||
|
||||
it('populates the content backdrop when a non-empty url is provided', async () => {
|
||||
const { db, set, update, where } = createDbMock();
|
||||
|
||||
await expect(
|
||||
setContentBackdropIfMissing(
|
||||
db,
|
||||
42,
|
||||
' https://example.com/backdrop.jpg '
|
||||
)
|
||||
).resolves.toEqual({ success: true });
|
||||
|
||||
expect(update).toHaveBeenCalledWith(schema.content);
|
||||
expect(set).toHaveBeenCalledWith({
|
||||
backdropUrl: 'https://example.com/backdrop.jpg',
|
||||
});
|
||||
expect(eqMock).toHaveBeenCalledWith(schema.content.id, 42);
|
||||
expect(where.mock.calls[0][0].conditions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('skips blank urls without touching content or recency tables', async () => {
|
||||
const { db, update } = createDbMock();
|
||||
|
||||
await expect(
|
||||
setContentBackdropIfMissing(db, 42, ' ')
|
||||
).resolves.toEqual({ success: true });
|
||||
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never updates recently viewed timestamps for backdrop-only backfill', async () => {
|
||||
const { db, update } = createDbMock();
|
||||
|
||||
await setContentBackdropIfMissing(
|
||||
db,
|
||||
42,
|
||||
'https://example.com/backdrop.jpg'
|
||||
);
|
||||
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(update).toHaveBeenCalledWith(schema.content);
|
||||
expect(update).not.toHaveBeenCalledWith(
|
||||
schema.recentlyViewed as unknown as never
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import * as schema from 'database-schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
|
||||
export async function persistContentBackdropIfMissing(
|
||||
db: AppDatabase,
|
||||
contentId: number,
|
||||
backdropUrl?: string
|
||||
): Promise<void> {
|
||||
const normalizedBackdropUrl = backdropUrl?.trim();
|
||||
if (!normalizedBackdropUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(schema.content)
|
||||
.set({ backdropUrl: normalizedBackdropUrl })
|
||||
.where(
|
||||
and(
|
||||
eq(schema.content.id, contentId),
|
||||
sql`(${schema.content.backdropUrl} IS NULL OR ${schema.content.backdropUrl} = '')`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function setContentBackdropIfMissing(
|
||||
db: AppDatabase,
|
||||
contentId: number,
|
||||
backdropUrl?: string
|
||||
): Promise<{ success: boolean }> {
|
||||
await persistContentBackdropIfMissing(db, contentId, backdropUrl);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
const andMock = jest.fn((...conditions: unknown[]) => ({
|
||||
kind: 'and',
|
||||
conditions,
|
||||
}));
|
||||
const eqMock = jest.fn((left: unknown, right: unknown) => ({
|
||||
kind: 'eq',
|
||||
left,
|
||||
right,
|
||||
}));
|
||||
|
||||
jest.mock('drizzle-orm', () => ({
|
||||
and: (...conditions: unknown[]) => andMock(...conditions),
|
||||
asc: jest.fn(),
|
||||
desc: jest.fn(),
|
||||
eq: (left: unknown, right: unknown) => eqMock(left, right),
|
||||
inArray: jest.fn(),
|
||||
or: jest.fn(),
|
||||
sql: jest.fn(),
|
||||
}));
|
||||
|
||||
import * as schema from 'database-schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import { getContentByXtreamId } from './content.operations';
|
||||
|
||||
function createDbMock(result: unknown[] = []) {
|
||||
const limit = jest.fn().mockResolvedValue(result);
|
||||
const where = jest.fn().mockReturnValue({ limit });
|
||||
const innerJoin = jest.fn().mockReturnValue({ where });
|
||||
const from = jest.fn().mockReturnValue({ innerJoin });
|
||||
const select = jest.fn().mockReturnValue({ from });
|
||||
|
||||
return {
|
||||
db: {
|
||||
select,
|
||||
} as unknown as AppDatabase,
|
||||
innerJoin,
|
||||
limit,
|
||||
select,
|
||||
where,
|
||||
};
|
||||
}
|
||||
|
||||
describe('content.operations', () => {
|
||||
beforeEach(() => {
|
||||
andMock.mockClear();
|
||||
eqMock.mockClear();
|
||||
});
|
||||
|
||||
it('adds the content type filter when resolving by xtream ID', async () => {
|
||||
const { db, where } = createDbMock([
|
||||
{
|
||||
title: 'Krypton',
|
||||
type: 'series',
|
||||
xtream_id: 290,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await getContentByXtreamId(
|
||||
db,
|
||||
290,
|
||||
'playlist-1',
|
||||
'series'
|
||||
);
|
||||
|
||||
expect(eqMock).toHaveBeenCalledWith(schema.content.xtreamId, 290);
|
||||
expect(eqMock).toHaveBeenCalledWith(
|
||||
schema.categories.playlistId,
|
||||
'playlist-1'
|
||||
);
|
||||
expect(eqMock).toHaveBeenCalledWith(schema.content.type, 'series');
|
||||
expect(where.mock.calls[0][0].conditions).toHaveLength(3);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
title: 'Krypton',
|
||||
type: 'series',
|
||||
xtream_id: 290,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the legacy lookup path when no content type is provided', async () => {
|
||||
const { db, where } = createDbMock();
|
||||
|
||||
await getContentByXtreamId(db, 290, 'playlist-1');
|
||||
|
||||
expect(eqMock).toHaveBeenCalledWith(schema.content.xtreamId, 290);
|
||||
expect(eqMock).toHaveBeenCalledWith(
|
||||
schema.categories.playlistId,
|
||||
'playlist-1'
|
||||
);
|
||||
expect(eqMock).not.toHaveBeenCalledWith(schema.content.type, 'series');
|
||||
expect(where.mock.calls[0][0].conditions).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,501 @@
|
||||
import { and, asc, desc, eq, inArray, or, sql } from 'drizzle-orm';
|
||||
import * as schema from 'database-schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import {
|
||||
checkpointOperation,
|
||||
chunkValues,
|
||||
type OperationControl,
|
||||
reportOperationProgress,
|
||||
} from './operation-control';
|
||||
|
||||
function escapeLikePattern(term: string): string {
|
||||
return term.replace(/[%_\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function buildLikePatterns(term: string): string[] {
|
||||
const variants = new Set<string>();
|
||||
const titleCase =
|
||||
term.length > 0
|
||||
? term.charAt(0).toLocaleUpperCase() +
|
||||
term.slice(1).toLocaleLowerCase()
|
||||
: term;
|
||||
|
||||
variants.add(term);
|
||||
variants.add(term.toLocaleLowerCase());
|
||||
variants.add(term.toLocaleUpperCase());
|
||||
variants.add(titleCase);
|
||||
|
||||
return [...variants].map((value) => `%${escapeLikePattern(value)}%`);
|
||||
}
|
||||
|
||||
export type GlobalRecentlyAddedKind = 'all' | 'vod' | 'series';
|
||||
|
||||
function getRecentlyAddedContentTypes(
|
||||
kind: GlobalRecentlyAddedKind
|
||||
): Array<'movie' | 'series'> {
|
||||
if (kind === 'vod') {
|
||||
return ['movie'];
|
||||
}
|
||||
|
||||
if (kind === 'series') {
|
||||
return ['series'];
|
||||
}
|
||||
|
||||
return ['movie', 'series'];
|
||||
}
|
||||
|
||||
function selectContentFields() {
|
||||
return {
|
||||
id: schema.content.id,
|
||||
category_id: schema.content.categoryId,
|
||||
title: schema.content.title,
|
||||
rating: schema.content.rating,
|
||||
added: schema.content.added,
|
||||
poster_url: schema.content.posterUrl,
|
||||
epg_channel_id: schema.content.epgChannelId,
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
direct_source: schema.content.directSource,
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
};
|
||||
}
|
||||
|
||||
export async function hasContent(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
type: 'live' | 'movie' | 'series'
|
||||
): Promise<boolean> {
|
||||
const result = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.content)
|
||||
.innerJoin(
|
||||
schema.categories,
|
||||
eq(schema.content.categoryId, schema.categories.id)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.categories.playlistId, playlistId),
|
||||
eq(schema.content.type, type)
|
||||
)
|
||||
);
|
||||
|
||||
return result[0].count > 0;
|
||||
}
|
||||
|
||||
export async function getContent(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
type: 'live' | 'movie' | 'series'
|
||||
) {
|
||||
const baseQuery = db
|
||||
.select(selectContentFields())
|
||||
.from(schema.content)
|
||||
.innerJoin(
|
||||
schema.categories,
|
||||
eq(schema.content.categoryId, schema.categories.id)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.categories.playlistId, playlistId),
|
||||
eq(schema.content.type, type)
|
||||
)
|
||||
);
|
||||
|
||||
return type === 'live'
|
||||
? baseQuery.orderBy(asc(schema.content.id))
|
||||
: baseQuery.orderBy(desc(schema.content.added));
|
||||
}
|
||||
|
||||
export type RecentlyAddedPlaylistType =
|
||||
| 'xtream'
|
||||
| 'stalker'
|
||||
| 'm3u-file'
|
||||
| 'm3u-text'
|
||||
| 'm3u-url';
|
||||
|
||||
export async function getGlobalRecentlyAdded(
|
||||
db: AppDatabase,
|
||||
kind: GlobalRecentlyAddedKind = 'all',
|
||||
limit = 200,
|
||||
playlistType?: RecentlyAddedPlaylistType
|
||||
) {
|
||||
const contentTypes = getRecentlyAddedContentTypes(kind);
|
||||
const normalizedLimit = Number.isFinite(limit)
|
||||
? Math.min(Math.max(Math.trunc(limit), 1), 200)
|
||||
: 200;
|
||||
const addedOrder = sql<number>`CAST(${schema.content.added} AS INTEGER)`;
|
||||
|
||||
const whereConditions = [
|
||||
inArray(schema.content.type, contentTypes),
|
||||
eq(schema.categories.hidden, false),
|
||||
sql`${schema.content.added} <> ''`,
|
||||
];
|
||||
|
||||
if (playlistType) {
|
||||
whereConditions.push(eq(schema.playlists.type, playlistType));
|
||||
}
|
||||
|
||||
return db
|
||||
.select({
|
||||
...selectContentFields(),
|
||||
added_at: schema.content.added,
|
||||
playlist_id: schema.playlists.id,
|
||||
playlist_name: schema.playlists.name,
|
||||
})
|
||||
.from(schema.content)
|
||||
.innerJoin(
|
||||
schema.categories,
|
||||
eq(schema.content.categoryId, schema.categories.id)
|
||||
)
|
||||
.innerJoin(
|
||||
schema.playlists,
|
||||
eq(schema.categories.playlistId, schema.playlists.id)
|
||||
)
|
||||
.where(and(...whereConditions))
|
||||
.orderBy(desc(addedOrder))
|
||||
.limit(normalizedLimit);
|
||||
}
|
||||
|
||||
type XtreamContentValue = {
|
||||
categoryId: number;
|
||||
title: string;
|
||||
rating: string;
|
||||
added: string;
|
||||
posterUrl: string;
|
||||
epgChannelId?: string | null;
|
||||
tvArchive?: number | null;
|
||||
tvArchiveDuration?: number | null;
|
||||
directSource?: string | null;
|
||||
xtreamId: number;
|
||||
type: 'live' | 'movie' | 'series';
|
||||
};
|
||||
|
||||
type XtreamContentSource = Record<string, unknown> & {
|
||||
category_id?: string | number;
|
||||
rating?: string | number;
|
||||
rating_imdb?: string;
|
||||
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;
|
||||
};
|
||||
|
||||
function toXtreamContentValue(
|
||||
stream: Record<string, unknown>,
|
||||
type: 'live' | 'movie' | 'series',
|
||||
categoryMap: Map<number, number>
|
||||
): XtreamContentValue | null {
|
||||
const source = stream as XtreamContentSource;
|
||||
const streamCategoryId =
|
||||
type === 'series'
|
||||
? parseInt(String(source.category_id || '0'))
|
||||
: parseInt(String(source.category_id));
|
||||
|
||||
const categoryId = categoryMap.get(streamCategoryId);
|
||||
if (!categoryId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title =
|
||||
type === 'series'
|
||||
? source.title ||
|
||||
source.name ||
|
||||
`Unknown Series ${source.series_id ?? ''}`.trim()
|
||||
: source.name ||
|
||||
source.title ||
|
||||
`Unknown Stream ${source.stream_id ?? ''}`.trim();
|
||||
|
||||
return {
|
||||
categoryId,
|
||||
title,
|
||||
rating: String(source.rating || source.rating_imdb || ''),
|
||||
added:
|
||||
type === 'series'
|
||||
? String(source.last_modified || '')
|
||||
: String(source.added || ''),
|
||||
posterUrl: String(
|
||||
source.stream_icon || source.poster || source.cover || ''
|
||||
),
|
||||
epgChannelId:
|
||||
type === 'live'
|
||||
? String(source.epg_channel_id ?? '').trim() || null
|
||||
: null,
|
||||
tvArchive:
|
||||
type === 'live'
|
||||
? Number.parseInt(String(source.tv_archive ?? '0'), 10) || 0
|
||||
: null,
|
||||
tvArchiveDuration:
|
||||
type === 'live'
|
||||
? Number.parseInt(
|
||||
String(source.tv_archive_duration ?? '0'),
|
||||
10
|
||||
) || 0
|
||||
: null,
|
||||
directSource:
|
||||
type === 'live'
|
||||
? String(source.direct_source ?? '').trim() || null
|
||||
: null,
|
||||
xtreamId:
|
||||
type === 'series'
|
||||
? parseInt(String(source.series_id || '0'))
|
||||
: parseInt(String(source.stream_id || '0')),
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveContent(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
streams: Array<Record<string, unknown>>,
|
||||
type: 'live' | 'movie' | 'series',
|
||||
control?: OperationControl
|
||||
): Promise<{ success: boolean; count: number }> {
|
||||
const dbType =
|
||||
type === 'series' ? 'series' : type === 'movie' ? 'movies' : 'live';
|
||||
|
||||
const existingContent = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.content)
|
||||
.innerJoin(
|
||||
schema.categories,
|
||||
eq(schema.content.categoryId, schema.categories.id)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.categories.playlistId, playlistId),
|
||||
eq(schema.categories.type, dbType),
|
||||
eq(schema.content.type, type)
|
||||
)
|
||||
);
|
||||
|
||||
if ((existingContent[0]?.count ?? 0) > 0) {
|
||||
return { success: true, count: existingContent[0].count };
|
||||
}
|
||||
|
||||
const categories = await db
|
||||
.select({
|
||||
id: schema.categories.id,
|
||||
xtreamId: schema.categories.xtreamId,
|
||||
})
|
||||
.from(schema.categories)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.categories.playlistId, playlistId),
|
||||
eq(schema.categories.type, dbType)
|
||||
)
|
||||
);
|
||||
|
||||
const categoryMap = new Map(categories.map((category) => [
|
||||
category.xtreamId,
|
||||
category.id,
|
||||
]));
|
||||
|
||||
const values = streams
|
||||
.map((stream) => toXtreamContentValue(stream, type, categoryMap))
|
||||
.filter((value): value is XtreamContentValue => value !== null);
|
||||
|
||||
const total = values.length;
|
||||
const chunkSize = 100;
|
||||
let totalInserted = 0;
|
||||
|
||||
for (let index = 0; index < values.length; index += chunkSize) {
|
||||
await checkpointOperation(control);
|
||||
const chunk = values.slice(index, index + chunkSize);
|
||||
await db
|
||||
.insert(schema.content)
|
||||
.values(chunk)
|
||||
.onConflictDoNothing({
|
||||
target: [
|
||||
schema.content.categoryId,
|
||||
schema.content.type,
|
||||
schema.content.xtreamId,
|
||||
],
|
||||
});
|
||||
totalInserted += chunk.length;
|
||||
await reportOperationProgress(control, {
|
||||
phase: 'saving-content',
|
||||
current: totalInserted,
|
||||
total,
|
||||
increment: chunk.length,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, count: totalInserted };
|
||||
}
|
||||
|
||||
export async function clearXtreamImportCache(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
type: 'live' | 'movie' | 'series'
|
||||
): Promise<{ success: boolean }> {
|
||||
const dbType =
|
||||
type === 'series' ? 'series' : type === 'movie' ? 'movies' : 'live';
|
||||
|
||||
const categoryRows = await db
|
||||
.select({ id: schema.categories.id })
|
||||
.from(schema.categories)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.categories.playlistId, playlistId),
|
||||
eq(schema.categories.type, dbType)
|
||||
)
|
||||
);
|
||||
|
||||
const categoryIds = categoryRows.map((category) => category.id);
|
||||
if (categoryIds.length === 0) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const contentRows = await db
|
||||
.select({ id: schema.content.id })
|
||||
.from(schema.content)
|
||||
.where(inArray(schema.content.categoryId, categoryIds));
|
||||
|
||||
for (const chunk of chunkValues(
|
||||
contentRows.map((row) => row.id),
|
||||
100
|
||||
)) {
|
||||
await db.delete(schema.content).where(inArray(schema.content.id, chunk));
|
||||
}
|
||||
|
||||
for (const chunk of chunkValues(categoryIds, 100)) {
|
||||
await db
|
||||
.delete(schema.categories)
|
||||
.where(inArray(schema.categories.id, chunk));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getContentByXtreamId(
|
||||
db: AppDatabase,
|
||||
xtreamId: number,
|
||||
playlistId: string,
|
||||
contentType?: 'live' | 'movie' | 'series'
|
||||
) {
|
||||
const conditions = [
|
||||
eq(schema.content.xtreamId, xtreamId),
|
||||
eq(schema.categories.playlistId, playlistId),
|
||||
];
|
||||
|
||||
if (contentType) {
|
||||
conditions.push(eq(schema.content.type, contentType));
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.select(selectContentFields())
|
||||
.from(schema.content)
|
||||
.innerJoin(
|
||||
schema.categories,
|
||||
eq(schema.content.categoryId, schema.categories.id)
|
||||
)
|
||||
.where(and(...conditions))
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
export async function searchContent(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
searchTerm: string,
|
||||
types: string[],
|
||||
excludeHidden = false
|
||||
) {
|
||||
if (!types || types.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchTermLower = searchTerm.toLocaleLowerCase();
|
||||
const likePatterns = buildLikePatterns(searchTerm);
|
||||
const likeConditions = likePatterns.map(
|
||||
(pattern) => sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'`
|
||||
);
|
||||
|
||||
const conditions = [
|
||||
eq(schema.categories.playlistId, playlistId),
|
||||
inArray(schema.content.type, types as Array<'live' | 'movie' | 'series'>),
|
||||
or(...likeConditions),
|
||||
];
|
||||
|
||||
if (excludeHidden) {
|
||||
conditions.push(eq(schema.categories.hidden, false));
|
||||
}
|
||||
|
||||
const candidates = await db
|
||||
.select(selectContentFields())
|
||||
.from(schema.content)
|
||||
.innerJoin(
|
||||
schema.categories,
|
||||
eq(schema.content.categoryId, schema.categories.id)
|
||||
)
|
||||
.where(and(...conditions))
|
||||
.limit(200);
|
||||
|
||||
return candidates
|
||||
.filter((item) =>
|
||||
item.title?.toLocaleLowerCase().includes(searchTermLower)
|
||||
)
|
||||
.slice(0, 50);
|
||||
}
|
||||
|
||||
export async function globalSearch(
|
||||
db: AppDatabase,
|
||||
searchTerm: string,
|
||||
types: string[],
|
||||
excludeHidden = false
|
||||
) {
|
||||
if (!types || types.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchTermLower = searchTerm.toLocaleLowerCase();
|
||||
const likePatterns = buildLikePatterns(searchTerm);
|
||||
const likeConditions = likePatterns.map(
|
||||
(pattern) => sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'`
|
||||
);
|
||||
|
||||
const conditions = [
|
||||
inArray(schema.content.type, types as Array<'live' | 'movie' | 'series'>),
|
||||
or(...likeConditions),
|
||||
];
|
||||
|
||||
if (excludeHidden) {
|
||||
conditions.push(eq(schema.categories.hidden, false));
|
||||
}
|
||||
|
||||
const candidates = await db
|
||||
.select({
|
||||
...selectContentFields(),
|
||||
playlist_id: schema.categories.playlistId,
|
||||
playlist_name: schema.playlists.name,
|
||||
})
|
||||
.from(schema.content)
|
||||
.innerJoin(
|
||||
schema.categories,
|
||||
eq(schema.content.categoryId, schema.categories.id)
|
||||
)
|
||||
.innerJoin(
|
||||
schema.playlists,
|
||||
eq(schema.categories.playlistId, schema.playlists.id)
|
||||
)
|
||||
.where(and(...conditions))
|
||||
.orderBy(schema.content.title)
|
||||
.limit(200);
|
||||
|
||||
return candidates
|
||||
.filter((item) =>
|
||||
item.title?.toLocaleLowerCase().includes(searchTermLower)
|
||||
)
|
||||
.slice(0, 50);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
|
||||
import * as schema from 'database-schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import {
|
||||
chunkValues,
|
||||
checkpointOperation,
|
||||
type OperationControl,
|
||||
reportOperationProgress,
|
||||
} from './operation-control';
|
||||
import { persistContentBackdropIfMissing } from './content-backdrop.operations';
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 100;
|
||||
|
||||
export async function addFavorite(
|
||||
db: AppDatabase,
|
||||
contentId: number,
|
||||
playlistId: string,
|
||||
options?: { backdropUrl?: string }
|
||||
): Promise<{ success: boolean }> {
|
||||
await db.insert(schema.favorites).values({
|
||||
contentId,
|
||||
playlistId,
|
||||
});
|
||||
|
||||
await persistContentBackdropIfMissing(db, contentId, options?.backdropUrl);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function removeFavorite(
|
||||
db: AppDatabase,
|
||||
contentId: number,
|
||||
playlistId: string
|
||||
): Promise<{ success: boolean }> {
|
||||
await db
|
||||
.delete(schema.favorites)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.favorites.contentId, contentId),
|
||||
eq(schema.favorites.playlistId, playlistId)
|
||||
)
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function isFavorite(
|
||||
db: AppDatabase,
|
||||
contentId: number,
|
||||
playlistId: string
|
||||
): Promise<boolean> {
|
||||
const result = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.favorites)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.favorites.contentId, contentId),
|
||||
eq(schema.favorites.playlistId, playlistId)
|
||||
)
|
||||
);
|
||||
|
||||
return result[0].count > 0;
|
||||
}
|
||||
|
||||
export async function getFavorites(db: AppDatabase, playlistId: string) {
|
||||
return db
|
||||
.select({
|
||||
id: schema.content.id,
|
||||
category_id: schema.content.categoryId,
|
||||
title: schema.content.title,
|
||||
rating: schema.content.rating,
|
||||
added: schema.content.added,
|
||||
poster_url: schema.content.posterUrl,
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
added_at: schema.favorites.addedAt,
|
||||
position: schema.favorites.position,
|
||||
})
|
||||
.from(schema.favorites)
|
||||
.innerJoin(
|
||||
schema.content,
|
||||
eq(schema.favorites.contentId, schema.content.id)
|
||||
)
|
||||
.where(eq(schema.favorites.playlistId, playlistId))
|
||||
.orderBy(
|
||||
asc(schema.favorites.position),
|
||||
desc(schema.favorites.addedAt)
|
||||
);
|
||||
}
|
||||
|
||||
export async function getGlobalFavorites(db: AppDatabase) {
|
||||
return db
|
||||
.select({
|
||||
id: schema.content.id,
|
||||
category_id: schema.content.categoryId,
|
||||
title: schema.content.title,
|
||||
rating: schema.content.rating,
|
||||
added: schema.content.added,
|
||||
poster_url: schema.content.posterUrl,
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
playlist_id: schema.playlists.id,
|
||||
playlist_name: schema.playlists.name,
|
||||
added_at: schema.favorites.addedAt,
|
||||
position: schema.favorites.position,
|
||||
})
|
||||
.from(schema.favorites)
|
||||
.innerJoin(
|
||||
schema.content,
|
||||
eq(schema.favorites.contentId, schema.content.id)
|
||||
)
|
||||
.innerJoin(
|
||||
schema.categories,
|
||||
eq(schema.content.categoryId, schema.categories.id)
|
||||
)
|
||||
.innerJoin(
|
||||
schema.playlists,
|
||||
eq(schema.categories.playlistId, schema.playlists.id)
|
||||
)
|
||||
.where(eq(schema.content.type, 'live'))
|
||||
.orderBy(asc(schema.favorites.position), desc(schema.favorites.addedAt))
|
||||
.limit(300);
|
||||
}
|
||||
|
||||
export async function getAllGlobalFavorites(db: AppDatabase) {
|
||||
return db
|
||||
.select({
|
||||
id: schema.content.id,
|
||||
category_id: schema.content.categoryId,
|
||||
title: schema.content.title,
|
||||
rating: schema.content.rating,
|
||||
added: schema.content.added,
|
||||
poster_url: schema.content.posterUrl,
|
||||
backdrop_url: schema.content.backdropUrl,
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
playlist_id: schema.playlists.id,
|
||||
playlist_name: schema.playlists.name,
|
||||
added_at: schema.favorites.addedAt,
|
||||
position: schema.favorites.position,
|
||||
})
|
||||
.from(schema.favorites)
|
||||
.innerJoin(
|
||||
schema.content,
|
||||
eq(schema.favorites.contentId, schema.content.id)
|
||||
)
|
||||
.innerJoin(
|
||||
schema.categories,
|
||||
eq(schema.content.categoryId, schema.categories.id)
|
||||
)
|
||||
.innerJoin(
|
||||
schema.playlists,
|
||||
eq(schema.categories.playlistId, schema.playlists.id)
|
||||
)
|
||||
.orderBy(asc(schema.favorites.position), desc(schema.favorites.addedAt))
|
||||
.limit(500);
|
||||
}
|
||||
|
||||
export async function reorderGlobalFavorites(
|
||||
db: AppDatabase,
|
||||
updates: { content_id: number; position: number }[],
|
||||
control?: OperationControl
|
||||
): Promise<{ success: boolean }> {
|
||||
if (!Array.isArray(updates) || updates.length === 0) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
let current = 0;
|
||||
const total = updates.length;
|
||||
|
||||
for (const chunk of chunkValues(updates, DEFAULT_BATCH_SIZE)) {
|
||||
await checkpointOperation(control);
|
||||
|
||||
for (const { content_id, position } of chunk) {
|
||||
await db
|
||||
.update(schema.favorites)
|
||||
.set({ position })
|
||||
.where(eq(schema.favorites.contentId, content_id));
|
||||
}
|
||||
|
||||
current += chunk.length;
|
||||
await reportOperationProgress(control, {
|
||||
phase: 'reordering-global-favorites',
|
||||
current,
|
||||
total,
|
||||
increment: chunk.length,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface OperationProgressUpdate {
|
||||
phase: string;
|
||||
current?: number;
|
||||
total?: number;
|
||||
increment?: number;
|
||||
}
|
||||
|
||||
export interface OperationControl {
|
||||
checkpoint?: () => void | Promise<void>;
|
||||
onProgress?: (
|
||||
progress: OperationProgressUpdate
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export async function checkpointOperation(
|
||||
control?: OperationControl
|
||||
): Promise<void> {
|
||||
await control?.checkpoint?.();
|
||||
}
|
||||
|
||||
export async function reportOperationProgress(
|
||||
control: OperationControl | undefined,
|
||||
progress: OperationProgressUpdate
|
||||
): Promise<void> {
|
||||
await control?.onProgress?.(progress);
|
||||
await checkpointOperation(control);
|
||||
}
|
||||
|
||||
export function chunkValues<T>(
|
||||
values: T[],
|
||||
chunkSize: number
|
||||
): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
|
||||
for (let index = 0; index < values.length; index += chunkSize) {
|
||||
chunks.push(values.slice(index, index + chunkSize));
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { and, desc, eq, sql } from 'drizzle-orm';
|
||||
import * as schema from 'database-schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
|
||||
type PlaylistType = 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
|
||||
|
||||
type PlaybackPositionPayload = {
|
||||
contentXtreamId: number;
|
||||
contentType: 'vod' | 'episode';
|
||||
seriesXtreamId?: number;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
positionSeconds: number;
|
||||
durationSeconds?: number;
|
||||
playlistType?: PlaylistType;
|
||||
};
|
||||
|
||||
async function ensurePlaylistExists(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
playlistType: PlaylistType = 'stalker'
|
||||
): Promise<void> {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(schema.playlists)
|
||||
.where(eq(schema.playlists.id, playlistId))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
await db.insert(schema.playlists).values({
|
||||
id: playlistId,
|
||||
name: 'Imported Playlist',
|
||||
type: playlistType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePlaybackPosition(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
data: PlaybackPositionPayload
|
||||
): Promise<{ success: boolean }> {
|
||||
await ensurePlaylistExists(db, playlistId, data.playlistType);
|
||||
|
||||
const values = {
|
||||
playlistId,
|
||||
contentXtreamId: data.contentXtreamId,
|
||||
contentType: data.contentType,
|
||||
seriesXtreamId: data.seriesXtreamId,
|
||||
seasonNumber: data.seasonNumber,
|
||||
episodeNumber: data.episodeNumber,
|
||||
positionSeconds: data.positionSeconds,
|
||||
durationSeconds: data.durationSeconds,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
};
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(schema.playbackPositions)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.playbackPositions.playlistId, playlistId),
|
||||
eq(
|
||||
schema.playbackPositions.contentXtreamId,
|
||||
data.contentXtreamId
|
||||
),
|
||||
eq(schema.playbackPositions.contentType, data.contentType)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(schema.playbackPositions)
|
||||
.set(values)
|
||||
.where(eq(schema.playbackPositions.id, existing[0].id));
|
||||
} else {
|
||||
await db.insert(schema.playbackPositions).values(values);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getPlaybackPosition(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
contentXtreamId: number,
|
||||
contentType: 'vod' | 'episode'
|
||||
) {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(schema.playbackPositions)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.playbackPositions.playlistId, playlistId),
|
||||
eq(schema.playbackPositions.contentXtreamId, contentXtreamId),
|
||||
eq(schema.playbackPositions.contentType, contentType)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
export async function getSeriesPlaybackPositions(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
seriesXtreamId: number
|
||||
) {
|
||||
return db
|
||||
.select()
|
||||
.from(schema.playbackPositions)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.playbackPositions.playlistId, playlistId),
|
||||
eq(schema.playbackPositions.seriesXtreamId, seriesXtreamId),
|
||||
eq(schema.playbackPositions.contentType, 'episode')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function getRecentPlaybackPositions(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
limit = 20
|
||||
) {
|
||||
return db
|
||||
.select()
|
||||
.from(schema.playbackPositions)
|
||||
.where(eq(schema.playbackPositions.playlistId, playlistId))
|
||||
.orderBy(desc(schema.playbackPositions.updatedAt))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
export async function getAllPlaybackPositions(
|
||||
db: AppDatabase,
|
||||
playlistId: string
|
||||
) {
|
||||
return db
|
||||
.select()
|
||||
.from(schema.playbackPositions)
|
||||
.where(eq(schema.playbackPositions.playlistId, playlistId));
|
||||
}
|
||||
|
||||
export async function clearAllPlaybackPositions(
|
||||
db: AppDatabase,
|
||||
playlistId: string
|
||||
): Promise<{ success: boolean }> {
|
||||
await db
|
||||
.delete(schema.playbackPositions)
|
||||
.where(eq(schema.playbackPositions.playlistId, playlistId));
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function clearPlaybackPosition(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
contentXtreamId: number,
|
||||
contentType: 'vod' | 'episode'
|
||||
): Promise<{ success: boolean }> {
|
||||
await db
|
||||
.delete(schema.playbackPositions)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.playbackPositions.playlistId, playlistId),
|
||||
eq(schema.playbackPositions.contentXtreamId, contentXtreamId),
|
||||
eq(schema.playbackPositions.contentType, contentType)
|
||||
)
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { parseAppPlaylist } from './playlist.operations';
|
||||
|
||||
describe('playlist.operations', () => {
|
||||
it('hydrates updateDate from lastUpdated when payload is stale', async () => {
|
||||
const parsed = parseAppPlaylist({
|
||||
id: 'playlist-1',
|
||||
name: 'Refresh Xtream Source',
|
||||
serverUrl: 'http://localhost:8080',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
dateCreated: '2026-04-03T08:00:00.000Z',
|
||||
lastUpdated: '2026-04-03T11:15:00.000Z',
|
||||
type: 'xtream',
|
||||
autoRefresh: false,
|
||||
count: 0,
|
||||
importDate: '2026-04-03T08:00:00.000Z',
|
||||
payload: JSON.stringify({
|
||||
_id: 'playlist-1',
|
||||
title: 'Refresh Xtream Source',
|
||||
count: 0,
|
||||
importDate: '2026-04-03T08:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
serverUrl: 'http://localhost:8080',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
expect(parsed).toEqual(
|
||||
expect.objectContaining({
|
||||
_id: 'playlist-1',
|
||||
updateDate: new Date('2026-04-03T11:15:00.000Z').getTime(),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,504 @@
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import * as schema from 'database-schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import {
|
||||
checkpointOperation,
|
||||
chunkValues,
|
||||
type OperationControl,
|
||||
reportOperationProgress,
|
||||
} from './operation-control';
|
||||
|
||||
const PLAYLIST_TYPES = {
|
||||
XTREAM: 'xtream',
|
||||
STALKER: 'stalker',
|
||||
M3U_FILE: 'm3u-file',
|
||||
M3U_TEXT: 'm3u-text',
|
||||
M3U_URL: 'm3u-url',
|
||||
} as const;
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 100;
|
||||
|
||||
type PlaylistType = (typeof PLAYLIST_TYPES)[keyof typeof PLAYLIST_TYPES];
|
||||
const PLAYLIST_TYPE_VALUES = new Set<PlaylistType>(
|
||||
Object.values(PLAYLIST_TYPES)
|
||||
);
|
||||
|
||||
function getStringValue(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function getNumericValue(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseJsonValue<T>(value: string | null | undefined, fallback: T): T {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse JSON value from DB:', error);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function inferPlaylistType(playlist: Record<string, unknown>): PlaylistType {
|
||||
const explicitType = getStringValue(playlist.type);
|
||||
if (
|
||||
explicitType &&
|
||||
PLAYLIST_TYPE_VALUES.has(explicitType as PlaylistType)
|
||||
) {
|
||||
return explicitType as PlaylistType;
|
||||
}
|
||||
|
||||
if (getStringValue(playlist.serverUrl)) {
|
||||
return PLAYLIST_TYPES.XTREAM;
|
||||
}
|
||||
|
||||
if (getStringValue(playlist.macAddress)) {
|
||||
return PLAYLIST_TYPES.STALKER;
|
||||
}
|
||||
|
||||
if (getStringValue(playlist.filePath)) {
|
||||
return PLAYLIST_TYPES.M3U_FILE;
|
||||
}
|
||||
|
||||
if (getStringValue(playlist.url)) {
|
||||
return PLAYLIST_TYPES.M3U_URL;
|
||||
}
|
||||
|
||||
return PLAYLIST_TYPES.M3U_TEXT;
|
||||
}
|
||||
|
||||
function buildPlaylistRow(
|
||||
playlist: Record<string, unknown>
|
||||
): schema.NewPlaylist | null {
|
||||
const id = getStringValue(playlist._id) ?? getStringValue(playlist.id);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = inferPlaylistType(playlist);
|
||||
const portalUrl = getStringValue(playlist.portalUrl);
|
||||
const url = getStringValue(playlist.url);
|
||||
const nowIso = new Date().toISOString();
|
||||
const updateDate = getNumericValue(playlist.updateDate);
|
||||
|
||||
return {
|
||||
id,
|
||||
name:
|
||||
getStringValue(playlist.title) ??
|
||||
getStringValue(playlist.name) ??
|
||||
id,
|
||||
serverUrl: getStringValue(playlist.serverUrl),
|
||||
username: getStringValue(playlist.username),
|
||||
password: getStringValue(playlist.password),
|
||||
lastUpdated:
|
||||
typeof updateDate === 'number'
|
||||
? new Date(updateDate).toISOString()
|
||||
: getStringValue(playlist.lastUpdated),
|
||||
type,
|
||||
userAgent: getStringValue(playlist.userAgent),
|
||||
origin: getStringValue(playlist.origin),
|
||||
referrer: getStringValue(playlist.referrer),
|
||||
filePath: getStringValue(playlist.filePath),
|
||||
autoRefresh: Boolean(playlist.autoRefresh),
|
||||
macAddress: getStringValue(playlist.macAddress),
|
||||
url: type === PLAYLIST_TYPES.STALKER ? (portalUrl ?? url) : url,
|
||||
portalUrl,
|
||||
count: getNumericValue(playlist.count),
|
||||
importDate: getStringValue(playlist.importDate),
|
||||
updateDate,
|
||||
position: getNumericValue(playlist.position),
|
||||
favorites:
|
||||
playlist.favorites !== undefined
|
||||
? JSON.stringify(playlist.favorites)
|
||||
: undefined,
|
||||
recentlyViewed:
|
||||
playlist.recentlyViewed !== undefined
|
||||
? JSON.stringify(playlist.recentlyViewed)
|
||||
: undefined,
|
||||
payload: JSON.stringify(playlist),
|
||||
lastUsage: getStringValue(playlist.lastUsage) ?? nowIso,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAppPlaylist(row: schema.Playlist): Record<string, unknown> {
|
||||
const payload = parseJsonValue<Record<string, unknown> | null>(
|
||||
row.payload,
|
||||
null
|
||||
);
|
||||
const base = payload && typeof payload === 'object' ? payload : {};
|
||||
const favorites = parseJsonValue<unknown[]>(row.favorites, []);
|
||||
const recentlyViewed = parseJsonValue<unknown[]>(row.recentlyViewed, []);
|
||||
const importDate =
|
||||
row.importDate ?? row.dateCreated ?? new Date().toISOString();
|
||||
const portalUrl =
|
||||
row.portalUrl ??
|
||||
(row.type === PLAYLIST_TYPES.STALKER ? row.url : null);
|
||||
const updateDate =
|
||||
row.updateDate ??
|
||||
(row.lastUpdated ? new Date(row.lastUpdated).getTime() : undefined);
|
||||
|
||||
return {
|
||||
...base,
|
||||
_id: row.id,
|
||||
title:
|
||||
getStringValue(base.title) ??
|
||||
getStringValue(base.name) ??
|
||||
row.name,
|
||||
count: row.count ?? getNumericValue(base.count) ?? 0,
|
||||
importDate: getStringValue(base.importDate) ?? importDate,
|
||||
lastUsage:
|
||||
row.lastUsage ??
|
||||
getStringValue(base.lastUsage) ??
|
||||
getStringValue(base.importDate) ??
|
||||
importDate,
|
||||
favorites,
|
||||
recentlyViewed,
|
||||
autoRefresh: row.autoRefresh ?? Boolean(base.autoRefresh),
|
||||
url:
|
||||
row.type === PLAYLIST_TYPES.M3U_URL
|
||||
? row.url ?? getStringValue(base.url)
|
||||
: getStringValue(base.url),
|
||||
filePath: row.filePath ?? getStringValue(base.filePath),
|
||||
userAgent: row.userAgent ?? getStringValue(base.userAgent),
|
||||
referrer: row.referrer ?? getStringValue(base.referrer),
|
||||
origin: row.origin ?? getStringValue(base.origin),
|
||||
updateDate,
|
||||
position: row.position ?? getNumericValue(base.position),
|
||||
serverUrl: row.serverUrl ?? getStringValue(base.serverUrl),
|
||||
username: row.username ?? getStringValue(base.username),
|
||||
password: row.password ?? getStringValue(base.password),
|
||||
macAddress: row.macAddress ?? getStringValue(base.macAddress),
|
||||
portalUrl: portalUrl ?? getStringValue(base.portalUrl),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createPlaylist(
|
||||
db: AppDatabase,
|
||||
playlist: {
|
||||
id: string;
|
||||
name: string;
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
macAddress?: string;
|
||||
url?: string;
|
||||
type: string;
|
||||
}
|
||||
): Promise<{ success: boolean }> {
|
||||
await db.insert(schema.playlists).values({
|
||||
id: playlist.id,
|
||||
name: playlist.name,
|
||||
serverUrl: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
macAddress: playlist.macAddress,
|
||||
url: playlist.url,
|
||||
portalUrl:
|
||||
playlist.type === PLAYLIST_TYPES.STALKER ? playlist.url : undefined,
|
||||
type: playlist.type as PlaylistType,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function upsertAppPlaylist(
|
||||
db: AppDatabase,
|
||||
playlist: Record<string, unknown>
|
||||
): Promise<{ success: boolean }> {
|
||||
const row = buildPlaylistRow(playlist);
|
||||
if (!row) {
|
||||
throw new Error('Playlist ID is required for upsert');
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(schema.playlists)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({
|
||||
target: schema.playlists.id,
|
||||
set: row,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function upsertAppPlaylists(
|
||||
db: AppDatabase,
|
||||
playlists: Record<string, unknown>[]
|
||||
): Promise<{ success: boolean; count: number }> {
|
||||
if (!Array.isArray(playlists) || playlists.length === 0) {
|
||||
return { success: true, count: 0 };
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
|
||||
for (const playlist of playlists) {
|
||||
const row = buildPlaylistRow(playlist);
|
||||
if (!row) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(schema.playlists)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({
|
||||
target: schema.playlists.id,
|
||||
set: row,
|
||||
});
|
||||
|
||||
count += 1;
|
||||
}
|
||||
|
||||
return { success: true, count };
|
||||
}
|
||||
|
||||
export async function getAppPlaylists(db: AppDatabase) {
|
||||
const rows = await db.select().from(schema.playlists);
|
||||
return rows.map((row) => parseAppPlaylist(row));
|
||||
}
|
||||
|
||||
export async function getAppPlaylist(db: AppDatabase, playlistId: string) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.playlists)
|
||||
.where(eq(schema.playlists.id, playlistId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ? parseAppPlaylist(rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function getPlaylist(db: AppDatabase, playlistId: string) {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(schema.playlists)
|
||||
.where(eq(schema.playlists.id, playlistId))
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
export async function updatePlaylist(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
updates: {
|
||||
name?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
serverUrl?: string;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
): Promise<{ success: boolean }> {
|
||||
await db
|
||||
.update(schema.playlists)
|
||||
.set(updates)
|
||||
.where(eq(schema.playlists.id, playlistId));
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deletePlaylist(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
control?: OperationControl
|
||||
): Promise<{ success: boolean }> {
|
||||
const [favoriteRows, recentlyViewedRows, playbackPositionRows, downloadRows] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({ id: schema.favorites.id })
|
||||
.from(schema.favorites)
|
||||
.where(eq(schema.favorites.playlistId, playlistId)),
|
||||
db
|
||||
.select({ id: schema.recentlyViewed.id })
|
||||
.from(schema.recentlyViewed)
|
||||
.where(eq(schema.recentlyViewed.playlistId, playlistId)),
|
||||
db
|
||||
.select({ id: schema.playbackPositions.id })
|
||||
.from(schema.playbackPositions)
|
||||
.where(eq(schema.playbackPositions.playlistId, playlistId)),
|
||||
db
|
||||
.select({ id: schema.downloads.id })
|
||||
.from(schema.downloads)
|
||||
.where(eq(schema.downloads.playlistId, playlistId)),
|
||||
]);
|
||||
|
||||
const categoryRows = await db
|
||||
.select({ id: schema.categories.id })
|
||||
.from(schema.categories)
|
||||
.where(eq(schema.categories.playlistId, playlistId));
|
||||
const categoryIds = categoryRows.map((category) => category.id);
|
||||
const contentRows =
|
||||
categoryIds.length > 0
|
||||
? await db
|
||||
.select({ id: schema.content.id })
|
||||
.from(schema.content)
|
||||
.where(inArray(schema.content.categoryId, categoryIds))
|
||||
: [];
|
||||
|
||||
for (const [phase, ids, column, table] of [
|
||||
[
|
||||
'deleting-favorites',
|
||||
favoriteRows.map((row) => row.id),
|
||||
schema.favorites.id,
|
||||
schema.favorites,
|
||||
],
|
||||
[
|
||||
'deleting-recently-viewed',
|
||||
recentlyViewedRows.map((row) => row.id),
|
||||
schema.recentlyViewed.id,
|
||||
schema.recentlyViewed,
|
||||
],
|
||||
[
|
||||
'deleting-playback-positions',
|
||||
playbackPositionRows.map((row) => row.id),
|
||||
schema.playbackPositions.id,
|
||||
schema.playbackPositions,
|
||||
],
|
||||
[
|
||||
'deleting-downloads',
|
||||
downloadRows.map((row) => row.id),
|
||||
schema.downloads.id,
|
||||
schema.downloads,
|
||||
],
|
||||
[
|
||||
'deleting-content',
|
||||
contentRows.map((row) => row.id),
|
||||
schema.content.id,
|
||||
schema.content,
|
||||
],
|
||||
[
|
||||
'deleting-categories',
|
||||
categoryIds,
|
||||
schema.categories.id,
|
||||
schema.categories,
|
||||
],
|
||||
] as const) {
|
||||
let current = 0;
|
||||
const total = ids.length;
|
||||
|
||||
for (const chunk of chunkValues(ids, DEFAULT_BATCH_SIZE)) {
|
||||
await checkpointOperation(control);
|
||||
await db.delete(table).where(inArray(column, chunk));
|
||||
current += chunk.length;
|
||||
await reportOperationProgress(control, {
|
||||
phase,
|
||||
current,
|
||||
total,
|
||||
increment: chunk.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await checkpointOperation(control);
|
||||
await db.delete(schema.playlists).where(eq(schema.playlists.id, playlistId));
|
||||
await reportOperationProgress(control, {
|
||||
phase: 'deleting-playlist',
|
||||
current: 1,
|
||||
total: 1,
|
||||
increment: 1,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getAppState(db: AppDatabase, key: string) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.appState)
|
||||
.where(eq(schema.appState.key, key))
|
||||
.limit(1);
|
||||
|
||||
return rows[0]?.value ?? null;
|
||||
}
|
||||
|
||||
export async function setAppState(
|
||||
db: AppDatabase,
|
||||
key: string,
|
||||
value: string
|
||||
): Promise<{ success: boolean }> {
|
||||
const updatedAt = new Date().toISOString();
|
||||
|
||||
await db
|
||||
.insert(schema.appState)
|
||||
.values({
|
||||
key,
|
||||
value,
|
||||
updatedAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.appState.key,
|
||||
set: { value, updatedAt },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteAllPlaylists(
|
||||
db: AppDatabase,
|
||||
control?: OperationControl
|
||||
): Promise<{ success: boolean }> {
|
||||
const deleteStages = [
|
||||
{
|
||||
phase: 'deleting-favorites',
|
||||
execute: () => db.delete(schema.favorites),
|
||||
},
|
||||
{
|
||||
phase: 'deleting-recently-viewed',
|
||||
execute: () => db.delete(schema.recentlyViewed),
|
||||
},
|
||||
{
|
||||
phase: 'deleting-playback-positions',
|
||||
execute: () => db.delete(schema.playbackPositions),
|
||||
},
|
||||
{
|
||||
phase: 'deleting-downloads',
|
||||
execute: () => db.delete(schema.downloads),
|
||||
},
|
||||
{
|
||||
phase: 'deleting-content',
|
||||
execute: () => db.delete(schema.content),
|
||||
},
|
||||
{
|
||||
phase: 'deleting-categories',
|
||||
execute: () => db.delete(schema.categories),
|
||||
},
|
||||
{
|
||||
phase: 'deleting-playlists',
|
||||
execute: () => db.delete(schema.playlists),
|
||||
},
|
||||
] as const;
|
||||
|
||||
let current = 0;
|
||||
const total = deleteStages.length;
|
||||
|
||||
for (const stage of deleteStages) {
|
||||
await checkpointOperation(control);
|
||||
await stage.execute();
|
||||
current += 1;
|
||||
await reportOperationProgress(control, {
|
||||
phase: stage.phase,
|
||||
current,
|
||||
total,
|
||||
increment: 1,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
|
||||
import * as schema from 'database-schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import { persistContentBackdropIfMissing } from './content-backdrop.operations';
|
||||
|
||||
export async function getRecentlyViewed(db: AppDatabase) {
|
||||
return db
|
||||
.select({
|
||||
id: schema.content.id,
|
||||
category_id: schema.content.categoryId,
|
||||
title: schema.content.title,
|
||||
rating: schema.content.rating,
|
||||
added: schema.content.added,
|
||||
poster_url: schema.content.posterUrl,
|
||||
backdrop_url: schema.content.backdropUrl,
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
playlist_id: schema.categories.playlistId,
|
||||
playlist_name: schema.playlists.name,
|
||||
viewed_at: schema.recentlyViewed.viewedAt,
|
||||
})
|
||||
.from(schema.recentlyViewed)
|
||||
.innerJoin(
|
||||
schema.content,
|
||||
eq(schema.recentlyViewed.contentId, schema.content.id)
|
||||
)
|
||||
.innerJoin(
|
||||
schema.categories,
|
||||
eq(schema.content.categoryId, schema.categories.id)
|
||||
)
|
||||
.innerJoin(
|
||||
schema.playlists,
|
||||
eq(schema.categories.playlistId, schema.playlists.id)
|
||||
)
|
||||
.orderBy(desc(schema.recentlyViewed.viewedAt))
|
||||
.limit(100);
|
||||
}
|
||||
|
||||
export async function clearRecentlyViewed(
|
||||
db: AppDatabase
|
||||
): Promise<{ success: boolean }> {
|
||||
await db.delete(schema.recentlyViewed);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getRecentItems(
|
||||
db: AppDatabase,
|
||||
playlistId: string
|
||||
) {
|
||||
return db
|
||||
.select({
|
||||
id: schema.content.id,
|
||||
category_id: schema.content.categoryId,
|
||||
title: schema.content.title,
|
||||
rating: schema.content.rating,
|
||||
added: schema.content.added,
|
||||
poster_url: schema.content.posterUrl,
|
||||
backdrop_url: schema.content.backdropUrl,
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
viewed_at: schema.recentlyViewed.viewedAt,
|
||||
})
|
||||
.from(schema.recentlyViewed)
|
||||
.innerJoin(
|
||||
schema.content,
|
||||
eq(schema.recentlyViewed.contentId, schema.content.id)
|
||||
)
|
||||
.where(eq(schema.recentlyViewed.playlistId, playlistId))
|
||||
.orderBy(desc(schema.recentlyViewed.viewedAt))
|
||||
.limit(100);
|
||||
}
|
||||
|
||||
export async function addRecentItem(
|
||||
db: AppDatabase,
|
||||
contentId: number,
|
||||
playlistId: string,
|
||||
options?: { backdropUrl?: string }
|
||||
): Promise<{ success: boolean }> {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(schema.recentlyViewed)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.recentlyViewed.contentId, contentId),
|
||||
eq(schema.recentlyViewed.playlistId, playlistId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(schema.recentlyViewed)
|
||||
.set({ viewedAt: sql`CURRENT_TIMESTAMP` })
|
||||
.where(
|
||||
and(
|
||||
eq(schema.recentlyViewed.contentId, contentId),
|
||||
eq(schema.recentlyViewed.playlistId, playlistId)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
await db.insert(schema.recentlyViewed).values({
|
||||
contentId,
|
||||
playlistId,
|
||||
});
|
||||
}
|
||||
|
||||
await persistContentBackdropIfMissing(db, contentId, options?.backdropUrl);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function clearPlaylistRecentItems(
|
||||
db: AppDatabase,
|
||||
playlistId: string
|
||||
): Promise<{ success: boolean }> {
|
||||
const contentIds = await db
|
||||
.select({ id: schema.content.id })
|
||||
.from(schema.content)
|
||||
.innerJoin(
|
||||
schema.categories,
|
||||
eq(schema.content.categoryId, schema.categories.id)
|
||||
)
|
||||
.where(eq(schema.categories.playlistId, playlistId));
|
||||
|
||||
if (contentIds.length > 0) {
|
||||
await db.delete(schema.recentlyViewed).where(
|
||||
inArray(
|
||||
schema.recentlyViewed.contentId,
|
||||
contentIds.map((content) => content.id)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function removeRecentItem(
|
||||
db: AppDatabase,
|
||||
contentId: number,
|
||||
playlistId: string
|
||||
): Promise<{ success: boolean }> {
|
||||
await db
|
||||
.delete(schema.recentlyViewed)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.recentlyViewed.contentId, contentId),
|
||||
eq(schema.recentlyViewed.playlistId, playlistId)
|
||||
)
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import * as schema from 'database-schema';
|
||||
import type {
|
||||
XtreamBackupFavoriteItem,
|
||||
XtreamBackupHiddenCategory,
|
||||
XtreamBackupRecentlyViewedItem,
|
||||
} from 'shared-interfaces';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import {
|
||||
checkpointOperation,
|
||||
chunkValues,
|
||||
type OperationControl,
|
||||
reportOperationProgress,
|
||||
} from './operation-control';
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 100;
|
||||
|
||||
type ContentIdentity = {
|
||||
id: number;
|
||||
xtreamId: number;
|
||||
contentType: XtreamBackupFavoriteItem['contentType'];
|
||||
};
|
||||
|
||||
function toContentIdentityKey(
|
||||
contentType: XtreamBackupFavoriteItem['contentType'],
|
||||
xtreamId: number
|
||||
): string {
|
||||
return `${contentType}:${xtreamId}`;
|
||||
}
|
||||
|
||||
export async function deleteXtreamContent(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
control?: OperationControl
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
favorites: XtreamBackupFavoriteItem[];
|
||||
recentlyViewed: XtreamBackupRecentlyViewedItem[];
|
||||
hiddenCategories: XtreamBackupHiddenCategory[];
|
||||
}> {
|
||||
const categories = await db
|
||||
.select({
|
||||
id: schema.categories.id,
|
||||
xtreamId: schema.categories.xtreamId,
|
||||
type: schema.categories.type,
|
||||
hidden: schema.categories.hidden,
|
||||
})
|
||||
.from(schema.categories)
|
||||
.where(eq(schema.categories.playlistId, playlistId));
|
||||
|
||||
const categoryIds = categories.map((category) => category.id);
|
||||
const hiddenCategories = categories
|
||||
.filter((category) => category.hidden)
|
||||
.map((category) => ({
|
||||
xtreamId: category.xtreamId,
|
||||
categoryType: category.type,
|
||||
}));
|
||||
|
||||
let favorites: XtreamBackupFavoriteItem[] = [];
|
||||
let recentlyViewed: XtreamBackupRecentlyViewedItem[] = [];
|
||||
|
||||
if (categoryIds.length > 0) {
|
||||
const favoritedContent = await db
|
||||
.select({
|
||||
xtreamId: schema.content.xtreamId,
|
||||
contentType: schema.content.type,
|
||||
addedAt: schema.favorites.addedAt,
|
||||
position: schema.favorites.position,
|
||||
})
|
||||
.from(schema.favorites)
|
||||
.innerJoin(
|
||||
schema.content,
|
||||
eq(schema.favorites.contentId, schema.content.id)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.favorites.playlistId, playlistId),
|
||||
inArray(schema.content.categoryId, categoryIds)
|
||||
)
|
||||
);
|
||||
|
||||
favorites = favoritedContent.map((favorite) => ({
|
||||
xtreamId: favorite.xtreamId,
|
||||
contentType: favorite.contentType,
|
||||
addedAt: favorite.addedAt ?? undefined,
|
||||
position: favorite.position,
|
||||
}));
|
||||
|
||||
const recentlyViewedContent = await db
|
||||
.select({
|
||||
xtreamId: schema.content.xtreamId,
|
||||
contentType: schema.content.type,
|
||||
viewedAt: schema.recentlyViewed.viewedAt,
|
||||
})
|
||||
.from(schema.recentlyViewed)
|
||||
.innerJoin(
|
||||
schema.content,
|
||||
eq(schema.recentlyViewed.contentId, schema.content.id)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.recentlyViewed.playlistId, playlistId),
|
||||
inArray(schema.content.categoryId, categoryIds)
|
||||
)
|
||||
);
|
||||
|
||||
recentlyViewed = recentlyViewedContent.map((item) => ({
|
||||
xtreamId: item.xtreamId,
|
||||
contentType: item.contentType,
|
||||
viewedAt: item.viewedAt || new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const contentRows = await db
|
||||
.select({ id: schema.content.id })
|
||||
.from(schema.content)
|
||||
.where(inArray(schema.content.categoryId, categoryIds));
|
||||
|
||||
let deletedContent = 0;
|
||||
const totalContent = contentRows.length;
|
||||
|
||||
for (const chunk of chunkValues(
|
||||
contentRows.map((content) => content.id),
|
||||
DEFAULT_BATCH_SIZE
|
||||
)) {
|
||||
await checkpointOperation(control);
|
||||
await db
|
||||
.delete(schema.content)
|
||||
.where(inArray(schema.content.id, chunk));
|
||||
deletedContent += chunk.length;
|
||||
await reportOperationProgress(control, {
|
||||
phase: 'deleting-content',
|
||||
current: deletedContent,
|
||||
total: totalContent,
|
||||
increment: chunk.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let deletedCategories = 0;
|
||||
const totalCategories = categoryIds.length;
|
||||
|
||||
for (const chunk of chunkValues(categoryIds, DEFAULT_BATCH_SIZE)) {
|
||||
await checkpointOperation(control);
|
||||
await db
|
||||
.delete(schema.categories)
|
||||
.where(inArray(schema.categories.id, chunk));
|
||||
deletedCategories += chunk.length;
|
||||
await reportOperationProgress(control, {
|
||||
phase: 'deleting-categories',
|
||||
current: deletedCategories,
|
||||
total: totalCategories,
|
||||
increment: chunk.length,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
favorites,
|
||||
recentlyViewed,
|
||||
hiddenCategories,
|
||||
};
|
||||
}
|
||||
|
||||
async function getContentIdentityMap(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
identities: Array<{
|
||||
contentType: XtreamBackupFavoriteItem['contentType'];
|
||||
xtreamId: number;
|
||||
}>
|
||||
): Promise<Map<string, number>> {
|
||||
const xtreamIds = Array.from(
|
||||
new Set(identities.map((item) => item.xtreamId))
|
||||
);
|
||||
|
||||
if (xtreamIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const content = await db
|
||||
.select({
|
||||
id: schema.content.id,
|
||||
xtreamId: schema.content.xtreamId,
|
||||
contentType: schema.content.type,
|
||||
})
|
||||
.from(schema.content)
|
||||
.innerJoin(
|
||||
schema.categories,
|
||||
eq(schema.content.categoryId, schema.categories.id)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.categories.playlistId, playlistId),
|
||||
inArray(schema.content.xtreamId, xtreamIds)
|
||||
)
|
||||
);
|
||||
|
||||
return new Map(
|
||||
content.map((item: ContentIdentity) => [
|
||||
toContentIdentityKey(item.contentType, item.xtreamId),
|
||||
item.id,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
export async function restoreXtreamUserData(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
favorites: XtreamBackupFavoriteItem[],
|
||||
recentlyViewed: XtreamBackupRecentlyViewedItem[],
|
||||
control?: OperationControl
|
||||
): Promise<{ success: boolean }> {
|
||||
await checkpointOperation(control);
|
||||
await db
|
||||
.delete(schema.favorites)
|
||||
.where(eq(schema.favorites.playlistId, playlistId));
|
||||
|
||||
await checkpointOperation(control);
|
||||
await db
|
||||
.delete(schema.recentlyViewed)
|
||||
.where(eq(schema.recentlyViewed.playlistId, playlistId));
|
||||
|
||||
const contentByIdentity = await getContentIdentityMap(db, playlistId, [
|
||||
...favorites.map((item) => ({
|
||||
contentType: item.contentType,
|
||||
xtreamId: item.xtreamId,
|
||||
})),
|
||||
...recentlyViewed.map((item) => ({
|
||||
contentType: item.contentType,
|
||||
xtreamId: item.xtreamId,
|
||||
})),
|
||||
]);
|
||||
|
||||
const favoriteValues = favorites
|
||||
.map((item, index) => {
|
||||
const contentId = contentByIdentity.get(
|
||||
toContentIdentityKey(item.contentType, item.xtreamId)
|
||||
);
|
||||
|
||||
if (!contentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
contentId,
|
||||
playlistId,
|
||||
addedAt: item.addedAt ?? new Date().toISOString(),
|
||||
position: item.position ?? index,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
value
|
||||
): value is {
|
||||
contentId: number;
|
||||
playlistId: string;
|
||||
addedAt: string;
|
||||
position: number | null;
|
||||
} => value !== null
|
||||
);
|
||||
|
||||
let restoredFavorites = 0;
|
||||
const totalFavorites = favoriteValues.length;
|
||||
|
||||
for (const chunk of chunkValues(favoriteValues, DEFAULT_BATCH_SIZE)) {
|
||||
await checkpointOperation(control);
|
||||
await db.insert(schema.favorites).values(chunk);
|
||||
restoredFavorites += chunk.length;
|
||||
await reportOperationProgress(control, {
|
||||
phase: 'restoring-favorites',
|
||||
current: restoredFavorites,
|
||||
total: totalFavorites,
|
||||
increment: chunk.length,
|
||||
});
|
||||
}
|
||||
|
||||
const recentlyViewedValues = recentlyViewed
|
||||
.map((item) => {
|
||||
const contentId = contentByIdentity.get(
|
||||
toContentIdentityKey(item.contentType, item.xtreamId)
|
||||
);
|
||||
|
||||
if (!contentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
contentId,
|
||||
playlistId,
|
||||
viewedAt: item.viewedAt,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
value
|
||||
): value is {
|
||||
contentId: number;
|
||||
playlistId: string;
|
||||
viewedAt: string;
|
||||
} => value !== null
|
||||
);
|
||||
|
||||
let restoredRecentlyViewed = 0;
|
||||
const totalRecentlyViewed = recentlyViewedValues.length;
|
||||
|
||||
for (const chunk of chunkValues(recentlyViewedValues, DEFAULT_BATCH_SIZE)) {
|
||||
await checkpointOperation(control);
|
||||
await db.insert(schema.recentlyViewed).values(chunk);
|
||||
restoredRecentlyViewed += chunk.length;
|
||||
await reportOperationProgress(control, {
|
||||
phase: 'restoring-recently-viewed',
|
||||
current: restoredRecentlyViewed,
|
||||
total: totalRecentlyViewed,
|
||||
increment: chunk.length,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Database schema re-export from shared library
|
||||
* This file maintains backwards compatibility for existing imports
|
||||
*/
|
||||
|
||||
export {
|
||||
// Tables
|
||||
playlists,
|
||||
categories,
|
||||
content,
|
||||
recentlyViewed,
|
||||
favorites,
|
||||
epgChannels,
|
||||
epgPrograms,
|
||||
playbackPositions,
|
||||
downloads,
|
||||
appState,
|
||||
// Types
|
||||
type Playlist,
|
||||
type NewPlaylist,
|
||||
type AppState,
|
||||
type NewAppState,
|
||||
type Category,
|
||||
type NewCategory,
|
||||
type Content,
|
||||
type NewContent,
|
||||
type RecentlyViewed,
|
||||
type NewRecentlyViewed,
|
||||
type Favorite,
|
||||
type NewFavorite,
|
||||
type EpgChannel,
|
||||
type NewEpgChannel,
|
||||
type EpgProgramDb,
|
||||
type NewEpgProgramDb,
|
||||
type PlaybackPosition,
|
||||
type NewPlaybackPosition,
|
||||
type Download,
|
||||
type NewDownload,
|
||||
} from 'database';
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Database IPC event handlers for Electron
|
||||
* Provides database operations to the renderer process
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
import './database/category.events';
|
||||
import './database/content.events';
|
||||
import './database/downloads.events';
|
||||
import './database/epg-db.events';
|
||||
import './database/favorites.events';
|
||||
import './database/playback-position.events';
|
||||
import './database/playlist.events';
|
||||
import './database/recently-viewed.events';
|
||||
import './database/xtream.events';
|
||||
|
||||
export default class DatabaseEvents {
|
||||
static bootstrapDatabaseEvents(): Electron.IpcMain {
|
||||
return ipcMain;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Category IPC event handlers
|
||||
* Operations for managing categories within playlists
|
||||
*/
|
||||
|
||||
import { handleWorkerRequest } from './worker-events.utils';
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_HAS_CATEGORIES',
|
||||
(playlistId: string, type: 'live' | 'movies' | 'series') => ({
|
||||
playlistId,
|
||||
type,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_GET_CATEGORIES',
|
||||
(playlistId: string, type: 'live' | 'movies' | 'series') => ({
|
||||
playlistId,
|
||||
type,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_SAVE_CATEGORIES',
|
||||
(
|
||||
playlistId: string,
|
||||
categories: Array<{
|
||||
category_name: string;
|
||||
category_id: string | number;
|
||||
}>,
|
||||
type: 'live' | 'movies' | 'series',
|
||||
hiddenCategoryXtreamIds?: number[]
|
||||
) => ({
|
||||
playlistId,
|
||||
categories,
|
||||
type,
|
||||
hiddenCategoryXtreamIds,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_UPDATE_CATEGORY_VISIBILITY',
|
||||
(categoryIds: number[], hidden: boolean) => ({
|
||||
categoryIds,
|
||||
hidden,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_GET_ALL_CATEGORIES',
|
||||
(playlistId: string, type: 'live' | 'movies' | 'series') => ({
|
||||
playlistId,
|
||||
type,
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Content IPC event handlers
|
||||
* Operations for managing and searching content (streams, movies, series)
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import {
|
||||
handleWorkerRequest,
|
||||
requestWorkerWithEvents,
|
||||
} from './worker-events.utils';
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_HAS_CONTENT',
|
||||
(playlistId: string, type: 'live' | 'movie' | 'series') => ({
|
||||
playlistId,
|
||||
type,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_GET_CONTENT',
|
||||
(playlistId: string, type: 'live' | 'movie' | 'series') => ({
|
||||
playlistId,
|
||||
type,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_GET_GLOBAL_RECENTLY_ADDED',
|
||||
(
|
||||
kind: 'all' | 'vod' | 'series' = 'all',
|
||||
limit = 200,
|
||||
playlistType?:
|
||||
| 'xtream'
|
||||
| 'stalker'
|
||||
| 'm3u-file'
|
||||
| 'm3u-text'
|
||||
| 'm3u-url'
|
||||
) => ({
|
||||
kind,
|
||||
limit,
|
||||
playlistType,
|
||||
})
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
'DB_SAVE_CONTENT',
|
||||
async (
|
||||
event,
|
||||
playlistId: string,
|
||||
streams: Array<Record<string, unknown>>,
|
||||
type: 'live' | 'movie' | 'series',
|
||||
operationId?: string
|
||||
) => {
|
||||
try {
|
||||
return await requestWorkerWithEvents(
|
||||
event,
|
||||
'DB_SAVE_CONTENT',
|
||||
{
|
||||
playlistId,
|
||||
streams,
|
||||
type,
|
||||
operationId,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error handling DB_SAVE_CONTENT:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_CLEAR_XTREAM_IMPORT_CACHE',
|
||||
(playlistId: string, type: 'live' | 'movie' | 'series') => ({
|
||||
playlistId,
|
||||
type,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_GET_CONTENT_BY_XTREAM_ID',
|
||||
(
|
||||
xtreamId: number,
|
||||
playlistId: string,
|
||||
contentType?: 'live' | 'movie' | 'series'
|
||||
) => ({
|
||||
xtreamId,
|
||||
playlistId,
|
||||
contentType,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_SET_CONTENT_BACKDROP_IF_MISSING',
|
||||
(contentId: number, backdropUrl?: string) => ({
|
||||
contentId,
|
||||
backdropUrl,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_SEARCH_CONTENT',
|
||||
(
|
||||
playlistId: string,
|
||||
searchTerm: string,
|
||||
types: string[],
|
||||
excludeHidden = false
|
||||
) => ({
|
||||
playlistId,
|
||||
searchTerm,
|
||||
types,
|
||||
excludeHidden,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_GLOBAL_SEARCH',
|
||||
(searchTerm: string, types: string[], excludeHidden = false) => ({
|
||||
searchTerm,
|
||||
types,
|
||||
excludeHidden,
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,649 @@
|
||||
/**
|
||||
* Downloads IPC event handlers
|
||||
* Uses electron-dl for download management with queue control and progress tracking
|
||||
*/
|
||||
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm';
|
||||
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron';
|
||||
import { download, File as ElectronDlFile } from 'electron-dl';
|
||||
import { existsSync, unlinkSync } from 'fs';
|
||||
import { basename, extname, join } from 'path';
|
||||
import { getDatabase } from '../../database/connection';
|
||||
import * as schema from '../../database/schema';
|
||||
|
||||
type DownloadStatus = 'queued' | 'downloading' | 'completed' | 'failed' | 'canceled';
|
||||
|
||||
interface DownloadTask {
|
||||
id: number;
|
||||
url: string;
|
||||
fileName: string;
|
||||
directory: string;
|
||||
headers?: Record<string, string>;
|
||||
downloadItem?: ElectronDlFile;
|
||||
}
|
||||
|
||||
// Download queue management
|
||||
const downloadQueue: DownloadTask[] = [];
|
||||
let activeDownload: DownloadTask | null = null;
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
/**
|
||||
* Set the main window reference for sending updates
|
||||
*/
|
||||
export function setMainWindow(win: BrowserWindow) {
|
||||
mainWindow = win;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast download updates to renderer
|
||||
*/
|
||||
function broadcastUpdate() {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('DOWNLOADS_UPDATE_EVENT');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension from URL
|
||||
*/
|
||||
function getExtensionFromUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
const ext = extname(pathname);
|
||||
return ext || '.mp4';
|
||||
} catch {
|
||||
return '.mp4';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename for filesystem
|
||||
*/
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[<>:"/\\|?*]/g, '_').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the download queue - starts next download if none active
|
||||
*/
|
||||
async function processQueue() {
|
||||
if (activeDownload || downloadQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const task = downloadQueue.shift();
|
||||
if (!task) return;
|
||||
|
||||
activeDownload = task;
|
||||
await startDownload(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a download using electron-dl
|
||||
*/
|
||||
async function startDownload(task: DownloadTask) {
|
||||
const db = await getDatabase();
|
||||
|
||||
// Update status to downloading
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
status: 'downloading',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
|
||||
broadcastUpdate();
|
||||
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
console.error('[Downloads] No main window available');
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
status: 'failed',
|
||||
errorMessage: 'No window available for download',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
activeDownload = null;
|
||||
broadcastUpdate();
|
||||
processQueue();
|
||||
return;
|
||||
}
|
||||
|
||||
let lastProgressUpdate = 0;
|
||||
const PROGRESS_THROTTLE_MS = 500;
|
||||
|
||||
try {
|
||||
const downloadOptions: Parameters<typeof download>[2] = {
|
||||
directory: task.directory,
|
||||
filename: task.fileName,
|
||||
overwrite: true,
|
||||
onStarted: (item) => {
|
||||
console.log(`[Downloads] Started: ${task.fileName}`);
|
||||
task.downloadItem = item as unknown as ElectronDlFile;
|
||||
},
|
||||
onProgress: async (progress) => {
|
||||
const now = Date.now();
|
||||
if (now - lastProgressUpdate < PROGRESS_THROTTLE_MS) {
|
||||
return;
|
||||
}
|
||||
lastProgressUpdate = now;
|
||||
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded: progress.transferredBytes,
|
||||
totalBytes: progress.totalBytes,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
|
||||
broadcastUpdate();
|
||||
},
|
||||
onCompleted: async (file) => {
|
||||
console.log(`[Downloads] Completed: ${task.fileName}`);
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
status: 'completed',
|
||||
filePath: file.path,
|
||||
fileName: file.filename,
|
||||
bytesDownloaded: file.fileSize,
|
||||
totalBytes: file.fileSize,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
|
||||
activeDownload = null;
|
||||
broadcastUpdate();
|
||||
processQueue();
|
||||
},
|
||||
onCancel: async () => {
|
||||
console.log(`[Downloads] Canceled: ${task.fileName}`);
|
||||
// Delete partial file if it exists
|
||||
const partialPath = join(task.directory, task.fileName);
|
||||
if (existsSync(partialPath)) {
|
||||
try {
|
||||
unlinkSync(partialPath);
|
||||
} catch (e) {
|
||||
console.error('[Downloads] Failed to delete partial file:', e);
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
status: 'canceled',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
|
||||
activeDownload = null;
|
||||
broadcastUpdate();
|
||||
processQueue();
|
||||
},
|
||||
};
|
||||
|
||||
// Add headers if provided (user-agent, referer, origin)
|
||||
if (task.headers) {
|
||||
(downloadOptions as any).headers = task.headers;
|
||||
}
|
||||
|
||||
await download(mainWindow, task.url, downloadOptions);
|
||||
} catch (error) {
|
||||
console.error(`[Downloads] Error downloading ${task.fileName}:`, error);
|
||||
|
||||
// Delete partial file if it exists
|
||||
const partialPath = join(task.directory, task.fileName);
|
||||
if (existsSync(partialPath)) {
|
||||
try {
|
||||
unlinkSync(partialPath);
|
||||
} catch (e) {
|
||||
console.error('[Downloads] Failed to delete partial file:', e);
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
status: 'failed',
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
|
||||
activeDownload = null;
|
||||
broadcastUpdate();
|
||||
processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a new download
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'DOWNLOADS_START',
|
||||
async (
|
||||
_event,
|
||||
data: {
|
||||
playlistId: string;
|
||||
xtreamId: number;
|
||||
contentType: 'vod' | 'episode';
|
||||
title: string;
|
||||
url: string;
|
||||
posterUrl?: string;
|
||||
downloadFolder: string;
|
||||
headers?: { userAgent?: string; referer?: string; origin?: string };
|
||||
seriesXtreamId?: number;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
// Playlist info for auto-creation if needed
|
||||
playlistName?: string;
|
||||
playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
|
||||
serverUrl?: string;
|
||||
portalUrl?: string;
|
||||
macAddress?: string;
|
||||
}
|
||||
) => {
|
||||
try {
|
||||
console.log('[Downloads] Enqueue download:', data.title);
|
||||
const db = await getDatabase();
|
||||
|
||||
// Ensure playlist exists in database (required for foreign key constraint)
|
||||
if (data.playlistId) {
|
||||
const existingPlaylist = await db
|
||||
.select()
|
||||
.from(schema.playlists)
|
||||
.where(eq(schema.playlists.id, data.playlistId))
|
||||
.limit(1);
|
||||
|
||||
if (existingPlaylist.length === 0) {
|
||||
// Create playlist entry for downloads to work
|
||||
console.log('[Downloads] Creating playlist entry for:', data.playlistId);
|
||||
await db.insert(schema.playlists).values({
|
||||
id: data.playlistId,
|
||||
name: data.playlistName || 'Unknown Playlist',
|
||||
type: data.playlistType || 'stalker',
|
||||
serverUrl: data.serverUrl,
|
||||
macAddress: data.macAddress,
|
||||
url: data.portalUrl,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw new Error('playlistId is required for downloads');
|
||||
}
|
||||
|
||||
// Check if already exists
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(schema.downloads)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.downloads.playlistId, data.playlistId),
|
||||
eq(schema.downloads.xtreamId, data.xtreamId),
|
||||
eq(schema.downloads.contentType, data.contentType)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
const item = existing[0];
|
||||
// If completed or failed/canceled, allow retry by updating
|
||||
if (['completed', 'failed', 'canceled'].includes(item.status)) {
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
status: 'queued',
|
||||
url: data.url,
|
||||
bytesDownloaded: 0,
|
||||
totalBytes: null,
|
||||
errorMessage: null,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, item.id));
|
||||
|
||||
const ext = getExtensionFromUrl(data.url);
|
||||
const fileName = sanitizeFilename(data.title) + ext;
|
||||
|
||||
downloadQueue.push({
|
||||
id: item.id,
|
||||
url: data.url,
|
||||
fileName,
|
||||
directory: data.downloadFolder,
|
||||
headers: data.headers
|
||||
? {
|
||||
'User-Agent': data.headers.userAgent || '',
|
||||
Referer: data.headers.referer || '',
|
||||
Origin: data.headers.origin || '',
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
broadcastUpdate();
|
||||
processQueue();
|
||||
return { success: true, id: item.id };
|
||||
}
|
||||
|
||||
// Already queued or downloading
|
||||
return { success: false, error: 'Download already in progress', id: item.id };
|
||||
}
|
||||
|
||||
// Create new download entry
|
||||
const ext = getExtensionFromUrl(data.url);
|
||||
const fileName = sanitizeFilename(data.title) + ext;
|
||||
|
||||
const result = await db.insert(schema.downloads).values({
|
||||
playlistId: data.playlistId,
|
||||
xtreamId: data.xtreamId,
|
||||
contentType: data.contentType,
|
||||
title: data.title,
|
||||
url: data.url,
|
||||
posterUrl: data.posterUrl,
|
||||
fileName,
|
||||
status: 'queued',
|
||||
seriesXtreamId: data.seriesXtreamId,
|
||||
seasonNumber: data.seasonNumber,
|
||||
episodeNumber: data.episodeNumber,
|
||||
});
|
||||
|
||||
const insertedId = Number(result.lastInsertRowid);
|
||||
|
||||
downloadQueue.push({
|
||||
id: insertedId,
|
||||
url: data.url,
|
||||
fileName,
|
||||
directory: data.downloadFolder,
|
||||
headers: data.headers
|
||||
? {
|
||||
'User-Agent': data.headers.userAgent || '',
|
||||
Referer: data.headers.referer || '',
|
||||
Origin: data.headers.origin || '',
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
broadcastUpdate();
|
||||
processQueue();
|
||||
|
||||
return { success: true, id: insertedId };
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Error enqueuing download:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Cancel a download
|
||||
*/
|
||||
ipcMain.handle('DOWNLOADS_CANCEL', async (_event, downloadId: number) => {
|
||||
try {
|
||||
console.log('[Downloads] Cancel download:', downloadId);
|
||||
const db = await getDatabase();
|
||||
|
||||
// Check if it's the active download
|
||||
if (activeDownload && activeDownload.id === downloadId) {
|
||||
if (activeDownload.downloadItem) {
|
||||
(activeDownload.downloadItem as any).cancel?.();
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Remove from queue
|
||||
const queueIndex = downloadQueue.findIndex((t) => t.id === downloadId);
|
||||
if (queueIndex !== -1) {
|
||||
downloadQueue.splice(queueIndex, 1);
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
status: 'canceled',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, downloadId));
|
||||
|
||||
broadcastUpdate();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Download not found in queue' };
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Error canceling download:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Retry a failed/canceled download
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'DOWNLOADS_RETRY',
|
||||
async (_event, downloadId: number, downloadFolder: string) => {
|
||||
try {
|
||||
console.log('[Downloads] Retry download:', downloadId);
|
||||
const db = await getDatabase();
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(schema.downloads)
|
||||
.where(eq(schema.downloads.id, downloadId))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return { success: false, error: 'Download not found' };
|
||||
}
|
||||
|
||||
const item = existing[0];
|
||||
if (!['failed', 'canceled'].includes(item.status)) {
|
||||
return { success: false, error: 'Can only retry failed or canceled downloads' };
|
||||
}
|
||||
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
status: 'queued',
|
||||
bytesDownloaded: 0,
|
||||
totalBytes: null,
|
||||
errorMessage: null,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, downloadId));
|
||||
|
||||
const ext = getExtensionFromUrl(item.url);
|
||||
const fileName = sanitizeFilename(item.title) + ext;
|
||||
|
||||
downloadQueue.push({
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
fileName,
|
||||
directory: downloadFolder,
|
||||
});
|
||||
|
||||
broadcastUpdate();
|
||||
processQueue();
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Error retrying download:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Remove a download from the list
|
||||
*/
|
||||
ipcMain.handle('DOWNLOADS_REMOVE', async (_event, downloadId: number) => {
|
||||
try {
|
||||
console.log('[Downloads] Remove download:', downloadId);
|
||||
const db = await getDatabase();
|
||||
|
||||
// Cancel if active
|
||||
if (activeDownload && activeDownload.id === downloadId) {
|
||||
if (activeDownload.downloadItem) {
|
||||
(activeDownload.downloadItem as any).cancel?.();
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from queue
|
||||
const queueIndex = downloadQueue.findIndex((t) => t.id === downloadId);
|
||||
if (queueIndex !== -1) {
|
||||
downloadQueue.splice(queueIndex, 1);
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
await db.delete(schema.downloads).where(eq(schema.downloads.id, downloadId));
|
||||
|
||||
broadcastUpdate();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Error removing download:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get all downloads for a playlist
|
||||
*/
|
||||
ipcMain.handle('DOWNLOADS_GET_LIST', async (_event, playlistId?: string) => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
|
||||
if (playlistId) {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(schema.downloads)
|
||||
.where(eq(schema.downloads.playlistId, playlistId))
|
||||
.orderBy(schema.downloads.createdAt);
|
||||
return result;
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(schema.downloads)
|
||||
.orderBy(schema.downloads.createdAt);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Error getting download list:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get download by ID
|
||||
*/
|
||||
ipcMain.handle('DOWNLOADS_GET', async (_event, downloadId: number) => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const result = await db
|
||||
.select()
|
||||
.from(schema.downloads)
|
||||
.where(eq(schema.downloads.id, downloadId))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Error getting download:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get default download folder
|
||||
*/
|
||||
ipcMain.handle('DOWNLOADS_GET_DEFAULT_FOLDER', async () => {
|
||||
return app.getPath('downloads');
|
||||
});
|
||||
|
||||
/**
|
||||
* Select download folder via dialog
|
||||
*/
|
||||
ipcMain.handle('DOWNLOADS_SELECT_FOLDER', async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
title: 'Select Download Folder',
|
||||
defaultPath: app.getPath('downloads'),
|
||||
});
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.filePaths[0];
|
||||
});
|
||||
|
||||
/**
|
||||
* Reveal file in system file manager
|
||||
*/
|
||||
ipcMain.handle('DOWNLOADS_REVEAL_FILE', async (_event, filePath: string) => {
|
||||
if (existsSync(filePath)) {
|
||||
shell.showItemInFolder(filePath);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: 'File not found' };
|
||||
});
|
||||
|
||||
/**
|
||||
* Play downloaded file
|
||||
*/
|
||||
ipcMain.handle('DOWNLOADS_PLAY_FILE', async (_event, filePath: string) => {
|
||||
if (existsSync(filePath)) {
|
||||
await shell.openPath(filePath);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: 'File not found' };
|
||||
});
|
||||
|
||||
/**
|
||||
* Clear all completed downloads
|
||||
*/
|
||||
ipcMain.handle('DOWNLOADS_CLEAR_COMPLETED', async (_event, playlistId?: string) => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
|
||||
if (playlistId) {
|
||||
await db
|
||||
.delete(schema.downloads)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.downloads.playlistId, playlistId),
|
||||
inArray(schema.downloads.status, ['completed', 'failed', 'canceled'])
|
||||
)
|
||||
);
|
||||
} else {
|
||||
await db
|
||||
.delete(schema.downloads)
|
||||
.where(
|
||||
inArray(schema.downloads.status, ['completed', 'failed', 'canceled'])
|
||||
);
|
||||
}
|
||||
|
||||
broadcastUpdate();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Error clearing completed:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Reset stale downloads on startup (downloading -> failed)
|
||||
*/
|
||||
export async function resetStaleDownloads() {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
status: 'failed',
|
||||
errorMessage: 'Download interrupted by application restart',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(
|
||||
inArray(schema.downloads.status, ['queued', 'downloading'])
|
||||
);
|
||||
console.log('[Downloads] Reset stale downloads');
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Error resetting stale downloads:', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* EPG Database IPC event handlers
|
||||
* Operations for storing and querying EPG data in SQLite with FTS5 support
|
||||
*/
|
||||
|
||||
import { and, eq, gte, lte, sql } from 'drizzle-orm';
|
||||
import { ipcMain } from 'electron';
|
||||
import { getDatabase } from '../../database/connection';
|
||||
import * as schema from '../../database/schema';
|
||||
|
||||
const loggerLabel = '[EPG DB]';
|
||||
|
||||
/**
|
||||
* Save EPG channels in bulk (upsert)
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'EPG_DB_SAVE_CHANNELS',
|
||||
async (
|
||||
_event,
|
||||
sourceUrl: string,
|
||||
channels: Array<{
|
||||
id: string;
|
||||
displayName: string;
|
||||
iconUrl?: string;
|
||||
url?: string;
|
||||
}>
|
||||
) => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
|
||||
// Delete existing channels from this source first
|
||||
await db
|
||||
.delete(schema.epgChannels)
|
||||
.where(eq(schema.epgChannels.sourceUrl, sourceUrl));
|
||||
|
||||
if (channels.length === 0) return { success: true, count: 0 };
|
||||
|
||||
// Insert in chunks
|
||||
const CHUNK_SIZE = 500;
|
||||
let totalInserted = 0;
|
||||
|
||||
for (let i = 0; i < channels.length; i += CHUNK_SIZE) {
|
||||
const chunk = channels.slice(i, i + CHUNK_SIZE);
|
||||
const values = chunk.map((ch) => ({
|
||||
id: ch.id,
|
||||
displayName: ch.displayName,
|
||||
iconUrl: ch.iconUrl || null,
|
||||
url: ch.url || null,
|
||||
sourceUrl,
|
||||
}));
|
||||
|
||||
await db.insert(schema.epgChannels).values(values);
|
||||
totalInserted += chunk.length;
|
||||
}
|
||||
|
||||
console.log(
|
||||
loggerLabel,
|
||||
`Saved ${totalInserted} channels from ${sourceUrl}`
|
||||
);
|
||||
return { success: true, count: totalInserted };
|
||||
} catch (error) {
|
||||
console.error(loggerLabel, 'Error saving EPG channels:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Save EPG programs in bulk
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'EPG_DB_SAVE_PROGRAMS',
|
||||
async (
|
||||
_event,
|
||||
programs: Array<{
|
||||
channelId: string;
|
||||
start: string;
|
||||
stop: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
iconUrl?: string;
|
||||
rating?: string;
|
||||
episodeNum?: string;
|
||||
}>
|
||||
) => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
|
||||
if (programs.length === 0) return { success: true, count: 0 };
|
||||
|
||||
// Insert in chunks for better performance
|
||||
const CHUNK_SIZE = 500;
|
||||
let totalInserted = 0;
|
||||
|
||||
for (let i = 0; i < programs.length; i += CHUNK_SIZE) {
|
||||
const chunk = programs.slice(i, i + CHUNK_SIZE);
|
||||
const values = chunk.map((prog) => ({
|
||||
channelId: prog.channelId,
|
||||
start: prog.start,
|
||||
stop: prog.stop,
|
||||
title: prog.title,
|
||||
description: prog.description || null,
|
||||
category: prog.category || null,
|
||||
iconUrl: prog.iconUrl || null,
|
||||
rating: prog.rating || null,
|
||||
episodeNum: prog.episodeNum || null,
|
||||
}));
|
||||
|
||||
await db.insert(schema.epgPrograms).values(values);
|
||||
totalInserted += chunk.length;
|
||||
}
|
||||
|
||||
console.log(loggerLabel, `Saved ${totalInserted} programs`);
|
||||
return { success: true, count: totalInserted };
|
||||
} catch (error) {
|
||||
console.error(loggerLabel, 'Error saving EPG programs:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get programs for a specific channel
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'EPG_DB_GET_CHANNEL_PROGRAMS',
|
||||
async (_event, channelId: string, fromTime?: string, toTime?: string) => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
|
||||
let query;
|
||||
|
||||
// Apply time filters if provided
|
||||
if (fromTime && toTime) {
|
||||
query = db
|
||||
.select()
|
||||
.from(schema.epgPrograms)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.epgPrograms.channelId, channelId),
|
||||
gte(schema.epgPrograms.stop, fromTime),
|
||||
lte(schema.epgPrograms.start, toTime)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Return all programs for this channel
|
||||
query = db
|
||||
.select()
|
||||
.from(schema.epgPrograms)
|
||||
.where(eq(schema.epgPrograms.channelId, channelId));
|
||||
}
|
||||
|
||||
const results = await query.orderBy(schema.epgPrograms.start);
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
loggerLabel,
|
||||
'Error getting channel programs:',
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get current program for a channel (what's on now)
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'EPG_DB_GET_CURRENT_PROGRAM',
|
||||
async (_event, channelId: string) => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(schema.epgPrograms)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.epgPrograms.channelId, channelId),
|
||||
lte(schema.epgPrograms.start, now),
|
||||
gte(schema.epgPrograms.stop, now)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
} catch (error) {
|
||||
console.error(loggerLabel, 'Error getting current program:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Full-text search EPG programs using FTS5 with LIKE fallback
|
||||
* Handles Cyrillic and other Unicode text properly
|
||||
* Includes channel display name via JOIN
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'EPG_DB_SEARCH_PROGRAMS',
|
||||
async (_event, searchTerm: string, limit = 50) => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const trimmedTerm = searchTerm.trim();
|
||||
|
||||
if (!trimmedTerm) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Use LIKE for substring matching (works better with Cyrillic)
|
||||
// This is more intuitive for users expecting exact substring matches
|
||||
const likePattern = `%${trimmedTerm}%`;
|
||||
|
||||
// JOIN with epg_channels to get channel display name
|
||||
// Include all programs (past and future) for catchup/archive feature
|
||||
const results = await db.all(sql`
|
||||
SELECT
|
||||
p.*,
|
||||
c.display_name as channel_name
|
||||
FROM epg_programs p
|
||||
LEFT JOIN epg_channels c ON p.channel_id = c.id
|
||||
WHERE (
|
||||
p.title LIKE ${likePattern}
|
||||
OR p.description LIKE ${likePattern}
|
||||
OR p.category LIKE ${likePattern}
|
||||
)
|
||||
ORDER BY p.start
|
||||
LIMIT ${limit}
|
||||
`);
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error(loggerLabel, 'Error searching EPG programs:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get all EPG channels
|
||||
*/
|
||||
ipcMain.handle('EPG_DB_GET_CHANNELS', async () => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const results = await db
|
||||
.select()
|
||||
.from(schema.epgChannels)
|
||||
.orderBy(schema.epgChannels.displayName);
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error(loggerLabel, 'Error getting EPG channels:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get channel by ID or display name
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'EPG_DB_GET_CHANNEL',
|
||||
async (_event, channelIdOrName: string) => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
|
||||
// Try exact ID match first
|
||||
let result = await db
|
||||
.select()
|
||||
.from(schema.epgChannels)
|
||||
.where(eq(schema.epgChannels.id, channelIdOrName))
|
||||
.limit(1);
|
||||
|
||||
if (result.length > 0) return result[0];
|
||||
|
||||
// Try display name match (case-insensitive)
|
||||
result = await db
|
||||
.select()
|
||||
.from(schema.epgChannels)
|
||||
.where(
|
||||
sql`LOWER(${schema.epgChannels.displayName}) = LOWER(${channelIdOrName})`
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
} catch (error) {
|
||||
console.error(loggerLabel, 'Error getting EPG channel:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Clear all EPG data for a specific source URL
|
||||
*/
|
||||
ipcMain.handle('EPG_DB_CLEAR_SOURCE', async (_event, sourceUrl: string) => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
|
||||
// Deleting channels will cascade delete programs due to foreign key
|
||||
await db
|
||||
.delete(schema.epgChannels)
|
||||
.where(eq(schema.epgChannels.sourceUrl, sourceUrl));
|
||||
|
||||
console.log(loggerLabel, `Cleared EPG data for source: ${sourceUrl}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(loggerLabel, 'Error clearing EPG source:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Clear all EPG data
|
||||
*/
|
||||
ipcMain.handle('EPG_DB_CLEAR_ALL', async () => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
|
||||
await db.delete(schema.epgPrograms);
|
||||
await db.delete(schema.epgChannels);
|
||||
|
||||
console.log(loggerLabel, 'Cleared all EPG data');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(loggerLabel, 'Error clearing all EPG data:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get EPG statistics
|
||||
*/
|
||||
ipcMain.handle('EPG_DB_GET_STATS', async () => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
|
||||
const channelCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.epgChannels);
|
||||
|
||||
const programCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.epgPrograms);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const futureProgramCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.epgPrograms)
|
||||
.where(gte(schema.epgPrograms.stop, now));
|
||||
|
||||
return {
|
||||
channels: channelCount[0].count,
|
||||
programs: programCount[0].count,
|
||||
futurePrograms: futureProgramCount[0].count,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(loggerLabel, 'Error getting EPG stats:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Favorites IPC event handlers
|
||||
* Operations for managing user's favorite content
|
||||
*/
|
||||
|
||||
import { handleWorkerRequest } from './worker-events.utils';
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_ADD_FAVORITE',
|
||||
(contentId: number, playlistId: string, backdropUrl?: string) => ({
|
||||
contentId,
|
||||
playlistId,
|
||||
backdropUrl,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_REMOVE_FAVORITE',
|
||||
(contentId: number, playlistId: string) => ({
|
||||
contentId,
|
||||
playlistId,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_IS_FAVORITE',
|
||||
(contentId: number, playlistId: string) => ({
|
||||
contentId,
|
||||
playlistId,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest('DB_GET_FAVORITES', (playlistId: string) => ({
|
||||
playlistId,
|
||||
}));
|
||||
|
||||
handleWorkerRequest('DB_GET_GLOBAL_FAVORITES', () => ({}));
|
||||
handleWorkerRequest('DB_GET_ALL_GLOBAL_FAVORITES', () => ({}));
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_REORDER_GLOBAL_FAVORITES',
|
||||
(updates: { content_id: number; position: number }[]) => ({
|
||||
updates,
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Playback Position IPC event handlers
|
||||
* Operations for managing video playback progress
|
||||
*/
|
||||
|
||||
import { handleWorkerRequest } from './worker-events.utils';
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_SAVE_PLAYBACK_POSITION',
|
||||
(playlistId: string, data: unknown) => ({
|
||||
playlistId,
|
||||
data,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_GET_PLAYBACK_POSITION',
|
||||
(
|
||||
playlistId: string,
|
||||
contentXtreamId: number,
|
||||
contentType: 'vod' | 'episode'
|
||||
) => ({
|
||||
playlistId,
|
||||
contentXtreamId,
|
||||
contentType,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_GET_SERIES_PLAYBACK_POSITIONS',
|
||||
(playlistId: string, seriesXtreamId: number) => ({
|
||||
playlistId,
|
||||
seriesXtreamId,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_GET_RECENT_PLAYBACK_POSITIONS',
|
||||
(playlistId: string, limit?: number) => ({
|
||||
playlistId,
|
||||
limit,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest('DB_GET_ALL_PLAYBACK_POSITIONS', (playlistId: string) => ({
|
||||
playlistId,
|
||||
}));
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_CLEAR_ALL_PLAYBACK_POSITIONS',
|
||||
(playlistId: string) => ({
|
||||
playlistId,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_CLEAR_PLAYBACK_POSITION',
|
||||
(
|
||||
playlistId: string,
|
||||
contentXtreamId: number,
|
||||
contentType: 'vod' | 'episode'
|
||||
) => ({
|
||||
playlistId,
|
||||
contentXtreamId,
|
||||
contentType,
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Playlist IPC event handlers
|
||||
* CRUD operations for playlists
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import { databaseWorkerClient } from '../../services/database-worker-client';
|
||||
import {
|
||||
handleWorkerRequest,
|
||||
requestWorkerWithEvents,
|
||||
} from './worker-events.utils';
|
||||
|
||||
handleWorkerRequest('DB_CREATE_PLAYLIST', (playlist: Record<string, unknown>) => playlist);
|
||||
handleWorkerRequest(
|
||||
'DB_UPSERT_APP_PLAYLIST',
|
||||
(playlist: Record<string, unknown>) => playlist
|
||||
);
|
||||
handleWorkerRequest(
|
||||
'DB_UPSERT_APP_PLAYLISTS',
|
||||
(playlists: Record<string, unknown>[]) => playlists
|
||||
);
|
||||
handleWorkerRequest('DB_GET_APP_PLAYLISTS', () => ({}));
|
||||
handleWorkerRequest('DB_GET_APP_PLAYLIST', (playlistId: string) => ({ playlistId }));
|
||||
handleWorkerRequest('DB_GET_PLAYLIST', (playlistId: string) => ({ playlistId }));
|
||||
handleWorkerRequest(
|
||||
'DB_UPDATE_PLAYLIST',
|
||||
(
|
||||
playlistId: string,
|
||||
updates: {
|
||||
name?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
serverUrl?: string;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
) => ({
|
||||
playlistId,
|
||||
updates,
|
||||
})
|
||||
);
|
||||
handleWorkerRequest('DB_GET_APP_STATE', (key: string) => ({ key }));
|
||||
handleWorkerRequest('DB_SET_APP_STATE', (key: string, value: string) => ({
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
|
||||
ipcMain.handle(
|
||||
'DB_DELETE_PLAYLIST',
|
||||
async (
|
||||
event,
|
||||
playlistId: string,
|
||||
operationId?: string
|
||||
) => {
|
||||
try {
|
||||
return await requestWorkerWithEvents(
|
||||
event,
|
||||
'DB_DELETE_PLAYLIST',
|
||||
{
|
||||
playlistId,
|
||||
operationId,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error handling DB_DELETE_PLAYLIST:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
'DB_DELETE_ALL_PLAYLISTS',
|
||||
async (event, operationId?: string) => {
|
||||
try {
|
||||
return await requestWorkerWithEvents(
|
||||
event,
|
||||
'DB_DELETE_ALL_PLAYLISTS',
|
||||
{ operationId }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error handling DB_DELETE_ALL_PLAYLISTS:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle('DB_CANCEL_OPERATION', async (_event, operationId: string) => {
|
||||
try {
|
||||
return await databaseWorkerClient.cancel(operationId);
|
||||
} catch (error) {
|
||||
console.error('Error handling DB_CANCEL_OPERATION:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Recently Viewed IPC event handlers
|
||||
* Operations for managing user's recently viewed content
|
||||
*/
|
||||
|
||||
import { handleWorkerRequest } from './worker-events.utils';
|
||||
|
||||
handleWorkerRequest('DB_GET_RECENTLY_VIEWED', () => ({}));
|
||||
handleWorkerRequest('DB_CLEAR_RECENTLY_VIEWED', () => ({}));
|
||||
|
||||
handleWorkerRequest('DB_GET_RECENT_ITEMS', (playlistId: string) => ({
|
||||
playlistId,
|
||||
}));
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_ADD_RECENT_ITEM',
|
||||
(contentId: number, playlistId: string, backdropUrl?: string) => ({
|
||||
contentId,
|
||||
playlistId,
|
||||
backdropUrl,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_CLEAR_PLAYLIST_RECENT_ITEMS',
|
||||
(playlistId: string) => ({
|
||||
playlistId,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_REMOVE_RECENT_ITEM',
|
||||
(contentId: number, playlistId: string) => ({
|
||||
contentId,
|
||||
playlistId,
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { databaseWorkerClient } from '../../services/database-worker-client';
|
||||
import type {
|
||||
DbOperationEvent,
|
||||
DbWorkerOperation,
|
||||
} from '../../workers/database-worker.types';
|
||||
|
||||
export function forwardWorkerEvent(
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
workerEvent: DbOperationEvent
|
||||
): void {
|
||||
if (event.sender.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.sender.send('DB_OPERATION_EVENT', workerEvent);
|
||||
}
|
||||
|
||||
export function handleWorkerRequest<TArgs extends unknown[]>(
|
||||
channel: DbWorkerOperation,
|
||||
buildPayload: (...args: TArgs) => unknown
|
||||
): void {
|
||||
ipcMain.handle(channel, async (_event, ...args: TArgs) => {
|
||||
try {
|
||||
return await databaseWorkerClient.request(
|
||||
channel,
|
||||
buildPayload(...args)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Error handling ${channel}:`, error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestWorkerWithEvents<TResult, TPayload>(
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
channel: DbWorkerOperation,
|
||||
payload: TPayload
|
||||
): Promise<TResult> {
|
||||
return databaseWorkerClient.request<TResult>(channel, payload, {
|
||||
onEvent: (workerEvent) => forwardWorkerEvent(event, workerEvent),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Xtream-specific IPC event handlers
|
||||
* Operations for refreshing and managing Xtream playlist data
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import { requestWorkerWithEvents } from './worker-events.utils';
|
||||
|
||||
ipcMain.handle(
|
||||
'DB_DELETE_XTREAM_CONTENT',
|
||||
async (event, playlistId: string, operationId?: string) => {
|
||||
try {
|
||||
return await requestWorkerWithEvents(
|
||||
event,
|
||||
'DB_DELETE_XTREAM_CONTENT',
|
||||
{
|
||||
playlistId,
|
||||
operationId,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error handling DB_DELETE_XTREAM_CONTENT:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
'DB_RESTORE_XTREAM_USER_DATA',
|
||||
async (
|
||||
event,
|
||||
playlistId: string,
|
||||
favorites: unknown[],
|
||||
recentlyViewed: unknown[],
|
||||
operationId?: string
|
||||
) => {
|
||||
try {
|
||||
return await requestWorkerWithEvents(
|
||||
event,
|
||||
'DB_RESTORE_XTREAM_USER_DATA',
|
||||
{
|
||||
playlistId,
|
||||
favorites,
|
||||
recentlyViewed,
|
||||
operationId,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error handling DB_RESTORE_XTREAM_USER_DATA:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* This module is responsible on handling all the inter process communications
|
||||
* between the frontend to the electron backend.
|
||||
*/
|
||||
|
||||
import { app, ipcMain } from 'electron';
|
||||
import * as os from 'os';
|
||||
import { environment } from '../../environments/environment';
|
||||
import {
|
||||
DEBUG_TRACE_EVENT_CHANNEL,
|
||||
isRendererApiTraceEnabled,
|
||||
trace,
|
||||
} from '../services/debug-trace';
|
||||
|
||||
export default class ElectronEvents {
|
||||
static bootstrapElectronEvents(): Electron.IpcMain {
|
||||
return ipcMain;
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve app version
|
||||
ipcMain.handle('get-app-version', (event) => {
|
||||
console.log(`Fetching application version... [v${environment.version}]`);
|
||||
|
||||
return environment.version;
|
||||
});
|
||||
|
||||
// Handle App termination
|
||||
ipcMain.on('quit', (event, code) => {
|
||||
app.exit(code);
|
||||
});
|
||||
|
||||
ipcMain.on(DEBUG_TRACE_EVENT_CHANNEL, (event, payload) => {
|
||||
if (!isRendererApiTraceEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
trace('renderer-api', 'event', {
|
||||
payload,
|
||||
senderId: event.sender.id,
|
||||
url: event.sender.getURL(),
|
||||
});
|
||||
});
|
||||
|
||||
// Get local IP addresses for remote control URL display
|
||||
ipcMain.handle('get-local-ip-addresses', () => {
|
||||
const interfaces = os.networkInterfaces();
|
||||
const addresses: string[] = [];
|
||||
|
||||
for (const name of Object.keys(interfaces)) {
|
||||
for (const iface of interfaces[name] || []) {
|
||||
// Skip internal (loopback) and non-IPv4 addresses
|
||||
if (iface.family === 'IPv4' && !iface.internal) {
|
||||
addresses.push(iface.address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return addresses;
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import type EpgEventsType from './epg.events';
|
||||
|
||||
const mockWorkerInstances: any[] = [];
|
||||
const resolveWorkerRuntimeBootstrap = jest.fn();
|
||||
const getDatabase = jest.fn();
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getAppPath: () => '/mock/app.asar',
|
||||
},
|
||||
BrowserWindow: {
|
||||
getAllWindows: () => [],
|
||||
},
|
||||
ipcMain: {
|
||||
handle: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('worker_threads', () => {
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
class MockWorker extends EventEmitter {
|
||||
postMessage = jest.fn();
|
||||
terminate = jest.fn().mockResolvedValue(0);
|
||||
}
|
||||
|
||||
return {
|
||||
Worker: jest.fn().mockImplementation(() => {
|
||||
const worker = new MockWorker();
|
||||
mockWorkerInstances.push(worker);
|
||||
return worker;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../workers/worker-runtime-paths', () => ({
|
||||
resolveWorkerRuntimeBootstrap: (...args: unknown[]) =>
|
||||
resolveWorkerRuntimeBootstrap(...args),
|
||||
}));
|
||||
|
||||
jest.mock('../database/connection', () => ({
|
||||
getDatabase: (...args: unknown[]) => getDatabase(...args),
|
||||
}));
|
||||
|
||||
describe('EpgEvents', () => {
|
||||
let EpgEvents: typeof EpgEventsType;
|
||||
let consoleLogSpy: jest.SpyInstance;
|
||||
let consoleErrorSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetModules();
|
||||
mockWorkerInstances.length = 0;
|
||||
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
resolveWorkerRuntimeBootstrap.mockReset();
|
||||
resolveWorkerRuntimeBootstrap.mockReturnValue({
|
||||
workerPath: '/mock/workers/epg-parser.worker.js',
|
||||
workerPathCandidates: ['/mock/workers/epg-parser.worker.js'],
|
||||
nativeModuleSearchPaths: [
|
||||
'/mock/resources/app.asar.unpacked/node_modules',
|
||||
],
|
||||
});
|
||||
|
||||
({ default: EpgEvents } = await import('./epg.events'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
getDatabase.mockReset();
|
||||
});
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
it('uses the shared worker bootstrap and passes native module search paths to the EPG worker', async () => {
|
||||
const fetchPromise = (EpgEvents as unknown as Record<string, any>)[
|
||||
'fetchEpgFromUrl'
|
||||
]('https://example.com/guide.xml');
|
||||
const worker = mockWorkerInstances[0];
|
||||
const { Worker } = jest.requireMock('worker_threads');
|
||||
|
||||
expect(resolveWorkerRuntimeBootstrap).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workerFilename: 'epg-parser.worker.js',
|
||||
developmentWorkerDir: expect.stringContaining('workers'),
|
||||
})
|
||||
);
|
||||
expect(Worker).toHaveBeenCalledWith(expect.any(URL), {
|
||||
resourceLimits: {
|
||||
maxOldGenerationSizeMb: 4096,
|
||||
maxYoungGenerationSizeMb: 512,
|
||||
},
|
||||
workerData: {
|
||||
nativeModuleSearchPaths: [
|
||||
'/mock/resources/app.asar.unpacked/node_modules',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
worker.emit('message', { type: 'READY' });
|
||||
await flushPromises();
|
||||
|
||||
expect(worker.postMessage).toHaveBeenCalledWith({
|
||||
type: 'FETCH_EPG',
|
||||
url: 'https://example.com/guide.xml',
|
||||
});
|
||||
|
||||
worker.emit('message', {
|
||||
type: 'EPG_COMPLETE',
|
||||
stats: { totalChannels: 1, totalPrograms: 2 },
|
||||
});
|
||||
|
||||
await expect(fetchPromise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects with actionable worker path errors', async () => {
|
||||
resolveWorkerRuntimeBootstrap.mockImplementation(() => {
|
||||
const error = new Error(
|
||||
'Unable to resolve worker "epg-parser.worker.js".\nTried:\n- /missing/epg-parser.worker.js'
|
||||
);
|
||||
error.name = 'WorkerPathResolutionError';
|
||||
throw error;
|
||||
});
|
||||
|
||||
await expect(EpgEvents.clearEpgData()).rejects.toMatchObject({
|
||||
name: 'WorkerPathResolutionError',
|
||||
message: expect.stringContaining('epg-parser.worker.js'),
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to case-insensitive channel id lookup for EPG programs', async () => {
|
||||
const select = jest.fn();
|
||||
const programLimitExact = jest.fn().mockResolvedValue([]);
|
||||
const channelLimit = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'BBC.ONE.UK', displayName: 'BBC One' }]);
|
||||
const programLimitResolved = jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
channelId: 'BBC.ONE.UK',
|
||||
start: '2026-04-14T10:00:00Z',
|
||||
stop: '2026-04-14T11:00:00Z',
|
||||
title: 'News',
|
||||
description: null,
|
||||
category: null,
|
||||
iconUrl: null,
|
||||
rating: null,
|
||||
episodeNum: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const from = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
where: jest.fn().mockReturnValue({
|
||||
orderBy: jest.fn().mockReturnValue({
|
||||
limit: programLimitExact,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
where: jest.fn().mockReturnValue({
|
||||
limit: channelLimit,
|
||||
}),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
where: jest.fn().mockReturnValue({
|
||||
orderBy: jest.fn().mockReturnValue({
|
||||
limit: programLimitResolved,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
select.mockImplementation(() => ({ from }));
|
||||
|
||||
getDatabase.mockResolvedValue({ select });
|
||||
|
||||
const programs = await (EpgEvents as unknown as Record<string, any>)[
|
||||
'handleGetChannelPrograms'
|
||||
]('bbc.one.uk');
|
||||
|
||||
expect(programs).toHaveLength(1);
|
||||
expect(programs[0].channel).toBe('BBC.ONE.UK');
|
||||
});
|
||||
|
||||
it('resolves channel metadata using exact id, case-insensitive id, and display name fallback', async () => {
|
||||
const where = jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'BBC.ONE.UK',
|
||||
displayName: 'BBC One',
|
||||
iconUrl: 'https://example.com/bbc-one.png',
|
||||
},
|
||||
{
|
||||
id: 'guide-news',
|
||||
displayName: 'Guide News',
|
||||
iconUrl: 'https://example.com/guide-news.png',
|
||||
},
|
||||
]);
|
||||
const from = jest.fn().mockReturnValue({ where });
|
||||
const select = jest.fn().mockReturnValue({ from });
|
||||
|
||||
getDatabase.mockResolvedValue({ select });
|
||||
|
||||
const metadata = await (EpgEvents as unknown as Record<string, any>)[
|
||||
'handleGetChannelMetadata'
|
||||
](['BBC.ONE.UK', 'bbc.one.uk', 'Guide News', 'Missing Channel']);
|
||||
|
||||
expect(metadata).toEqual({
|
||||
'BBC.ONE.UK': {
|
||||
id: 'BBC.ONE.UK',
|
||||
displayName: 'BBC One',
|
||||
iconUrl: 'https://example.com/bbc-one.png',
|
||||
},
|
||||
'bbc.one.uk': {
|
||||
id: 'BBC.ONE.UK',
|
||||
displayName: 'BBC One',
|
||||
iconUrl: 'https://example.com/bbc-one.png',
|
||||
},
|
||||
'Guide News': {
|
||||
id: 'guide-news',
|
||||
displayName: 'Guide News',
|
||||
iconUrl: 'https://example.com/guide-news.png',
|
||||
},
|
||||
'Missing Channel': null,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops malformed EPG rows with invalid stop dates', async () => {
|
||||
const select = jest.fn();
|
||||
const from = jest.fn();
|
||||
const where = jest.fn();
|
||||
const orderBy = jest.fn();
|
||||
const limit = jest.fn();
|
||||
|
||||
select.mockImplementation(() => ({ from }));
|
||||
from.mockReturnValue({ where });
|
||||
where.mockReturnValue({ orderBy });
|
||||
orderBy.mockReturnValue({ limit });
|
||||
limit.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
channelId: 'id2e2cd03c90ad',
|
||||
start: '2026-04-14T20:00:00+00:00',
|
||||
stop: '2026-04-14T21:00:00+00:00',
|
||||
title: 'valid',
|
||||
description: null,
|
||||
category: null,
|
||||
iconUrl: null,
|
||||
rating: null,
|
||||
episodeNum: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
channelId: 'id2e2cd03c90ad',
|
||||
start: '2026-04-14T21:00:00+00:00',
|
||||
stop: '',
|
||||
title: 'invalid',
|
||||
description: null,
|
||||
category: null,
|
||||
iconUrl: null,
|
||||
rating: null,
|
||||
episodeNum: null,
|
||||
},
|
||||
]);
|
||||
|
||||
getDatabase.mockResolvedValue({ select });
|
||||
|
||||
const programs = await (EpgEvents as unknown as Record<string, any>)[
|
||||
'handleGetChannelPrograms'
|
||||
]('id2e2cd03c90ad');
|
||||
|
||||
expect(programs).toHaveLength(1);
|
||||
expect(programs[0].title).toBe('valid');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,835 @@
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { EpgChannelMetadata, EpgProgram } from 'shared-interfaces';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { Worker } from 'worker_threads';
|
||||
import { getDatabase } from '../database/connection';
|
||||
import * as schema from '../database/schema';
|
||||
import { resolveWorkerRuntimeBootstrap } from '../workers/worker-runtime-paths';
|
||||
|
||||
/**
|
||||
* EPG Events Handler
|
||||
* Manages EPG data fetching and querying using worker threads.
|
||||
* Database operations are performed in the worker thread to avoid blocking the main thread.
|
||||
*/
|
||||
export default class EpgEvents {
|
||||
private static fetchedUrls: Set<string> = new Set();
|
||||
private static workers: Map<string, Worker> = new Map();
|
||||
private static readonly loggerLabel = '[EPG Events]';
|
||||
private static readonly FETCH_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
private static createEpgWorker(): Worker {
|
||||
const bootstrap = resolveWorkerRuntimeBootstrap({
|
||||
isPackaged: app.isPackaged,
|
||||
workerFilename: 'epg-parser.worker.js',
|
||||
developmentWorkerDir: path.join(__dirname, 'workers'),
|
||||
resourcesPath: (
|
||||
process as NodeJS.Process & { resourcesPath?: string }
|
||||
).resourcesPath,
|
||||
appPath: app.getAppPath(),
|
||||
});
|
||||
|
||||
const workerURL = pathToFileURL(bootstrap.workerPath);
|
||||
return new Worker(workerURL, {
|
||||
resourceLimits: {
|
||||
maxOldGenerationSizeMb: 4096,
|
||||
maxYoungGenerationSizeMb: 512,
|
||||
},
|
||||
workerData: {
|
||||
nativeModuleSearchPaths: bootstrap.nativeModuleSearchPaths,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send EPG progress to all renderer windows
|
||||
*/
|
||||
private static sendProgressToRenderer(
|
||||
url: string,
|
||||
status: 'queued' | 'loading' | 'complete' | 'error',
|
||||
stats?: { totalChannels: number; totalPrograms: number },
|
||||
error?: string,
|
||||
queuePosition?: number
|
||||
): void {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
windows.forEach((win) => {
|
||||
win.webContents.send('EPG_PROGRESS_UPDATE', {
|
||||
url,
|
||||
status,
|
||||
stats,
|
||||
error,
|
||||
queuePosition,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap EPG events
|
||||
*/
|
||||
static bootstrapEpgEvents(): Electron.IpcMain {
|
||||
// Fetch EPG from URLs
|
||||
ipcMain.handle('FETCH_EPG', async (_event, args: { url: string[] }) => {
|
||||
return await this.handleFetchEpg(args.url);
|
||||
});
|
||||
|
||||
// Get programs for a specific channel
|
||||
ipcMain.handle(
|
||||
'GET_CHANNEL_PROGRAMS',
|
||||
async (_event, args: { channelId: string }) => {
|
||||
return this.handleGetChannelPrograms(args.channelId);
|
||||
}
|
||||
);
|
||||
|
||||
// Get all channels from database
|
||||
ipcMain.handle('EPG_GET_CHANNELS', async () => {
|
||||
return this.handleGetAllChannels();
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
'EPG_GET_CHANNEL_METADATA',
|
||||
async (_event, args: { channelIds: string[] }) => {
|
||||
return this.handleGetChannelMetadata(args.channelIds);
|
||||
}
|
||||
);
|
||||
|
||||
// Get channels by range (pagination)
|
||||
ipcMain.handle(
|
||||
'EPG_GET_CHANNELS_BY_RANGE',
|
||||
async (_event, args: { skip: number; limit: number }) => {
|
||||
return this.handleGetChannelsByRange(args.skip, args.limit);
|
||||
}
|
||||
);
|
||||
|
||||
// Force fetch (ignore cache)
|
||||
ipcMain.handle('EPG_FORCE_FETCH', async (_event, url: string) => {
|
||||
this.fetchedUrls.delete(url);
|
||||
return await this.handleFetchEpg([url]);
|
||||
});
|
||||
|
||||
// Clear all EPG data
|
||||
ipcMain.handle('EPG_CLEAR_ALL', async () => {
|
||||
await this.clearEpgData();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Check if EPG data for URLs is fresh (not stale)
|
||||
ipcMain.handle(
|
||||
'EPG_CHECK_FRESHNESS',
|
||||
async (
|
||||
_event,
|
||||
args: { urls: string[]; maxAgeHours?: number }
|
||||
): Promise<{ staleUrls: string[]; freshUrls: string[] }> => {
|
||||
return this.checkEpgFreshness(
|
||||
args.urls,
|
||||
args.maxAgeHours ?? 12
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return ipcMain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check which EPG URLs have fresh data vs stale/missing data
|
||||
* @param urls - EPG source URLs to check
|
||||
* @param maxAgeHours - Maximum age in hours before data is considered stale
|
||||
*/
|
||||
private static async checkEpgFreshness(
|
||||
urls: string[],
|
||||
maxAgeHours: number
|
||||
): Promise<{ staleUrls: string[]; freshUrls: string[] }> {
|
||||
const staleUrls: string[] = [];
|
||||
const freshUrls: string[] = [];
|
||||
const cutoffTime = new Date(
|
||||
Date.now() - maxAgeHours * 60 * 60 * 1000
|
||||
).toISOString();
|
||||
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
|
||||
for (const url of urls) {
|
||||
if (!url?.trim()) continue;
|
||||
|
||||
const result = await db
|
||||
.select({ updatedAt: schema.epgChannels.updatedAt })
|
||||
.from(schema.epgChannels)
|
||||
.where(eq(schema.epgChannels.sourceUrl, url))
|
||||
.limit(1);
|
||||
|
||||
const isFresh =
|
||||
result.length > 0 &&
|
||||
result[0].updatedAt &&
|
||||
result[0].updatedAt >= cutoffTime;
|
||||
|
||||
if (isFresh) {
|
||||
freshUrls.push(url);
|
||||
this.fetchedUrls.add(url);
|
||||
} else {
|
||||
staleUrls.push(url);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
this.loggerLabel,
|
||||
'Error checking EPG freshness:',
|
||||
error
|
||||
);
|
||||
return { staleUrls: urls, freshUrls: [] };
|
||||
}
|
||||
|
||||
// Log summary
|
||||
if (freshUrls.length > 0) {
|
||||
console.log(
|
||||
this.loggerLabel,
|
||||
`EPG fresh (skipping): ${freshUrls.length} source(s)`
|
||||
);
|
||||
}
|
||||
if (staleUrls.length > 0) {
|
||||
console.log(
|
||||
this.loggerLabel,
|
||||
`EPG stale (will fetch): ${staleUrls.length} source(s)`
|
||||
);
|
||||
}
|
||||
|
||||
return { staleUrls, freshUrls };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle EPG fetch from URLs
|
||||
* Automatically skips URLs with fresh data (less than 12 hours old)
|
||||
* Processes URLs sequentially to avoid SQLite database locking issues
|
||||
*/
|
||||
private static async handleFetchEpg(
|
||||
urls: string[]
|
||||
): Promise<{ success: boolean; message?: string; skipped?: string[] }> {
|
||||
const validUrls = urls.filter((url) => url?.trim());
|
||||
|
||||
if (validUrls.length === 0) {
|
||||
return { success: false, message: 'No valid URLs provided' };
|
||||
}
|
||||
|
||||
// Check which URLs have fresh data and can be skipped
|
||||
const { staleUrls, freshUrls } = await this.checkEpgFreshness(
|
||||
validUrls,
|
||||
12
|
||||
);
|
||||
|
||||
if (staleUrls.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'All EPG data is fresh',
|
||||
skipped: freshUrls,
|
||||
};
|
||||
}
|
||||
|
||||
// Exclude URLs already processed this session — otherwise the loop sends
|
||||
// a 'queued' status, then fetchEpgFromUrl silently skips the URL and no
|
||||
// completion update ever arrives, leaving the UI stuck at "queued".
|
||||
const urlsToFetch = staleUrls.filter(
|
||||
(url) => !this.fetchedUrls.has(url)
|
||||
);
|
||||
|
||||
if (urlsToFetch.length === 0) {
|
||||
console.log(
|
||||
this.loggerLabel,
|
||||
`All ${staleUrls.length} stale URL(s) already fetched this session; skipping`
|
||||
);
|
||||
return { success: true, skipped: freshUrls };
|
||||
}
|
||||
|
||||
// Send queued status for URLs that will actually be fetched
|
||||
urlsToFetch.forEach((url, index) => {
|
||||
this.sendProgressToRenderer(
|
||||
url,
|
||||
'queued',
|
||||
undefined,
|
||||
undefined,
|
||||
index + 1
|
||||
);
|
||||
});
|
||||
|
||||
// Process URLs sequentially to avoid database locking
|
||||
const errors: string[] = [];
|
||||
for (let i = 0; i < urlsToFetch.length; i++) {
|
||||
const url = urlsToFetch[i];
|
||||
try {
|
||||
await this.fetchEpgFromUrl(url);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
this.loggerLabel,
|
||||
`Error fetching EPG from ${url}:`,
|
||||
error
|
||||
);
|
||||
errors.push(
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
success: errors.length < urlsToFetch.length, // Partial success if some worked
|
||||
message: errors.join('; '),
|
||||
skipped: freshUrls,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, skipped: freshUrls };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch EPG from a single URL using worker thread
|
||||
* The worker handles both parsing AND database operations
|
||||
*/
|
||||
private static async fetchEpgFromUrl(url: string): Promise<void> {
|
||||
// Skip if already fetched this session
|
||||
if (this.fetchedUrls.has(url)) {
|
||||
console.log(
|
||||
this.loggerLabel,
|
||||
`Skipping already fetched URL: ${url}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = this.createEpgWorker();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
this.loggerLabel,
|
||||
'Failed to create worker:',
|
||||
error
|
||||
);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.workers.set(url, worker);
|
||||
|
||||
// Guards against double-settling and keeps the outer loop moving
|
||||
// when the worker dies or hangs without sending EPG_COMPLETE/EPG_ERROR.
|
||||
let settled = false;
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
fn();
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
const errorMessage = `EPG fetch timed out after ${
|
||||
this.FETCH_TIMEOUT_MS / 1000
|
||||
}s`;
|
||||
console.error(this.loggerLabel, `${errorMessage}: ${url}`);
|
||||
this.sendProgressToRenderer(
|
||||
url,
|
||||
'error',
|
||||
undefined,
|
||||
errorMessage
|
||||
);
|
||||
worker.terminate();
|
||||
this.workers.delete(url);
|
||||
settle(() => reject(new Error(errorMessage)));
|
||||
}, this.FETCH_TIMEOUT_MS);
|
||||
|
||||
worker.on(
|
||||
'message',
|
||||
async (message: {
|
||||
type: string;
|
||||
error?: string;
|
||||
url?: string;
|
||||
stats?: { totalChannels: number; totalPrograms: number };
|
||||
}) => {
|
||||
try {
|
||||
switch (message.type) {
|
||||
case 'READY':
|
||||
// Notify renderer that loading started
|
||||
this.sendProgressToRenderer(url, 'loading', {
|
||||
totalChannels: 0,
|
||||
totalPrograms: 0,
|
||||
});
|
||||
worker.postMessage({ type: 'FETCH_EPG', url });
|
||||
break;
|
||||
|
||||
case 'EPG_PROGRESS':
|
||||
if (message.stats) {
|
||||
// Forward progress to renderer
|
||||
this.sendProgressToRenderer(
|
||||
url,
|
||||
'loading',
|
||||
message.stats
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'EPG_COMPLETE':
|
||||
console.log(
|
||||
this.loggerLabel,
|
||||
`EPG parsing complete for ${url}:`,
|
||||
message.stats
|
||||
);
|
||||
// Notify renderer of completion
|
||||
this.sendProgressToRenderer(
|
||||
url,
|
||||
'complete',
|
||||
message.stats
|
||||
);
|
||||
this.fetchedUrls.add(url);
|
||||
worker.terminate();
|
||||
this.workers.delete(url);
|
||||
settle(() => resolve());
|
||||
break;
|
||||
|
||||
case 'EPG_ERROR':
|
||||
console.error(
|
||||
this.loggerLabel,
|
||||
'Worker error:',
|
||||
message.error
|
||||
);
|
||||
// Notify renderer of error
|
||||
this.sendProgressToRenderer(
|
||||
url,
|
||||
'error',
|
||||
undefined,
|
||||
message.error
|
||||
);
|
||||
worker.terminate();
|
||||
this.workers.delete(url);
|
||||
settle(() =>
|
||||
reject(
|
||||
new Error(
|
||||
message.error || 'Unknown error'
|
||||
)
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
this.loggerLabel,
|
||||
'Error handling message:',
|
||||
err
|
||||
);
|
||||
this.sendProgressToRenderer(
|
||||
url,
|
||||
'error',
|
||||
undefined,
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
worker.terminate();
|
||||
this.workers.delete(url);
|
||||
settle(() => reject(err));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
worker.on('error', (error) => {
|
||||
console.error(this.loggerLabel, 'Worker error event:', error);
|
||||
this.sendProgressToRenderer(
|
||||
url,
|
||||
'error',
|
||||
undefined,
|
||||
error.message
|
||||
);
|
||||
worker.terminate();
|
||||
this.workers.delete(url);
|
||||
settle(() => reject(error));
|
||||
});
|
||||
|
||||
worker.on('exit', (code) => {
|
||||
// If the worker exits without emitting EPG_COMPLETE/EPG_ERROR,
|
||||
// settle the promise so the outer sequential loop can advance
|
||||
// instead of hanging forever.
|
||||
if (settled) return;
|
||||
const errorMessage = `Worker exited unexpectedly (code ${code})`;
|
||||
console.error(this.loggerLabel, `${errorMessage}: ${url}`);
|
||||
this.sendProgressToRenderer(
|
||||
url,
|
||||
'error',
|
||||
undefined,
|
||||
errorMessage
|
||||
);
|
||||
this.workers.delete(url);
|
||||
settle(() => reject(new Error(errorMessage)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static normalizeChannelLookupKeys(channelIds: string[]): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
channelIds
|
||||
.map((channelId) => channelId.trim())
|
||||
.filter((channelId) => channelId.length > 0)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private static resolveChannelMetadataCandidate(
|
||||
channelId: string,
|
||||
candidates: EpgChannelMetadata[]
|
||||
): EpgChannelMetadata | null {
|
||||
const lowerChannelId = channelId.toLowerCase();
|
||||
|
||||
const exactIdMatch =
|
||||
candidates.find((candidate) => candidate.id === channelId) ?? null;
|
||||
if (exactIdMatch) {
|
||||
return exactIdMatch;
|
||||
}
|
||||
|
||||
const caseInsensitiveIdMatch =
|
||||
candidates.find(
|
||||
(candidate) => candidate.id.toLowerCase() === lowerChannelId
|
||||
) ?? null;
|
||||
if (caseInsensitiveIdMatch) {
|
||||
return caseInsensitiveIdMatch;
|
||||
}
|
||||
|
||||
const exactDisplayNameMatch =
|
||||
candidates.find(
|
||||
(candidate) => candidate.displayName === channelId
|
||||
) ?? null;
|
||||
if (exactDisplayNameMatch) {
|
||||
return exactDisplayNameMatch;
|
||||
}
|
||||
|
||||
return (
|
||||
candidates.find(
|
||||
(candidate) =>
|
||||
candidate.displayName.toLowerCase() === lowerChannelId
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform database row to flat EpgProgram interface
|
||||
*/
|
||||
private static transformDbRowToEpgProgram(row: {
|
||||
id: number;
|
||||
channelId: string;
|
||||
start: string;
|
||||
stop: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
iconUrl: string | null;
|
||||
rating: string | null;
|
||||
episodeNum: string | null;
|
||||
}) {
|
||||
return {
|
||||
start: row.start,
|
||||
stop: row.stop,
|
||||
channel: row.channelId,
|
||||
title: row.title,
|
||||
desc: row.description,
|
||||
category: row.category,
|
||||
iconUrl: row.iconUrl,
|
||||
rating: row.rating,
|
||||
episodeNum: row.episodeNum,
|
||||
};
|
||||
}
|
||||
|
||||
private static isValidEpgProgram(program: EpgProgram): boolean {
|
||||
return Boolean(
|
||||
program.start &&
|
||||
program.stop &&
|
||||
!Number.isNaN(new Date(program.start).getTime()) &&
|
||||
!Number.isNaN(new Date(program.stop).getTime())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get programs for a specific channel from database
|
||||
*/
|
||||
private static async handleGetChannelPrograms(
|
||||
channelId: string
|
||||
): Promise<EpgProgram[]> {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const trimmedChannelId = channelId.trim();
|
||||
|
||||
if (!trimmedChannelId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Try exact channel ID match first
|
||||
let results = await db
|
||||
.select()
|
||||
.from(schema.epgPrograms)
|
||||
.where(eq(schema.epgPrograms.channelId, trimmedChannelId))
|
||||
.orderBy(schema.epgPrograms.start)
|
||||
.limit(500);
|
||||
|
||||
if (results.length > 0) {
|
||||
return results
|
||||
.map(this.transformDbRowToEpgProgram)
|
||||
.filter(this.isValidEpgProgram);
|
||||
}
|
||||
|
||||
// Some playlists provide the right tvg-id with different casing than
|
||||
// the XMLTV feed. Resolve the canonical channel row before giving up.
|
||||
let channel = await db
|
||||
.select()
|
||||
.from(schema.epgChannels)
|
||||
.where(
|
||||
sql`${schema.epgChannels.id} = ${trimmedChannelId} COLLATE NOCASE`
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (channel.length > 0) {
|
||||
results = await db
|
||||
.select()
|
||||
.from(schema.epgPrograms)
|
||||
.where(eq(schema.epgPrograms.channelId, channel[0].id))
|
||||
.orderBy(schema.epgPrograms.start)
|
||||
.limit(500);
|
||||
|
||||
if (results.length > 0) {
|
||||
return results
|
||||
.map(this.transformDbRowToEpgProgram)
|
||||
.filter(this.isValidEpgProgram);
|
||||
}
|
||||
}
|
||||
|
||||
// Try exact display name match before giving up. Using wildcard LIKE
|
||||
// here can scan the whole table on the Electron main process.
|
||||
channel = await db
|
||||
.select()
|
||||
.from(schema.epgChannels)
|
||||
.where(eq(schema.epgChannels.displayName, trimmedChannelId))
|
||||
.limit(1);
|
||||
|
||||
if (channel.length === 0) {
|
||||
channel = await db
|
||||
.select()
|
||||
.from(schema.epgChannels)
|
||||
.where(
|
||||
sql`${schema.epgChannels.displayName} = ${trimmedChannelId} COLLATE NOCASE`
|
||||
)
|
||||
.limit(1);
|
||||
}
|
||||
|
||||
if (channel.length > 0) {
|
||||
results = await db
|
||||
.select()
|
||||
.from(schema.epgPrograms)
|
||||
.where(eq(schema.epgPrograms.channelId, channel[0].id))
|
||||
.orderBy(schema.epgPrograms.start)
|
||||
.limit(500);
|
||||
|
||||
return results
|
||||
.map(this.transformDbRowToEpgProgram)
|
||||
.filter(this.isValidEpgProgram);
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(
|
||||
this.loggerLabel,
|
||||
'Error getting channel programs:',
|
||||
error
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all channels from database
|
||||
*/
|
||||
private static async handleGetAllChannels(): Promise<{
|
||||
channels: Array<{ id: string; displayName: string }>;
|
||||
programs: never[];
|
||||
}> {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const channels = await db
|
||||
.select({
|
||||
id: schema.epgChannels.id,
|
||||
displayName: schema.epgChannels.displayName,
|
||||
})
|
||||
.from(schema.epgChannels)
|
||||
.orderBy(schema.epgChannels.displayName);
|
||||
|
||||
return { channels, programs: [] };
|
||||
} catch (error) {
|
||||
console.error(
|
||||
this.loggerLabel,
|
||||
'Error getting all channels:',
|
||||
error
|
||||
);
|
||||
return { channels: [], programs: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve EPG channel metadata for a batch of lookup keys.
|
||||
*
|
||||
* Lookup precedence per key:
|
||||
* 1. exact channel id
|
||||
* 2. case-insensitive channel id
|
||||
* 3. exact display name
|
||||
* 4. case-insensitive display name
|
||||
*/
|
||||
private static async handleGetChannelMetadata(
|
||||
channelIds: string[]
|
||||
): Promise<Record<string, EpgChannelMetadata | null>> {
|
||||
try {
|
||||
const normalizedChannelIds =
|
||||
this.normalizeChannelLookupKeys(channelIds);
|
||||
|
||||
if (normalizedChannelIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
const lowerKeys = Array.from(
|
||||
new Set(
|
||||
normalizedChannelIds.map((channelId) =>
|
||||
channelId.toLowerCase()
|
||||
)
|
||||
)
|
||||
);
|
||||
const lowerKeyValues = lowerKeys.map((key) => sql`${key}`);
|
||||
|
||||
const candidates = await db
|
||||
.select({
|
||||
id: schema.epgChannels.id,
|
||||
displayName: schema.epgChannels.displayName,
|
||||
iconUrl: schema.epgChannels.iconUrl,
|
||||
})
|
||||
.from(schema.epgChannels)
|
||||
.where(sql`
|
||||
LOWER(${schema.epgChannels.id}) IN (${sql.join(lowerKeyValues, sql`, `)})
|
||||
OR LOWER(${schema.epgChannels.displayName}) IN (${sql.join(lowerKeyValues, sql`, `)})
|
||||
`);
|
||||
|
||||
return Object.fromEntries(
|
||||
normalizedChannelIds.map((channelId) => [
|
||||
channelId,
|
||||
this.resolveChannelMetadataCandidate(channelId, candidates),
|
||||
])
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
this.loggerLabel,
|
||||
'Error getting channel metadata:',
|
||||
error
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channels by range (for pagination) with their programs
|
||||
*/
|
||||
private static async handleGetChannelsByRange(
|
||||
skip: number,
|
||||
limit: number
|
||||
): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
displayName: string;
|
||||
iconUrl: string | null;
|
||||
programs: EpgProgram[];
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const channels = await db
|
||||
.select({
|
||||
id: schema.epgChannels.id,
|
||||
displayName: schema.epgChannels.displayName,
|
||||
iconUrl: schema.epgChannels.iconUrl,
|
||||
})
|
||||
.from(schema.epgChannels)
|
||||
.orderBy(schema.epgChannels.displayName)
|
||||
.offset(skip)
|
||||
.limit(limit);
|
||||
|
||||
// Fetch programs for each channel
|
||||
const channelsWithPrograms = await Promise.all(
|
||||
channels.map(async (channel) => {
|
||||
const programs = await db
|
||||
.select()
|
||||
.from(schema.epgPrograms)
|
||||
.where(eq(schema.epgPrograms.channelId, channel.id))
|
||||
.orderBy(schema.epgPrograms.start);
|
||||
|
||||
return {
|
||||
...channel,
|
||||
programs: programs.map(this.transformDbRowToEpgProgram),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return channelsWithPrograms;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
this.loggerLabel,
|
||||
'Error getting channels by range:',
|
||||
error
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all EPG data using worker thread to avoid blocking main thread
|
||||
*/
|
||||
static async clearEpgData(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = this.createEpgWorker();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
this.loggerLabel,
|
||||
'Failed to create worker for clear:',
|
||||
error
|
||||
);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
worker.on(
|
||||
'message',
|
||||
(message: { type: string; error?: string }) => {
|
||||
if (message.type === 'READY') {
|
||||
worker.postMessage({ type: 'CLEAR_EPG' });
|
||||
} else if (message.type === 'CLEAR_COMPLETE') {
|
||||
console.log(
|
||||
this.loggerLabel,
|
||||
'EPG data cleared via worker'
|
||||
);
|
||||
this.fetchedUrls.clear();
|
||||
// Terminate any running fetch workers
|
||||
this.workers.forEach((w) => w.terminate());
|
||||
this.workers.clear();
|
||||
worker.terminate();
|
||||
resolve();
|
||||
} else if (message.type === 'EPG_ERROR') {
|
||||
console.error(
|
||||
this.loggerLabel,
|
||||
'Worker clear error:',
|
||||
message.error
|
||||
);
|
||||
worker.terminate();
|
||||
reject(new Error(message.error || 'Clear failed'));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
worker.on('error', (error) => {
|
||||
console.error(
|
||||
this.loggerLabel,
|
||||
'Worker error during clear:',
|
||||
error
|
||||
);
|
||||
worker.terminate();
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ExternalPlayerSession } from 'shared-interfaces';
|
||||
import { ExternalPlayerSessionRegistry } from './external-player-session-registry';
|
||||
|
||||
describe('ExternalPlayerSessionRegistry', () => {
|
||||
let updates: ExternalPlayerSession[];
|
||||
let registry: ExternalPlayerSessionRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
updates = [];
|
||||
registry = new ExternalPlayerSessionRegistry((session) => {
|
||||
updates.push(session);
|
||||
});
|
||||
});
|
||||
|
||||
it('creates launching sessions and marks them opened', () => {
|
||||
const session = registry.beginSession({
|
||||
player: 'mpv',
|
||||
title: 'Example',
|
||||
streamUrl: 'https://example.com/video.m3u8',
|
||||
});
|
||||
|
||||
expect(session.status).toBe('launching');
|
||||
expect(registry.getActiveSessionId()).toBe(session.id);
|
||||
|
||||
const opened = registry.markOpened(session.id);
|
||||
expect(opened?.status).toBe('opened');
|
||||
expect(updates.at(-1)?.status).toBe('opened');
|
||||
});
|
||||
|
||||
it('keeps close capability and closes the session explicitly', async () => {
|
||||
const close = jest.fn();
|
||||
const session = registry.beginSession({
|
||||
player: 'vlc',
|
||||
title: 'Example',
|
||||
streamUrl: 'https://example.com/video.m3u8',
|
||||
});
|
||||
|
||||
registry.attachCloser(session.id, close);
|
||||
|
||||
expect(registry.getSession(session.id)?.canClose).toBe(true);
|
||||
|
||||
const closed = await registry.closeSession(session.id);
|
||||
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(closed?.status).toBe('closed');
|
||||
expect(closed?.canClose).toBe(false);
|
||||
expect(registry.getActiveSessionId()).toBeNull();
|
||||
});
|
||||
|
||||
it('marks runtime failures as errors without clearing the active id', () => {
|
||||
const session = registry.beginSession({
|
||||
player: 'mpv',
|
||||
title: 'Example',
|
||||
streamUrl: 'https://example.com/video.m3u8',
|
||||
});
|
||||
|
||||
const errored = registry.markError(session.id, 'Failed to launch');
|
||||
|
||||
expect(errored?.status).toBe('error');
|
||||
expect(errored?.error).toBe('Failed to launch');
|
||||
expect(registry.getActiveSessionId()).toBe(session.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
ExternalPlayerName,
|
||||
ExternalPlayerSession,
|
||||
PlayerContentInfo,
|
||||
} from 'shared-interfaces';
|
||||
|
||||
interface CreateExternalPlayerSessionOptions {
|
||||
player: ExternalPlayerName;
|
||||
title: string;
|
||||
thumbnail?: string | null;
|
||||
streamUrl: string;
|
||||
contentInfo?: PlayerContentInfo;
|
||||
}
|
||||
|
||||
interface UpdateExternalPlayerSessionOptions {
|
||||
status?: ExternalPlayerSession['status'];
|
||||
error?: string;
|
||||
canClose?: boolean;
|
||||
}
|
||||
|
||||
interface ExternalPlayerSessionRuntime {
|
||||
snapshot: ExternalPlayerSession;
|
||||
close?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export class ExternalPlayerSessionRegistry {
|
||||
private readonly sessions = new Map<string, ExternalPlayerSessionRuntime>();
|
||||
private activeSessionId: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly onUpdate: (session: ExternalPlayerSession) => void
|
||||
) {}
|
||||
|
||||
beginSession(
|
||||
options: CreateExternalPlayerSessionOptions
|
||||
): ExternalPlayerSession {
|
||||
const startedAt = new Date().toISOString();
|
||||
const session: ExternalPlayerSession = {
|
||||
id: crypto.randomUUID(),
|
||||
player: options.player,
|
||||
status: 'launching',
|
||||
title: options.title,
|
||||
thumbnail: options.thumbnail ?? null,
|
||||
streamUrl: options.streamUrl,
|
||||
contentInfo: options.contentInfo,
|
||||
startedAt,
|
||||
updatedAt: startedAt,
|
||||
canClose: false,
|
||||
};
|
||||
|
||||
this.sessions.set(session.id, { snapshot: session });
|
||||
this.activeSessionId = session.id;
|
||||
this.onUpdate(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
getActiveSessionId(): string | null {
|
||||
return this.activeSessionId;
|
||||
}
|
||||
|
||||
getSession(id: string): ExternalPlayerSession | null {
|
||||
return this.sessions.get(id)?.snapshot ?? null;
|
||||
}
|
||||
|
||||
attachCloser(
|
||||
id: string,
|
||||
close: () => Promise<void> | void
|
||||
): ExternalPlayerSession | null {
|
||||
const runtime = this.sessions.get(id);
|
||||
if (!runtime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
runtime.close = close;
|
||||
return this.updateSession(id, { canClose: true });
|
||||
}
|
||||
|
||||
updateSession(
|
||||
id: string,
|
||||
options: UpdateExternalPlayerSessionOptions
|
||||
): ExternalPlayerSession | null {
|
||||
const runtime = this.sessions.get(id);
|
||||
if (!runtime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const next: ExternalPlayerSession = {
|
||||
...runtime.snapshot,
|
||||
...options,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
runtime.snapshot = next;
|
||||
this.onUpdate(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
markOpened(id: string): ExternalPlayerSession | null {
|
||||
return this.updateSession(id, { status: 'opened' });
|
||||
}
|
||||
|
||||
markPlaying(id: string): ExternalPlayerSession | null {
|
||||
const current = this.getSession(id);
|
||||
if (!current || current.status === 'playing') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return this.updateSession(id, { status: 'playing' });
|
||||
}
|
||||
|
||||
markClosed(id: string): ExternalPlayerSession | null {
|
||||
if (this.activeSessionId === id) {
|
||||
this.activeSessionId = null;
|
||||
}
|
||||
return this.updateSession(id, { status: 'closed', canClose: false });
|
||||
}
|
||||
|
||||
markError(id: string, error: string): ExternalPlayerSession | null {
|
||||
return this.updateSession(id, {
|
||||
status: 'error',
|
||||
error,
|
||||
canClose: false,
|
||||
});
|
||||
}
|
||||
|
||||
async closeSession(id: string): Promise<ExternalPlayerSession | null> {
|
||||
const runtime = this.sessions.get(id);
|
||||
if (!runtime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await runtime.close?.();
|
||||
} finally {
|
||||
return this.markClosed(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
jest.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../app', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
mainWindow: null,
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../services/store.service', () => ({
|
||||
MPV_PLAYER_PATH: 'MPV_PLAYER_PATH',
|
||||
MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE',
|
||||
VLC_PLAYER_PATH: 'VLC_PLAYER_PATH',
|
||||
store: {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../services/stalker-playback-context.service', () => ({
|
||||
getStalkerPlaybackContextHeaders: jest.fn(() => undefined),
|
||||
}));
|
||||
|
||||
import {
|
||||
buildExternalPlayerSpawnSpec,
|
||||
isRunningInFlatpak,
|
||||
parseVlcRcPlaybackState,
|
||||
parseVlcRcNumericResponse,
|
||||
resolveExternalPlayerLaunchContext,
|
||||
shouldReuseMpvInstance,
|
||||
shouldUseMpvSocketBridge,
|
||||
} from './player.events';
|
||||
|
||||
function createPathExists(existingPaths: string[]) {
|
||||
return (candidatePath: string) => existingPaths.includes(candidatePath);
|
||||
}
|
||||
|
||||
describe('player.events Flatpak launch helpers', () => {
|
||||
it('detects Flatpak only on Linux when /.flatpak-info exists', () => {
|
||||
expect(isRunningInFlatpak(createPathExists(['/.flatpak-info']), 'linux')).toBe(
|
||||
true
|
||||
);
|
||||
expect(isRunningInFlatpak(createPathExists(['/.flatpak-info']), 'darwin')).toBe(
|
||||
false
|
||||
);
|
||||
expect(isRunningInFlatpak(createPathExists([]), 'linux')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps direct Linux player launching outside Flatpak', () => {
|
||||
const launchContext = resolveExternalPlayerLaunchContext('mpv', undefined, {
|
||||
platform: 'linux',
|
||||
isFlatpak: false,
|
||||
pathExists: createPathExists(['/usr/local/bin/mpv']),
|
||||
});
|
||||
const spawnSpec = buildExternalPlayerSpawnSpec(launchContext, [
|
||||
'--ytdl=no',
|
||||
'https://example.com/stream.m3u8',
|
||||
]);
|
||||
|
||||
expect(launchContext).toEqual({
|
||||
mode: 'direct',
|
||||
playerPath: '/usr/local/bin/mpv',
|
||||
command: '/usr/local/bin/mpv',
|
||||
argsPrefix: [],
|
||||
});
|
||||
expect(spawnSpec).toEqual({
|
||||
mode: 'direct',
|
||||
playerPath: '/usr/local/bin/mpv',
|
||||
command: '/usr/local/bin/mpv',
|
||||
args: ['--ytdl=no', 'https://example.com/stream.m3u8'],
|
||||
});
|
||||
});
|
||||
|
||||
it('builds Flatpak host launches with bare player names by default', () => {
|
||||
const launchContext = resolveExternalPlayerLaunchContext('vlc', undefined, {
|
||||
platform: 'linux',
|
||||
isFlatpak: true,
|
||||
pathExists: createPathExists(['/usr/bin/vlc']),
|
||||
});
|
||||
const spawnSpec = buildExternalPlayerSpawnSpec(launchContext, [
|
||||
'--extraintf=rc',
|
||||
'https://example.com/stream.m3u8',
|
||||
]);
|
||||
|
||||
expect(launchContext).toEqual({
|
||||
mode: 'flatpak-host',
|
||||
playerPath: 'vlc',
|
||||
command: 'flatpak-spawn',
|
||||
argsPrefix: ['--host', '--watch-bus', 'vlc'],
|
||||
});
|
||||
expect(spawnSpec.args).toEqual([
|
||||
'--host',
|
||||
'--watch-bus',
|
||||
'vlc',
|
||||
'--extraintf=rc',
|
||||
'https://example.com/stream.m3u8',
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes custom host player paths through Flatpak launching unchanged', () => {
|
||||
const launchContext = resolveExternalPlayerLaunchContext(
|
||||
'mpv',
|
||||
'/opt/mpv/bin/mpv',
|
||||
{
|
||||
platform: 'linux',
|
||||
isFlatpak: true,
|
||||
}
|
||||
);
|
||||
|
||||
expect(launchContext).toEqual({
|
||||
mode: 'flatpak-host',
|
||||
playerPath: '/opt/mpv/bin/mpv',
|
||||
command: 'flatpak-spawn',
|
||||
argsPrefix: ['--host', '--watch-bus', '/opt/mpv/bin/mpv'],
|
||||
});
|
||||
});
|
||||
|
||||
it('disables MPV reuse and socket bridging only in Flatpak', () => {
|
||||
expect(shouldReuseMpvInstance(true, true)).toBe(false);
|
||||
expect(shouldUseMpvSocketBridge(true)).toBe(false);
|
||||
|
||||
expect(shouldReuseMpvInstance(true, false)).toBe(true);
|
||||
expect(shouldReuseMpvInstance(false, false)).toBe(false);
|
||||
expect(shouldUseMpvSocketBridge(false)).toBe(true);
|
||||
});
|
||||
|
||||
it('parses numeric VLC RC responses that include the prompt prefix', () => {
|
||||
expect(
|
||||
parseVlcRcNumericResponse(`VLC media player 3.0.20 Vetinari
|
||||
Command Line Interface initialized. Type \`help' for help.
|
||||
> 20
|
||||
> `)
|
||||
).toBe('20');
|
||||
|
||||
expect(
|
||||
parseVlcRcNumericResponse(`VLC media player 3.0.20 Vetinari
|
||||
Command Line Interface initialized. Type \`help' for help.
|
||||
> 9281
|
||||
> `)
|
||||
).toBe('9281');
|
||||
});
|
||||
|
||||
it('returns an empty string for non-numeric VLC RC responses', () => {
|
||||
expect(
|
||||
parseVlcRcNumericResponse(`VLC media player 3.0.20 Vetinari
|
||||
Command Line Interface initialized. Type \`help' for help.
|
||||
> ( state playing )
|
||||
> `)
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('parses VLC RC playback states from status output', () => {
|
||||
expect(
|
||||
parseVlcRcPlaybackState(`VLC media player 3.0.20 Vetinari
|
||||
Command Line Interface initialized. Type \`help' for help.
|
||||
> ( audio volume: 196 )
|
||||
( state playing )
|
||||
> `)
|
||||
).toBe('playing');
|
||||
|
||||
expect(
|
||||
parseVlcRcPlaybackState(`VLC media player 3.0.20 Vetinari
|
||||
Command Line Interface initialized. Type \`help' for help.
|
||||
> ( state stopped )
|
||||
> `)
|
||||
).toBe('stopped');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* This module is responsible on handling all the inter process communications
|
||||
* between the frontend to the electron backend.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { app, dialog, ipcMain, WebContents } from 'electron';
|
||||
import { parse } from 'iptv-playlist-parser';
|
||||
import { createPlaylistObject, getFilenameFromUrl } from 'm3u-utils';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { basename } from 'node:path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { Worker } from 'worker_threads';
|
||||
import {
|
||||
AUTO_UPDATE_PLAYLISTS,
|
||||
PLAYLIST_CANCEL_REFRESH,
|
||||
PLAYLIST_REFRESH,
|
||||
PLAYLIST_REFRESH_EVENT,
|
||||
Playlist,
|
||||
PlaylistRefreshEvent,
|
||||
PlaylistRefreshPayload,
|
||||
} from 'shared-interfaces';
|
||||
import { resolveWorkerRuntimeBootstrap } from '../workers/worker-runtime-paths';
|
||||
import type {
|
||||
PlaylistRefreshWorkerMessage,
|
||||
PlaylistRefreshWorkerResponseMessage,
|
||||
} from '../workers/playlist-refresh.worker.types';
|
||||
|
||||
export default class PlaylistEvents {
|
||||
static bootstrapPlaylistEvents(): Electron.IpcMain {
|
||||
return ipcMain;
|
||||
}
|
||||
}
|
||||
|
||||
const https = require('https');
|
||||
|
||||
type ActivePlaylistRefresh = {
|
||||
reject: (reason?: unknown) => void;
|
||||
resolve: (value: Playlist) => void;
|
||||
sender: WebContents;
|
||||
worker: Worker;
|
||||
};
|
||||
|
||||
const activePlaylistRefreshes = new Map<string, ActivePlaylistRefresh>();
|
||||
|
||||
/**
|
||||
* Fetches and parses a playlist from a URL
|
||||
* @param url - The URL to fetch the playlist from
|
||||
* @param title - Optional title for the playlist
|
||||
* @returns Parsed playlist object
|
||||
*/
|
||||
async function fetchPlaylistFromUrl(
|
||||
url: string,
|
||||
title?: string
|
||||
): Promise<any> {
|
||||
const agent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
const result = await axios.get(url, { httpsAgent: agent });
|
||||
const parsedPlaylist = parse(result.data);
|
||||
|
||||
const extractedName =
|
||||
url && url.length > 1 ? getFilenameFromUrl(url) : '';
|
||||
const playlistName =
|
||||
!extractedName || extractedName === 'Untitled playlist'
|
||||
? 'Imported from URL'
|
||||
: extractedName;
|
||||
|
||||
const playlistObject = createPlaylistObject(
|
||||
title ?? playlistName,
|
||||
parsedPlaylist,
|
||||
url,
|
||||
'URL'
|
||||
);
|
||||
|
||||
return playlistObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and parses a playlist from a file path
|
||||
* @param filePath - The path to the playlist file
|
||||
* @param title - Title for the playlist
|
||||
* @returns Parsed playlist object
|
||||
*/
|
||||
async function fetchPlaylistFromFile(
|
||||
filePath: string,
|
||||
title: string
|
||||
): Promise<any> {
|
||||
const fileContent = await readFile(filePath, 'utf-8');
|
||||
const parsedPlaylist = parse(fileContent);
|
||||
const playlistObject = createPlaylistObject(
|
||||
title,
|
||||
parsedPlaylist,
|
||||
filePath,
|
||||
'FILE'
|
||||
);
|
||||
return playlistObject;
|
||||
}
|
||||
|
||||
function resolvePlaylistRefreshWorker(): Worker {
|
||||
const bootstrap = resolveWorkerRuntimeBootstrap({
|
||||
isPackaged: app.isPackaged,
|
||||
workerFilename: 'playlist-refresh.worker.js',
|
||||
developmentWorkerDir: __dirname + '/workers',
|
||||
resourcesPath: (
|
||||
process as NodeJS.Process & { resourcesPath?: string }
|
||||
).resourcesPath,
|
||||
appPath: app.getAppPath(),
|
||||
});
|
||||
|
||||
return new Worker(pathToFileURL(bootstrap.workerPath), {
|
||||
workerData: {
|
||||
nativeModuleSearchPaths: bootstrap.nativeModuleSearchPaths,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function emitPlaylistRefreshEvent(
|
||||
sender: WebContents,
|
||||
event: PlaylistRefreshEvent
|
||||
): void {
|
||||
if (sender.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
sender.send(PLAYLIST_REFRESH_EVENT, event);
|
||||
}
|
||||
|
||||
function createPlaylistRefreshError(error: {
|
||||
message: string;
|
||||
name?: string;
|
||||
stack?: string;
|
||||
}): Error {
|
||||
const workerError = new Error(error.message);
|
||||
workerError.name = error.name || 'PlaylistRefreshWorkerError';
|
||||
workerError.stack = error.stack || workerError.stack;
|
||||
return workerError;
|
||||
}
|
||||
|
||||
ipcMain.handle('fetch-playlist-by-url', async (event, url, title?: string) => {
|
||||
try {
|
||||
return await fetchPlaylistFromUrl(url, title);
|
||||
} catch (error) {
|
||||
console.error('Error fetching playlist:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
'update-playlist-from-file-path',
|
||||
async (event, filePath, title) => {
|
||||
try {
|
||||
return await fetchPlaylistFromFile(filePath, title);
|
||||
} catch (error) {
|
||||
console.error('Error reading playlist from file:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle('open-playlist-from-file', async () => {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{ name: 'Playlists', extensions: ['m3u', 'm3u8', 'pls', 'txt'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
});
|
||||
|
||||
if (canceled || filePaths.length === 0) {
|
||||
console.log('User canceled file open dialog.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const filePath = filePaths[0];
|
||||
|
||||
try {
|
||||
// Extract filename from path and remove extension for a clean title
|
||||
const filename = basename(filePath);
|
||||
const title = filename.replace(/\.(m3u8?|pls|txt)$/i, '') || 'from file';
|
||||
|
||||
return await fetchPlaylistFromFile(filePath, title);
|
||||
} catch (error) {
|
||||
console.error('Error reading or parsing the file:', error);
|
||||
throw new Error('Failed to process the selected file.');
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(AUTO_UPDATE_PLAYLISTS, async (event, playlists) => {
|
||||
console.log(`Auto-updating ${playlists.length} playlist(s)...`);
|
||||
|
||||
const updatedPlaylists = [];
|
||||
|
||||
for (const playlist of playlists) {
|
||||
try {
|
||||
let playlistObject;
|
||||
|
||||
if (playlist.importDate && playlist.url) {
|
||||
// Update from URL
|
||||
console.log(
|
||||
`Updating playlist "${playlist.title}" from URL: ${playlist.url}`
|
||||
);
|
||||
playlistObject = await fetchPlaylistFromUrl(
|
||||
playlist.url,
|
||||
playlist.title
|
||||
);
|
||||
} else if (playlist.filePath) {
|
||||
// Update from file path
|
||||
console.log(
|
||||
`Updating playlist "${playlist.title}" from file: ${playlist.filePath}`
|
||||
);
|
||||
playlistObject = await fetchPlaylistFromFile(
|
||||
playlist.filePath,
|
||||
playlist.title
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`Skipping playlist "${playlist.title}": no URL or file path found`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Preserve user data when updating playlist
|
||||
updatedPlaylists.push({
|
||||
...playlistObject,
|
||||
_id: playlist._id,
|
||||
autoRefresh: playlist.autoRefresh,
|
||||
favorites: playlist.favorites || [], // Preserve favorites
|
||||
userAgent: playlist.userAgent, // Preserve custom user agent
|
||||
});
|
||||
|
||||
console.log(`Successfully updated playlist "${playlist.title}"`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to update playlist "${playlist.title}":`,
|
||||
error
|
||||
);
|
||||
// Continue with other playlists even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Auto-update completed: ${updatedPlaylists.length} updated`);
|
||||
return updatedPlaylists;
|
||||
});
|
||||
|
||||
ipcMain.handle(PLAYLIST_REFRESH, async (event, payload: PlaylistRefreshPayload) => {
|
||||
const worker = resolvePlaylistRefreshWorker();
|
||||
|
||||
return await new Promise<Playlist>((resolve, reject) => {
|
||||
const cleanup = async (): Promise<void> => {
|
||||
activePlaylistRefreshes.delete(payload.operationId);
|
||||
worker.removeAllListeners();
|
||||
await worker.terminate().catch(() => undefined);
|
||||
};
|
||||
|
||||
activePlaylistRefreshes.set(payload.operationId, {
|
||||
worker,
|
||||
sender: event.sender,
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
|
||||
worker.on('message', async (message: PlaylistRefreshWorkerMessage<Playlist>) => {
|
||||
if (message.type === 'ready') {
|
||||
worker.postMessage({
|
||||
type: 'request',
|
||||
payload,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'event') {
|
||||
emitPlaylistRefreshEvent(event.sender, message.event);
|
||||
return;
|
||||
}
|
||||
|
||||
await cleanup();
|
||||
|
||||
const response = message as PlaylistRefreshWorkerResponseMessage<Playlist>;
|
||||
if (response.success && response.result) {
|
||||
resolve(response.result);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(
|
||||
createPlaylistRefreshError(
|
||||
response.error ?? {
|
||||
message: 'Playlist refresh worker request failed',
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
worker.on('error', async (error) => {
|
||||
await cleanup();
|
||||
reject(error);
|
||||
});
|
||||
|
||||
worker.on('exit', async (code) => {
|
||||
if (!activePlaylistRefreshes.has(payload.operationId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await cleanup();
|
||||
reject(
|
||||
new Error(
|
||||
code === 0
|
||||
? 'Playlist refresh worker exited unexpectedly'
|
||||
: `Playlist refresh worker stopped with exit code ${code}`
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
PLAYLIST_CANCEL_REFRESH,
|
||||
async (_event, operationId: string): Promise<{ success: boolean }> => {
|
||||
const activeRefresh = activePlaylistRefreshes.get(operationId);
|
||||
if (!activeRefresh) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
activeRefresh.worker.postMessage({
|
||||
type: 'cancel',
|
||||
operationId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle('save-file-dialog', async (event, defaultPath, filters) => {
|
||||
try {
|
||||
const { canceled, filePath } = await dialog.showSaveDialog({
|
||||
defaultPath,
|
||||
filters: filters || [
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
});
|
||||
|
||||
if (canceled || !filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
console.error('Error showing save dialog:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('write-file', async (event, filePath, content) => {
|
||||
try {
|
||||
await writeFile(filePath, content, 'utf-8');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error writing file:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
jest.mock('electron', () => ({
|
||||
BrowserWindow: {
|
||||
getAllWindows: () => [],
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../environments/environment', () => ({
|
||||
environment: {
|
||||
production: false,
|
||||
},
|
||||
}));
|
||||
|
||||
import { sanitizePortalDebugEvent } from './portal-debug.events';
|
||||
|
||||
describe('sanitizePortalDebugEvent', () => {
|
||||
it('turns Error objects into plain serializable data', () => {
|
||||
const error = new Error('boom');
|
||||
(error as Error & { code?: string }).code = 'ECONNREFUSED';
|
||||
|
||||
const event = sanitizePortalDebugEvent({
|
||||
requestId: 'req-1',
|
||||
provider: 'xtream',
|
||||
operation: 'get_account_info',
|
||||
transport: 'electron-main',
|
||||
startedAt: new Date().toISOString(),
|
||||
durationMs: 12,
|
||||
status: 'error',
|
||||
request: { url: 'http://localhost:3211/player_api.php' },
|
||||
error,
|
||||
});
|
||||
|
||||
expect(event.error).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'Error',
|
||||
message: 'boom',
|
||||
code: 'ECONNREFUSED',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces circular references with markers', () => {
|
||||
const request: Record<string, unknown> = {
|
||||
method: 'GET',
|
||||
};
|
||||
request['self'] = request;
|
||||
|
||||
const event = sanitizePortalDebugEvent({
|
||||
requestId: 'req-2',
|
||||
provider: 'stalker',
|
||||
operation: 'get_profile',
|
||||
transport: 'electron-main',
|
||||
startedAt: new Date().toISOString(),
|
||||
durationMs: 4,
|
||||
status: 'success',
|
||||
request,
|
||||
response: { ok: true },
|
||||
});
|
||||
|
||||
expect(event.request).toEqual({
|
||||
method: 'GET',
|
||||
self: '[Circular]',
|
||||
});
|
||||
});
|
||||
|
||||
it('strips functions from nested debug payloads', () => {
|
||||
const event = sanitizePortalDebugEvent({
|
||||
requestId: 'req-3',
|
||||
provider: 'xtream',
|
||||
operation: 'test',
|
||||
transport: 'electron-main',
|
||||
startedAt: new Date().toISOString(),
|
||||
durationMs: 1,
|
||||
status: 'success',
|
||||
request: {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
toJSON: () => 'nope',
|
||||
},
|
||||
},
|
||||
response: undefined,
|
||||
});
|
||||
|
||||
expect(event.request).toEqual({
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
toJSON: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { PORTAL_DEBUG_EVENT, PortalDebugEvent } from 'shared-interfaces';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
const MAX_DEBUG_DEPTH = 6;
|
||||
|
||||
function sanitizePortalDebugValue(
|
||||
value: unknown,
|
||||
seen = new WeakSet<object>(),
|
||||
depth = 0
|
||||
): unknown {
|
||||
if (
|
||||
value == null ||
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value === 'function' ||
|
||||
typeof value === 'symbol'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (depth >= MAX_DEBUG_DEPTH) {
|
||||
return '[MaxDepth]';
|
||||
}
|
||||
|
||||
if (value instanceof Error) {
|
||||
const baseError = {
|
||||
name: value.name,
|
||||
message: value.message,
|
||||
stack: value.stack,
|
||||
} as Record<string, unknown>;
|
||||
|
||||
for (const [key, entry] of Object.entries(
|
||||
value as unknown as Record<string, unknown>
|
||||
)) {
|
||||
baseError[key] = sanitizePortalDebugValue(
|
||||
entry,
|
||||
seen,
|
||||
depth + 1
|
||||
);
|
||||
}
|
||||
|
||||
return baseError;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
if (value instanceof URL) {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) =>
|
||||
sanitizePortalDebugValue(entry, seen, depth + 1)
|
||||
);
|
||||
}
|
||||
|
||||
if (value instanceof Map) {
|
||||
return Object.fromEntries(
|
||||
[...value.entries()].map(([key, entry]) => [
|
||||
String(key),
|
||||
sanitizePortalDebugValue(entry, seen, depth + 1),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
if (value instanceof Set) {
|
||||
return [...value].map((entry) =>
|
||||
sanitizePortalDebugValue(entry, seen, depth + 1)
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
if (seen.has(value)) {
|
||||
return '[Circular]';
|
||||
}
|
||||
|
||||
seen.add(value);
|
||||
|
||||
const entries = Object.entries(value as Record<string, unknown>).map(
|
||||
([key, entry]) => [
|
||||
key,
|
||||
sanitizePortalDebugValue(entry, seen, depth + 1),
|
||||
]
|
||||
);
|
||||
|
||||
seen.delete(value);
|
||||
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function sanitizePortalDebugEvent(
|
||||
event: PortalDebugEvent
|
||||
): PortalDebugEvent {
|
||||
return {
|
||||
...event,
|
||||
request: sanitizePortalDebugValue(event.request),
|
||||
response: sanitizePortalDebugValue(event.response),
|
||||
error: sanitizePortalDebugValue(event.error),
|
||||
};
|
||||
}
|
||||
|
||||
export function emitPortalDebugEvent(event: PortalDebugEvent): void {
|
||||
if (environment.production) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serializedEvent = sanitizePortalDebugEvent(event);
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const window of windows) {
|
||||
window.webContents.send(PORTAL_DEBUG_EVENT, serializedEvent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron';
|
||||
import * as http from 'http';
|
||||
import { httpServer } from '../server/http-server';
|
||||
import { store } from '../services/store.service';
|
||||
|
||||
interface RemoteControlCommand {
|
||||
type:
|
||||
| 'channel-select-number'
|
||||
| 'volume-up'
|
||||
| 'volume-down'
|
||||
| 'volume-toggle-mute';
|
||||
number?: number;
|
||||
}
|
||||
|
||||
interface RemoteControlStatus {
|
||||
portal: 'm3u' | 'xtream' | 'stalker' | 'unknown';
|
||||
isLiveView: boolean;
|
||||
channelName?: string;
|
||||
channelNumber?: number;
|
||||
epgTitle?: string;
|
||||
epgStart?: string;
|
||||
epgEnd?: string;
|
||||
supportsVolume?: boolean;
|
||||
volume?: number;
|
||||
muted?: boolean;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
class RemoteControlEvents {
|
||||
private remoteControlStatus: RemoteControlStatus = {
|
||||
portal: 'unknown',
|
||||
isLiveView: false,
|
||||
supportsVolume: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Bootstrap remote control events
|
||||
*/
|
||||
bootstrapRemoteControlEvents(): void {
|
||||
// Register HTTP API endpoints
|
||||
httpServer.registerRemoteControlHandler(
|
||||
'/api/remote-control/channel/up',
|
||||
this.handleChannelUp.bind(this)
|
||||
);
|
||||
|
||||
httpServer.registerRemoteControlHandler(
|
||||
'/api/remote-control/channel/down',
|
||||
this.handleChannelDown.bind(this)
|
||||
);
|
||||
httpServer.registerRemoteControlHandler(
|
||||
'/api/remote-control/channel/select-number',
|
||||
this.handleChannelSelectNumber.bind(this)
|
||||
);
|
||||
httpServer.registerRemoteControlHandler(
|
||||
'/api/remote-control/volume/up',
|
||||
this.handleVolumeUp.bind(this)
|
||||
);
|
||||
httpServer.registerRemoteControlHandler(
|
||||
'/api/remote-control/volume/down',
|
||||
this.handleVolumeDown.bind(this)
|
||||
);
|
||||
httpServer.registerRemoteControlHandler(
|
||||
'/api/remote-control/volume/toggle-mute',
|
||||
this.handleToggleMute.bind(this)
|
||||
);
|
||||
httpServer.registerRemoteControlHandler(
|
||||
'/api/remote-control/status',
|
||||
this.handleGetStatus.bind(this)
|
||||
);
|
||||
|
||||
// Start HTTP server if remote control is enabled in settings
|
||||
const remoteControlEnabled = store.get('remoteControl', false);
|
||||
const remoteControlPort = store.get('remoteControlPort', 8765);
|
||||
if (remoteControlEnabled) {
|
||||
httpServer.start(remoteControlPort);
|
||||
}
|
||||
|
||||
// Register IPC handlers (for when the main app wants to use remote control)
|
||||
ipcMain.handle('REMOTE_CONTROL_CHANNEL_UP', () => {
|
||||
return this.changeChannelUp();
|
||||
});
|
||||
|
||||
ipcMain.handle('REMOTE_CONTROL_CHANNEL_DOWN', () => {
|
||||
return this.changeChannelDown();
|
||||
});
|
||||
|
||||
ipcMain.on(
|
||||
'REMOTE_CONTROL_STATUS_UPDATE',
|
||||
(_event, status: Partial<RemoteControlStatus>) => {
|
||||
this.remoteControlStatus = {
|
||||
...this.remoteControlStatus,
|
||||
...status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP handler for channel up
|
||||
*/
|
||||
private handleChannelUp(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse
|
||||
): void {
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
this.changeChannelUp();
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP handler for channel down
|
||||
*/
|
||||
private handleChannelDown(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse
|
||||
): void {
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
this.changeChannelDown();
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP handler for selecting channel by number
|
||||
*/
|
||||
private handleChannelSelectNumber(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse
|
||||
): void {
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
this.readJsonBody(req, res, (body) => {
|
||||
const value = Number(body?.number);
|
||||
if (!Number.isFinite(value) || value < 1) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Invalid channel number' }));
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendRemoteCommandToRenderer({
|
||||
type: 'channel-select-number',
|
||||
number: Math.floor(value),
|
||||
});
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
});
|
||||
}
|
||||
|
||||
private handleVolumeUp(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse
|
||||
): void {
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendRemoteCommandToRenderer({ type: 'volume-up' });
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
}
|
||||
|
||||
private handleVolumeDown(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse
|
||||
): void {
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendRemoteCommandToRenderer({ type: 'volume-down' });
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
}
|
||||
|
||||
private handleToggleMute(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse
|
||||
): void {
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendRemoteCommandToRenderer({ type: 'volume-toggle-mute' });
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
}
|
||||
|
||||
private handleGetStatus(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse
|
||||
): void {
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(this.remoteControlStatus));
|
||||
}
|
||||
|
||||
/**
|
||||
* Change to the next channel
|
||||
*/
|
||||
private changeChannelUp(): void {
|
||||
console.log('Channel UP requested');
|
||||
this.sendChannelChangeToRenderer('up');
|
||||
}
|
||||
|
||||
/**
|
||||
* Change to the previous channel
|
||||
*/
|
||||
private changeChannelDown(): void {
|
||||
console.log('Channel DOWN requested');
|
||||
this.sendChannelChangeToRenderer('down');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send channel change message to the renderer process
|
||||
*/
|
||||
private sendChannelChangeToRenderer(direction: 'up' | 'down'): void {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
if (windows.length > 0) {
|
||||
windows[0].webContents.send('CHANNEL_CHANGE', { direction });
|
||||
console.log(`Sent CHANNEL_CHANGE ${direction} to renderer`);
|
||||
} else {
|
||||
console.warn('No browser windows found to send channel change');
|
||||
}
|
||||
}
|
||||
|
||||
private sendRemoteCommandToRenderer(command: RemoteControlCommand): void {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
if (windows.length > 0) {
|
||||
windows[0].webContents.send('REMOTE_CONTROL_COMMAND', command);
|
||||
console.log(
|
||||
`Sent REMOTE_CONTROL_COMMAND ${command.type} to renderer`
|
||||
);
|
||||
} else {
|
||||
console.warn('No browser windows found to send remote command');
|
||||
}
|
||||
}
|
||||
|
||||
private readJsonBody(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
callback: (body: any) => void
|
||||
): void {
|
||||
let data = '';
|
||||
let totalSize = 0;
|
||||
let payloadTooLarge = false;
|
||||
const MAX_BODY_SIZE = 10 * 1024; // 10KB
|
||||
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
if (payloadTooLarge) return;
|
||||
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > MAX_BODY_SIZE) {
|
||||
payloadTooLarge = true;
|
||||
res.writeHead(413, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Payload too large' }));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
data += chunk.toString();
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (payloadTooLarge) return;
|
||||
|
||||
if (!data) {
|
||||
callback({});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
callback(JSON.parse(data));
|
||||
} catch {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Invalid JSON payload' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new RemoteControlEvents();
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import {
|
||||
MPV_REUSE_INSTANCE,
|
||||
store,
|
||||
} from '../services/store.service';
|
||||
import { httpServer } from '../server/http-server';
|
||||
|
||||
export default class SettingsEvents {
|
||||
static bootstrapSettingsEvents(): Electron.IpcMain {
|
||||
return ipcMain;
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => {
|
||||
console.log('Received SETTINGS_UPDATE with data:', arg);
|
||||
|
||||
// Only set values that are defined
|
||||
if (arg.mpvReuseInstance !== undefined) {
|
||||
store.set(MPV_REUSE_INSTANCE, arg.mpvReuseInstance);
|
||||
}
|
||||
|
||||
// Handle remote control settings
|
||||
if (arg.remoteControl !== undefined || arg.remoteControlPort !== undefined) {
|
||||
const enabled = arg.remoteControl ?? store.get('remoteControl', false);
|
||||
const port = arg.remoteControlPort ?? store.get('remoteControlPort', 8765);
|
||||
|
||||
// Save to store
|
||||
if (arg.remoteControl !== undefined) {
|
||||
store.set('remoteControl', enabled);
|
||||
}
|
||||
if (arg.remoteControlPort !== undefined) {
|
||||
store.set('remoteControlPort', port);
|
||||
}
|
||||
|
||||
// Update HTTP server
|
||||
httpServer.updateSettings(enabled, port);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ipcMain, session } from 'electron';
|
||||
|
||||
export default class SharedEvents {
|
||||
static bootstrapSharedEvents(): Electron.IpcMain {
|
||||
return ipcMain;
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('set-user-agent', (event, userAgent, referer) => {
|
||||
setUserAgent(userAgent, referer); // TODO: test if defaults needed
|
||||
return true;
|
||||
});
|
||||
|
||||
/**
|
||||
* Sets the user agent header for all http requests
|
||||
* @param userAgent user agent to use
|
||||
* @param referer referer to use
|
||||
*/
|
||||
export function setUserAgent(userAgent: string, referer?: string): void {
|
||||
if (userAgent === undefined || userAgent === null || userAgent === '') {
|
||||
userAgent = this.defaultUserAgent;
|
||||
}
|
||||
|
||||
// Remove trailing slash from referer if it exists
|
||||
let originURL: string;
|
||||
if (referer?.endsWith('/')) {
|
||||
originURL = referer.slice(0, -1);
|
||||
}
|
||||
|
||||
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||
(details, callback) => {
|
||||
details.requestHeaders['User-Agent'] = userAgent;
|
||||
details.requestHeaders['Referer'] = referer as string;
|
||||
details.requestHeaders['Origin'] = originURL as string;
|
||||
callback({ requestHeaders: details.requestHeaders });
|
||||
}
|
||||
);
|
||||
console.log(`Success: Set "${userAgent}" as user agent header`);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* This module is responsible on handling all the setup events that is submitted by squirrel.
|
||||
*/
|
||||
|
||||
import { app } from 'electron';
|
||||
import { spawn } from 'child_process';
|
||||
import { resolve, join, basename } from 'path';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
export default class SquirrelEvents {
|
||||
private static isAppFirstRun = false;
|
||||
|
||||
// app paths
|
||||
private static appFolder = resolve(process.execPath, '..');
|
||||
private static appRootFolder = resolve(SquirrelEvents.appFolder, '..');
|
||||
private static updateExe = resolve(
|
||||
join(SquirrelEvents.appRootFolder, 'Update.exe')
|
||||
);
|
||||
private static exeName = resolve(
|
||||
join(
|
||||
SquirrelEvents.appRootFolder,
|
||||
'app-' + environment.version,
|
||||
basename(process.execPath)
|
||||
)
|
||||
);
|
||||
|
||||
static handleEvents(): boolean {
|
||||
if (process.argv.length === 1 || process.platform !== 'win32') {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (process.argv[1]) {
|
||||
case '--squirrel-install':
|
||||
case '--squirrel-updated':
|
||||
// Install desktop and start menu shortcuts
|
||||
SquirrelEvents.update(['--createShortcut', SquirrelEvents.exeName]);
|
||||
|
||||
return true;
|
||||
|
||||
case '--squirrel-uninstall':
|
||||
// Remove desktop and start menu shortcuts
|
||||
SquirrelEvents.update(['--removeShortcut', SquirrelEvents.exeName]);
|
||||
|
||||
return true;
|
||||
|
||||
case '--squirrel-obsolete':
|
||||
app.quit();
|
||||
return true;
|
||||
|
||||
case '--squirrel-firstrun':
|
||||
// Check if it the first run of the software
|
||||
SquirrelEvents.isAppFirstRun = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static isFirstRun(): boolean {
|
||||
return SquirrelEvents.isAppFirstRun;
|
||||
}
|
||||
|
||||
private static update(args: Array<string>) {
|
||||
try {
|
||||
spawn(SquirrelEvents.updateExe, args, { detached: true }).on(
|
||||
'close',
|
||||
() => setTimeout(app.quit, 1000)
|
||||
);
|
||||
} catch (error) {
|
||||
setTimeout(app.quit, 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user