chore: tighten validation and runtime logging

* chore: tighten validation and runtime logging

* fix(i18n): localize new settings labels
This commit is contained in:
4gray authored and GitHub committed 2026-05-15 17:43:42 +02:00
1 parent d24c77a143
commit 2d5c4fa4f9
52 files changed
+767 -214

No files matched your search

+3
View File
@@ -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:
+1
View File
@@ -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:
+1
View File
@@ -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:
+3
View File
@@ -11,6 +11,9 @@
"targets": {
"e2e": {
"dependsOn": ["electron-backend:build-e2e"]
},
"lint": {
"executor": "@nx/eslint:lint"
}
},
"tags": ["scope:e2e", "domain:electron-main", "type:e2e"]
+1
View File
@@ -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",
@@ -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<void> {
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;
@@ -27,10 +27,7 @@ function summarizeObject(
const entries = Object.entries(value);
const summary: Record<string, unknown> = {};
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;
+4 -1
View File
@@ -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"]
}
+7 -5
View File
@@ -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(
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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 прайгравальніка",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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プレーヤーのパス",
+6
View File
@@ -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 플레이어 경로",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+2
View File
@@ -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": "Тема оформления",
+6
View File
@@ -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",
+6
View File
@@ -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播放器路径",
+6
View File
@@ -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 播放器路徑",
+56
View File
@@ -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
```
+11
View File
@@ -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'] }],
},
},
];
+22
View File
@@ -0,0 +1,22 @@
export default {
displayName: 'epg-data-access',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/epg/data-access',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/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'],
};
+8
View File
@@ -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"
}
@@ -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();
});
});
*/
+8 -2
View File
@@ -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<Record<string, EpgChannelMetadata | null>>;
};
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(
+6
View File
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
+1 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "../../../../tsconfig.base.json",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
+17
View File
@@ -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"
]
}
+13
View File
@@ -0,0 +1,13 @@
export default {
displayName: 'database',
preset: '../../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': [
'ts-jest',
{ tsconfig: '<rootDir>/tsconfig.spec.json' },
],
},
moduleFileExtensions: ['ts', 'js'],
coverageDirectory: '../../../coverage/libs/shared/database',
};
+8
View File
@@ -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"
}
@@ -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);
});
});
+16
View File
@@ -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"
]
}
+3
View File
@@ -22,6 +22,9 @@
"jestConfig": "libs/shared/interfaces/jest.config.ts",
"tsConfig": "libs/shared/interfaces/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+1
View File
@@ -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';
@@ -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();
});
});
@@ -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);
}
};
}
+3
View File
@@ -21,6 +21,9 @@
"options": {
"jestConfig": "libs/shared/m3u-utils/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
@@ -26,9 +26,7 @@ import { ChannelActions } from '@iptvnator/m3u-state';
@if (displayIcon() && !logoError()) {
<div
class="backdrop"
[style.backgroundImage]="
'url(' + displayIcon() + ')'
"
[style.backgroundImage]="'url(' + displayIcon() + ')'"
></div>
}
<div class="vignette"></div>
@@ -54,7 +52,10 @@ import { ChannelActions } from '@iptvnator/m3u-state';
<h2 class="station-name">
{{ channelName() || 'Radio' }}
</h2>
<span class="station-badge" [class.live]="playState() === 'play'">
<span
class="station-badge"
[class.live]="playState() === 'play'"
>
@if (playState() === 'play') {
<span class="pulse"></span> LIVE
} @else {
@@ -74,14 +75,10 @@ import { ChannelActions } from '@iptvnator/m3u-state';
<button
class="play-btn"
mat-fab
(click)="
playState() === 'play' ? stop() : play()
"
(click)="playState() === 'play' ? stop() : play()"
>
<mat-icon>{{
playState() === 'play'
? 'pause'
: 'play_arrow'
playState() === 'play' ? 'pause' : 'play_arrow'
}}</mat-icon>
</button>
@@ -95,19 +92,10 @@ import { ChannelActions } from '@iptvnator/m3u-state';
</div>
<div class="volume-row">
<button
mat-icon-button
class="vol-icon"
(click)="mute()"
>
<button mat-icon-button class="vol-icon" (click)="mute()">
<mat-icon>{{ volumeIcon() }}</mat-icon>
</button>
<mat-slider
class="vol-slider"
min="0"
max="1"
step="0.05"
>
<mat-slider class="vol-slider" min="0" max="1" step="0.05">
<input
matSliderThumb
[ngModel]="volume()"
@@ -209,7 +197,9 @@ export class AudioPlayerComponent {
play() {
const audio = this.audioRef()?.nativeElement;
if (!audio) return;
audio.play().catch((err) => 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;
@@ -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());
}
@@ -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;
+8
View File
@@ -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"
}
+2 -1
View File
@@ -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",
+15
View File
@@ -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.
+122
View File
@@ -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 || '<root>'}`);
}
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;
}
+3
View File
@@ -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"]