mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
chore: tighten validation and runtime logging
* chore: tighten validation and runtime logging * fix(i18n): localize new settings labels
This commit is contained in:
1 parent
d24c77a143
commit
2d5c4fa4f9
52 files changed
+767
-214
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
"targets": {
|
||||
"e2e": {
|
||||
"dependsOn": ["electron-backend:build-e2e"]
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
},
|
||||
"tags": ["scope:e2e", "domain:electron-main", "type:e2e"]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 прайгравальніка",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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プレーヤーのパス",
|
||||
|
||||
@@ -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 플레이어 경로",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Тема оформления",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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播放器路径",
|
||||
|
||||
@@ -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 播放器路徑",
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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'] }],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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'],
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
*/
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -22,6 +22,9 @@
|
||||
"jestConfig": "libs/shared/interfaces/jest.config.ts",
|
||||
"tsConfig": "libs/shared/interfaces/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
Reference in new issue
Block a user