From 2d5c4fa4f9f1153dcce572e35cd8efc47cf6f86a Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 15 May 2026 17:43:42 +0200 Subject: [PATCH] chore: tighten validation and runtime logging * chore: tighten validation and runtime logging * fix(i18n): localize new settings labels --- .github/workflows/ci.yml | 3 + AGENTS.md | 1 + CLAUDE.md | 1 + apps/electron-backend-e2e/project.json | 3 + apps/electron-backend/project.json | 1 + .../src/app/events/player.events.ts | 113 ++++++++-------- .../src/app/services/debug-trace.ts | 20 +-- apps/web-e2e/project.json | 5 +- apps/web/src/app/app.component.ts | 12 +- apps/web/src/assets/i18n/ar.json | 6 + apps/web/src/assets/i18n/ary.json | 6 + apps/web/src/assets/i18n/by.json | 6 + apps/web/src/assets/i18n/de.json | 6 + apps/web/src/assets/i18n/el.json | 6 + apps/web/src/assets/i18n/es.json | 6 + apps/web/src/assets/i18n/fr.json | 6 + apps/web/src/assets/i18n/it.json | 6 + apps/web/src/assets/i18n/ja.json | 6 + apps/web/src/assets/i18n/ko.json | 6 + apps/web/src/assets/i18n/nl.json | 6 + apps/web/src/assets/i18n/pl.json | 6 + apps/web/src/assets/i18n/pt.json | 6 + apps/web/src/assets/i18n/ru.json | 2 + apps/web/src/assets/i18n/tr.json | 6 + apps/web/src/assets/i18n/zh.json | 6 + apps/web/src/assets/i18n/zhtw.json | 6 + docs/architecture/validation-map.md | 56 ++++++++ eslint.config.mjs | 11 ++ libs/epg/data-access/jest.config.ts | 22 ++++ libs/epg/data-access/project.json | 8 ++ .../data-access/src/lib/epg.service.spec.ts | 122 ++++-------------- libs/epg/data-access/src/lib/epg.service.ts | 10 +- libs/epg/data-access/src/test-setup.ts | 6 + libs/epg/data-access/tsconfig.json | 2 +- libs/epg/data-access/tsconfig.spec.json | 17 +++ libs/shared/database/jest.config.ts | 13 ++ libs/shared/database/project.json | 8 ++ .../database/src/lib/connection.spec.ts | 120 +++++++++++++++++ libs/shared/database/tsconfig.spec.json | 16 +++ libs/shared/interfaces/project.json | 3 + libs/shared/interfaces/src/index.ts | 1 + .../src/lib/dev-logger.util.spec.ts | 55 ++++++++ .../interfaces/src/lib/dev-logger.util.ts | 22 ++++ libs/shared/m3u-utils/project.json | 3 + .../audio-player/audio-player.component.ts | 37 ++---- .../html-video-player.component.ts | 14 +- .../lib/vjs-player/vjs-player.component.ts | 27 ++-- libs/workspace/shell/util/project.json | 8 ++ package.json | 3 +- tools/i18n/README.md | 15 +++ tools/i18n/check-drift.mjs | 122 ++++++++++++++++++ tools/packaging/project.json | 3 + 52 files changed, 767 insertions(+), 214 deletions(-) create mode 100644 docs/architecture/validation-map.md create mode 100644 libs/epg/data-access/jest.config.ts create mode 100644 libs/epg/data-access/src/test-setup.ts create mode 100644 libs/epg/data-access/tsconfig.spec.json create mode 100644 libs/shared/database/jest.config.ts create mode 100644 libs/shared/database/src/lib/connection.spec.ts create mode 100644 libs/shared/database/tsconfig.spec.json create mode 100644 libs/shared/interfaces/src/lib/dev-logger.util.spec.ts create mode 100644 libs/shared/interfaces/src/lib/dev-logger.util.ts create mode 100644 tools/i18n/check-drift.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9dbc869e..c29a78130 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: - name: Typecheck web and Electron entry points run: pnpm run typecheck:ci + - name: Check i18n drift + run: pnpm run i18n:check + - name: Run curated Nx unit test suite run: pnpm run test:unit:ci env: diff --git a/AGENTS.md b/AGENTS.md index c1bde1d3a..e00346a30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,7 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend - `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_PLAYER=1` traces external-player launch/reuse/polling debug output - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal - GPU/compositor debugging: diff --git a/CLAUDE.md b/CLAUDE.md index e99b3c071..0d97a20b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,6 +123,7 @@ Useful narrower flags: - `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_PLAYER=1` traces external-player launch/reuse/polling debug output - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal For GPU/compositor debugging: diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index b21c54f82..1e886ba34 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -11,6 +11,9 @@ "targets": { "e2e": { "dependsOn": ["electron-backend:build-e2e"] + }, + "lint": { + "executor": "@nx/eslint:lint" } }, "tags": ["scope:e2e", "domain:electron-main", "type:e2e"] diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index 9993dd2ae..a1c984353 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -3,6 +3,7 @@ "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/electron-backend/src", "projectType": "application", + "tags": ["scope:electron", "domain:electron-main", "type:app"], "targets": { "build-worker": { "executor": "nx:run-commands", diff --git a/apps/electron-backend/src/app/events/player.events.ts b/apps/electron-backend/src/app/events/player.events.ts index ebec84ebe..460d41f28 100644 --- a/apps/electron-backend/src/app/events/player.events.ts +++ b/apps/electron-backend/src/app/events/player.events.ts @@ -22,6 +22,7 @@ import { ChildProcess, spawn } from 'child_process'; import { existsSync } from 'fs'; import { AddressInfo, createConnection, createServer } from 'net'; import path from 'path'; +import { isExternalPlayerTraceEnabled, trace } from '../services/debug-trace'; import { getStalkerPlaybackContextHeaders } from '../services/stalker-playback-context.service'; import { ExternalPlayerSessionRegistry } from './external-player-session-registry'; @@ -40,6 +41,14 @@ let positionPollingInterval: NodeJS.Timeout | null = null; let vlcProcess: ChildProcess | null = null; let vlcRcPort: number | null = null; +function traceExternalPlayer(message: string, payload?: unknown): void { + if (!isExternalPlayerTraceEnabled()) { + return; + } + + trace('external-player', message, payload); +} + interface ExternalPlaybackSnapshot { positionSeconds: number; durationSeconds: number | null; @@ -291,7 +300,12 @@ function sendVlcRcCommand(port: number, command: string): Promise { settled = true; clearTimeout(timeoutHandle); if (!client.destroyed) client.destroy(); - err ? reject(err) : resolve(); + if (err) { + reject(err); + return; + } + + resolve(); }; const timeoutHandle = setTimeout( @@ -408,7 +422,6 @@ async function getMpvProperty( let data = ''; client.on('connect', () => { - // console.log('[PlayerEvents] MPV IPC connected, sending:', request.trim()); client.write(request); }); @@ -535,14 +548,11 @@ function startPositionPolling( // Initial delay to let video load setTimeout(() => { - // console.log('[PlayerEvents] Starting position polling interval'); positionPollingInterval = setInterval(async () => { try { const position = await getMpvProperty(socketPath, 'time-pos'); const duration = await getMpvProperty(socketPath, 'duration'); - // console.log(`[PlayerEvents] Polling MPV: pos=${position}, dur=${duration}`); - if (position !== null && App.mainWindow) { sendPlaybackPositionUpdate(sessionId, contentInfo, { positionSeconds: Math.floor(position), @@ -550,12 +560,11 @@ function startPositionPolling( }); } } catch (err) { - // console.error('[PlayerEvents] Error during polling:', err); // MPV may have closed, stop polling stopPositionPolling(); } - }, 5000); // Changed to 5 seconds for debugging - }, 2000); // Reduced initial delay + }, 5000); + }, 2000); } // VLC Polling @@ -643,12 +652,6 @@ function startVlcPositionPolling( // VLC needs time to start and bind port setTimeout(() => { - /* - console.log( - `[PlayerEvents] Starting VLC polling on port ${port}`, - contentInfo - ); - */ vlcPollingInterval = setInterval(async () => { try { const snapshot = await getVlcPlaybackSnapshot(port); @@ -669,7 +672,6 @@ function startVlcPositionPolling( stopVlcPositionPolling(); } } catch (err) { - // console.error('[PlayerEvents] VLC polling error:', err); stopVlcPositionPolling(); } }, 2000); @@ -692,7 +694,10 @@ function sendMpvCommand( const request = JSON.stringify({ command: [command, ...args] }) + '\n'; client.on('connect', () => { - console.log('Connected to MPV socket, sending command:', request); + traceExternalPlayer('mpv ipc command', { + command, + argsCount: args.length, + }); client.write(request); client.end(); resolve(); @@ -768,7 +773,7 @@ ipcMain.handle( mergedHeaders ); - console.log('[MPV] Opening player', { + traceExternalPlayer('open mpv player', { path: mpvLaunchContext.playerPath, launchMode: mpvLaunchContext.mode, requestedReuseInstance, @@ -791,7 +796,7 @@ ipcMain.handle( !mpvProcess.killed && mpvSocketPath ) { - console.log('Reusing existing MPV instance'); + traceExternalPlayer('reuse existing mpv instance'); try { if (effectiveUserAgent) { await sendMpvCommand('set_property', [ @@ -828,8 +833,8 @@ ipcMain.handle( } await sendMpvCommand('loadfile', loadFileArgs); - console.log( - 'Successfully loaded new URL in existing MPV instance' + traceExternalPlayer( + 'loaded new url in existing mpv instance' ); externalPlayerSessions.attachCloser( @@ -881,7 +886,7 @@ ipcMain.handle( } // Create new MPV process - console.log('Creating new MPV instance'); + traceExternalPlayer('create new mpv instance'); let socketPath: string | null = null; const args: string[] = []; @@ -954,7 +959,7 @@ ipcMain.handle( continue; } - console.log('[MPV stdout]:', output); + traceExternalPlayer('mpv stdout', { output }); // MPV sometimes outputs errors to stdout instead of stderr if ( @@ -1016,7 +1021,7 @@ ipcMain.handle( }); proc.on('exit', (code) => { - console.log(`MPV exited with code ${code}`); + traceExternalPlayer('mpv exited', { code }); mpvProcess = null; mpvSocketPath = null; stopPositionPolling(); @@ -1044,10 +1049,9 @@ ipcMain.handle( if (reuseInstance && socketPath) { mpvProcess = proc; mpvSocketPath = socketPath; - console.log( - 'Stored MPV process for reuse with socket:', - socketPath - ); + traceExternalPlayer('stored mpv process for reuse', { + socketPath, + }); } else { // Detach the process so it can continue running independently proc.unref(); @@ -1092,18 +1096,20 @@ ipcMain.handle( 'SET_MPV_PLAYER_PATH', (_event, mpvPlayerPath: string | null | undefined) => { const normalizedPlayerPath = normalizePlayerPathForStore(mpvPlayerPath); - console.log('... setting mpv player path', normalizedPlayerPath); + traceExternalPlayer('set mpv player path', { + playerPath: normalizedPlayerPath, + }); store.set(MPV_PLAYER_PATH, normalizedPlayerPath); } ); ipcMain.handle('SET_MPV_REUSE_INSTANCE', (_event, reuseInstance: boolean) => { - console.log('... setting mpv reuse instance', reuseInstance); + traceExternalPlayer('set mpv reuse instance', { reuseInstance }); store.set(MPV_REUSE_INSTANCE, reuseInstance); // If disabling reuse, kill the existing process if (!reuseInstance && mpvProcess && !mpvProcess.killed) { - console.log('Disabling reuse, cleaning up existing MPV process'); + traceExternalPlayer('clean up mpv process after disabling reuse'); mpvProcess.kill(); mpvProcess = null; mpvSocketPath = null; @@ -1167,7 +1173,7 @@ ipcMain.handle( mergedHeaders['User-Agent'] ?? mergedHeaders['user-agent'] ?? undefined; - console.log('[VLC] Opening player', { + traceExternalPlayer('open vlc player', { path: vlcLaunchContext.playerPath, launchMode: vlcLaunchContext.mode, requestedReuseInstance, @@ -1190,10 +1196,9 @@ ipcMain.handle( !vlcProcess.killed && vlcRcPort ) { - console.log( - 'Reusing existing VLC instance on RC port', - vlcRcPort - ); + traceExternalPlayer('reuse existing vlc instance', { + rcPort: vlcRcPort, + }); try { const enqueueCommands = buildVlcEnqueueCommands({ url, @@ -1205,8 +1210,8 @@ ipcMain.handle( startTime, }); await sendVlcRcCommands(vlcRcPort, enqueueCommands); - console.log( - 'Successfully loaded new URL in existing VLC instance' + traceExternalPlayer( + 'loaded new url in existing vlc instance' ); const reusedRcPort = vlcRcPort; @@ -1283,7 +1288,7 @@ ipcMain.handle( if (contentInfo || reuseInstance) { try { rcPort = await getFreePort(); - console.log('Using VLC RC port:', rcPort); + traceExternalPlayer('using vlc rc port', { rcPort }); } catch (e) { console.error('Failed to get free port for VLC:', e); } @@ -1333,7 +1338,10 @@ ipcMain.handle( args.push(`:meta-title=${title}`); } - console.log('VLC Args:', args); + traceExternalPlayer('vlc args prepared', { + argCount: args.length, + hasRcPort: rcPort > 0, + }); let lastVlcSnapshot: ExternalPlaybackSnapshot | null = null; // Wrap spawn in a promise to catch startup errors @@ -1358,10 +1366,9 @@ ipcMain.handle( if (trackProcess && rcPort > 0) { vlcProcess = proc; vlcRcPort = rcPort; - console.log( - 'Tracking VLC process for reuse on RC port', - rcPort - ); + traceExternalPlayer('tracking vlc process for reuse', { + rcPort, + }); } const markVlcSessionClosed = () => { @@ -1433,7 +1440,7 @@ ipcMain.handle( proc.stdout.on('data', (data) => { const output = data.toString().trim(); if (output) { - console.log('[VLC stdout]:', output); + traceExternalPlayer('vlc stdout', { output }); } }); } @@ -1455,7 +1462,9 @@ ipcMain.handle( vlcRcPort = null; } if (!isRetry && rcPort > 0) { - console.log('Retrying VLC without RC interface...'); + traceExternalPlayer( + 'retry vlc without rc interface after start error' + ); // Retry without RC args const retryArgs = playerArgs.filter( (arg) => @@ -1480,7 +1489,7 @@ ipcMain.handle( }); proc.on('exit', (code) => { - console.log(`VLC exited with code ${code}`); + traceExternalPlayer('vlc exited', { code }); if (vlcProcess === proc) { vlcProcess = null; vlcRcPort = null; @@ -1501,8 +1510,8 @@ ipcMain.handle( } if (code === 1 && !isRetry && rcPort > 0) { - console.log( - 'VLC exited with error, retrying without RC interface...' + traceExternalPlayer( + 'retry vlc without rc interface after exit' ); stopVlcPositionPolling(); const retryArgs = playerArgs.filter( @@ -1559,18 +1568,20 @@ ipcMain.handle( 'SET_VLC_PLAYER_PATH', (_event, vlcPlayerPath: string | null | undefined) => { const normalizedPlayerPath = normalizePlayerPathForStore(vlcPlayerPath); - console.log('... setting vlc player path', normalizedPlayerPath); + traceExternalPlayer('set vlc player path', { + playerPath: normalizedPlayerPath, + }); store.set(VLC_PLAYER_PATH, normalizedPlayerPath); } ); ipcMain.handle('SET_VLC_REUSE_INSTANCE', (_event, reuseInstance: boolean) => { - console.log('... setting vlc reuse instance', reuseInstance); + traceExternalPlayer('set vlc reuse instance', { reuseInstance }); store.set(VLC_REUSE_INSTANCE, reuseInstance); // If disabling reuse, kill the existing tracked process if (!reuseInstance && vlcProcess && !vlcProcess.killed) { - console.log('Disabling reuse, cleaning up existing VLC process'); + traceExternalPlayer('clean up vlc process after disabling reuse'); vlcProcess.kill(); vlcProcess = null; vlcRcPort = null; diff --git a/apps/electron-backend/src/app/services/debug-trace.ts b/apps/electron-backend/src/app/services/debug-trace.ts index 8a24806ff..19a727ea3 100644 --- a/apps/electron-backend/src/app/services/debug-trace.ts +++ b/apps/electron-backend/src/app/services/debug-trace.ts @@ -27,10 +27,7 @@ function summarizeObject( const entries = Object.entries(value); const summary: Record = {}; - if ( - value.constructor?.name && - value.constructor.name !== 'Object' - ) { + if (value.constructor?.name && value.constructor.name !== 'Object') { summary.__type = value.constructor.name; } @@ -73,6 +70,10 @@ export function isRendererConsoleTraceEnabled(): boolean { return readFlag('IPTVNATOR_TRACE_RENDERER_CONSOLE'); } +export function isExternalPlayerTraceEnabled(): boolean { + return isStartupTraceEnabled() || readFlag('IPTVNATOR_TRACE_PLAYER'); +} + export function roundTraceDuration(durationMs: number): number { return Math.round(durationMs * 10) / 10; } @@ -81,10 +82,7 @@ export function compactSqlForTrace(sql: string): string { return truncateString(sql.replace(/\s+/g, ' ').trim()); } -export function summarizeForTrace( - value: unknown, - depth = 0 -): unknown { +export function summarizeForTrace(value: unknown, depth = 0): unknown { if ( value == null || typeof value === 'boolean' || @@ -160,11 +158,7 @@ export function safeStringifyForTrace(payload: unknown): string { } } -export function trace( - scope: string, - message: string, - payload?: unknown -): void { +export function trace(scope: string, message: string, payload?: unknown): void { if (payload === undefined) { console.log(`${TRACE_PREFIX}[${scope}] ${message}`); return; diff --git a/apps/web-e2e/project.json b/apps/web-e2e/project.json index 7b39c17a9..bba3cea4c 100644 --- a/apps/web-e2e/project.json +++ b/apps/web-e2e/project.json @@ -10,7 +10,10 @@ ], "// targets": "to see all targets run: nx show project web-e2e --web", "targets": { - "e2e": {} + "e2e": {}, + "lint": { + "executor": "@nx/eslint:lint" + } }, "tags": ["scope:e2e", "domain:web", "type:e2e"] } diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 15b11610f..efda3665f 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -17,9 +17,12 @@ import { Settings, STORE_KEY, Theme, + createDevLogger, } from '@iptvnator/shared/interfaces'; import { SettingsService } from './services/settings.service'; +const debugAppComponent = createDevLogger('AppComponent'); + @Component({ selector: 'app-root', templateUrl: './app.component.html', @@ -106,8 +109,7 @@ export class AppComponent implements OnInit { // Settings are stored in IndexedDB and loaded by the settings store // Only specific Electron settings (MPV/VLC paths) are sent when changed in settings component - const resolvedLang = - settings.language ?? this.DEFAULT_LANG; + const resolvedLang = settings.language ?? this.DEFAULT_LANG; this.translate.use(resolvedLang); // Mirror the active language to localStorage so the next // cold start can read it synchronously in app.config.ts's @@ -166,7 +168,7 @@ export class AppComponent implements OnInit { const result = await window.electron.checkEpgFreshness(urls, 12); if (result.freshUrls.length > 0) { - console.log( + debugAppComponent( `EPG: ${result.freshUrls.length} source(s) already fresh, skipping fetch` ); // Show snackbar if all EPG sources are fresh (no stale URLs) @@ -180,7 +182,7 @@ export class AppComponent implements OnInit { } if (result.staleUrls.length > 0) { - console.log( + debugAppComponent( `EPG: Fetching ${result.staleUrls.length} stale source(s)` ); this.epgService.fetchEpg(result.staleUrls); @@ -218,7 +220,7 @@ export class AppComponent implements OnInit { // Trigger auto-update if there are playlists to update if (playlistsToUpdate.length > 0) { - console.log( + debugAppComponent( `Auto-updating ${playlistsToUpdate.length} playlist(s) on startup` ); this.dataService.sendIpcEvent( diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index c60b6a34d..9cb061cf2 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -246,6 +246,8 @@ "NEW_VERSION_AVAILABLE": "إصدار جديد متاح", "LATEST_VERSION": "أنت تستخدم الإصدار الأحدث", "STREAM_FORMAT": "تنسيق البث", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK": "تشغيل MPV/VLC بالنقر المزدوج", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "يتطلب النقر المزدوج على صفوف القنوات قبل بدء التشغيل عبر MPV أو VLC.", "LANGUAGE": "اللغة", "SETTINGS_SAVED": "نجاح! تم حفظ الإعدادات.", "THEME": "السمة البصرية", @@ -326,6 +328,10 @@ "MPV_PLAYER_ARGUMENTS": "mpv arguments", "MPV_REUSE_INSTANCE_LABEL": "إعادة استخدام نسخة MPV", "MPV_REUSE_INSTANCE_DESCRIPTION": "عند التفعيل، سيتم إعادة استخدام نفس نافذة MPV لتشغيل البثوث المختلفة. عند التعطيل، يفتح كل بث في نافذة MPV جديدة.", + "RECORDING_FOLDER_LABEL": "مجلد التسجيلات", + "RECORDING_FOLDER_DESCRIPTION": "مجلد تسجيلات البث المباشر من MPV المضمن. اتركه فارغًا لاستخدام مجلد التنزيلات في النظام.", + "RECORDING_FOLDER": "مجلد التسجيلات", + "RECORDING_FOLDER_SELECT": "اختر مجلد التسجيلات", "VLC_PLAYER_PATH_LABEL": "مسار VLC", "VLC_PLAYER_PATH_DESCRIPTION": "حدد مسار VLC. على macOS يمكنك استخدام حزمة تطبيق VLC مثل /Applications/VLC.app أو مسار الملف التنفيذي.", "VLC_PLAYER_PATH": "مسار مشغل VLC", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 3440e2823..b7f10113e 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -246,6 +246,8 @@ "NEW_VERSION_AVAILABLE": "كاين إصدار جديد متوفر", "LATEST_VERSION": "راك تستعمل أحدث إصدار", "STREAM_FORMAT": "صيغة البث", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK": "شغل MPV/VLC بدوبل كليك", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "خاص دير دوبل كليك على صفوف القنوات قبل ما يبدا التشغيل بـ MPV ولا VLC.", "LANGUAGE": "اللغة", "SETTINGS_SAVED": "نجاح! الإعدادات تحفضو.", "THEME": "المظهر المرئي", @@ -326,6 +328,10 @@ "MPV_PLAYER_ARGUMENTS": "mpv arguments", "MPV_REUSE_INSTANCE_LABEL": "إعادة استخدام نسخة MPV", "MPV_REUSE_INSTANCE_DESCRIPTION": "إلى كانت مفعلة، نفس نافذة MPV غادي تستعمل لتشغيل بثوث مختلفة. إلى كانت معطلة، كل بث كيتحل في نافذة MPV جديدة.", + "RECORDING_FOLDER_LABEL": "مجلد التسجيلات", + "RECORDING_FOLDER_DESCRIPTION": "المجلد ديال تسجيلات البث المباشر من MPV المدمج. خليه خاوي باش تستعمل مجلد التنزيلات ديال النظام.", + "RECORDING_FOLDER": "مجلد التسجيلات", + "RECORDING_FOLDER_SELECT": "اختار مجلد التسجيلات", "VLC_PLAYER_PATH_LABEL": "مسار VLC", "VLC_PLAYER_PATH_DESCRIPTION": "حدد مسار VLC. فـ macOS تقدر تستعمل باقة تطبيق VLC بحال /Applications/VLC.app أو مسار الملف التنفيذي.", "VLC_PLAYER_PATH": "مسار مشغل VLC", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 136647f34..e2e7f7b5f 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -246,6 +246,8 @@ "NEW_VERSION_AVAILABLE": "Даступная новая версія праграмы", "LATEST_VERSION": "Вы выкарыстоўваеце апошнюю версію праграмы", "STREAM_FORMAT": "Фармат патоку", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK": "Запускаць MPV/VLC падвойным націскам", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Патрабаваць падвойны націск па радках каналаў перад пачаткам прайгравання праз MPV або VLC.", "LANGUAGE": "Мова", "SETTINGS_SAVED": "Новыя налады былі паспяхова захаваны!", "THEME": "Тэма афармлення", @@ -326,6 +328,10 @@ "MPV_PLAYER_ARGUMENTS": "mpv arguments", "MPV_REUSE_INSTANCE_LABEL": "Паўторна выкарыстоўваць асобнік MPV", "MPV_REUSE_INSTANCE_DESCRIPTION": "Калі ўключана, тое ж акно MPV будзе паўторна выкарыстоўвацца для прайгравання розных патокаў. Калі адключана, кожны паток адкрываецца ў новым акне MPV.", + "RECORDING_FOLDER_LABEL": "Папка для запісаў", + "RECORDING_FOLDER_DESCRIPTION": "Папка для запісаў жывых патокаў з убудаванага MPV. Пакіньце пустым, каб выкарыстоўваць сістэмную папку спампаванняў.", + "RECORDING_FOLDER": "Папка для запісаў", + "RECORDING_FOLDER_SELECT": "Выбраць папку для запісаў", "VLC_PLAYER_PATH_LABEL": "Шлях да VLC прайгравальніка", "VLC_PLAYER_PATH_DESCRIPTION": "Укажыце шлях да VLC. На macOS можна выкарыстоўваць пакет праграмы VLC, напрыклад /Applications/VLC.app, або шлях да выканальнага файла.", "VLC_PLAYER_PATH": "Шлях да VLC прайгравальніка", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 16980bfd7..a49f9857c 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -246,6 +246,8 @@ "NEW_VERSION_AVAILABLE": "EIne neue Version der Anwendung ist verfügbar", "LATEST_VERSION": "Sie nutzen die neueste Version der Anwendung", "STREAM_FORMAT": "Stream-Format", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK": "MPV/VLC per Doppelklick starten", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Erfordert einen Doppelklick auf Kanalzeilen, bevor die Wiedergabe mit MPV oder VLC startet.", "LANGUAGE": "Sprache", "SETTINGS_SAVED": "Einstellungen wurden erfolgreich gespeichert!", "THEME": "Farbdesign", @@ -326,6 +328,10 @@ "MPV_PLAYER_ARGUMENTS": "mpv arguments", "MPV_REUSE_INSTANCE_LABEL": "MPV-Instanz wiederverwenden", "MPV_REUSE_INSTANCE_DESCRIPTION": "Wenn aktiviert, wird dasselbe MPV-Fenster zum Abspielen verschiedener Streams wiederverwendet. Wenn deaktiviert, wird jeder Stream in einem neuen MPV-Fenster geöffnet.", + "RECORDING_FOLDER_LABEL": "Aufnahmeordner", + "RECORDING_FOLDER_DESCRIPTION": "Ordner für Live-Stream-Aufnahmen aus Embedded MPV. Leer lassen, um den systemweiten Downloads-Ordner zu verwenden.", + "RECORDING_FOLDER": "Aufnahmeordner", + "RECORDING_FOLDER_SELECT": "Aufnahmeordner auswählen", "VLC_PLAYER_PATH_LABEL": "VLC-Pfad", "VLC_PLAYER_PATH_DESCRIPTION": "Lege den Pfad zu VLC fest. Unter macOS kannst du das VLC-App-Bundle wie /Applications/VLC.app oder den ausführbaren Pfad verwenden.", "VLC_PLAYER_PATH": "VLC-Playerpfad", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 1fe997e15..d4aecf808 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -246,6 +246,8 @@ "NEW_VERSION_AVAILABLE": "Υπάρχει διαθέσιμη μια νέα έκδοση", "LATEST_VERSION": "Χρησιμοποιείτε την πιο πρόσφατη έκδοση", "STREAM_FORMAT": "Μορφή Stream", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK": "Εκκίνηση MPV/VLC με διπλό κλικ", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Απαιτεί διπλό κλικ στις γραμμές καναλιών πριν ξεκινήσει η αναπαραγωγή με MPV ή VLC.", "LANGUAGE": "Γλώσσα", "SETTINGS_SAVED": "Επιτυχία! Η διαμόρφωση αποθηκεύτηκε.", "THEME": "Οπτικό θέμα", @@ -326,6 +328,10 @@ "MPV_PLAYER_ARGUMENTS": "mpv arguments", "MPV_REUSE_INSTANCE_LABEL": "Επαναχρησιμοποίηση της παρουσίας MPV", "MPV_REUSE_INSTANCE_DESCRIPTION": "Όταν είναι ενεργοποιημένη, το ίδιο παράθυρο MPV θα επαναχρησιμοποιηθεί για την αναπαραγωγή διαφορετικών ροών. Όταν είναι απενεργοποιημένη, κάθε ροή ανοίγει σε ένα νέο παράθυρο MPV.", + "RECORDING_FOLDER_LABEL": "Φάκελος εγγραφών", + "RECORDING_FOLDER_DESCRIPTION": "Φάκελος για εγγραφές ζωντανών ροών από το ενσωματωμένο MPV. Αφήστε το κενό για να χρησιμοποιηθεί ο φάκελος Λήψεις του συστήματος.", + "RECORDING_FOLDER": "Φάκελος εγγραφών", + "RECORDING_FOLDER_SELECT": "Επιλογή φακέλου εγγραφών", "VLC_PLAYER_PATH_LABEL": "διαδρομή VLC", "VLC_PLAYER_PATH_DESCRIPTION": "Ορίστε τη διαδρομή προς το VLC. Στο macOS μπορείτε να χρησιμοποιήσετε το πακέτο εφαρμογής VLC, όπως /Applications/VLC.app, ή τη διαδρομή του εκτελέσιμου αρχείου.", "VLC_PLAYER_PATH": "διαδρομή VLC player", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index ba934cc30..4954162bf 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -246,6 +246,8 @@ "NEW_VERSION_AVAILABLE": "Hay una nueva versión disponible", "LATEST_VERSION": "Estás usando la versión reciente", "STREAM_FORMAT": "Formato de transmision", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK": "Iniciar MPV/VLC con doble clic", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Requiere doble clic en las filas de canales antes de iniciar la reproducción con MPV o VLC.", "LANGUAGE": "Idioma", "SETTINGS_SAVED": "¡Completado! La configuración se guardó.", "THEME": "Tema visual", @@ -326,6 +328,10 @@ "MPV_PLAYER_ARGUMENTS": "mpv arguments", "MPV_REUSE_INSTANCE_LABEL": "Reutilizar instancia de MPV", "MPV_REUSE_INSTANCE_DESCRIPTION": "Cuando esta opción está activada, se reutiliza la misma ventana de MPV para reproducir distintos streams. Cuando está desactivada, cada stream se abre en una nueva ventana de MPV.", + "RECORDING_FOLDER_LABEL": "Carpeta de grabaciones", + "RECORDING_FOLDER_DESCRIPTION": "Carpeta para grabaciones de streams en vivo desde MPV integrado. Déjala vacía para usar la carpeta Descargas del sistema.", + "RECORDING_FOLDER": "Carpeta de grabaciones", + "RECORDING_FOLDER_SELECT": "Seleccionar carpeta de grabaciones", "VLC_PLAYER_PATH_LABEL": "ruta VLC", "VLC_PLAYER_PATH_DESCRIPTION": "Establece la ruta a VLC. En macOS puedes usar el paquete de la app VLC, como /Applications/VLC.app, o la ruta del ejecutable.", "VLC_PLAYER_PATH": "Ruta del reproductor VLC", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index f5979d846..55735bbf5 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -246,6 +246,8 @@ "NEW_VERSION_AVAILABLE": "Une nouvelle version est disponible", "LATEST_VERSION": "Vous utilisez la dernière version", "STREAM_FORMAT": "Format du flux", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK": "Lancer MPV/VLC par double-clic", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Exiger un double-clic sur les lignes de chaînes avant de lancer la lecture avec MPV ou VLC.", "LANGUAGE": "Langue", "SETTINGS_SAVED": "La configuration a été enregistrée avec succès.", "THEME": "Thème", @@ -326,6 +328,10 @@ "MPV_PLAYER_ARGUMENTS": "mpv arguments", "MPV_REUSE_INSTANCE_LABEL": "Réutiliser l'instance MPV", "MPV_REUSE_INSTANCE_DESCRIPTION": "Lorsqu'elle est activée, la même fenêtre MPV sera réutilisée pour lire différents flux. Lorsqu'elle est désactivée, chaque flux s'ouvre dans une nouvelle fenêtre MPV.", + "RECORDING_FOLDER_LABEL": "Dossier d’enregistrement", + "RECORDING_FOLDER_DESCRIPTION": "Dossier des enregistrements de flux en direct depuis MPV intégré. Laissez vide pour utiliser le dossier Téléchargements du système.", + "RECORDING_FOLDER": "Dossier d’enregistrement", + "RECORDING_FOLDER_SELECT": "Choisir le dossier d’enregistrement", "VLC_PLAYER_PATH_LABEL": "Chemin VLC", "VLC_PLAYER_PATH_DESCRIPTION": "Définissez le chemin vers VLC. Sur macOS, vous pouvez utiliser le bundle de l’app VLC, comme /Applications/VLC.app, ou le chemin de l’exécutable.", "VLC_PLAYER_PATH": "Chemin du lecteur VLC", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 4a2eec0e4..623961154 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -246,6 +246,8 @@ "NEW_VERSION_AVAILABLE": "È disponibile una nuova versione", "LATEST_VERSION": "Questa versione è aggiornata", "STREAM_FORMAT": "Formato streaming", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK": "Avvia MPV/VLC con doppio clic", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Richiede un doppio clic sulle righe dei canali prima di avviare la riproduzione con MPV o VLC.", "LANGUAGE": "Lingua", "SETTINGS_SAVED": "La configurazione è stata salvata.", "THEME": "Tema visuale", @@ -326,6 +328,10 @@ "MPV_PLAYER_ARGUMENTS": "mpv arguments", "MPV_REUSE_INSTANCE_LABEL": "Riutilizza istanza MPV", "MPV_REUSE_INSTANCE_DESCRIPTION": "Quando attivo, la stessa finestra MPV viene riutilizzata per riprodurre stream diversi. Quando disattivo, ogni stream viene aperto in una nuova finestra MPV.", + "RECORDING_FOLDER_LABEL": "Cartella registrazioni", + "RECORDING_FOLDER_DESCRIPTION": "Cartella per le registrazioni degli stream live da MPV integrato. Lascia vuoto per usare la cartella Download del sistema.", + "RECORDING_FOLDER": "Cartella registrazioni", + "RECORDING_FOLDER_SELECT": "Seleziona cartella registrazioni", "VLC_PLAYER_PATH_LABEL": "Percorso VLC", "VLC_PLAYER_PATH_DESCRIPTION": "Imposta il percorso di VLC. Su macOS puoi usare il bundle dell’app VLC, come /Applications/VLC.app, oppure il percorso dell’eseguibile.", "VLC_PLAYER_PATH": "Percorso lettore VLC", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index b79b2017d..dfc276188 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -246,6 +246,8 @@ "NEW_VERSION_AVAILABLE": "新しいバージョンが利用可能です", "LATEST_VERSION": "最新バージョンを使用中です", "STREAM_FORMAT": "ストリーム形式", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK": "ダブルクリックで MPV/VLC を起動", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "MPV または VLC の再生を開始する前に、チャンネル行をダブルクリックする必要があります。", "LANGUAGE": "言語", "SETTINGS_SAVED": "成功!設定が保存されました。", "THEME": "視覚テーマ", @@ -326,6 +328,10 @@ "MPV_PLAYER_ARGUMENTS": "mpv arguments", "MPV_REUSE_INSTANCE_LABEL": "MPVインスタンスを再利用", "MPV_REUSE_INSTANCE_DESCRIPTION": "有効にすると、異なるストリームの再生に同じMPVウィンドウが再利用されます。無効にすると、各ストリームが新しいMPVウィンドウで開きます。", + "RECORDING_FOLDER_LABEL": "録画フォルダー", + "RECORDING_FOLDER_DESCRIPTION": "Embedded MPV のライブストリーム録画を保存するフォルダーです。空のままにするとシステムのダウンロードフォルダーを使用します。", + "RECORDING_FOLDER": "録画フォルダー", + "RECORDING_FOLDER_SELECT": "録画フォルダーを選択", "VLC_PLAYER_PATH_LABEL": "VLCのパス", "VLC_PLAYER_PATH_DESCRIPTION": "VLC へのパスを設定します。macOS では /Applications/VLC.app のような VLC アプリバンドル、または実行ファイルのパスを使用できます。", "VLC_PLAYER_PATH": "VLCプレーヤーのパス", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index d093647e0..29cfe4746 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -246,6 +246,8 @@ "NEW_VERSION_AVAILABLE": "새로운 버전이 있습니다", "LATEST_VERSION": "최신 버전을 사용하고 있습니다", "STREAM_FORMAT": "스트림 형식", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK": "두 번 클릭하여 MPV/VLC 시작", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "MPV 또는 VLC 재생을 시작하기 전에 채널 행을 두 번 클릭하도록 합니다.", "LANGUAGE": "언어", "SETTINGS_SAVED": "설정이 저장되었습니다.", "THEME": "테마", @@ -326,6 +328,10 @@ "MPV_PLAYER_ARGUMENTS": "mpv arguments", "MPV_REUSE_INSTANCE_LABEL": "MPV 인스턴스 재사용", "MPV_REUSE_INSTANCE_DESCRIPTION": "활성화하면 동일한 MPV 창에서 다른 스트림을 재생합니다. 비활성화하면 각 스트림이 새 MPV 창에서 열립니다.", + "RECORDING_FOLDER_LABEL": "녹화 폴더", + "RECORDING_FOLDER_DESCRIPTION": "Embedded MPV의 라이브 스트림 녹화를 저장할 폴더입니다. 비워 두면 시스템 다운로드 폴더를 사용합니다.", + "RECORDING_FOLDER": "녹화 폴더", + "RECORDING_FOLDER_SELECT": "녹화 폴더 선택", "VLC_PLAYER_PATH_LABEL": "VLC 경로", "VLC_PLAYER_PATH_DESCRIPTION": "VLC 경로를 설정합니다. macOS에서는 /Applications/VLC.app 같은 VLC 앱 번들이나 실행 파일 경로를 사용할 수 있습니다.", "VLC_PLAYER_PATH": "VLC 플레이어 경로", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 4fbdaffc2..8522610f7 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -246,6 +246,8 @@ "NEW_VERSION_AVAILABLE": "Een nieuwe versie is beschikbaar", "LATEST_VERSION": "Je gebruikt de nieuwste versie", "STREAM_FORMAT": "Streamformaat", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK": "MPV/VLC starten met dubbelklik", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Vereist een dubbelklik op kanaalrijen voordat MPV- of VLC-weergave start.", "LANGUAGE": "Taal", "SETTINGS_SAVED": "Succes! De instellingen zijn opgeslagen.", "THEME": "Visueel thema", @@ -326,6 +328,10 @@ "MPV_PLAYER_ARGUMENTS": "mpv arguments", "MPV_REUSE_INSTANCE_LABEL": "MPV-instantie hergebruiken", "MPV_REUSE_INSTANCE_DESCRIPTION": "Wanneer ingeschakeld, wordt hetzelfde MPV-venster hergebruikt voor het afspelen van verschillende streams. Wanneer uitgeschakeld, wordt elke stream geopend in een nieuw MPV-venster.", + "RECORDING_FOLDER_LABEL": "Opnamemap", + "RECORDING_FOLDER_DESCRIPTION": "Map voor live-streamopnames vanuit Embedded MPV. Laat leeg om de systeemmap Downloads te gebruiken.", + "RECORDING_FOLDER": "Opnamemap", + "RECORDING_FOLDER_SELECT": "Opnamemap selecteren", "VLC_PLAYER_PATH_LABEL": "VLC-bestandspad", "VLC_PLAYER_PATH_DESCRIPTION": "Stel het pad naar VLC in. Op macOS kun je de VLC-appbundel gebruiken, zoals /Applications/VLC.app, of het pad naar het uitvoerbare bestand.", "VLC_PLAYER_PATH": "VLC-speler bestandspad", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 00ade7d51..38a2f44a5 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -246,6 +246,8 @@ "NEW_VERSION_AVAILABLE": "Dostępna jest nowa wersja", "LATEST_VERSION": "Używasz najnowszej wersji", "STREAM_FORMAT": "Format strumienia", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK": "Uruchamiaj MPV/VLC podwójnym kliknięciem", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Wymagaj podwójnego kliknięcia wiersza kanału przed rozpoczęciem odtwarzania w MPV lub VLC.", "LANGUAGE": "Język", "SETTINGS_SAVED": "Sukces! Konfiguracja została zapisana.", "THEME": "Motyw wizualny", @@ -326,6 +328,10 @@ "MPV_PLAYER_ARGUMENTS": "mpv arguments", "MPV_REUSE_INSTANCE_LABEL": "Użyj ponownie instancji MPV", "MPV_REUSE_INSTANCE_DESCRIPTION": "Po włączeniu to samo okno MPV będzie używane do odtwarzania różnych strumieni. Po wyłączeniu każdy strumień otwiera się w nowym oknie MPV.", + "RECORDING_FOLDER_LABEL": "Folder nagrań", + "RECORDING_FOLDER_DESCRIPTION": "Folder na nagrania strumieni na żywo z wbudowanego MPV. Pozostaw puste, aby użyć systemowego folderu Pobrane.", + "RECORDING_FOLDER": "Folder nagrań", + "RECORDING_FOLDER_SELECT": "Wybierz folder nagrań", "VLC_PLAYER_PATH_LABEL": "Ścieżka VLC", "VLC_PLAYER_PATH_DESCRIPTION": "Ustaw ścieżkę do VLC. W macOS możesz użyć pakietu aplikacji VLC, np. /Applications/VLC.app, albo ścieżki do pliku wykonywalnego.", "VLC_PLAYER_PATH": "Ścieżka odtwarzacza VLC", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index b1898a703..1d8b8b47c 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -246,6 +246,8 @@ "NEW_VERSION_AVAILABLE": "Há uma nova versão disponível", "LATEST_VERSION": "Você está usando a versão mais recente", "STREAM_FORMAT": "Formato de stream", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK": "Iniciar MPV/VLC com duplo clique", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Exige um duplo clique nas linhas de canais antes de iniciar a reprodução no MPV ou VLC.", "LANGUAGE": "Idioma", "SETTINGS_SAVED": "Sucesso! A configuração foi salva.", "THEME": "Tema visual", @@ -326,6 +328,10 @@ "MPV_PLAYER_ARGUMENTS": "mpv arguments", "MPV_REUSE_INSTANCE_LABEL": "Reutilizar instância do MPV", "MPV_REUSE_INSTANCE_DESCRIPTION": "Quando ativado, a mesma janela do MPV será reutilizada para reproduzir streams diferentes. Quando desativado, cada stream abre em uma nova janela do MPV.", + "RECORDING_FOLDER_LABEL": "Pasta de gravações", + "RECORDING_FOLDER_DESCRIPTION": "Pasta para gravações de streams ao vivo do MPV incorporado. Deixe em branco para usar a pasta Downloads do sistema.", + "RECORDING_FOLDER": "Pasta de gravações", + "RECORDING_FOLDER_SELECT": "Selecionar pasta de gravações", "VLC_PLAYER_PATH_LABEL": "Caminho do VLC", "VLC_PLAYER_PATH_DESCRIPTION": "Defina o caminho para o VLC. No macOS você pode usar o pacote do app VLC, como /Applications/VLC.app, ou o caminho do executável.", "VLC_PLAYER_PATH": "Caminho do reprodutor VLC", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 08fcc32e1..f1d858f05 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -246,6 +246,8 @@ "NEW_VERSION_AVAILABLE": "Доступна новая версия приложения", "LATEST_VERSION": "Вы используете последнюю версию приложения", "STREAM_FORMAT": "Формат потока", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK": "Запускать MPV/VLC двойным щелчком", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Требовать двойной щелчок по строке канала перед запуском воспроизведения через MPV или VLC.", "LANGUAGE": "Язык", "SETTINGS_SAVED": "Новые настройки были успешно сохранены!", "THEME": "Тема оформления", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index de5ed271f..803089552 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -246,6 +246,8 @@ "NEW_VERSION_AVAILABLE": "Yeni bir sürüm mevcut", "LATEST_VERSION": "En son sürümü kullanıyorsunuz", "STREAM_FORMAT": "Yayın Formatı", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK": "MPV/VLC'yi çift tıklamayla başlat", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "MPV veya VLC oynatımı başlamadan önce kanal satırlarına çift tıklanmasını gerektirir.", "LANGUAGE": "Dil", "SETTINGS_SAVED": "Başarılı! Yapılandırma kaydedildi.", "THEME": "Görünüm teması", @@ -326,6 +328,10 @@ "MPV_PLAYER_ARGUMENTS": "mpv arguments", "MPV_REUSE_INSTANCE_LABEL": "MPV örneğini yeniden kullan", "MPV_REUSE_INSTANCE_DESCRIPTION": "Etkinleştirildiğinde, farklı yayınları oynatmak için aynı MPV penceresi yeniden kullanılır. Devre dışı bırakıldığında, her yayın yeni bir MPV penceresinde açılır.", + "RECORDING_FOLDER_LABEL": "Kayıt klasörü", + "RECORDING_FOLDER_DESCRIPTION": "Yerleşik MPV'den canlı yayın kayıtlarının kaydedileceği klasör. Sistem İndirilenler klasörünü kullanmak için boş bırakın.", + "RECORDING_FOLDER": "Kayıt klasörü", + "RECORDING_FOLDER_SELECT": "Kayıt klasörü seç", "VLC_PLAYER_PATH_LABEL": "VLC yolu", "VLC_PLAYER_PATH_DESCRIPTION": "VLC yolunu ayarlayın. macOS’ta /Applications/VLC.app gibi VLC uygulama paketini veya yürütülebilir dosya yolunu kullanabilirsiniz.", "VLC_PLAYER_PATH": "VLC oynatıcı yolu", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index c8e595224..7a8da6975 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -246,6 +246,8 @@ "NEW_VERSION_AVAILABLE": "有新版本可用 ", "LATEST_VERSION": "您使用的是最新版本", "STREAM_FORMAT": "流格式", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK": "双击启动 MPV/VLC", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "在 MPV 或 VLC 开始播放前,要求双击频道行。", "LANGUAGE": "语言", "SETTINGS_SAVED": "配置已成功保存", "THEME": "主题", @@ -326,6 +328,10 @@ "MPV_PLAYER_ARGUMENTS": "mpv arguments", "MPV_REUSE_INSTANCE_LABEL": "复用 MPV 实例", "MPV_REUSE_INSTANCE_DESCRIPTION": "启用后,将复用同一个 MPV 窗口播放不同的视频流。禁用后,每个视频流都会在新的 MPV 窗口中打开。", + "RECORDING_FOLDER_LABEL": "录制文件夹", + "RECORDING_FOLDER_DESCRIPTION": "用于保存 Embedded MPV 直播录制的文件夹。留空则使用系统下载文件夹。", + "RECORDING_FOLDER": "录制文件夹", + "RECORDING_FOLDER_SELECT": "选择录制文件夹", "VLC_PLAYER_PATH_LABEL": "VLC路径", "VLC_PLAYER_PATH_DESCRIPTION": "设置 VLC 路径。在 macOS 上可以使用 VLC 应用包,例如 /Applications/VLC.app,也可以使用可执行文件路径。", "VLC_PLAYER_PATH": "VLC播放器路径", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 692eabdff..4738de9ca 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -246,6 +246,8 @@ "NEW_VERSION_AVAILABLE": "有新版本可用", "LATEST_VERSION": "您正在使用最新版本", "STREAM_FORMAT": "串流格式", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK": "雙擊啟動 MPV/VLC", + "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "在 MPV 或 VLC 開始播放前,要求雙擊頻道列。", "LANGUAGE": "語言", "SETTINGS_SAVED": "成功!設定已儲存。", "THEME": "視覺主題", @@ -326,6 +328,10 @@ "MPV_PLAYER_ARGUMENTS": "mpv arguments", "MPV_REUSE_INSTANCE_LABEL": "重複使用 MPV 實體", "MPV_REUSE_INSTANCE_DESCRIPTION": "啟用後,相同的 MPV 視窗將重複用於播放不同串流。停用時,每個串流會在新的 MPV 視窗中開啟。", + "RECORDING_FOLDER_LABEL": "錄製資料夾", + "RECORDING_FOLDER_DESCRIPTION": "用於儲存 Embedded MPV 直播錄製的資料夾。留空則使用系統下載資料夾。", + "RECORDING_FOLDER": "錄製資料夾", + "RECORDING_FOLDER_SELECT": "選擇錄製資料夾", "VLC_PLAYER_PATH_LABEL": "VLC 路徑", "VLC_PLAYER_PATH_DESCRIPTION": "設定 VLC 路徑。在 macOS 上可以使用 VLC App bundle,例如 /Applications/VLC.app,也可以使用可執行檔路徑。", "VLC_PLAYER_PATH": "VLC 播放器路徑", diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md new file mode 100644 index 000000000..21633a269 --- /dev/null +++ b/docs/architecture/validation-map.md @@ -0,0 +1,56 @@ +# Validation Map + +This map records the lowest-cost validation commands agents should reach for +before broad CI-sized runs. + +## Discovery + +```bash +pnpm nx show projects --withTarget test +pnpm nx show projects --withTarget lint +pnpm nx show projects --withTarget e2e +``` + +## Unit And Type Checks + +| Area | Command | +| ---------------------------------- | ----------------------------------- | +| Angular renderer entry points | `pnpm run typecheck:web` | +| Electron main process entry points | `pnpm run typecheck:backend` | +| Curated CI unit suite | `pnpm run test:unit:ci` | +| EPG data access | `pnpm nx test epg-data-access` | +| Workspace shell utilities | `pnpm nx test workspace-shell-util` | +| Shared SQLite schema/connection | `pnpm nx test database` | +| Packaging metadata | `pnpm nx test packaging` | + +## E2E + +| Area | Command | +| --------------------- | ----------------------------------------------- | +| Web app browser flows | `pnpm nx run web-e2e:e2e -- --project=chromium` | +| Electron flows | `pnpm nx run electron-backend-e2e:e2e` | + +Use atomized E2E targets when available, for example +`pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts`. + +## I18n + +```bash +pnpm run i18n:check +``` + +The i18n check is non-mutating. It compares every locale file in +`apps/web/src/assets/i18n/` against `en.json` and fails on missing or extra keys. +Identical English fallback values are reported as warnings by default; use +`node tools/i18n/check-drift.mjs --fail-on-identical` for a stricter translation +audit. + +## Logging + +Runtime playback and EPG debug logs should use the existing logger or trace +helpers instead of unconditional `console.log`. Electron external-player traces +are gated by: + +```bash +IPTVNATOR_TRACE_PLAYER=1 pnpm run serve:backend +``` diff --git a/eslint.config.mjs b/eslint.config.mjs index 33d621ba0..41093fbd1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -246,4 +246,15 @@ export default [ '@angular-eslint/template/interactive-supports-focus': 'off', }, }, + { + files: [ + 'apps/web/src/app/app.component.ts', + 'apps/electron-backend/src/app/events/player.events.ts', + 'libs/epg/data-access/**/*.ts', + 'libs/ui/playback/**/*.ts', + ], + rules: { + 'no-console': ['warn', { allow: ['debug', 'warn', 'error'] }], + }, + }, ]; diff --git a/libs/epg/data-access/jest.config.ts b/libs/epg/data-access/jest.config.ts new file mode 100644 index 000000000..af0fb9639 --- /dev/null +++ b/libs/epg/data-access/jest.config.ts @@ -0,0 +1,22 @@ +export default { + displayName: 'epg-data-access', + preset: '../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../coverage/libs/epg/data-access', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], + moduleFileExtensions: ['ts', 'js', 'html'], +}; diff --git a/libs/epg/data-access/project.json b/libs/epg/data-access/project.json index 39322175f..445d2dc04 100644 --- a/libs/epg/data-access/project.json +++ b/libs/epg/data-access/project.json @@ -6,6 +6,14 @@ "projectType": "library", "tags": ["scope:shared", "domain:playback", "type:data-access"], "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/epg/data-access/jest.config.ts", + "tsConfig": "libs/epg/data-access/tsconfig.spec.json" + } + }, "lint": { "executor": "@nx/eslint:lint" } diff --git a/libs/epg/data-access/src/lib/epg.service.spec.ts b/libs/epg/data-access/src/lib/epg.service.spec.ts index 5ba79370b..c5cab5637 100644 --- a/libs/epg/data-access/src/lib/epg.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg.service.spec.ts @@ -1,119 +1,45 @@ -/* import { TestBed } from '@angular/core/testing'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslateService } from '@ngx-translate/core'; -import { invoke } from '@tauri-apps/api/core'; -import { MockModule, MockProviders } from 'ng-mocks'; -import { DataService } from './data.service'; +import { firstValueFrom } from 'rxjs'; import { EpgService } from './epg.service'; -jest.mock('@tauri-apps/api/core', () => ({ - invoke: jest.fn(), - isTauri: () => true, -})); - describe('EpgService', () => { let service: EpgService; - let snackBar: MatSnackBar; - let store: MockStore; - let translateService: TranslateService; - let dispatchSpy: jest.SpyInstance; beforeEach(() => { TestBed.configureTestingModule({ providers: [ EpgService, - MockProviders(DataService, TranslateService, MatSnackBar), - provideMockStore(), + { + provide: MatSnackBar, + useValue: { + open: jest.fn(), + }, + }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + }, + }, ], - imports: [MockModule(MatSnackBarModule)], }); service = TestBed.inject(EpgService); - snackBar = TestBed.inject(MatSnackBar); - store = TestBed.inject(MockStore); - translateService = TestBed.inject(TranslateService); + }); - jest.spyOn(translateService, 'instant').mockImplementation( - (key) => key + it('returns an empty batch result when the desktop bridge is unavailable', async () => { + const result = await firstValueFrom( + service.getCurrentProgramsForChannels(['channel-1']) ); - jest.spyOn(snackBar, 'open'); - dispatchSpy = jest.spyOn(store, 'dispatch'); - // Suppress console.error during tests - jest.spyOn(console, 'error').mockImplementation(() => {}); - jest.spyOn(console, 'log').mockImplementation(() => {}); + expect(result).toEqual(new Map()); }); - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should create a service instance', () => { - expect(service).toBeTruthy(); - }); - - describe('fetchEpg', () => { - it('should show success notification on successful fetch', async () => { - (invoke as jest.Mock).mockResolvedValueOnce({}); - service.fetchEpg(['http://example.com/epg.xml']); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(snackBar.open).toHaveBeenCalledWith( - 'EPG.FETCH_SUCCESS', - null, - expect.any(Object) - ); - }); - - it('should show error notification on fetch failure', async () => { - (invoke as jest.Mock).mockRejectedValueOnce(new Error('Failed')); - service.fetchEpg(['http://example.com/epg.xml']); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(snackBar.open).toHaveBeenCalledWith( - 'EPG.ERROR', - 'CLOSE', - expect.any(Object) - ); - }); - }); - - describe('getChannelPrograms', () => { - it('should update programs and set EPG flag to true when programs exist', (done) => { - const mockPrograms = [ - { - start: new Date().toISOString(), - stop: new Date().toISOString(), - title: 'Test', - }, - ]; - (invoke as jest.Mock).mockResolvedValueOnce(mockPrograms); - - service.getChannelPrograms('test-channel'); - - service.currentEpgPrograms$.subscribe((programs) => { - expect(programs).toHaveLength(1); - expect(dispatchSpy).toHaveBeenCalledWith( - expect.objectContaining({ value: true }) - ); - done(); - }); - }); - - it('should set EPG flag to false when no programs found', (done) => { - (invoke as jest.Mock).mockResolvedValueOnce([]); - - service.getChannelPrograms('test-channel'); - - service.currentEpgPrograms$.subscribe((programs) => { - expect(programs).toHaveLength(0); - expect(dispatchSpy).toHaveBeenCalledWith( - expect.objectContaining({ value: false }) - ); - done(); - }); - }); + it('returns null for current program lookup when the desktop bridge is unavailable', async () => { + await expect( + firstValueFrom(service.getCurrentProgramForChannel('channel-1')) + ).resolves.toBeNull(); }); }); - */ diff --git a/libs/epg/data-access/src/lib/epg.service.ts b/libs/epg/data-access/src/lib/epg.service.ts index 1b4648881..c71c6739e 100644 --- a/libs/epg/data-access/src/lib/epg.service.ts +++ b/libs/epg/data-access/src/lib/epg.service.ts @@ -3,7 +3,11 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, forkJoin, from, Observable, of } from 'rxjs'; import { catchError, map, tap, timeout } from 'rxjs/operators'; -import { EpgChannelMetadata, EpgProgram } from '@iptvnator/shared/interfaces'; +import { + createDevLogger, + EpgChannelMetadata, + EpgProgram, +} from '@iptvnator/shared/interfaces'; import { normalizeEpgPrograms } from './epg-program-normalization.util'; interface CachedProgram { @@ -17,6 +21,8 @@ type EpgChannelMetadataApi = { ) => Promise>; }; +const debugEpgService = createDevLogger('EpgService'); + @Injectable({ providedIn: 'root', }) @@ -71,7 +77,7 @@ export class EpgService { */ getChannelPrograms(channelId: string): void { if (!this.isDesktop) return; - console.log('Fetching EPG for channel ID:', channelId); + debugEpgService('Fetching EPG for channel ID:', channelId); from(window.electron.getChannelPrograms(channelId)) .pipe( diff --git a/libs/epg/data-access/src/test-setup.ts b/libs/epg/data-access/src/test-setup.ts new file mode 100644 index 000000000..0d88648e3 --- /dev/null +++ b/libs/epg/data-access/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/libs/epg/data-access/tsconfig.json b/libs/epg/data-access/tsconfig.json index 7d33479d8..f7fcb0d81 100644 --- a/libs/epg/data-access/tsconfig.json +++ b/libs/epg/data-access/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../../tsconfig.base.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "isolatedModules": true, "target": "es2022", diff --git a/libs/epg/data-access/tsconfig.spec.json b/libs/epg/data-access/tsconfig.spec.json new file mode 100644 index 000000000..7b3e56bde --- /dev/null +++ b/libs/epg/data-access/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"], + "moduleResolution": "node10" + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/shared/database/jest.config.ts b/libs/shared/database/jest.config.ts new file mode 100644 index 000000000..bfc3a8462 --- /dev/null +++ b/libs/shared/database/jest.config.ts @@ -0,0 +1,13 @@ +export default { + displayName: 'database', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { tsconfig: '/tsconfig.spec.json' }, + ], + }, + moduleFileExtensions: ['ts', 'js'], + coverageDirectory: '../../../coverage/libs/shared/database', +}; diff --git a/libs/shared/database/project.json b/libs/shared/database/project.json index f945a3e35..4898aea9e 100644 --- a/libs/shared/database/project.json +++ b/libs/shared/database/project.json @@ -16,6 +16,14 @@ "format": ["cjs"] } }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/shared/database/jest.config.ts", + "tsConfig": "libs/shared/database/tsconfig.spec.json" + } + }, "lint": { "command": "eslint libs/shared/database/**/*.ts" } diff --git a/libs/shared/database/src/lib/connection.spec.ts b/libs/shared/database/src/lib/connection.spec.ts new file mode 100644 index 000000000..5f60cd3fc --- /dev/null +++ b/libs/shared/database/src/lib/connection.spec.ts @@ -0,0 +1,120 @@ +import { __databaseConnectionTestHooks } from './connection'; + +function compactSql(statement: string): string { + return statement.replace(/\s+/g, ' ').trim(); +} + +function createdObjectNames(prefix: string, statements: readonly string[]) { + return statements + .map(compactSql) + .filter((statement) => statement.startsWith(prefix)) + .map((statement) => { + const match = statement.match( + /^(?:CREATE\s+(?:UNIQUE\s+)?(?:VIRTUAL\s+)?(?:TABLE|INDEX|TRIGGER)\s+IF\s+NOT\s+EXISTS)\s+([^\s(]+)/i + ); + return match?.[1] ?? null; + }) + .filter((name): name is string => Boolean(name)); +} + +describe('database schema statements', () => { + const { + createTableStatements, + columnMigrationStatements, + indexMigrationStatements, + } = __databaseConnectionTestHooks; + + it('defines the core fresh-install tables, indexes, and FTS triggers', () => { + const schemaSql = createTableStatements.map(compactSql).join('\n'); + + expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS playlists'); + expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS categories'); + expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS content'); + expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS epg_channels'); + expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS epg_programs'); + expect(schemaSql).toContain( + 'CREATE VIRTUAL TABLE IF NOT EXISTS epg_programs_fts USING fts5' + ); + expect(schemaSql).toContain( + 'CREATE TABLE IF NOT EXISTS playback_positions' + ); + expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS downloads'); + expect(schemaSql).toContain( + 'CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique' + ); + expect(schemaSql).toContain( + 'CREATE UNIQUE INDEX IF NOT EXISTS recently_viewed_content_playlist_unique' + ); + expect(schemaSql).toContain( + 'CREATE TRIGGER IF NOT EXISTS epg_programs_ai' + ); + expect(schemaSql).toContain( + 'CREATE TRIGGER IF NOT EXISTS epg_programs_ad' + ); + expect(schemaSql).toContain( + 'CREATE TRIGGER IF NOT EXISTS epg_programs_au' + ); + }); + + it('keeps legacy column migrations idempotent ALTER TABLE statements', () => { + expect(columnMigrationStatements.length).toBeGreaterThan(0); + expect( + columnMigrationStatements + .map(compactSql) + .every((statement) => statement.startsWith('ALTER TABLE ')) + ).toBe(true); + expect(columnMigrationStatements.map(compactSql)).toEqual( + expect.arrayContaining([ + 'ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0', + 'ALTER TABLE playlists ADD COLUMN payload TEXT', + 'ALTER TABLE favorites ADD COLUMN position INTEGER DEFAULT 0', + 'ALTER TABLE content ADD COLUMN backdrop_url TEXT', + ]) + ); + }); + + it('keeps legacy index migrations idempotent IF NOT EXISTS statements', () => { + expect(indexMigrationStatements.length).toBeGreaterThan(0); + expect( + indexMigrationStatements + .map(compactSql) + .every((statement) => + /^CREATE (?:UNIQUE )?INDEX IF NOT EXISTS /i.test(statement) + ) + ).toBe(true); + expect(indexMigrationStatements.map(compactSql)).toEqual( + expect.arrayContaining([ + 'CREATE UNIQUE INDEX IF NOT EXISTS categories_playlist_type_xtream_unique ON categories(playlist_id, type, xtream_id)', + 'CREATE UNIQUE INDEX IF NOT EXISTS content_category_type_xtream_unique ON content(category_id, type, xtream_id)', + 'CREATE INDEX IF NOT EXISTS favorites_playlist_position_idx ON favorites(playlist_id, position, added_at DESC)', + ]) + ); + }); + + it('does not define duplicate fresh-install schema object names', () => { + const objectNames = [ + ...createdObjectNames( + 'CREATE TABLE IF NOT EXISTS', + createTableStatements + ), + ...createdObjectNames( + 'CREATE VIRTUAL TABLE IF NOT EXISTS', + createTableStatements + ), + ...createdObjectNames( + 'CREATE INDEX IF NOT EXISTS', + createTableStatements + ), + ...createdObjectNames( + 'CREATE UNIQUE INDEX IF NOT EXISTS', + createTableStatements + ), + ...createdObjectNames( + 'CREATE TRIGGER IF NOT EXISTS', + createTableStatements + ), + ]; + + expect(objectNames).toHaveLength(new Set(objectNames).size); + }); +}); diff --git a/libs/shared/database/tsconfig.spec.json b/libs/shared/database/tsconfig.spec.json new file mode 100644 index 000000000..90592e418 --- /dev/null +++ b/libs/shared/database/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "esModuleInterop": true, + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/shared/interfaces/project.json b/libs/shared/interfaces/project.json index e7bb02e87..bbd5cb522 100644 --- a/libs/shared/interfaces/project.json +++ b/libs/shared/interfaces/project.json @@ -22,6 +22,9 @@ "jestConfig": "libs/shared/interfaces/jest.config.ts", "tsConfig": "libs/shared/interfaces/tsconfig.spec.json" } + }, + "lint": { + "executor": "@nx/eslint:lint" } } } diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 02552fbb7..b63c9a86c 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -1,5 +1,6 @@ export * from './lib/channel.interface'; export * from './lib/channel.model'; +export * from './lib/dev-logger.util'; export * from './lib/embedded-mpv-session.interface'; export * from './lib/epg-channel-metadata.model'; export * from './lib/epg-channel-with-programs.interface'; diff --git a/libs/shared/interfaces/src/lib/dev-logger.util.spec.ts b/libs/shared/interfaces/src/lib/dev-logger.util.spec.ts new file mode 100644 index 000000000..813478371 --- /dev/null +++ b/libs/shared/interfaces/src/lib/dev-logger.util.spec.ts @@ -0,0 +1,55 @@ +import { createDevLogger } from './dev-logger.util'; + +describe('createDevLogger', () => { + const globalWithNgDevMode = globalThis as typeof globalThis & { + ngDevMode?: boolean; + }; + + const originalNgDevMode = globalWithNgDevMode.ngDevMode; + const originalNodeEnv = process.env['NODE_ENV']; + let debugSpy: jest.SpyInstance; + + beforeEach(() => { + globalWithNgDevMode.ngDevMode = true; + process.env['NODE_ENV'] = 'development'; + debugSpy = jest.spyOn(console, 'debug').mockImplementation(); + }); + + afterEach(() => { + globalWithNgDevMode.ngDevMode = originalNgDevMode; + if (originalNodeEnv === undefined) { + delete process.env['NODE_ENV']; + } else { + process.env['NODE_ENV'] = originalNodeEnv; + } + debugSpy.mockRestore(); + }); + + it('logs scoped debug output outside production and test mode', () => { + const debug = createDevLogger('Scope'); + + debug('message', { id: 1 }); + + expect(debugSpy).toHaveBeenCalledWith('[Scope]', 'message', { + id: 1, + }); + }); + + it('suppresses debug output in Angular production mode', () => { + globalWithNgDevMode.ngDevMode = false; + const debug = createDevLogger('Scope'); + + debug('message'); + + expect(debugSpy).not.toHaveBeenCalled(); + }); + + it('suppresses debug output during tests', () => { + process.env['NODE_ENV'] = 'test'; + const debug = createDevLogger('Scope'); + + debug('message'); + + expect(debugSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/shared/interfaces/src/lib/dev-logger.util.ts b/libs/shared/interfaces/src/lib/dev-logger.util.ts new file mode 100644 index 000000000..58a910cd4 --- /dev/null +++ b/libs/shared/interfaces/src/lib/dev-logger.util.ts @@ -0,0 +1,22 @@ +export type DevLogFn = (...args: unknown[]) => void; + +function isDevLoggingEnabled(): boolean { + const global = globalThis as typeof globalThis & { + ngDevMode?: boolean; + process?: { env?: { NODE_ENV?: string } }; + }; + + return ( + global.ngDevMode !== false && global.process?.env?.NODE_ENV !== 'test' + ); +} + +export function createDevLogger(scope: string): DevLogFn { + const prefix = `[${scope}]`; + + return (...args: unknown[]) => { + if (isDevLoggingEnabled()) { + console.debug(prefix, ...args); + } + }; +} diff --git a/libs/shared/m3u-utils/project.json b/libs/shared/m3u-utils/project.json index 165cdb9b0..fa27a4916 100644 --- a/libs/shared/m3u-utils/project.json +++ b/libs/shared/m3u-utils/project.json @@ -21,6 +21,9 @@ "options": { "jestConfig": "libs/shared/m3u-utils/jest.config.ts" } + }, + "lint": { + "executor": "@nx/eslint:lint" } } } diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts index 916b03754..a0fde9dba 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts @@ -26,9 +26,7 @@ import { ChannelActions } from '@iptvnator/m3u-state'; @if (displayIcon() && !logoError()) {
}
@@ -54,7 +52,10 @@ import { ChannelActions } from '@iptvnator/m3u-state';

{{ channelName() || 'Radio' }}

- + @if (playState() === 'play') { LIVE } @else { @@ -74,14 +75,10 @@ import { ChannelActions } from '@iptvnator/m3u-state'; @@ -95,19 +92,10 @@ import { ChannelActions } from '@iptvnator/m3u-state';
- - + console.log(err)); + audio.play().catch((err) => { + console.warn('[AudioPlayer] Audio playback failed:', err); + }); this.playState.set('play'); } @@ -219,8 +209,7 @@ export class AudioPlayerComponent { } setVolume(value: number) { - const clamped = - Math.round(Math.max(0, Math.min(1, value)) * 100) / 100; + const clamped = Math.round(Math.max(0, Math.min(1, value)) * 100) / 100; this.volume.set(clamped); const audio = this.audioRef()?.nativeElement; if (audio) audio.volume = clamped; diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index 93b8ea720..e32b84b9d 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -14,7 +14,7 @@ import { import Hls, { type ErrorData, type ManifestParsedData } from 'hls.js'; import mpegts from 'mpegts.js'; import { DataService } from '@iptvnator/services'; -import { Channel } from '@iptvnator/shared/interfaces'; +import { Channel, createDevLogger } from '@iptvnator/shared/interfaces'; import { InlinePlaybackPlayer, PlaybackDiagnostic, @@ -26,6 +26,8 @@ import { getPlaybackMediaExtensionFromUrl, } from '../playback-diagnostics/playback-diagnostics.util'; +const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer'); + /** * This component contains the implementation of HTML5 based video player */ @@ -121,7 +123,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { this.playChannel(changes['channel'].currentValue); } if (changes['volume']?.currentValue !== undefined) { - console.log( + debugHtmlPlayer( 'Setting HTML5 player volume to:', changes['volume'].currentValue ); @@ -157,7 +159,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { } if ((extension === 'ts' || !extension) && mpegts.isSupported()) { - console.log( + debugHtmlPlayer( 'Using mpegts.js for TS stream:', channel.name, url @@ -193,7 +195,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { Hls && Hls.isSupported() ) { - console.log('... switching channel to ', channel.name, url); + debugHtmlPlayer('Switching channel to:', channel.name, url); this.hls = new Hls(); this.hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => { this.handleHlsManifestParsed(url, data); @@ -205,7 +207,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { this.hls.loadSource(url); this.handlePlayOperation(); } else { - console.log('Using native video player...'); + debugHtmlPlayer('Using native video player'); this.addSourceToVideo( this.videoPlayer.nativeElement, url, @@ -315,7 +317,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { */ onVolumeChange(): void { const currentVolume = this.videoPlayer.nativeElement.volume; - console.log('Volume changed to:', currentVolume); + debugHtmlPlayer('Volume changed to:', currentVolume); localStorage.setItem('volume', currentVolume.toString()); } diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index 0418f2e06..8a5eba63a 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -12,6 +12,7 @@ import { } from '@angular/core'; import '@yangkghjh/videojs-aspect-ratio-panel'; import mpegts from 'mpegts.js'; +import { createDevLogger } from '@iptvnator/shared/interfaces'; import videoJs from 'video.js'; import 'videojs-contrib-quality-levels'; import 'videojs-quality-selector-hls'; @@ -89,6 +90,8 @@ type VideoJsPlayer = Omit< error: () => { code?: number; message?: string } | null; }; +const debugVjsPlayer = createDevLogger('VjsPlayer'); + @Component({ selector: 'app-vjs-player', templateUrl: './vjs-player.component.html', @@ -145,7 +148,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { : { ...this.options(), autoplay: true }; this.player = videoJs(this.target().nativeElement, vjsOptions, () => { - console.log( + debugVjsPlayer( 'Setting VideoJS player initial volume to:', this.volume() ); @@ -168,7 +171,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { const audioTracks = this.player.audioTracks(); if (audioTracks) { audioTracks.addEventListener('addtrack', () => { - console.log( + debugVjsPlayer( '[AudioTrack] addtrack event fired, total tracks:', this.player.audioTracks().length ); @@ -176,7 +179,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { this.setupAudioTrackMenu(); }); audioTracks.addEventListener('removetrack', () => { - console.log( + debugVjsPlayer( '[AudioTrack] removetrack event fired, total tracks:', this.player.audioTracks().length ); @@ -246,7 +249,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { } } if (changes['volume']?.currentValue !== undefined && this.player) { - console.log( + debugVjsPlayer( 'Setting VideoJS player volume to:', changes['volume'].currentValue ); @@ -300,7 +303,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { ?.el(); if (!videoEl) return; - console.log('Using mpegts.js for TS stream:', url); + debugVjsPlayer('Using mpegts.js for TS stream:', url); const isLive = this.options().isLive !== false; this.mpegtsPlayer = mpegts.createPlayer({ type: 'mpegts', @@ -455,13 +458,13 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { */ private logAudioTracks(): void { const audioTracks = this.player.audioTracks(); - console.log( + debugVjsPlayer( '[AudioTrack] Audio tracks count:', audioTracks?.length ?? 0 ); for (let i = 0; i < (audioTracks?.length ?? 0); i++) { const t = audioTracks[i]; - console.log( + debugVjsPlayer( `[AudioTrack] Track ${i}: label="${t.label}", language="${t.language}", enabled=${t.enabled}, kind="${t.kind}"` ); } @@ -476,12 +479,12 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { tech?.vhs?.playlists?.master?.mediaGroups?.AUDIO; if (audioMediaGroups) { - console.log( + debugVjsPlayer( '[AudioTrack] HLS AUDIO media groups:', JSON.stringify(Object.keys(audioMediaGroups)) ); } else { - console.log( + debugVjsPlayer( '[AudioTrack] HLS AUDIO media groups: none found in playlist metadata' ); } @@ -494,16 +497,16 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { */ private setupAudioTrackMenu(): void { const audioTracks = this.player.audioTracks(); - console.log( + debugVjsPlayer( '[AudioTrack] setupAudioTrackMenu called, tracks:', audioTracks?.length ?? 0 ); if (!audioTracks || audioTracks.length <= 1) { - console.log( + debugVjsPlayer( '[AudioTrack] Skipping menu: need >1 tracks, have', audioTracks?.length ?? 0 ); - console.log( + debugVjsPlayer( '[AudioTrack] If VLC/MPV show more tracks, the HLS manifest likely does not expose alternate audio via EXT-X-MEDIA' ); return; diff --git a/libs/workspace/shell/util/project.json b/libs/workspace/shell/util/project.json index d8228d2c9..09e65ab5d 100644 --- a/libs/workspace/shell/util/project.json +++ b/libs/workspace/shell/util/project.json @@ -6,6 +6,14 @@ "projectType": "library", "tags": ["scope:workspace", "domain:workspace", "type:util"], "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/workspace/shell/util/jest.config.ts", + "tsConfig": "libs/workspace/shell/util/tsconfig.spec.json" + } + }, "lint": { "executor": "@nx/eslint:lint" } diff --git a/package.json b/package.json index ab604d64b..340fd32b3 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "test:frontend": "nx test web", "test:backend": "nx test electron-backend", "test:unit:all": "nx run-many --target=test --all --parallel=3", - "test:unit:ci": "nx run-many --target=test --projects=electron-backend,web,services,m3u-state,portal-stalker-data-access,portal-shared-util,playlist-shared-ui,portal-xtream-data-access,portal-xtream-feature,workspace-dashboard-data-access,components,@iptvnator/pipes,packaging --parallel=3 --output-style=static", + "test:unit:ci": "nx run-many --target=test --projects=electron-backend,web,services,m3u-state,portal-stalker-data-access,portal-shared-util,playlist-shared-ui,portal-xtream-data-access,portal-xtream-feature,workspace-dashboard-data-access,components,@iptvnator/pipes,packaging,epg-data-access,workspace-shell-util,database --parallel=3 --output-style=static", "typecheck:web": "tsc -p apps/web/tsconfig.app.json --noEmit", "typecheck:backend": "tsc -p apps/electron-backend/tsconfig.app.json --noEmit", "typecheck:ci": "pnpm run typecheck:web && pnpm run typecheck:backend", @@ -46,6 +46,7 @@ "smoke:packaged": "node tools/testing/launch-packaged-electron.mjs", "serve:website": "nx serve website", "build:website": "nx build website", + "i18n:check": "node tools/i18n/check-drift.mjs", "release:artwork:dry-run": "tsx tools/release/generate-marketing-artwork.ts --dry-run", "release:artwork:manifest": "tsx tools/release/generate-marketing-artwork.ts --manifest", "release:artwork:generate": "tsx tools/release/generate-marketing-artwork.ts --generate", diff --git a/tools/i18n/README.md b/tools/i18n/README.md index d65e929de..c16dcd770 100644 --- a/tools/i18n/README.md +++ b/tools/i18n/README.md @@ -43,6 +43,21 @@ for(const f of fs.readdirSync('apps/web/src/assets/i18n').filter(x=>x.endsWith(' }" ``` +For a non-mutating CI/agent check, run: + +```bash +pnpm run i18n:check +``` + +The check fails on missing or extra keys against `en.json`. It reports values +that are still identical to English as warnings so untranslated fallback strings +remain visible without blocking key-parity validation. For stricter translation +audits, run: + +```bash +node tools/i18n/check-drift.mjs --fail-on-identical +``` + ## Translation rules - Preserve `{{interpolation}}` placeholders verbatim. diff --git a/tools/i18n/check-drift.mjs b/tools/i18n/check-drift.mjs new file mode 100644 index 000000000..7966a58de --- /dev/null +++ b/tools/i18n/check-drift.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, '..', '..'); +const I18N_DIR = resolve(REPO_ROOT, 'apps/web/src/assets/i18n'); +const EN_LOCALE = 'en.json'; +const MAX_EXAMPLES = 5; +const FAIL_ON_IDENTICAL = process.argv.includes('--fail-on-identical'); + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, 'utf8')); +} + +function collectLeaves(value, prefix = '', leaves = new Map()) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Expected object at ${prefix || ''}`); + } + + for (const [key, entry] of Object.entries(value)) { + const path = prefix ? `${prefix}.${key}` : key; + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + collectLeaves(entry, path, leaves); + } else { + leaves.set(path, entry); + } + } + + return leaves; +} + +function listLocaleFiles() { + return readdirSync(I18N_DIR) + .filter((file) => file.endsWith('.json')) + .sort(); +} + +function formatExamples(values) { + if (values.length === 0) { + return ''; + } + + const suffix = values.length > MAX_EXAMPLES ? ', ...' : ''; + return ` (${values.slice(0, MAX_EXAMPLES).join(', ')}${suffix})`; +} + +function formatError(error) { + return error instanceof Error ? error.message : String(error); +} + +let enLeaves; +try { + enLeaves = collectLeaves(readJson(resolve(I18N_DIR, EN_LOCALE))); +} catch (error) { + console.error(`FAIL ${EN_LOCALE}: ${formatError(error)}`); + process.exit(1); +} + +const enKeys = [...enLeaves.keys()]; +let failed = false; +let identicalTotal = 0; + +for (const localeFile of listLocaleFiles()) { + if (localeFile === EN_LOCALE) { + continue; + } + + try { + const localeLeaves = collectLeaves( + readJson(resolve(I18N_DIR, localeFile)) + ); + const localeKeys = [...localeLeaves.keys()]; + const missing = enKeys.filter((key) => !localeLeaves.has(key)); + const extra = localeKeys.filter((key) => !enLeaves.has(key)); + const identical = enKeys.filter((key) => { + if (!localeLeaves.has(key)) { + return false; + } + + const localeValue = localeLeaves.get(key); + const enValue = enLeaves.get(key); + + return ( + typeof localeValue === 'string' && + typeof enValue === 'string' && + localeValue.trim().length > 0 && + localeValue === enValue + ); + }); + + identicalTotal += identical.length; + const localeFailed = + missing.length > 0 || + extra.length > 0 || + (FAIL_ON_IDENTICAL && identical.length > 0); + failed = failed || localeFailed; + + const status = localeFailed ? 'FAIL' : 'ok'; + console.log( + `${status} ${localeFile}: missing=${missing.length}${formatExamples( + missing + )} extra=${extra.length}${formatExamples(extra)} identical_en=${ + identical.length + }${formatExamples(identical)}` + ); + } catch (error) { + failed = true; + console.log(`FAIL ${localeFile}: ${formatError(error)}`); + } +} + +if (identicalTotal > 0 && !FAIL_ON_IDENTICAL) { + console.log( + `warn identical_en=${identicalTotal}; pass --fail-on-identical to make English fallback values fatal.` + ); +} + +if (failed) { + process.exitCode = 1; +} diff --git a/tools/packaging/project.json b/tools/packaging/project.json index 2a56b3a75..6cbc84e1e 100644 --- a/tools/packaging/project.json +++ b/tools/packaging/project.json @@ -19,6 +19,9 @@ "command": "node --test tools/packaging/electron-package-identity.test.mjs", "cwd": "{workspaceRoot}" } + }, + "lint": { + "command": "eslint \"tools/packaging/**/*.{js,cjs,mjs,ts}\"" } }, "tags": ["scope:tools", "domain:packaging", "type:tool"]