release: veröffentliche Twitch VOD Manager 1.0.1
Startet die öffentliche Versionslinie mit einer bereinigten Ein-Commit-Historie, stellt den Updater auf GitHub Releases um, entfernt interne Release-Ziele und beschränkt den gepackten Anwendungssatz auf notwendige Laufzeitdateien. Enthält aktualisierte produktive Abhängigkeiten ohne bekannte npm-Audit-Funde sowie die geprüfte öffentliche Quell-Positivliste.
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
coverage/
|
||||
tmp_*/
|
||||
*.log
|
||||
*.local
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
@@ -0,0 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.1 - 2026-08-05
|
||||
|
||||
- New clean public release line based on the complete desktop application.
|
||||
- Twitch VOD, clip, trim, split, merge, queue, history and automation workflows.
|
||||
- Streamer profiles, VOD previews, themes, localization and command palette.
|
||||
- Resumable downloads, integrity checks, secure local storage and SQLite migration.
|
||||
- Automatic update checks and downloads through GitHub Releases.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Twitch VOD Manager contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Twitch VOD Manager
|
||||
|
||||
Twitch VOD Manager is a Windows desktop application for finding, downloading, trimming, splitting, merging and organizing Twitch VODs and clips.
|
||||
|
||||
## Features
|
||||
|
||||
- Search streamers and browse available VODs
|
||||
- Download complete VODs or precise time ranges
|
||||
- Split long recordings into configurable parts
|
||||
- Merge related downloads and track group progress
|
||||
- Resume interrupted downloads and verify completed files
|
||||
- Manage queues, history, profiles and per-streamer automation
|
||||
- Capture live streams and Twitch chat
|
||||
- Use light and dark themes with German and English localization
|
||||
- Receive application updates through GitHub Releases
|
||||
|
||||
## Installation
|
||||
|
||||
Download the current Windows installer from [GitHub Releases](https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest).
|
||||
|
||||
The application stores its settings and local database on the computer where it is installed. No Twitch credentials, user settings, download history or personal data are included in this repository or its release files.
|
||||
|
||||
## Development
|
||||
|
||||
Requirements:
|
||||
|
||||
- Node.js 20 or newer
|
||||
- Windows for building the NSIS installer
|
||||
|
||||
```powershell
|
||||
npm ci
|
||||
npm run test:e2e:release
|
||||
npm run dist:win
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Twitch VOD Manager is available under the [MIT License](LICENSE).
|
||||
@@ -0,0 +1,4 @@
|
||||
!macro customInit
|
||||
; Kill running Twitch VOD Manager process before installation
|
||||
nsExec::ExecToLog 'taskkill /F /IM "Twitch VOD Manager.exe"'
|
||||
!macroend
|
||||
@@ -0,0 +1,25 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import security from 'eslint-plugin-security';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
security.configs.recommended,
|
||||
{
|
||||
files: ['src/**/*.ts'],
|
||||
rules: {
|
||||
// Tune down noisy rules for existing codebase
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'no-console': 'off',
|
||||
'security/detect-object-injection': 'off', // Too many false positives with Record types
|
||||
'security/detect-non-literal-fs-filename': 'off', // All paths come from controlled sources
|
||||
'no-async-promise-executor': 'warn',
|
||||
'no-empty': ['warn', { allowEmptyCatch: true }],
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['dist/**', 'release/**', 'node_modules/**', 'scripts/**', 'tmp_*/**']
|
||||
}
|
||||
];
|
||||
Generated
+6752
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "1.0.1",
|
||||
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
||||
"main": "dist/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "npm run build && electron .",
|
||||
"test:unit": "vitest run --passWithNoTests",
|
||||
"test:unit:watch": "vitest",
|
||||
"test:e2e:update-logic": "node scripts/smoke-test-update-version-logic.js",
|
||||
"test:e2e:public-release": "node scripts/smoke-test-public-release-config.js",
|
||||
"test:e2e": "node scripts/smoke-test.js",
|
||||
"test:e2e:guide": "node scripts/smoke-test-template-guide.js",
|
||||
"test:e2e:full": "node scripts/smoke-test-full.js",
|
||||
"test:e2e:release": "npm run build && npm run test:unit && npm run test:e2e:update-logic && npm run test:e2e:public-release && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full",
|
||||
"test:e2e:stress": "npm run test:e2e:release && npm run test:e2e:release && npm run test:e2e:release",
|
||||
"pack": "npm run build && electron-builder --dir",
|
||||
"dist": "npm run build && electron-builder",
|
||||
"dist:win": "npm run test:e2e:release && electron-builder --win",
|
||||
"test:merge-split": "node scripts/smoke-test-merge-split-logic.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.16.1",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"electron-updater": "^6.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^20.10.0",
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.9.0",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-plugin-security": "^4.0.0",
|
||||
"playwright": "^1.60.0",
|
||||
"typescript": "^5.3.0",
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"build": {
|
||||
"appId": "io.github.sucukdeluxe.twitch-vod-manager",
|
||||
"productName": "Twitch VOD Manager",
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"src/index.html",
|
||||
"src/styles.css",
|
||||
"package.json"
|
||||
],
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"signAndEditExecutable": false,
|
||||
"artifactName": "Twitch-VOD-Manager-Setup-${version}.${ext}"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"deleteAppDataOnUninstall": false,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "Twitch VOD Manager v${version}",
|
||||
"include": "build/installer.nsh"
|
||||
},
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest/download/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"files": [
|
||||
".gitignore",
|
||||
"CHANGELOG.md",
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"assets",
|
||||
"build",
|
||||
"eslint.config.mjs",
|
||||
"package-lock.json",
|
||||
"package.json",
|
||||
"scripts/public-release-files.json",
|
||||
"scripts/smoke-test-full.js",
|
||||
"scripts/smoke-test-merge-split-logic.js",
|
||||
"scripts/smoke-test-public-release-config.js",
|
||||
"scripts/smoke-test-settings-autosave.js",
|
||||
"scripts/smoke-test-template-guide.js",
|
||||
"scripts/smoke-test-update-version-logic.js",
|
||||
"scripts/smoke-test.js",
|
||||
"src",
|
||||
"tsconfig.json",
|
||||
"vitest.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
const { _electron: electron } = require('playwright');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager');
|
||||
const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json');
|
||||
const QUEUE_FILE = path.join(APPDATA_DIR, 'download_queue.json');
|
||||
const TMP_DIR = path.join(process.cwd(), 'tmp_e2e_full');
|
||||
const MEDIA_A = path.join(TMP_DIR, 'in_a.mp4');
|
||||
const MEDIA_B = path.join(TMP_DIR, 'in_b.mp4');
|
||||
|
||||
function backupFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
return fs.readFileSync(filePath);
|
||||
}
|
||||
|
||||
function restoreFile(filePath, backup) {
|
||||
if (backup === null) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.rmSync(filePath, { force: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, backup);
|
||||
}
|
||||
|
||||
function findFileRecursive(rootDir, fileName) {
|
||||
if (!fs.existsSync(rootDir)) return null;
|
||||
|
||||
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(rootDir, entry.name);
|
||||
if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) {
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const nested = findFileRecursive(fullPath, fileName);
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveFfmpegBinary() {
|
||||
const direct = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore', windowsHide: true });
|
||||
if (direct.status === 0) return 'ffmpeg';
|
||||
|
||||
const bundledRoot = path.join(APPDATA_DIR, 'tools', 'ffmpeg');
|
||||
const bundled = findFileRecursive(bundledRoot, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg');
|
||||
if (bundled) return bundled;
|
||||
|
||||
throw new Error('ffmpeg not found. Install ffmpeg or run app preflight auto-fix first.');
|
||||
}
|
||||
|
||||
function runFfmpeg(ffmpegPath, args) {
|
||||
const res = spawnSync(ffmpegPath, args, { windowsHide: true, stdio: 'pipe' });
|
||||
if (res.status !== 0) {
|
||||
const stderr = (res.stderr || Buffer.from('')).toString('utf-8').slice(0, 800);
|
||||
throw new Error(`ffmpeg failed: ${stderr || `exit ${res.status}`}`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureTestMedia() {
|
||||
fs.mkdirSync(TMP_DIR, { recursive: true });
|
||||
const ffmpeg = resolveFfmpegBinary();
|
||||
|
||||
runFfmpeg(ffmpeg, [
|
||||
'-y',
|
||||
'-f', 'lavfi',
|
||||
'-i', 'testsrc=size=640x360:rate=30',
|
||||
'-t', '4',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
MEDIA_A
|
||||
]);
|
||||
|
||||
runFfmpeg(ffmpeg, [
|
||||
'-y',
|
||||
'-f', 'lavfi',
|
||||
'-i', 'testsrc=size=640x360:rate=30',
|
||||
'-t', '3',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
MEDIA_B
|
||||
]);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const configBackup = backupFile(CONFIG_FILE);
|
||||
const queueBackup = backupFile(QUEUE_FILE);
|
||||
|
||||
let app;
|
||||
try {
|
||||
ensureTestMedia();
|
||||
|
||||
const electronPath = require('electron');
|
||||
app = await electron.launch({
|
||||
executablePath: electronPath,
|
||||
args: ['.'],
|
||||
cwd: process.cwd()
|
||||
});
|
||||
|
||||
const win = await app.firstWindow();
|
||||
const issues = [];
|
||||
|
||||
win.on('pageerror', (err) => {
|
||||
issues.push(`pageerror: ${String(err)}`);
|
||||
});
|
||||
|
||||
win.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
issues.push(`console.error: ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
await win.waitForTimeout(2200);
|
||||
|
||||
const summary = await win.evaluate(async ({ mediaA, mediaB, tmpDir }) => {
|
||||
const failures = [];
|
||||
const checks = {};
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) failures.push(message);
|
||||
};
|
||||
|
||||
const waitFor = async (predicate, timeoutMs = 15000, intervalMs = 250) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (predicate()) return true;
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const clearQueue = async () => {
|
||||
const q = await window.api.getQueue();
|
||||
for (const item of q) {
|
||||
await window.api.removeFromQueue(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupDownloads = async () => {
|
||||
await window.api.cancelDownload();
|
||||
await sleep(400);
|
||||
};
|
||||
|
||||
const initialConfig = await window.api.getConfig();
|
||||
|
||||
try {
|
||||
await cleanupDownloads();
|
||||
await clearQueue();
|
||||
|
||||
const requiredGlobals = [
|
||||
'showTab',
|
||||
'addStreamer',
|
||||
'refreshVODs',
|
||||
'downloadClip',
|
||||
'saveSettings',
|
||||
'runPreflight',
|
||||
'refreshDebugLog',
|
||||
'toggleDebugAutoRefresh',
|
||||
'retryFailedDownloads',
|
||||
'toggleDownload'
|
||||
];
|
||||
|
||||
const missingGlobals = requiredGlobals.filter((name) => typeof window[name] !== 'function');
|
||||
checks.globals = { missingGlobals };
|
||||
assert(missingGlobals.length === 0, `Missing globals: ${missingGlobals.join(', ')}`);
|
||||
|
||||
const tabs = ['vods', 'clips', 'cutter', 'merge', 'settings'];
|
||||
const tabChecks = {};
|
||||
for (const tab of tabs) {
|
||||
window.showTab(tab);
|
||||
tabChecks[tab] = document.querySelector('.tab-content.active')?.id === `${tab}Tab`;
|
||||
}
|
||||
checks.tabs = tabChecks;
|
||||
assert(Object.values(tabChecks).every(Boolean), 'Tab switching failed for at least one tab');
|
||||
|
||||
window.showTab('settings');
|
||||
const preflight = await window.api.runPreflight(false);
|
||||
await window.runPreflight(false);
|
||||
await window.refreshDebugLog();
|
||||
checks.preflight = {
|
||||
ok: preflight.ok,
|
||||
checks: preflight.checks,
|
||||
panelText: (document.getElementById('preflightResult')?.textContent || '').slice(0, 180),
|
||||
healthBadge: (document.getElementById('healthBadge')?.textContent || '').trim()
|
||||
};
|
||||
assert(Boolean(checks.preflight.panelText), 'Preflight panel is empty');
|
||||
assert(Boolean(checks.preflight.healthBadge), 'Health badge is empty');
|
||||
|
||||
const lang = document.getElementById('languageSelect');
|
||||
lang.value = 'de';
|
||||
lang.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await sleep(160);
|
||||
const deState = {
|
||||
nav: (document.getElementById('navSettingsText')?.textContent || '').trim(),
|
||||
retry: (document.getElementById('btnRetryFailed')?.textContent || '').trim(),
|
||||
deText: (document.getElementById('languageDeText')?.textContent || '').trim(),
|
||||
deIcon: !!document.querySelector('#langOptionDe .flag-icon.flag-de'),
|
||||
deActive: !!document.getElementById('langOptionDe')?.classList.contains('active')
|
||||
};
|
||||
|
||||
lang.value = 'en';
|
||||
lang.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await sleep(160);
|
||||
const enState = {
|
||||
nav: (document.getElementById('navSettingsText')?.textContent || '').trim(),
|
||||
retry: (document.getElementById('btnRetryFailed')?.textContent || '').trim(),
|
||||
enText: (document.getElementById('languageEnText')?.textContent || '').trim(),
|
||||
enIcon: !!document.querySelector('#langOptionEn .flag-icon.flag-en'),
|
||||
enActive: !!document.getElementById('langOptionEn')?.classList.contains('active')
|
||||
};
|
||||
|
||||
checks.language = { deState, enState };
|
||||
assert(deState.nav.includes('Einstellungen'), 'German language switch failed');
|
||||
assert(enState.nav.includes('Settings'), 'English language switch failed');
|
||||
assert(deState.deIcon, 'German flag icon missing');
|
||||
assert(enState.enIcon, 'English flag icon missing');
|
||||
assert(deState.deActive, 'German language button did not activate');
|
||||
assert(enState.enActive, 'English language button did not activate');
|
||||
|
||||
await window.api.saveConfig({ client_id: '', client_secret: '', download_path: tmpDir });
|
||||
window.showTab('vods');
|
||||
await window.selectStreamer('xrohat');
|
||||
|
||||
await waitFor(() => document.querySelectorAll('.vod-card').length > 0, 18000, 300);
|
||||
const vodCards = document.querySelectorAll('.vod-card').length;
|
||||
checks.vods = {
|
||||
cards: vodCards,
|
||||
status: (document.getElementById('statusText')?.textContent || '').trim()
|
||||
};
|
||||
assert(vodCards > 0, 'No VOD cards loaded');
|
||||
|
||||
if (vodCards > 0) {
|
||||
document.querySelector('.vod-card .vod-btn.primary')?.click();
|
||||
await sleep(350);
|
||||
}
|
||||
|
||||
const queueAfterUiAdd = Number(document.getElementById('queueCount')?.textContent || '0');
|
||||
checks.queueBasic = { queueAfterUiAdd };
|
||||
assert(queueAfterUiAdd >= 1, 'Queue did not increase after VOD add button');
|
||||
|
||||
await clearQueue();
|
||||
|
||||
await window.api.saveConfig({ prevent_duplicate_downloads: true });
|
||||
await window.api.addToQueue({
|
||||
url: 'https://www.twitch.tv/videos/2695851503',
|
||||
title: '__E2E_FULL__dup',
|
||||
date: '2026-02-01T00:00:00Z',
|
||||
streamer: 'xrohat',
|
||||
duration_str: '1h0m0s'
|
||||
});
|
||||
await window.api.addToQueue({
|
||||
url: 'https://www.twitch.tv/videos/2695851503',
|
||||
title: '__E2E_FULL__dup',
|
||||
date: '2026-02-01T00:00:00Z',
|
||||
streamer: 'xrohat',
|
||||
duration_str: '1h0m0s'
|
||||
});
|
||||
let q = await window.api.getQueue();
|
||||
const duplicateCount = q.filter((item) => item.title === '__E2E_FULL__dup').length;
|
||||
checks.duplicatePrevention = { duplicateCount };
|
||||
assert(duplicateCount === 1, 'Duplicate prevention did not block second queue add');
|
||||
await clearQueue();
|
||||
|
||||
const runtimeMetrics = await window.api.getRuntimeMetrics();
|
||||
checks.runtimeMetrics = {
|
||||
hasQueue: !!runtimeMetrics?.queue,
|
||||
hasCache: !!runtimeMetrics?.caches,
|
||||
hasConfig: !!runtimeMetrics?.config,
|
||||
mode: runtimeMetrics?.config?.performanceMode || 'unknown'
|
||||
};
|
||||
assert(Boolean(checks.runtimeMetrics.hasQueue && checks.runtimeMetrics.hasCache && checks.runtimeMetrics.hasConfig), 'Runtime metrics snapshot missing expected sections');
|
||||
|
||||
window.showTab('clips');
|
||||
const clipUrl = document.getElementById('clipUrl');
|
||||
clipUrl.value = '';
|
||||
await window.downloadClip();
|
||||
const clipEmptyStatus = (document.getElementById('clipStatus')?.textContent || '').trim();
|
||||
assert(clipEmptyStatus.includes('Please enter a URL') || clipEmptyStatus.includes('Bitte URL eingeben'), 'Empty clip URL validation failed');
|
||||
|
||||
clipUrl.value = 'invalid-url';
|
||||
await window.downloadClip();
|
||||
const clipInvalidStatus = (document.getElementById('clipStatus')?.textContent || '').trim();
|
||||
assert(clipInvalidStatus.includes('Invalid clip URL') || clipInvalidStatus.includes('Ungueltige Clip-URL'), 'Invalid clip URL localization failed');
|
||||
|
||||
window.openClipDialog('https://www.twitch.tv/videos/2695851503', '__E2E_FULL__clip', '2026-02-01T00:00:00Z', 'xrohat', '1h0m0s');
|
||||
document.getElementById('clipStartTime').value = '00:00:10';
|
||||
document.getElementById('clipEndTime').value = '00:00:22';
|
||||
window.updateFromInput('start');
|
||||
window.updateFromInput('end');
|
||||
await window.confirmClipDialog();
|
||||
q = await window.api.getQueue();
|
||||
const clipItem = q.find((item) => item.title === '__E2E_FULL__clip');
|
||||
checks.clipQueue = { queued: !!clipItem, duration: clipItem?.customClip?.durationSec || 0 };
|
||||
assert(Boolean(clipItem && clipItem.customClip && clipItem.customClip.durationSec === 12), 'Clip dialog queue entry invalid');
|
||||
|
||||
await clearQueue();
|
||||
|
||||
await window.api.addToQueue({
|
||||
url: 'https://www.twitch.tv/videos/2695851503',
|
||||
title: '__E2E_FULL__pause',
|
||||
date: '2026-02-01T00:00:00Z',
|
||||
streamer: 'xrohat',
|
||||
duration_str: '4h0m0s'
|
||||
});
|
||||
|
||||
await window.api.startDownload();
|
||||
await waitFor(async () => {
|
||||
const list = await window.api.getQueue();
|
||||
const it = list.find((x) => x.title === '__E2E_FULL__pause');
|
||||
return it && (it.status === 'downloading' || it.status === 'error');
|
||||
}, 25000, 400);
|
||||
|
||||
await window.api.pauseDownload();
|
||||
await sleep(1400);
|
||||
q = await window.api.getQueue();
|
||||
const paused = q.find((item) => item.title === '__E2E_FULL__pause');
|
||||
checks.pauseResume = {
|
||||
pausedStatus: paused?.status || 'none',
|
||||
buttonText: (document.getElementById('btnStart')?.textContent || '').trim()
|
||||
};
|
||||
assert(paused?.status === 'paused', 'Pause did not set item status to paused');
|
||||
|
||||
await window.api.startDownload();
|
||||
await sleep(900);
|
||||
const resumed = await window.api.isDownloading();
|
||||
checks.pauseResume.resumed = resumed;
|
||||
assert(resumed === true, 'Resume did not restart downloading');
|
||||
|
||||
await cleanupDownloads();
|
||||
await clearQueue();
|
||||
|
||||
await window.api.addToQueue({
|
||||
url: 'not-a-valid-url',
|
||||
title: '__E2E_FULL__retry',
|
||||
date: '2026-02-01T00:00:00Z',
|
||||
streamer: 'xrohat',
|
||||
duration_str: '1h0m0s'
|
||||
});
|
||||
await window.api.startDownload();
|
||||
|
||||
const reachedError = await waitFor(async () => {
|
||||
const list = await window.api.getQueue();
|
||||
const it = list.find((item) => item.title === '__E2E_FULL__retry');
|
||||
return it && it.status === 'error';
|
||||
}, 90000, 1000);
|
||||
|
||||
q = await window.api.getQueue();
|
||||
const failed = q.find((item) => item.title === '__E2E_FULL__retry');
|
||||
checks.retryFlow = {
|
||||
failedStatus: failed?.status || 'none',
|
||||
failedReason: failed?.last_error || ''
|
||||
};
|
||||
assert(reachedError && failed?.status === 'error', 'Retry item did not reach deterministic error state');
|
||||
assert(Boolean(failed?.last_error), 'Retry test item missing error reason');
|
||||
|
||||
await window.api.retryFailedDownloads();
|
||||
await sleep(500);
|
||||
q = await window.api.getQueue();
|
||||
const afterRetry = q.find((item) => item.title === '__E2E_FULL__retry');
|
||||
checks.retryFlow.afterRetryStatus = afterRetry?.status || 'none';
|
||||
const retryAcceptedStatuses = ['pending', 'downloading', 'error'];
|
||||
assert(retryAcceptedStatuses.includes(afterRetry?.status || ''), 'Retry failed action did not update item state');
|
||||
|
||||
await cleanupDownloads();
|
||||
await clearQueue();
|
||||
|
||||
await window.api.addToQueue({
|
||||
url: 'https://www.twitch.tv/videos/does-not-exist',
|
||||
title: '__E2E_FULL__orderA',
|
||||
date: '2026-02-01T00:00:00Z',
|
||||
streamer: 'xrohat',
|
||||
duration_str: '1h0m0s'
|
||||
});
|
||||
await window.api.addToQueue({
|
||||
url: 'https://www.twitch.tv/videos/does-not-exist',
|
||||
title: '__E2E_FULL__orderB',
|
||||
date: '2026-02-01T00:00:00Z',
|
||||
streamer: 'xrohat',
|
||||
duration_str: '1h0m0s'
|
||||
});
|
||||
|
||||
q = await window.api.getQueue();
|
||||
const ids = q.map((item) => item.id);
|
||||
const reversed = [...ids].reverse();
|
||||
await window.api.reorderQueue(reversed);
|
||||
const reordered = await window.api.getQueue();
|
||||
const reorderOk = JSON.stringify(reordered.map((item) => item.id)) === JSON.stringify(reversed);
|
||||
checks.reorder = { reorderOk };
|
||||
assert(reorderOk, 'Queue reorder API failed');
|
||||
|
||||
await clearQueue();
|
||||
|
||||
const info = await window.api.getVideoInfo(mediaA);
|
||||
const frame = await window.api.extractFrame(mediaA, 1);
|
||||
const cut = await window.api.cutVideo(mediaA, 0.5, 1.7);
|
||||
const merge = await window.api.mergeVideos([mediaA, mediaB], `${tmpDir.replace(/\\/g, '/')}/merged_full.mp4`);
|
||||
checks.media = {
|
||||
infoOk: !!info && info.duration > 0,
|
||||
frameOk: typeof frame === 'string' && frame.length > 100,
|
||||
cutOk: cut.success,
|
||||
mergeOk: merge.success
|
||||
};
|
||||
assert(checks.media.infoOk, 'getVideoInfo failed for test media');
|
||||
assert(checks.media.frameOk, 'extractFrame failed for test media');
|
||||
assert(checks.media.cutOk, 'cutVideo failed for test media');
|
||||
assert(checks.media.mergeOk, 'mergeVideos failed for test media');
|
||||
|
||||
const updateResult = await window.api.checkUpdate();
|
||||
checks.update = updateResult;
|
||||
assert(typeof updateResult === 'object', 'checkUpdate did not return object');
|
||||
} catch (e) {
|
||||
failures.push(`Unexpected exception: ${String(e)}`);
|
||||
} finally {
|
||||
await cleanupDownloads();
|
||||
await clearQueue();
|
||||
await window.api.saveConfig(initialConfig);
|
||||
config = await window.api.getConfig();
|
||||
await window.connect();
|
||||
}
|
||||
|
||||
return { checks, failures };
|
||||
}, {
|
||||
mediaA: MEDIA_A.replace(/\\/g, '/'),
|
||||
mediaB: MEDIA_B.replace(/\\/g, '/'),
|
||||
tmpDir: TMP_DIR.replace(/\\/g, '/')
|
||||
});
|
||||
|
||||
await app.close();
|
||||
app = null;
|
||||
|
||||
const output = {
|
||||
...summary,
|
||||
runtimeIssues: issues
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
|
||||
const failed = output.failures.length > 0 || output.runtimeIssues.length > 0;
|
||||
process.exit(failed ? 1 : 0);
|
||||
} finally {
|
||||
if (app) {
|
||||
try {
|
||||
await app.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
restoreFile(CONFIG_FILE, configBackup);
|
||||
restoreFile(QUEUE_FILE, queueBackup);
|
||||
fs.rmSync(TMP_DIR, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
function run() {
|
||||
const failures = [];
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) failures.push(message);
|
||||
};
|
||||
|
||||
// ---- Test 1: parseDuration summation ----
|
||||
function parseDuration(duration) {
|
||||
let seconds = 0;
|
||||
const hours = duration.match(/(\d+)h/);
|
||||
const minutes = duration.match(/(\d+)m/);
|
||||
const secs = duration.match(/(\d+)s/);
|
||||
if (hours) seconds += parseInt(hours[1]) * 3600;
|
||||
if (minutes) seconds += parseInt(minutes[1]) * 60;
|
||||
if (secs) seconds += parseInt(secs[1]);
|
||||
return seconds;
|
||||
}
|
||||
|
||||
const vods = [
|
||||
{ duration_str: '2h30m0s' },
|
||||
{ duration_str: '1h45m30s' }
|
||||
];
|
||||
const totalDuration = vods.reduce((sum, v) => sum + parseDuration(v.duration_str), 0);
|
||||
assert(totalDuration === 15330, `Duration sum: expected 15330, got ${totalDuration}`);
|
||||
|
||||
// ---- Test 2: Chronological sort by ISO timestamp ----
|
||||
const items = [
|
||||
{ date: '2026-03-01T18:00:00Z', title: 'Evening' },
|
||||
{ date: '2026-03-01T16:00:00Z', title: 'Afternoon' },
|
||||
{ date: '2026-03-02T10:00:00Z', title: 'Next Day' }
|
||||
];
|
||||
const sorted = [...items].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
assert(sorted[0].title === 'Afternoon', `Sort[0]: expected Afternoon, got ${sorted[0].title}`);
|
||||
assert(sorted[1].title === 'Evening', `Sort[1]: expected Evening, got ${sorted[1].title}`);
|
||||
assert(sorted[2].title === 'Next Day', `Sort[2]: expected Next Day, got ${sorted[2].title}`);
|
||||
|
||||
// ---- Test 3: Same day, different times ----
|
||||
const sameDay = [
|
||||
{ date: '2026-03-01T18:30:00Z', title: 'Later' },
|
||||
{ date: '2026-03-01T16:15:00Z', title: 'Earlier' }
|
||||
];
|
||||
const sortedSameDay = [...sameDay].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
assert(sortedSameDay[0].title === 'Earlier', `SameDay[0]: expected Earlier, got ${sortedSameDay[0].title}`);
|
||||
assert(sortedSameDay[1].title === 'Later', `SameDay[1]: expected Later, got ${sortedSameDay[1].title}`);
|
||||
|
||||
// ---- Test 4: Merge group title generation ----
|
||||
function makeMergeTitle(items, isEnglish) {
|
||||
if (items.length === 2) return `Merge: ${items[0].title} + ${items[1].title}`;
|
||||
return `Merge: ${items[0].title} + ${items.length - 1} ${isEnglish ? 'more' : 'weitere'}`;
|
||||
}
|
||||
assert(
|
||||
makeMergeTitle([{ title: 'A' }, { title: 'B' }], true) === 'Merge: A + B',
|
||||
'Title 2 items failed'
|
||||
);
|
||||
assert(
|
||||
makeMergeTitle([{ title: 'A' }, { title: 'B' }, { title: 'C' }], false) === 'Merge: A + 2 weitere',
|
||||
'Title 3 items DE failed'
|
||||
);
|
||||
assert(
|
||||
makeMergeTitle([{ title: 'A' }, { title: 'B' }, { title: 'C' }], true) === 'Merge: A + 2 more',
|
||||
'Title 3 items EN failed'
|
||||
);
|
||||
|
||||
// ---- Test 5: Progress weighting (70/20/10) ----
|
||||
const totalSec = 10800; // 180min
|
||||
const vod1Dur = 3600; // 60min
|
||||
const vod2Dur = 7200; // 120min
|
||||
const vod1Weight = vod1Dur / totalSec;
|
||||
const vod2Weight = vod2Dur / totalSec;
|
||||
const priorWeight = vod1Weight;
|
||||
const vodProgress = 50;
|
||||
const overallProgress = (priorWeight + vod2Weight * (vodProgress / 100)) * 70;
|
||||
assert(
|
||||
Math.abs(overallProgress - 46.67) < 0.1,
|
||||
`Progress weighting: expected ~46.67, got ${overallProgress}`
|
||||
);
|
||||
|
||||
// ---- Test 6: Split part count ----
|
||||
const partMinutes = 60;
|
||||
const mergedDuration = 15330; // 4h15m30s
|
||||
const numParts = Math.ceil(mergedDuration / (partMinutes * 60));
|
||||
assert(numParts === 5, `Split parts: expected 5, got ${numParts}`);
|
||||
|
||||
// ---- Test 7: Object.keys explicit sort for downloadedFiles ----
|
||||
const downloadedFiles = { 2: '/path/c.mp4', 0: '/path/a.mp4', 1: '/path/b.mp4' };
|
||||
const sortedPaths = Object.keys(downloadedFiles)
|
||||
.sort((a, b) => Number(a) - Number(b))
|
||||
.map(k => downloadedFiles[Number(k)]);
|
||||
assert(sortedPaths[0] === '/path/a.mp4', `Sort files[0]: expected a.mp4, got ${sortedPaths[0]}`);
|
||||
assert(sortedPaths[1] === '/path/b.mp4', `Sort files[1]: expected b.mp4, got ${sortedPaths[1]}`);
|
||||
assert(sortedPaths[2] === '/path/c.mp4', `Sort files[2]: expected c.mp4, got ${sortedPaths[2]}`);
|
||||
|
||||
// ---- Test 8: FFmpeg split args order (-ss before -i) ----
|
||||
function buildSplitArgs(startSec, inputFile, durationSec) {
|
||||
const formatDur = (s) => {
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
|
||||
};
|
||||
return ['-ss', formatDur(startSec), '-i', inputFile, '-t', formatDur(durationSec), '-c', 'copy', '-y', 'out.mp4'];
|
||||
}
|
||||
const args = buildSplitArgs(3600, 'input.mp4', 3600);
|
||||
const ssIndex = args.indexOf('-ss');
|
||||
const iIndex = args.indexOf('-i');
|
||||
assert(ssIndex < iIndex, `FFmpeg args: -ss (${ssIndex}) must be before -i (${iIndex})`);
|
||||
|
||||
// ---- Test 9: ensureUniqueFilename pattern ----
|
||||
function ensureUnique(base, ext, existingFiles) {
|
||||
let candidate = base + ext;
|
||||
if (!existingFiles.includes(candidate)) return candidate;
|
||||
let counter = 1;
|
||||
while (existingFiles.includes(candidate)) {
|
||||
candidate = `${base}_${counter}${ext}`;
|
||||
counter++;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
assert(ensureUnique('video', '.mp4', []) === 'video.mp4', 'Unique: no conflict');
|
||||
assert(ensureUnique('video', '.mp4', ['video.mp4']) === 'video_1.mp4', 'Unique: one conflict');
|
||||
assert(ensureUnique('video', '.mp4', ['video.mp4', 'video_1.mp4']) === 'video_2.mp4', 'Unique: two conflicts');
|
||||
|
||||
// ---- Results ----
|
||||
if (failures.length > 0) {
|
||||
console.error(`FAIL: ${failures.length} test(s) failed:`);
|
||||
failures.forEach(f => console.error(` - ${f}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('All merge-split logic tests passed!');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,41 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = process.cwd();
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||
const packageLock = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf8'));
|
||||
const mainSource = fs.readFileSync(path.join(root, 'src', 'main.ts'), 'utf8');
|
||||
const indexSource = fs.readFileSync(path.join(root, 'src', 'index.html'), 'utf8');
|
||||
const manifestPath = path.join(root, 'scripts', 'public-release-files.json');
|
||||
const failures = [];
|
||||
|
||||
function check(condition, message) {
|
||||
if (!condition) failures.push(message);
|
||||
}
|
||||
|
||||
check(packageJson.version === '1.0.1', `package version is ${packageJson.version}`);
|
||||
check(packageLock.version === '1.0.1', `lockfile version is ${packageLock.version}`);
|
||||
check(packageLock.packages?.['']?.version === '1.0.1', `lockfile root package version is ${packageLock.packages?.['']?.version}`);
|
||||
check(packageJson.build?.appId === 'io.github.sucukdeluxe.twitch-vod-manager', `appId is ${packageJson.build?.appId}`);
|
||||
check(packageJson.build?.publish?.provider === 'generic', `publish provider is ${packageJson.build?.publish?.provider}`);
|
||||
check(packageJson.build?.publish?.url === 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest/download/', `publish URL is ${packageJson.build?.publish?.url}`);
|
||||
check(JSON.stringify(packageJson.build?.files) === JSON.stringify(['dist/**/*', 'src/index.html', 'src/styles.css', 'package.json']), 'packaged file list is not restricted');
|
||||
check(mainSource.includes('GITHUB_RELEASES_API_LATEST_URL'), 'GitHub releases API constant is missing');
|
||||
check(mainSource.includes('GITHUB_RELEASES_DOWNLOAD_BASE_URL'), 'GitHub releases download constant is missing');
|
||||
check(mainSource.includes('https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'), 'GitHub latest release API URL is missing');
|
||||
check(mainSource.includes('https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download'), 'GitHub release download URL is missing');
|
||||
check(indexSource.includes('Version: v1.0.1'), 'initial version label is not 1.0.1');
|
||||
check(!indexSource.includes('Version: v4.1.13'), 'legacy version label is still present');
|
||||
check(fs.existsSync(manifestPath), 'public release manifest is missing');
|
||||
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
const entries = Array.isArray(manifest.files) ? manifest.files : [];
|
||||
for (const entry of entries) {
|
||||
check(fs.existsSync(path.join(root, entry)), `public release entry does not exist: ${entry}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ failures }, null, 2));
|
||||
|
||||
if (failures.length) process.exitCode = 1;
|
||||
@@ -0,0 +1,196 @@
|
||||
const { _electron: electron } = require('playwright');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager');
|
||||
const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json');
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
download_path: path.join(process.env.USERPROFILE || 'C:\\Users\\ploet', 'Desktop', 'Twitch_VODs'),
|
||||
streamers: [],
|
||||
theme: 'twitch',
|
||||
download_mode: 'full',
|
||||
part_minutes: 120,
|
||||
language: 'en',
|
||||
filename_template_vod: '{title}.mp4',
|
||||
filename_template_parts: '{date}_Part{part_padded}.mp4',
|
||||
filename_template_clip: '{date}_{part}.mp4',
|
||||
smart_queue_scheduler: true,
|
||||
performance_mode: 'balanced',
|
||||
prevent_duplicate_downloads: true,
|
||||
metadata_cache_minutes: 10
|
||||
};
|
||||
|
||||
function backupFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
return fs.readFileSync(filePath);
|
||||
}
|
||||
|
||||
function restoreFile(filePath, backup) {
|
||||
if (backup === null) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.rmSync(filePath, { force: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, backup);
|
||||
}
|
||||
|
||||
function writeConfig(config) {
|
||||
fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true });
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
function readConfig() {
|
||||
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||||
}
|
||||
|
||||
async function launchApp() {
|
||||
const electronPath = require('electron');
|
||||
return electron.launch({
|
||||
executablePath: electronPath,
|
||||
args: ['.'],
|
||||
cwd: process.cwd()
|
||||
});
|
||||
}
|
||||
|
||||
async function setSettingsAndBlur(win, mode, partMinutes) {
|
||||
await win.evaluate(async ({ mode, partMinutes }) => {
|
||||
window.showTab('settings');
|
||||
const modeField = document.getElementById('downloadMode');
|
||||
const partField = document.getElementById('partMinutes');
|
||||
|
||||
modeField.value = mode;
|
||||
modeField.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
partField.focus();
|
||||
partField.value = String(partMinutes);
|
||||
partField.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
partField.blur();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}, { mode, partMinutes });
|
||||
}
|
||||
|
||||
async function setSettingsAndCloseImmediately(win, mode, partMinutes) {
|
||||
await win.evaluate(({ mode, partMinutes }) => {
|
||||
window.showTab('settings');
|
||||
const modeField = document.getElementById('downloadMode');
|
||||
const partField = document.getElementById('partMinutes');
|
||||
|
||||
modeField.value = mode;
|
||||
modeField.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
partField.focus();
|
||||
partField.value = String(partMinutes);
|
||||
partField.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}, { mode, partMinutes });
|
||||
}
|
||||
|
||||
async function readSettingsFromUi(win) {
|
||||
return win.evaluate(() => {
|
||||
window.showTab('settings');
|
||||
return {
|
||||
downloadMode: document.getElementById('downloadMode')?.value || '',
|
||||
partMinutes: document.getElementById('partMinutes')?.value || ''
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const configBackup = backupFile(CONFIG_FILE);
|
||||
const baseConfig = configBackup ? { ...DEFAULT_CONFIG, ...JSON.parse(String(configBackup)) } : { ...DEFAULT_CONFIG };
|
||||
|
||||
let app = null;
|
||||
try {
|
||||
writeConfig({
|
||||
...baseConfig,
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
download_mode: 'full',
|
||||
part_minutes: 120
|
||||
});
|
||||
|
||||
app = await launchApp();
|
||||
let win = await app.firstWindow();
|
||||
await win.waitForTimeout(2200);
|
||||
await setSettingsAndBlur(win, 'parts', 60);
|
||||
await app.close();
|
||||
app = null;
|
||||
|
||||
const afterBlurClose = readConfig();
|
||||
|
||||
app = await launchApp();
|
||||
win = await app.firstWindow();
|
||||
await win.waitForTimeout(2200);
|
||||
const reopenedAfterBlur = await readSettingsFromUi(win);
|
||||
await app.close();
|
||||
app = null;
|
||||
|
||||
writeConfig({
|
||||
...baseConfig,
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
download_mode: 'full',
|
||||
part_minutes: 120
|
||||
});
|
||||
|
||||
app = await launchApp();
|
||||
win = await app.firstWindow();
|
||||
await win.waitForTimeout(2200);
|
||||
await setSettingsAndCloseImmediately(win, 'parts', 75);
|
||||
await app.close();
|
||||
app = null;
|
||||
|
||||
const afterDirectClose = readConfig();
|
||||
|
||||
const result = {
|
||||
afterBlurClose: {
|
||||
config: {
|
||||
download_mode: afterBlurClose.download_mode,
|
||||
part_minutes: afterBlurClose.part_minutes
|
||||
},
|
||||
ui: reopenedAfterBlur
|
||||
},
|
||||
afterDirectClose: {
|
||||
config: {
|
||||
download_mode: afterDirectClose.download_mode,
|
||||
part_minutes: afterDirectClose.part_minutes
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
const blurCaseOk =
|
||||
afterBlurClose.download_mode === 'parts' &&
|
||||
afterBlurClose.part_minutes === 60 &&
|
||||
reopenedAfterBlur.downloadMode === 'parts' &&
|
||||
reopenedAfterBlur.partMinutes === '60';
|
||||
|
||||
const directCloseOk =
|
||||
afterDirectClose.download_mode === 'parts' &&
|
||||
afterDirectClose.part_minutes === 75;
|
||||
|
||||
process.exit(blurCaseOk && directCloseOk ? 0 : 1);
|
||||
} finally {
|
||||
if (app) {
|
||||
try {
|
||||
await app.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
restoreFile(CONFIG_FILE, configBackup);
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
const { _electron: electron } = require('playwright');
|
||||
|
||||
async function run() {
|
||||
const electronPath = require('electron');
|
||||
const app = await electron.launch({
|
||||
executablePath: electronPath,
|
||||
args: ['.'],
|
||||
cwd: process.cwd()
|
||||
});
|
||||
|
||||
const win = await app.firstWindow();
|
||||
const issues = [];
|
||||
const failures = [];
|
||||
|
||||
win.on('pageerror', (err) => {
|
||||
issues.push(`pageerror: ${String(err)}`);
|
||||
});
|
||||
|
||||
win.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
issues.push(`console.error: ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
const fail = (message) => failures.push(message);
|
||||
|
||||
let settingsPreview = '';
|
||||
let variableRows = 0;
|
||||
let clipPreviewBefore = '';
|
||||
let clipPreviewAfter = '';
|
||||
|
||||
try {
|
||||
await win.waitForTimeout(2500);
|
||||
|
||||
await win.evaluate(() => {
|
||||
window.showTab('settings');
|
||||
});
|
||||
await win.waitForTimeout(200);
|
||||
|
||||
await win.click('#settingsTemplateGuideBtn');
|
||||
await win.waitForTimeout(180);
|
||||
|
||||
const guideVisibleFromSettings = await win.evaluate(() => {
|
||||
return document.getElementById('templateGuideModal')?.classList.contains('show') || false;
|
||||
});
|
||||
|
||||
if (!guideVisibleFromSettings) {
|
||||
fail('Template guide did not open from settings');
|
||||
}
|
||||
|
||||
await win.fill('#templateGuideInput', '{title}_{part_padded}_{date_custom="yyyy-MM-dd"}.mp4');
|
||||
await win.waitForTimeout(160);
|
||||
|
||||
settingsPreview = await win.locator('#templateGuideOutput').innerText();
|
||||
if (!settingsPreview.includes('.mp4')) {
|
||||
fail('Settings template preview missing .mp4 output');
|
||||
}
|
||||
if (settingsPreview.includes('{title}') || settingsPreview.includes('{part_padded}') || settingsPreview.includes('{date_custom=')) {
|
||||
fail('Settings template preview did not replace placeholders');
|
||||
}
|
||||
|
||||
variableRows = await win.locator('#templateGuideBody tr').count();
|
||||
if (variableRows < 12) {
|
||||
fail(`Template variable table too short (${variableRows})`);
|
||||
}
|
||||
|
||||
await win.click('#templateGuideUseParts');
|
||||
await win.waitForTimeout(150);
|
||||
const partsContext = await win.locator('#templateGuideContext').innerText();
|
||||
if (!/part|teil/i.test(partsContext)) {
|
||||
fail('Template guide parts context text missing');
|
||||
}
|
||||
|
||||
await win.click('#templateGuideCloseBtn');
|
||||
await win.waitForTimeout(100);
|
||||
|
||||
await win.evaluate(async () => {
|
||||
window.showTab('vods');
|
||||
await window.selectStreamer('xrohat');
|
||||
});
|
||||
await win.waitForTimeout(3200);
|
||||
|
||||
const clipButtons = win.locator('.vod-card .vod-btn.secondary');
|
||||
const clipCount = await clipButtons.count();
|
||||
if (clipCount < 1) {
|
||||
fail('No clip buttons found in VOD list');
|
||||
} else {
|
||||
await clipButtons.first().click();
|
||||
await win.waitForTimeout(260);
|
||||
|
||||
await win.locator('input[name="filenameFormat"][value="template"]').check();
|
||||
await win.waitForTimeout(140);
|
||||
|
||||
await win.click('#clipTemplateGuideBtn');
|
||||
await win.waitForTimeout(140);
|
||||
|
||||
const clipContext = await win.locator('#templateGuideContext').innerText();
|
||||
if (!/clip/i.test(clipContext)) {
|
||||
fail('Template guide clip context text missing');
|
||||
}
|
||||
|
||||
await win.fill('#templateGuideInput', '{trim_start}_{part}.mp4');
|
||||
await win.waitForTimeout(120);
|
||||
clipPreviewBefore = await win.locator('#templateGuideOutput').innerText();
|
||||
|
||||
await win.fill('#clipStartTime', '00:00:10');
|
||||
await win.evaluate(() => {
|
||||
window.updateFromInput('start');
|
||||
});
|
||||
await win.waitForTimeout(240);
|
||||
|
||||
clipPreviewAfter = await win.locator('#templateGuideOutput').innerText();
|
||||
if (clipPreviewAfter === clipPreviewBefore) {
|
||||
fail('Clip template guide preview did not react to clip start time changes');
|
||||
}
|
||||
|
||||
await win.click('#templateGuideCloseBtn');
|
||||
await win.evaluate(() => {
|
||||
window.closeClipDialog();
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
const summary = {
|
||||
failures,
|
||||
issues,
|
||||
checks: {
|
||||
settingsPreview,
|
||||
variableRows,
|
||||
clipPreviewBefore,
|
||||
clipPreviewAfter
|
||||
}
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
|
||||
const hasFailure = failures.length > 0 || issues.length > 0;
|
||||
process.exit(hasFailure ? 1 : 0);
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
normalizeUpdateVersion,
|
||||
compareUpdateVersions,
|
||||
isNewerUpdateVersion
|
||||
} = require(path.join(process.cwd(), 'dist', 'main', 'domain', 'update-version-utils.js'));
|
||||
|
||||
function run() {
|
||||
const failures = [];
|
||||
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) failures.push(message);
|
||||
};
|
||||
|
||||
const comparisons = [
|
||||
{ left: '1.0.2', right: '1.0.1', expected: 1 },
|
||||
{ left: '1.0.1', right: '1.0.2', expected: -1 },
|
||||
{ left: 'v1.0.1', right: '1.0.1', expected: 0 },
|
||||
{ left: '1.0.1', right: '1.0.1.1', expected: -1 },
|
||||
{ left: '2.0.0', right: '1.99.999', expected: 1 },
|
||||
{ left: '1.0.1-beta', right: '1.0.1', expected: 0 }
|
||||
];
|
||||
|
||||
const compareResults = comparisons.map((testCase) => {
|
||||
const actual = compareUpdateVersions(testCase.left, testCase.right);
|
||||
const pass = actual === testCase.expected;
|
||||
assert(pass, `compare failed: ${testCase.left} vs ${testCase.right} expected ${testCase.expected}, got ${actual}`);
|
||||
return { ...testCase, actual, pass };
|
||||
});
|
||||
|
||||
const skipVersionScenarios = [
|
||||
{
|
||||
name: 'old downloaded, newer available',
|
||||
downloaded: '1.0.1',
|
||||
latestKnown: '1.0.2',
|
||||
expectedNeedsNewer: true
|
||||
},
|
||||
{
|
||||
name: 'already latest downloaded',
|
||||
downloaded: '1.0.2',
|
||||
latestKnown: '1.0.2',
|
||||
expectedNeedsNewer: false
|
||||
},
|
||||
{
|
||||
name: 'downgrade should not trigger',
|
||||
downloaded: '1.0.2',
|
||||
latestKnown: '1.0.1',
|
||||
expectedNeedsNewer: false
|
||||
}
|
||||
];
|
||||
|
||||
const scenarioResults = skipVersionScenarios.map((scenario) => {
|
||||
const needsNewer = isNewerUpdateVersion(scenario.latestKnown, scenario.downloaded);
|
||||
const pass = needsNewer === scenario.expectedNeedsNewer;
|
||||
assert(pass, `${scenario.name} expected ${scenario.expectedNeedsNewer}, got ${needsNewer}`);
|
||||
return { ...scenario, needsNewer, pass };
|
||||
});
|
||||
|
||||
const normalizationChecks = {
|
||||
fromVPrefix: normalizeUpdateVersion('v1.0.1') === '1.0.1',
|
||||
trimmed: normalizeUpdateVersion(' 1.0.1 ') === '1.0.1'
|
||||
};
|
||||
|
||||
assert(normalizationChecks.fromVPrefix, 'normalize did not remove v prefix');
|
||||
assert(normalizationChecks.trimmed, 'normalize did not trim whitespace');
|
||||
|
||||
const summary = {
|
||||
checks: {
|
||||
compareResults,
|
||||
scenarioResults,
|
||||
normalizationChecks
|
||||
},
|
||||
failures
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
|
||||
if (failures.length) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,149 @@
|
||||
const { _electron: electron } = require('playwright');
|
||||
|
||||
async function run() {
|
||||
const electronPath = require('electron');
|
||||
const app = await electron.launch({
|
||||
executablePath: electronPath,
|
||||
args: ['.'],
|
||||
cwd: process.cwd()
|
||||
});
|
||||
|
||||
const win = await app.firstWindow();
|
||||
const issues = [];
|
||||
|
||||
win.on('pageerror', (err) => {
|
||||
issues.push(`pageerror: ${String(err)}`);
|
||||
});
|
||||
|
||||
win.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
issues.push(`console.error: ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
await win.waitForTimeout(2500);
|
||||
|
||||
const globals = await win.evaluate(async () => {
|
||||
const names = [
|
||||
'showTab',
|
||||
'addStreamer',
|
||||
'refreshVODs',
|
||||
'downloadClip',
|
||||
'selectCutterVideo',
|
||||
'startCutting',
|
||||
'addMergeFiles',
|
||||
'startMerging',
|
||||
'saveSettings',
|
||||
'checkUpdate',
|
||||
'downloadUpdate',
|
||||
'updateFromInput',
|
||||
'updateFromSlider',
|
||||
'runPreflight',
|
||||
'retryFailedDownloads',
|
||||
'toggleDebugAutoRefresh'
|
||||
];
|
||||
const map = {};
|
||||
for (const n of names) map[n] = typeof window[n];
|
||||
return map;
|
||||
});
|
||||
|
||||
await win.evaluate(() => {
|
||||
window.showTab('clips');
|
||||
window.showTab('cutter');
|
||||
window.showTab('merge');
|
||||
window.showTab('settings');
|
||||
window.showTab('vods');
|
||||
});
|
||||
|
||||
const input = win.locator('#newStreamer');
|
||||
const randomName = `smoketest_${Date.now()}`;
|
||||
await input.fill(randomName);
|
||||
await win.evaluate(async () => {
|
||||
await window.addStreamer();
|
||||
});
|
||||
|
||||
const hasTempStreamer = await win.locator('#streamerList').innerText();
|
||||
|
||||
await win.evaluate(async (name) => {
|
||||
await window.removeStreamer(name);
|
||||
}, randomName);
|
||||
|
||||
await win.evaluate(async () => {
|
||||
await window.selectStreamer('xrohat');
|
||||
});
|
||||
|
||||
await win.waitForTimeout(3500);
|
||||
|
||||
const vodCount = await win.locator('.vod-card').count();
|
||||
|
||||
if (vodCount > 0) {
|
||||
await win.locator('.vod-card .vod-btn.primary').first().click();
|
||||
await win.waitForTimeout(500);
|
||||
}
|
||||
|
||||
const queueCountAfterAdd = await win.locator('#queueCount').innerText();
|
||||
|
||||
const queueRemove = win.locator('#queueList .remove').first();
|
||||
if (await queueRemove.count()) {
|
||||
await queueRemove.click();
|
||||
await win.waitForTimeout(300);
|
||||
}
|
||||
|
||||
await win.evaluate(() => {
|
||||
window.showTab('clips');
|
||||
});
|
||||
|
||||
await win.fill('#clipUrl', '');
|
||||
await win.evaluate(async () => {
|
||||
await window.downloadClip();
|
||||
});
|
||||
|
||||
const clipStatus = await win.locator('#clipStatus').innerText();
|
||||
|
||||
await win.evaluate(async () => {
|
||||
await window.runPreflight(false);
|
||||
await window.startCutting();
|
||||
await window.startMerging();
|
||||
});
|
||||
|
||||
const mergeButtonDisabled = await win.locator('#btnMerge').isDisabled();
|
||||
const preflightText = await win.locator('#preflightResult').innerText();
|
||||
const healthBadge = await win.locator('#healthBadge').innerText();
|
||||
|
||||
await app.close();
|
||||
|
||||
const failedGlobals = Object.entries(globals)
|
||||
.filter(([, type]) => type !== 'function')
|
||||
.map(([name, type]) => `${name}=${type}`);
|
||||
|
||||
const summary = {
|
||||
failedGlobals,
|
||||
hasTempStreamer: hasTempStreamer.includes(randomName),
|
||||
vodCount,
|
||||
queueCountAfterAdd,
|
||||
clipStatus,
|
||||
mergeButtonDisabled,
|
||||
preflightText,
|
||||
healthBadge,
|
||||
issues
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
|
||||
const hasFailure =
|
||||
failedGlobals.length > 0 ||
|
||||
!summary.hasTempStreamer ||
|
||||
summary.vodCount < 1 ||
|
||||
!(summary.clipStatus.includes('Bitte URL eingeben') || summary.clipStatus.includes('Please enter a URL')) ||
|
||||
!summary.mergeButtonDisabled ||
|
||||
!summary.preflightText ||
|
||||
!summary.healthBadge ||
|
||||
summary.issues.length > 0;
|
||||
|
||||
process.exit(hasFailure ? 1 : 0);
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
+846
@@ -0,0 +1,846 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:;">
|
||||
<title>Twitch VOD Manager</title>
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
</head>
|
||||
<body class="theme-twitch">
|
||||
<div class="update-banner" id="updateBanner">
|
||||
<span id="updateText">Neue Version verfügbar!</span>
|
||||
<div id="updateProgress" class="update-banner-progress-wrap is-hidden">
|
||||
<div class="update-banner-progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Update download" id="updateProgressGauge">
|
||||
<div id="updateProgressBar" class="update-banner-progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="updateModal" role="dialog" aria-modal="true" aria-labelledby="updateModalTitle" onclick="handleUpdateModalOverlayClick(event)">
|
||||
<div class="modal update-modal">
|
||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="dismissUpdateModal()">x</button>
|
||||
<div class="update-modal-eyebrow" id="updateModalEyebrow">Updates</div>
|
||||
<h2 id="updateModalTitle">Update verfugbar</h2>
|
||||
<p class="update-modal-message" id="updateModalMessage">Version 0.0.0 ist verfugbar. Jetzt herunterladen?</p>
|
||||
<div class="update-modal-meta is-hidden" id="updateModalMeta"></div>
|
||||
|
||||
<div class="update-changelog-card is-hidden" id="updateChangelogCard">
|
||||
<div class="update-changelog-header">
|
||||
<span class="update-changelog-label" id="updateChangelogLabel">Changelog</span>
|
||||
<button type="button" class="update-changelog-toggle" id="updateChangelogToggle" onclick="toggleUpdateChangelog()">Changelog anzeigen</button>
|
||||
</div>
|
||||
<div class="update-changelog-panel" id="updateChangelogPanel" hidden>
|
||||
<div class="update-changelog-content" id="updateChangelogContent"></div>
|
||||
<p class="update-changelog-empty" id="updateChangelogEmpty" hidden>Kein Changelog verfugbar.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions update-modal-actions">
|
||||
<button class="btn-secondary" id="updateModalDismissBtn" type="button" onclick="dismissUpdateModal()">Nein</button>
|
||||
<button class="btn-secondary" id="updateModalSkipBtn" type="button" onclick="skipUpdateVersion()">Diese Version ueberspringen</button>
|
||||
<button class="btn-primary" id="updateModalConfirmBtn" type="button" onclick="confirmUpdateModal()">Ja, herunterladen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clip Dialog Modal -->
|
||||
<div class="modal-overlay" id="clipModal" role="dialog" aria-modal="true" aria-labelledby="clipDialogTitle">
|
||||
<div class="modal clip-modal">
|
||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeClipDialog()">x</button>
|
||||
<h2 class="clip-modal-title" id="clipDialogTitle">VOD zuschneiden</h2>
|
||||
|
||||
<div class="clip-modal-field">
|
||||
<label class="clip-modal-label" id="clipDialogStartLabel" for="clipStartSlider">Start:</label>
|
||||
<input type="range" id="clipStartSlider" min="0" max="100" value="0" oninput="updateFromSlider('start')">
|
||||
<div class="clip-modal-time-row">
|
||||
<label class="clip-modal-meta" id="clipDialogStartTimeLabel" for="clipStartTime">Startzeit (HH:MM:SS):</label>
|
||||
<input type="text" id="clipStartTime" value="00:00:00" class="clip-modal-time-input" onchange="updateFromInput('start')">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clip-modal-field">
|
||||
<label class="clip-modal-label" id="clipDialogEndLabel" for="clipEndSlider">Ende:</label>
|
||||
<input type="range" id="clipEndSlider" min="0" max="100" value="60" oninput="updateFromSlider('end')">
|
||||
<div class="clip-modal-time-row">
|
||||
<label class="clip-modal-meta" id="clipDialogEndTimeLabel" for="clipEndTime">Endzeit (HH:MM:SS):</label>
|
||||
<input type="text" id="clipEndTime" value="00:01:00" class="clip-modal-time-input" onchange="updateFromInput('end')">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clip-modal-duration">
|
||||
<span id="clipDialogDurationLabel" class="clip-modal-meta">Dauer: </span>
|
||||
<span id="clipDurationDisplay" class="clip-modal-duration-value">00:01:00</span>
|
||||
</div>
|
||||
|
||||
<div class="clip-modal-field">
|
||||
<label class="clip-modal-label" id="clipDialogPartLabel" for="clipStartPart">Start Part-Nummer (optional, fur Fortsetzung):</label>
|
||||
<input type="text" id="clipStartPart" placeholder="z.B. 42" class="clip-modal-part-input" oninput="updateFilenameExamples()">
|
||||
<div id="clipDialogPartHint" class="clip-modal-hint">Leer lassen = Teil 1</div>
|
||||
</div>
|
||||
|
||||
<div class="clip-modal-field">
|
||||
<label class="clip-modal-label" id="clipDialogFormatLabel">Dateinamen-Format:</label>
|
||||
<label class="clip-radio-row">
|
||||
<input type="radio" name="filenameFormat" value="simple" checked onchange="updateFilenameExamples()">
|
||||
<span id="formatSimple" class="clip-radio-label">01.02.2026_1.mp4 (Standard)</span>
|
||||
</label>
|
||||
<label class="clip-radio-row">
|
||||
<input type="radio" name="filenameFormat" value="timestamp" onchange="updateFilenameExamples()">
|
||||
<span id="formatTimestamp" class="clip-radio-label">01.02.2026_CLIP_00-00-00_1.mp4 (mit Zeitstempel)</span>
|
||||
</label>
|
||||
<label class="clip-radio-row">
|
||||
<input type="radio" name="filenameFormat" value="parts" onchange="updateFilenameExamples()">
|
||||
<span id="formatParts" class="clip-radio-label">01.02.2026_Part01.mp4 (Parts-Format)</span>
|
||||
</label>
|
||||
<label class="clip-radio-row">
|
||||
<input type="radio" name="filenameFormat" value="template" onchange="updateFilenameExamples()">
|
||||
<span id="formatTemplate" class="clip-radio-label">{date}_{part}.mp4 (benutzerdefiniert)</span>
|
||||
</label>
|
||||
|
||||
<div id="clipFilenameTemplateWrap" class="clip-template-wrap">
|
||||
<input type="text" id="clipFilenameTemplate" value="{date}_{part}.mp4" placeholder="{date}_{part}.mp4" class="clip-modal-template-input" oninput="updateFilenameExamples()">
|
||||
<div id="clipTemplateHelp" class="clip-modal-hint">Platzhalter: {title} {id} {channel} {date} {part} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
|
||||
<div id="clipTemplateLint" class="template-lint ok">Template-Check: OK</div>
|
||||
<button type="button" class="btn-secondary" id="clipTemplateGuideBtn" onclick="openTemplateGuide('clip')">Template Guide</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clip-modal-actions">
|
||||
<button type="button" class="btn-pill success" id="clipDialogConfirmBtn" style="padding: 12px 30px;" onclick="confirmClipDialog()">Zur Queue hinzufugen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Events Viewer Modal -->
|
||||
<div class="modal-overlay" id="eventsViewerModal" role="dialog" aria-modal="true" aria-labelledby="eventsViewerTitle">
|
||||
<div class="modal viewer-modal viewer-modal-events">
|
||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeEventsViewer()">x</button>
|
||||
<h2 id="eventsViewerTitle" class="viewer-modal-title"></h2>
|
||||
<div id="eventsViewerStatus" class="viewer-modal-status" role="status" aria-live="polite"></div>
|
||||
<div id="eventsViewerList" class="viewer-modal-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Replay Viewer Modal -->
|
||||
<div class="modal-overlay" id="chatViewerModal" role="dialog" aria-modal="true" aria-labelledby="chatViewerTitle">
|
||||
<div class="modal viewer-modal viewer-modal-chat">
|
||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeChatViewer()">x</button>
|
||||
<h2 id="chatViewerTitle" class="viewer-modal-title"></h2>
|
||||
<div class="viewer-modal-filter-row">
|
||||
<input type="text" id="chatViewerFilter" class="viewer-modal-filter-input" placeholder="Filter..." oninput="onChatViewerFilterChange()">
|
||||
<span id="chatViewerStatus" class="viewer-modal-status viewer-modal-status-inline" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<div id="chatViewerList" class="viewer-modal-list viewer-modal-list-chat"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template Guide Modal -->
|
||||
<div class="modal-overlay" id="templateGuideModal" role="dialog" aria-modal="true" aria-labelledby="templateGuideTitle">
|
||||
<div class="modal template-guide-modal">
|
||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeTemplateGuide()">x</button>
|
||||
<h2 id="templateGuideTitle">Template Guide</h2>
|
||||
<p id="templateGuideIntro" class="template-guide-intro">Nutze Variablen fur Dateinamen und prufe das Ergebnis als Live-Vorschau.</p>
|
||||
|
||||
<div class="template-guide-actions">
|
||||
<button type="button" class="btn-secondary" id="templateGuideUseVod" onclick="setTemplateGuidePreset('vod')">VOD Template</button>
|
||||
<button type="button" class="btn-secondary" id="templateGuideUseParts" onclick="setTemplateGuidePreset('parts')">VOD Part Template</button>
|
||||
<button type="button" class="btn-secondary" id="templateGuideUseClip" onclick="setTemplateGuidePreset('clip')">Clip Template</button>
|
||||
</div>
|
||||
|
||||
<label id="templateGuideTemplateLabel" for="templateGuideInput" class="template-guide-label">Template</label>
|
||||
<input type="text" id="templateGuideInput" class="template-guide-input" oninput="updateTemplateGuidePreview()" placeholder="{title}.mp4">
|
||||
|
||||
<div class="template-guide-preview-box">
|
||||
<div class="template-guide-preview-label" id="templateGuideOutputLabel">Live Vorschau</div>
|
||||
<div id="templateGuideOutput" class="template-guide-output">-</div>
|
||||
<div id="templateGuideContext" class="template-guide-context"></div>
|
||||
</div>
|
||||
|
||||
<h3 id="templateGuideVarsTitle" class="template-guide-vars-title">Verfugbare Variablen</h3>
|
||||
<div class="template-guide-table-wrap">
|
||||
<table class="template-guide-table" aria-labelledby="templateGuideVarsTitle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th id="templateGuideVarCol" scope="col">Variable</th>
|
||||
<th id="templateGuideDescCol" scope="col">Beschreibung</th>
|
||||
<th id="templateGuideExampleCol" scope="col">Beispiel</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="templateGuideBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="template-guide-footer">
|
||||
<button type="button" class="btn-secondary" id="templateGuideCloseBtn" onclick="closeTemplateGuide()">Schliessen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app">
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z"/></svg>
|
||||
<span id="logoText">Twitch VOD Manager</span>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<div class="nav-item active" role="button" tabindex="0" aria-current="page" data-tab="vods" onclick="showTab('vods')">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
|
||||
<span id="navVodsText">Twitch VODs</span>
|
||||
</div>
|
||||
<div class="nav-item" role="button" tabindex="0" data-tab="clips" onclick="showTab('clips')">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
|
||||
<span id="navClipsText">Twitch Clips</span>
|
||||
</div>
|
||||
<div class="nav-item" role="button" tabindex="0" data-tab="cutter" onclick="showTab('cutter')">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M9.64 7.64c.23-.5.36-1.05.36-1.64 0-2.21-1.79-4-4-4S2 3.79 2 6s1.79 4 4 4c.59 0 1.14-.13 1.64-.36L10 12l-2.36 2.36C7.14 14.13 6.59 14 6 14c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4c0-.59-.13-1.14-.36-1.64L12 14l7 7h3v-1L9.64 7.64zM6 8c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm0 12c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm6-7.5c-.28 0-.5-.22-.5-.5s.22-.5.5-.5.5.22.5.5-.22.5-.5.5zM19 3l-6 6 2 2 7-7V3h-3z"/></svg>
|
||||
<span id="navCutterText">Video schneiden</span>
|
||||
</div>
|
||||
<div class="nav-item" role="button" tabindex="0" data-tab="merge" onclick="showTab('merge')">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg>
|
||||
<span id="navMergeText">Videos zusammenfugen</span>
|
||||
</div>
|
||||
<div class="nav-item" role="button" tabindex="0" data-tab="stats" onclick="showTab('stats')">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M3 13h2v8H3zm4-7h2v15H7zm4 4h2v11h-2zm4 4h2v7h-2zm4-8h2v15h-2z"/></svg>
|
||||
<span id="navStatsText">Statistik</span>
|
||||
</div>
|
||||
<div class="nav-item" role="button" tabindex="0" data-tab="archive" onclick="showTab('archive')">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||
<span id="navArchiveText">Archiv</span>
|
||||
</div>
|
||||
<div class="nav-item" role="button" tabindex="0" data-tab="settings" onclick="showTab('settings')">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||
<span id="navSettingsText">Einstellungen</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="section-title" id="streamerSectionTitle">
|
||||
<span class="section-title-label">
|
||||
<span id="streamerSectionTitleText">Streamer</span>
|
||||
<span id="streamerSectionCounter" class="streamer-section-counter"></span>
|
||||
</span>
|
||||
<button id="btnStreamerBulkRemove" class="btn-close is-hidden" type="button" onclick="bulkRemoveStreamers()" title="Bulk remove">x</button>
|
||||
</div>
|
||||
<input type="text" id="streamerListFilter" class="filter-input compact is-hidden" placeholder="Filter..." oninput="onStreamerListFilterChange()">
|
||||
<div class="streamers" id="streamerList"></div>
|
||||
|
||||
<div class="queue-section">
|
||||
<div class="queue-header">
|
||||
<span class="queue-title" id="queueTitleText">Warteschlange</span>
|
||||
<span class="queue-count" id="queueCount">0</span>
|
||||
</div>
|
||||
<div class="queue-list" id="queueList"></div>
|
||||
<div class="queue-actions">
|
||||
<button type="button" class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button>
|
||||
<button type="button" class="btn btn-merge-group is-hidden" id="btnMergeGroup" onclick="createMergeGroupFromSelection()">Merge & Split</button>
|
||||
<button type="button" class="btn btn-retry" id="btnRetryFailed" onclick="retryFailedDownloads()" title="Nur fehlgeschlagene Downloads erneut starten">Wiederholen</button>
|
||||
<button type="button" class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-bar" id="statsBar"></div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<header class="header">
|
||||
<h1 id="pageTitle">VODs</h1>
|
||||
<div class="header-actions">
|
||||
<div class="header-search">
|
||||
<input type="text" id="newStreamer" placeholder="Streamer hinzufugen..." onkeypress="if(event.key==='Enter')addStreamer()">
|
||||
<button id="btnAddStreamer" type="button" onclick="addStreamer()" aria-label="Add streamer" title="Add streamer">+</button>
|
||||
</div>
|
||||
<button type="button" class="btn-icon" onclick="refreshVODs()">
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
<span id="refreshText">Aktualisieren</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<!-- VODs Tab -->
|
||||
<div class="tab-content active" id="vodsTab">
|
||||
<div id="streamerProfileHeader" class="streamer-profile-header is-hidden"></div>
|
||||
<div class="vod-filter-row">
|
||||
<input type="text" id="vodFilterInput" class="filter-input" placeholder="Filter VODs..." oninput="onVodFilterInput()">
|
||||
<button type="button" id="vodFilterClearBtn" class="btn-close is-hidden" onclick="clearVodFilter()" title="Clear filter">x</button>
|
||||
<label id="vodSortLabel" for="vodSortSelect" class="form-sublabel vod-sort-label">Sort:</label>
|
||||
<select id="vodSortSelect" class="select-compact" onchange="onVodSortChange()">
|
||||
<option value="date_desc">Newest first</option>
|
||||
<option value="date_asc">Oldest first</option>
|
||||
<option value="views_desc">Most viewed</option>
|
||||
<option value="duration_desc">Longest first</option>
|
||||
<option value="duration_asc">Shortest first</option>
|
||||
</select>
|
||||
<span id="vodFilterCount" class="form-sublabel vod-filter-count"></span>
|
||||
<label id="vodHideDownloadedLabel" class="inline-toggle" title="">
|
||||
<input type="checkbox" id="vodHideDownloadedToggle" onchange="onVodHideDownloadedChange()">
|
||||
<span id="vodHideDownloadedText">Hide downloaded</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="vodBulkBar" class="vod-bulk-bar is-hidden">
|
||||
<span id="vodBulkCount" class="vod-bulk-count">0 selected</span>
|
||||
<span class="vod-bulk-spacer"></span>
|
||||
<button id="vodBulkAddBtn" class="btn-pill primary" type="button" onclick="bulkAddSelectedVodsToQueue()">+ Queue</button>
|
||||
<button id="vodBulkMarkBtn" class="btn-pill" type="button" onclick="bulkMarkSelectedDownloaded(true)">Mark as downloaded</button>
|
||||
<button id="vodBulkUnmarkBtn" class="btn-pill" type="button" onclick="bulkMarkSelectedDownloaded(false)">Unmark</button>
|
||||
<button id="vodBulkClearBtn" class="btn-pill" type="button" onclick="clearVodSelection()">Clear</button>
|
||||
</div>
|
||||
<div class="vod-grid" id="vodGrid">
|
||||
<div class="empty-state">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
|
||||
<h3 id="vodGridEmptyTitle">Keine VODs</h3>
|
||||
<p id="vodGridEmptyText">Wahle einen Streamer aus der Liste oder fuge einen neuen hinzu.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clips Tab -->
|
||||
<div class="tab-content" id="clipsTab">
|
||||
<div class="clip-input">
|
||||
<h2 id="clipsHeading">Twitch Clip-Download</h2>
|
||||
<input type="text" id="clipUrl" placeholder="https://clips.twitch.tv/... oder https://www.twitch.tv/.../clip/...">
|
||||
<button type="button" class="btn-primary" onclick="downloadClip()" id="btnClip">Clip herunterladen</button>
|
||||
<div class="clip-status" id="clipStatus" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card centered">
|
||||
<h3 id="clipsInfoTitle">Info</h3>
|
||||
<p id="clipsInfoText" class="info-text">
|
||||
Unterstutzte Formate:
|
||||
- https://clips.twitch.tv/ClipName
|
||||
- https://www.twitch.tv/streamer/clip/ClipName
|
||||
|
||||
Clips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Cutter Tab -->
|
||||
<div class="tab-content" id="cutterTab">
|
||||
<div class="cutter-container">
|
||||
<div class="settings-card">
|
||||
<h3 id="cutterSelectTitle">Video auswahlen</h3>
|
||||
<div class="form-row">
|
||||
<input type="text" id="cutterFilePath" readonly placeholder="Keine Datei ausgewahlt...">
|
||||
<button type="button" class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="video-preview" id="cutterPreview">
|
||||
<div class="placeholder">
|
||||
<svg aria-hidden="true" width="64" height="64" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
|
||||
<p id="cutterPreviewPlaceholder">Video auswaehlen um Vorschau zu sehen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cutter-info" id="cutterInfo">
|
||||
<div class="cutter-info-item">
|
||||
<span class="cutter-info-label" id="cutterInfoDurationLabel">Dauer</span>
|
||||
<span class="cutter-info-value" id="infoDuration">--:--:--</span>
|
||||
</div>
|
||||
<div class="cutter-info-item">
|
||||
<span class="cutter-info-label" id="cutterInfoResolutionLabel">Aufloesung</span>
|
||||
<span class="cutter-info-value" id="infoResolution">----x----</span>
|
||||
</div>
|
||||
<div class="cutter-info-item">
|
||||
<span class="cutter-info-label" id="cutterInfoFpsLabel">FPS</span>
|
||||
<span class="cutter-info-value" id="infoFps">--</span>
|
||||
</div>
|
||||
<div class="cutter-info-item">
|
||||
<span class="cutter-info-label" id="cutterInfoSelectionLabel">Auswahl</span>
|
||||
<span class="cutter-info-value" id="infoSelection">--:--:--</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline-container" id="timelineContainer">
|
||||
<div class="timeline" id="timeline" onclick="seekTimeline(event)">
|
||||
<div class="timeline-selection" id="timelineSelection"></div>
|
||||
<div class="timeline-current" id="timelineCurrent"></div>
|
||||
</div>
|
||||
|
||||
<div class="time-inputs">
|
||||
<div class="time-input-group">
|
||||
<label id="cutterStartLabel" for="startTime">Start:</label>
|
||||
<input type="text" id="startTime" value="00:00:00" onchange="updateTimeFromInput()">
|
||||
</div>
|
||||
<div class="time-input-group">
|
||||
<label id="cutterEndLabel" for="endTime">Ende:</label>
|
||||
<input type="text" id="endTime" value="00:00:00" onchange="updateTimeFromInput()">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-container" id="cutProgress">
|
||||
<div class="progress-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Cut progress" id="cutProgressGauge">
|
||||
<div class="progress-bar-fill" id="cutProgressBar"></div>
|
||||
</div>
|
||||
<div class="progress-text" id="cutProgressText">0%</div>
|
||||
</div>
|
||||
|
||||
<div class="cutter-actions">
|
||||
<button type="button" class="btn-primary" id="btnCut" onclick="startCutting()" disabled>Schneiden</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Merge Tab -->
|
||||
<div class="tab-content" id="mergeTab">
|
||||
<div class="merge-container">
|
||||
<div class="settings-card">
|
||||
<h3 id="mergeTitle">Videos zusammenfugen</h3>
|
||||
<p id="mergeDesc" class="card-intro">
|
||||
Wahle mehrere Videos aus um sie zu einem Video zusammenzufugen.
|
||||
Die Reihenfolge kann per Drag & Drop geandert werden.
|
||||
</p>
|
||||
<button type="button" class="btn-secondary" id="mergeAddBtn" onclick="addMergeFiles()">+ Videos hinzufugen</button>
|
||||
</div>
|
||||
|
||||
<div class="file-list" id="mergeFileList">
|
||||
<div class="empty-state merge-empty-state">
|
||||
<svg aria-hidden="true" width="48" height="48" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
<p id="mergeEmptyText">Keine Videos ausgewahlt</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-container" id="mergeProgress">
|
||||
<div class="progress-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Merge progress" id="mergeProgressGauge">
|
||||
<div class="progress-bar-fill" id="mergeProgressBar"></div>
|
||||
</div>
|
||||
<div class="progress-text" id="mergeProgressText">0%</div>
|
||||
</div>
|
||||
|
||||
<div class="merge-actions">
|
||||
<button type="button" class="btn-primary" id="btnMerge" onclick="startMerging()" disabled>Zusammenfugen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Tab -->
|
||||
<div class="tab-content" id="statsTab">
|
||||
<div class="settings-card">
|
||||
<div class="form-row section-header">
|
||||
<h3 id="statsTitle">Archiv-Statistik</h3>
|
||||
<div class="section-header-actions">
|
||||
<span id="statsLastScannedLabel" class="form-sublabel" role="status" aria-live="polite"></span>
|
||||
<button type="button" class="btn-secondary" id="btnStatsRefresh" onclick="refreshArchiveStats()">Aktualisieren</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="statsIntro" class="card-intro flush">Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter <code>{streamer}/live/</code>, VOD-Downloads direkt unter <code>{streamer}/</code>. Lade-Zeit skaliert mit der Anzahl Dateien.</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="statsSummaryTitle">Uebersicht</h3>
|
||||
<div id="statsSummaryGrid" class="stats-summary-grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="statsTopStreamersTitle">Top Streamer (nach Groesse)</h3>
|
||||
<div id="statsTopStreamers"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="statsActivityTitle">Aktivitaet (letzte 30 Tage)</h3>
|
||||
<div id="statsActivity"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="statsSizeBucketsTitle">Aufnahme-Groessen-Verteilung</h3>
|
||||
<div id="statsSizeBuckets"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Archive Search Tab -->
|
||||
<div class="tab-content" id="archiveTab">
|
||||
<div class="settings-card">
|
||||
<h3 id="archiveTitle">Archiv durchsuchen</h3>
|
||||
<p id="archiveIntro" class="card-intro">Suche nach Dateinamen, Streamern oder Datum-Strings. Treffer zeigen Recordings (Live + VOD); zugehoerige Chat- und Events-Dateien werden als Companion-Buttons angeboten.</p>
|
||||
<div class="form-row search-bar">
|
||||
<input type="text" id="archiveSearchQuery" class="filter-input flex-1-1-240" placeholder="Suche...">
|
||||
<select id="archiveSearchType" class="select-compact">
|
||||
<option value="all">Alle Typen</option>
|
||||
<option value="live">Live-Aufnahmen</option>
|
||||
<option value="vod">VOD-Downloads</option>
|
||||
</select>
|
||||
<select id="archiveSearchStreamer" class="select-compact size-md">
|
||||
<option value="">Alle Streamer</option>
|
||||
</select>
|
||||
<select id="archiveSearchSort" class="select-compact">
|
||||
<option value="date_desc">Neueste zuerst</option>
|
||||
<option value="date_asc">Aelteste zuerst</option>
|
||||
<option value="size_desc">Groesste zuerst</option>
|
||||
<option value="size_asc">Kleinste zuerst</option>
|
||||
<option value="name_asc">Name (A-Z)</option>
|
||||
</select>
|
||||
<button type="button" class="btn-secondary" id="btnArchiveSearch" onclick="performArchiveSearch()">Suchen</button>
|
||||
</div>
|
||||
<div id="archiveSearchSummary" class="form-sublabel" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div id="archiveSearchResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div class="tab-content" id="settingsTab">
|
||||
<div class="settings-card">
|
||||
<h3 id="designTitle">Design</h3>
|
||||
<div class="form-group">
|
||||
<label id="themeLabel" for="themeSelect">Theme</label>
|
||||
<select id="themeSelect" onchange="changeTheme(this.value)">
|
||||
<option value="twitch">Twitch</option>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="youtube">YouTube</option>
|
||||
<option value="apple">Apple</option>
|
||||
<option value="light" id="themeLightOption">Light</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="languageLabel">Sprache</label>
|
||||
<div class="language-picker" id="languagePicker" role="group" aria-labelledby="languageLabel">
|
||||
<button type="button" class="lang-option" id="langOptionDe" onclick="selectLanguageOption('de')" aria-pressed="false">
|
||||
<span class="flag-icon flag-de" aria-hidden="true"></span>
|
||||
<span id="languageDeText">Deutsch</span>
|
||||
</button>
|
||||
<button type="button" class="lang-option" id="langOptionEn" onclick="selectLanguageOption('en')" aria-pressed="false">
|
||||
<span class="flag-icon flag-en" aria-hidden="true"></span>
|
||||
<span id="languageEnText">English</span>
|
||||
</button>
|
||||
</div>
|
||||
<select id="languageSelect" onchange="changeLanguage(this.value)" style="display:none">
|
||||
<option value="de">de</option>
|
||||
<option value="en">en</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="apiTitle">Twitch API</h3>
|
||||
<p id="apiHelpText" class="card-intro">
|
||||
<span id="apiHelpIntro">Du brauchst eine Client-ID und ein Client-Secret von Twitch.</span>
|
||||
<a href="#" id="apiHelpLink" onclick="event.preventDefault(); openTwitchDevConsole()">dev.twitch.tv/console/apps</a>
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label id="clientIdLabel" for="clientId">Client ID</label>
|
||||
<input type="text" id="clientId" placeholder="Twitch Client ID">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="clientSecretLabel" for="clientSecret">Client Secret</label>
|
||||
<input type="password" id="clientSecret" placeholder="Twitch Client Secret">
|
||||
</div>
|
||||
<button type="button" class="btn-primary" id="saveSettingsBtn" onclick="saveSettings()">Speichern & Verbinden</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="downloadSettingsTitle">Download-Einstellungen</h3>
|
||||
<div class="form-group">
|
||||
<label id="storageLabel" for="downloadPath">Speicherort</label>
|
||||
<div class="form-row">
|
||||
<input type="text" id="downloadPath" readonly>
|
||||
<button type="button" class="btn-secondary" onclick="selectFolder()">Ordner</button>
|
||||
<button type="button" class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Offnen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="modeLabel" for="downloadMode">Download-Modus</label>
|
||||
<select id="downloadMode">
|
||||
<option value="full" id="modeFullText">Ganzes VOD</option>
|
||||
<option value="parts" id="modePartsText">In Teile splitten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="partMinutesLabel" for="partMinutes">Teil-Lange (Minuten)</label>
|
||||
<input type="number" id="partMinutes" value="120" min="10" max="480">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="parallelDownloadsLabel" for="parallelDownloads">Parallele Downloads</label>
|
||||
<select id="parallelDownloads">
|
||||
<option value="1" id="parallelDownloads1">1 (Standard)</option>
|
||||
<option value="2" id="parallelDownloads2">2 (Parallel)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="streamlinkQualityLabel" for="streamlinkQuality">Stream-Qualitaet</label>
|
||||
<select id="streamlinkQuality">
|
||||
<option value="best" id="streamlinkQualityBest">Best (Standard)</option>
|
||||
<option value="source" id="streamlinkQualitySource">Source (Original)</option>
|
||||
<option value="1080p60" id="streamlinkQuality1080p60">1080p60</option>
|
||||
<option value="720p60" id="streamlinkQuality720p60">720p60</option>
|
||||
<option value="720p" id="streamlinkQuality720p">720p</option>
|
||||
<option value="480p" id="streamlinkQuality480p">480p</option>
|
||||
<option value="audio_only" id="streamlinkQualityAudio">Audio only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="performanceModeLabel" for="performanceMode">Performance-Profil</label>
|
||||
<select id="performanceMode">
|
||||
<option value="stability" id="performanceModeStability">Max Stabilitat</option>
|
||||
<option value="balanced" id="performanceModeBalanced">Ausgewogen</option>
|
||||
<option value="speed" id="performanceModeSpeed">Max Geschwindigkeit</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="smartSchedulerToggle" checked>
|
||||
<span id="smartSchedulerLabel">Smart Queue Scheduler aktivieren</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="duplicatePreventionToggle" checked>
|
||||
<span id="duplicatePreventionLabel">Duplikate in Queue verhindern</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="persistQueueToggle" checked>
|
||||
<span id="persistQueueLabel">Queue zwischen App-Starts speichern</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="autoResumeQueueToggle">
|
||||
<span id="autoResumeQueueLabel">Queue beim Start automatisch fortsetzen</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="notifyEachCompletionToggle">
|
||||
<span id="notifyEachCompletionLabel">Benachrichtigung bei jedem fertigen Download</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="streamlinkDisableAdsToggle" checked>
|
||||
<span id="streamlinkDisableAdsLabel">Twitch-Ads beim Download ueberspringen</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="downloadChatReplayToggle">
|
||||
<span id="downloadChatReplayLabel">Chat-Replay parallel zum VOD speichern (.chat.json)</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="captureLiveChatToggle">
|
||||
<span id="captureLiveChatLabel">Live-Chat waehrend der Aufnahme mitschneiden (.chat.jsonl)</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="logStreamEventsToggle" checked>
|
||||
<span id="logStreamEventsLabel">Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl)</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="autoResumeLiveRecordingToggle" checked>
|
||||
<span id="autoResumeLiveRecordingLabel">Live-Aufnahme automatisch fortsetzen wenn Streamlink abbricht (max. 5 Versuche)</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="autoMergeResumedPartsToggle">
|
||||
<span id="autoMergeResumedPartsLabel">Fortgesetzte Aufnahme-Parts automatisch zu einer Datei zusammenfuegen (ffmpeg concat)</span>
|
||||
</label>
|
||||
<label class="toggle-row indented">
|
||||
<input type="checkbox" id="deletePartsAfterMergeToggle">
|
||||
<span id="deletePartsAfterMergeLabel">Einzelne Parts nach erfolgreichem Merge loeschen</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="metadataCacheMinutesLabel" for="metadataCacheMinutes">Metadata-Cache (Minuten)</label>
|
||||
<input type="number" id="metadataCacheMinutes" value="10" min="1" max="120">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-row" style="align-items:center; margin-bottom: 4px;">
|
||||
<label id="filenameTemplatesTitle">Dateinamen-Templates</label>
|
||||
<button class="btn-secondary" id="settingsTemplateGuideBtn" type="button" onclick="openTemplateGuide('vod')">Template Guide</button>
|
||||
</div>
|
||||
<div class="form-row" style="gap: 8px; margin: 8px 0 6px;">
|
||||
<button class="btn-secondary" id="templatePresetDefault" type="button" onclick="applyTemplatePreset('default')">Preset: Default</button>
|
||||
<button class="btn-secondary" id="templatePresetArchive" type="button" onclick="applyTemplatePreset('archive')">Preset: Archive</button>
|
||||
<button class="btn-secondary" id="templatePresetClipper" type="button" onclick="applyTemplatePreset('clipper')">Preset: Clipper</button>
|
||||
</div>
|
||||
<div class="filename-template-grid">
|
||||
<label id="vodTemplateLabel" for="vodFilenameTemplate">VOD Template</label>
|
||||
<input type="text" id="vodFilenameTemplate" class="input-monospace" placeholder="{title}.mp4" oninput="validateFilenameTemplates()">
|
||||
|
||||
<label id="partsTemplateLabel" for="partsFilenameTemplate">VOD Part Template</label>
|
||||
<input type="text" id="partsFilenameTemplate" class="input-monospace" placeholder="{date}_Part{part_padded}.mp4" oninput="validateFilenameTemplates()">
|
||||
|
||||
<label id="defaultClipTemplateLabel" for="defaultClipFilenameTemplate">Clip Template</label>
|
||||
<input type="text" id="defaultClipFilenameTemplate" class="input-monospace" placeholder="{date}_{part}.mp4" oninput="validateFilenameTemplates()">
|
||||
</div>
|
||||
<div id="filenameTemplateHint" class="form-note" style="margin-top: 8px;">Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
|
||||
<div id="filenameTemplateLint" class="template-lint ok">Template-Check: OK</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="updateTitle">Updates</h3>
|
||||
<p id="versionInfo" class="card-intro">Version: v1.0.1</p>
|
||||
<button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="form-row section-header">
|
||||
<h3 id="preflightTitle">System-Check</h3>
|
||||
<span class="health-badge unknown" id="healthBadge">System: Unbekannt</span>
|
||||
</div>
|
||||
<div class="form-row" style="margin-bottom: 10px;">
|
||||
<button type="button" class="btn-secondary" id="btnPreflightRun" onclick="runPreflight(false)">Check ausfuhren</button>
|
||||
<button type="button" class="btn-secondary" id="btnPreflightFix" onclick="runPreflight(true)">Auto-Fix Tools</button>
|
||||
</div>
|
||||
<pre id="preflightResult" class="log-panel">Noch kein Check ausgefuhrt.</pre>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="debugLogTitle">Live Debug-Log</h3>
|
||||
<div class="form-row aligned">
|
||||
<button type="button" class="btn-secondary" id="btnRefreshLog" onclick="refreshDebugLog()">Aktualisieren</button>
|
||||
<button type="button" class="btn-secondary" id="btnOpenDebugLogFile" onclick="openDebugLogFile()">Log-Datei oeffnen</button>
|
||||
<label class="inline-toggle">
|
||||
<input type="checkbox" id="debugAutoRefresh" onchange="toggleDebugAutoRefresh(this.checked)">
|
||||
<span id="autoRefreshText">Auto-Refresh</span>
|
||||
</label>
|
||||
</div>
|
||||
<pre id="debugLogOutput" class="log-panel">Lade...</pre>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="form-row section-header">
|
||||
<h3 id="storageCardTitle">Storage</h3>
|
||||
<button type="button" class="btn-secondary" id="btnRefreshStorage" onclick="refreshStorageStats()">Aktualisieren</button>
|
||||
</div>
|
||||
<p id="storageCardIntro" class="card-intro">Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.</p>
|
||||
<div id="storageSummary" class="form-sublabel" style="margin-bottom:8px;" role="status" aria-live="polite"></div>
|
||||
<div id="storageList"></div>
|
||||
|
||||
<hr>
|
||||
<h4 id="cleanupTitle">Auto-Cleanup</h4>
|
||||
<p id="cleanupIntro" class="card-intro">Aufnahmen aelter als X Tage automatisch archivieren oder loeschen. Schiebt Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) mit der Aufnahme.</p>
|
||||
<label class="toggle-row" style="margin-bottom: 8px;">
|
||||
<input type="checkbox" id="autoCleanupEnabledToggle">
|
||||
<span id="autoCleanupEnabledLabel">Auto-Cleanup aktivieren</span>
|
||||
</label>
|
||||
<div class="form-row" style="gap:12px; flex-wrap:wrap; margin-bottom: 8px;">
|
||||
<label class="form-stack size-sm">
|
||||
<span id="autoCleanupDaysLabel" class="form-sublabel">Tage-Schwelle</span>
|
||||
<input type="number" id="autoCleanupDays" min="1" max="3650" value="30">
|
||||
</label>
|
||||
<label class="form-stack size-md">
|
||||
<span id="autoCleanupTargetLabel" class="form-sublabel">Bereich</span>
|
||||
<select id="autoCleanupTarget">
|
||||
<option value="live_only" id="autoCleanupTargetLive">Nur Live-Aufnahmen</option>
|
||||
<option value="all" id="autoCleanupTargetAll">Alle Aufnahmen</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-stack size-md">
|
||||
<span id="autoCleanupActionLabel" class="form-sublabel">Aktion</span>
|
||||
<select id="autoCleanupAction">
|
||||
<option value="archive" id="autoCleanupActionArchive">In Archiv verschieben</option>
|
||||
<option value="delete" id="autoCleanupActionDelete">Loeschen</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row" style="margin-bottom: 8px; gap: 8px;">
|
||||
<button type="button" class="btn-secondary" id="btnCleanupDryRun" onclick="runCleanupDryRun()">Vorschau</button>
|
||||
<button type="button" class="btn-secondary" id="btnCleanupRunNow" onclick="runCleanupNow()">Jetzt ausfuehren</button>
|
||||
</div>
|
||||
<div id="cleanupReport" class="form-note" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="discordCardTitle">Discord-Webhook</h3>
|
||||
<p id="discordCardIntro" class="card-intro">Sende Benachrichtigungen an einen Discord-Channel via Webhook — nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.</p>
|
||||
<div class="form-group">
|
||||
<label id="discordWebhookUrlLabel" for="discordWebhookUrl">Webhook-URL</label>
|
||||
<input type="text" id="discordWebhookUrl" placeholder="https://discord.com/api/webhooks/...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="discordNotifyLiveStartToggle">
|
||||
<span id="discordNotifyLiveStartLabel">Bei Live-Aufnahme-Start benachrichtigen</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="discordNotifyLiveEndToggle">
|
||||
<span id="discordNotifyLiveEndLabel">Bei Live-Aufnahme-Ende benachrichtigen</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="discordNotifyVodCompleteToggle">
|
||||
<span id="discordNotifyVodCompleteLabel">Bei abgeschlossenem VOD-Download benachrichtigen</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="discordNotifyVodAutoQueuedToggle">
|
||||
<span id="discordNotifyVodAutoQueuedLabel">Bei automatisch eingereihten VODs benachrichtigen</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="autoVodCardTitle">Auto-VOD-Download</h3>
|
||||
<p id="autoVodCardIntro" class="card-intro">Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprueft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefuegt.</p>
|
||||
<div class="form-row aligned">
|
||||
<label id="autoVodPollMinutesLabel" class="form-sublabel" for="autoVodPollMinutes">Poll-Intervall (Minuten)</label>
|
||||
<input type="number" id="autoVodPollMinutes" min="5" max="360" value="15" class="input-narrow">
|
||||
<label id="autoVodMaxAgeHoursLabel" class="form-sublabel" for="autoVodMaxAgeHours" style="margin-left:12px;">Max. Alter (Stunden)</label>
|
||||
<input type="number" id="autoVodMaxAgeHours" min="1" max="720" value="24" class="input-narrow">
|
||||
</div>
|
||||
<div class="form-row" style="align-items: center; gap: 12px; flex-wrap: wrap;">
|
||||
<button type="button" class="btn-secondary" id="btnAutoVodScanNow" onclick="triggerManualAutoVodScan()">Jetzt scannen</button>
|
||||
<button type="button" class="btn-secondary" id="btnAutoRecordScanNow" onclick="triggerManualAutoRecordScan()">Live-Status pruefen</button>
|
||||
<span id="autoVodStatusLine" class="form-sublabel"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="backupCardTitle">Sicherung & Wartung</h3>
|
||||
<p id="backupCardIntro" class="card-intro">Konfiguration sichern, auf einem anderen Geraet wiederherstellen, oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.</p>
|
||||
<div class="form-row" style="margin-bottom: 10px; flex-wrap: wrap;">
|
||||
<button type="button" class="btn-secondary" id="btnExportConfig" onclick="exportConfigToFile()">Konfiguration exportieren</button>
|
||||
<button type="button" class="btn-secondary" id="btnImportConfig" onclick="importConfigFromFile()">Konfiguration importieren</button>
|
||||
<button type="button" class="btn-secondary" id="btnResetDownloadedIds" onclick="resetDownloadedIds()">Downloaded-VODs zuruecksetzen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="runtimeMetricsTitle">Runtime Metrics</h3>
|
||||
<div class="form-row aligned">
|
||||
<button type="button" class="btn-secondary" id="btnRefreshMetrics" onclick="refreshRuntimeMetrics()">Aktualisieren</button>
|
||||
<button type="button" class="btn-secondary" id="btnExportMetrics" onclick="exportRuntimeMetrics()">Export JSON</button>
|
||||
<label class="inline-toggle">
|
||||
<input type="checkbox" id="runtimeMetricsAutoRefresh" onchange="toggleRuntimeMetricsAutoRefresh(this.checked)">
|
||||
<span id="runtimeMetricsAutoRefreshText">Auto-Refresh</span>
|
||||
</label>
|
||||
</div>
|
||||
<pre id="runtimeMetricsOutput" class="log-panel">Lade...</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-bar">
|
||||
<div class="status-indicator">
|
||||
<div class="status-dot" id="statusDot" aria-hidden="true"></div>
|
||||
<span id="statusText">Nicht verbunden</span>
|
||||
</div>
|
||||
<span id="statusBarQueueSummary" class="status-bar-queue-summary"></span>
|
||||
<span id="versionText" class="status-bar-version"></span>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="commandPaletteModal" role="dialog" aria-modal="true" aria-labelledby="commandPaletteTitle">
|
||||
<div class="modal command-palette">
|
||||
<h2 id="commandPaletteTitle" class="cp-title">Command Palette</h2>
|
||||
<input
|
||||
type="text"
|
||||
id="commandPaletteInput"
|
||||
class="cp-input"
|
||||
placeholder="Suche Befehl..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
aria-label="Command Palette"
|
||||
/>
|
||||
<ul id="commandPaletteList" class="cp-list" role="listbox" aria-label="Command results"></ul>
|
||||
<p class="cp-hint" id="commandPaletteHint">Up/Down zum Navigieren, Enter zum Ausfuehren, Esc zum Schliessen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="../dist/renderer-locale-de.js"></script>
|
||||
<script src="../dist/renderer-locale-en.js"></script>
|
||||
<script src="../dist/renderer-texts.js"></script>
|
||||
<script src="../dist/renderer-shared.js"></script>
|
||||
<script src="../dist/renderer-settings.js"></script>
|
||||
<script src="../dist/renderer-streamers.js"></script>
|
||||
<script src="../dist/renderer-queue.js"></script>
|
||||
<script src="../dist/renderer-updates.js"></script>
|
||||
<script src="../dist/renderer-stats.js"></script>
|
||||
<script src="../dist/renderer-archive.js"></script>
|
||||
<script src="../dist/renderer-profile.js"></script>
|
||||
<script src="../dist/renderer-vod-hover.js"></script>
|
||||
<script src="../dist/renderer-command-palette.js"></script>
|
||||
<script src="../dist/renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+7420
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
||||
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { openDatabase, type DbHandle } from '../infra/db';
|
||||
import { createArchiveFilesStore, type ArchiveFilesStore } from './archive-files-store';
|
||||
|
||||
let tmpDir: string;
|
||||
let db: DbHandle;
|
||||
let store: ArchiveFilesStore;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'archive-'));
|
||||
db = openDatabase(path.join(tmpDir, 'app.db'));
|
||||
store = createArchiveFilesStore(db);
|
||||
});
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
describe('createArchiveFilesStore', () => {
|
||||
test('upsert + get roundtrip', () => {
|
||||
const rec = store.upsert({
|
||||
path: 'C:/vods/foo/2026-05-11.mp4',
|
||||
streamerLogin: 'Foo',
|
||||
sizeBytes: 1024 * 1024 * 100,
|
||||
durationSeconds: 3600,
|
||||
createdAt: 1700000000,
|
||||
verified: true,
|
||||
});
|
||||
expect(rec.path).toBe('C:/vods/foo/2026-05-11.mp4');
|
||||
expect(rec.streamerLogin).toBe('foo');
|
||||
expect(rec.sizeBytes).toBe(1024 * 1024 * 100);
|
||||
expect(rec.verified).toBe(true);
|
||||
|
||||
const fetched = store.get('C:/vods/foo/2026-05-11.mp4');
|
||||
expect(fetched?.streamerLogin).toBe('foo');
|
||||
});
|
||||
|
||||
test('upsert same path updates instead of duplicating', () => {
|
||||
store.upsert({ path: '/x', streamerLogin: 'a', sizeBytes: 100 });
|
||||
store.upsert({ path: '/x', streamerLogin: 'a', sizeBytes: 200 });
|
||||
const list = store.list();
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].sizeBytes).toBe(200);
|
||||
});
|
||||
|
||||
test('list returns all, ordered by created_at DESC NULLS LAST', () => {
|
||||
store.upsert({ path: '/older', streamerLogin: 'a', createdAt: 1000 });
|
||||
store.upsert({ path: '/newer', streamerLogin: 'a', createdAt: 2000 });
|
||||
store.upsert({ path: '/no-date', streamerLogin: 'a' });
|
||||
const list = store.list();
|
||||
expect(list.map(r => r.path)).toEqual(['/newer', '/older', '/no-date']);
|
||||
});
|
||||
|
||||
test('list(streamerLogin) filters and normalizes', () => {
|
||||
store.upsert({ path: '/a1', streamerLogin: 'alice' });
|
||||
store.upsert({ path: '/a2', streamerLogin: 'Alice' }); // normalized to alice
|
||||
store.upsert({ path: '/b1', streamerLogin: 'bob' });
|
||||
const aliceFiles = store.list('@Alice');
|
||||
expect(aliceFiles).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('setVerified toggles the flag', () => {
|
||||
store.upsert({ path: '/v', verified: false });
|
||||
store.setVerified('/v', true);
|
||||
expect(store.get('/v')?.verified).toBe(true);
|
||||
store.setVerified('/v', false);
|
||||
expect(store.get('/v')?.verified).toBe(false);
|
||||
});
|
||||
|
||||
test('delete removes the record', () => {
|
||||
store.upsert({ path: '/d', streamerLogin: 'x' });
|
||||
store.delete('/d');
|
||||
expect(store.get('/d')).toBeNull();
|
||||
});
|
||||
|
||||
test('summaryByStreamer aggregates counts and total bytes', () => {
|
||||
store.upsert({ path: '/a1', streamerLogin: 'alice', sizeBytes: 100 });
|
||||
store.upsert({ path: '/a2', streamerLogin: 'alice', sizeBytes: 200 });
|
||||
store.upsert({ path: '/b1', streamerLogin: 'bob', sizeBytes: 50 });
|
||||
store.upsert({ path: '/orphan', sizeBytes: 999 }); // no streamer — excluded
|
||||
|
||||
const summary = store.summaryByStreamer();
|
||||
// Sorted by total DESC: alice (300), bob (50)
|
||||
expect(summary).toHaveLength(2);
|
||||
expect(summary[0]).toEqual({ streamerLogin: 'alice', fileCount: 2, totalBytes: 300 });
|
||||
expect(summary[1]).toEqual({ streamerLogin: 'bob', fileCount: 1, totalBytes: 50 });
|
||||
});
|
||||
|
||||
test('totalBytes sums across everything', () => {
|
||||
store.upsert({ path: '/1', sizeBytes: 100 });
|
||||
store.upsert({ path: '/2', sizeBytes: 200 });
|
||||
store.upsert({ path: '/3', sizeBytes: 300, streamerLogin: 'a' });
|
||||
store.upsert({ path: '/4' }); // null bytes — coalesced to 0
|
||||
expect(store.totalBytes()).toBe(600);
|
||||
});
|
||||
|
||||
test('get returns null for missing path', () => {
|
||||
expect(store.get('/nope')).toBeNull();
|
||||
});
|
||||
|
||||
test('totalBytes on empty table = 0', () => {
|
||||
expect(store.totalBytes()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { DbHandle } from '../infra/db';
|
||||
import { normalizeLogin } from './config-normalize';
|
||||
|
||||
export interface ArchiveFileRecord {
|
||||
path: string;
|
||||
streamerLogin: string | null;
|
||||
sizeBytes: number | null;
|
||||
durationSeconds: number | null;
|
||||
createdAt: number | null;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface ArchiveFileWriteInput {
|
||||
path: string;
|
||||
streamerLogin?: string;
|
||||
sizeBytes?: number;
|
||||
durationSeconds?: number;
|
||||
createdAt?: number;
|
||||
verified?: boolean;
|
||||
}
|
||||
|
||||
export interface ArchiveStreamerSummary {
|
||||
streamerLogin: string;
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
export interface ArchiveFilesStore {
|
||||
upsert(input: ArchiveFileWriteInput): ArchiveFileRecord;
|
||||
get(path: string): ArchiveFileRecord | null;
|
||||
list(streamerLogin?: string): ArchiveFileRecord[];
|
||||
setVerified(path: string, verified: boolean): void;
|
||||
delete(path: string): void;
|
||||
summaryByStreamer(): ArchiveStreamerSummary[];
|
||||
totalBytes(): number;
|
||||
}
|
||||
|
||||
interface ArchiveRow {
|
||||
path: string;
|
||||
streamer_login: string | null;
|
||||
size_bytes: number | null;
|
||||
duration_seconds: number | null;
|
||||
created_at: number | null;
|
||||
verified: number;
|
||||
}
|
||||
|
||||
function rowToRecord(row: ArchiveRow): ArchiveFileRecord {
|
||||
return {
|
||||
path: row.path,
|
||||
streamerLogin: row.streamer_login,
|
||||
sizeBytes: row.size_bytes,
|
||||
durationSeconds: row.duration_seconds,
|
||||
createdAt: row.created_at,
|
||||
verified: row.verified === 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function createArchiveFilesStore(db: DbHandle): ArchiveFilesStore {
|
||||
return {
|
||||
upsert(input) {
|
||||
const streamerLogin = input.streamerLogin
|
||||
? normalizeLogin(input.streamerLogin)
|
||||
: null;
|
||||
const verified = input.verified ? 1 : 0;
|
||||
db.run(
|
||||
`INSERT INTO archive_files(path, streamer_login, size_bytes, duration_seconds, created_at, verified)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
streamer_login = excluded.streamer_login,
|
||||
size_bytes = excluded.size_bytes,
|
||||
duration_seconds = excluded.duration_seconds,
|
||||
created_at = excluded.created_at,
|
||||
verified = excluded.verified`,
|
||||
[
|
||||
input.path,
|
||||
streamerLogin,
|
||||
input.sizeBytes ?? null,
|
||||
input.durationSeconds ?? null,
|
||||
input.createdAt ?? null,
|
||||
verified,
|
||||
]
|
||||
);
|
||||
const row = db.get<ArchiveRow>('SELECT * FROM archive_files WHERE path = ?', [input.path]);
|
||||
if (!row) throw new Error(`archive-files-store: upsert lookup failed for ${input.path}`);
|
||||
return rowToRecord(row);
|
||||
},
|
||||
|
||||
get(p) {
|
||||
const row = db.get<ArchiveRow>('SELECT * FROM archive_files WHERE path = ?', [p]);
|
||||
return row ? rowToRecord(row) : null;
|
||||
},
|
||||
|
||||
list(streamerLogin) {
|
||||
const rows = streamerLogin
|
||||
? db.all<ArchiveRow>(
|
||||
'SELECT * FROM archive_files WHERE streamer_login = ? ORDER BY created_at DESC NULLS LAST, path',
|
||||
[normalizeLogin(streamerLogin)]
|
||||
)
|
||||
: db.all<ArchiveRow>('SELECT * FROM archive_files ORDER BY created_at DESC NULLS LAST, path');
|
||||
return rows.map(rowToRecord);
|
||||
},
|
||||
|
||||
setVerified(p, verified) {
|
||||
db.run(
|
||||
'UPDATE archive_files SET verified = ? WHERE path = ?',
|
||||
[verified ? 1 : 0, p]
|
||||
);
|
||||
},
|
||||
|
||||
delete(p) {
|
||||
db.run('DELETE FROM archive_files WHERE path = ?', [p]);
|
||||
},
|
||||
|
||||
summaryByStreamer() {
|
||||
const rows = db.all<{ streamer_login: string | null; cnt: number; total: number | null }>(
|
||||
`SELECT streamer_login, COUNT(*) AS cnt, COALESCE(SUM(size_bytes), 0) AS total
|
||||
FROM archive_files
|
||||
WHERE streamer_login IS NOT NULL
|
||||
GROUP BY streamer_login
|
||||
ORDER BY total DESC`
|
||||
);
|
||||
return rows
|
||||
.filter((r): r is { streamer_login: string; cnt: number; total: number | null } => r.streamer_login !== null)
|
||||
.map(r => ({
|
||||
streamerLogin: r.streamer_login,
|
||||
fileCount: r.cnt,
|
||||
totalBytes: r.total ?? 0,
|
||||
}));
|
||||
},
|
||||
|
||||
totalBytes() {
|
||||
const row = db.get<{ total: number | null }>(
|
||||
'SELECT COALESCE(SUM(size_bytes), 0) AS total FROM archive_files'
|
||||
);
|
||||
return row?.total ?? 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { openDatabase, type DbHandle } from '../infra/db';
|
||||
import { createChunkIndexStore, type ChunkIndexStore } from './chunk-index-store';
|
||||
|
||||
let tmpDir: string;
|
||||
let db: DbHandle;
|
||||
let store: ChunkIndexStore;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chunkstore-'));
|
||||
db = openDatabase(path.join(tmpDir, 'app.db'));
|
||||
store = createChunkIndexStore(db);
|
||||
});
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
describe('createChunkIndexStore', () => {
|
||||
test('record returns ChunkRecord with id > 0', () => {
|
||||
const rec = store.record('item-1', 0, 'sha1-abc', 1024);
|
||||
expect(rec.id).toBeGreaterThan(0);
|
||||
expect(rec.itemId).toBe('item-1');
|
||||
expect(rec.chunkSeq).toBe(0);
|
||||
expect(rec.sha1Hex).toBe('sha1-abc');
|
||||
expect(rec.bytes).toBe(1024);
|
||||
});
|
||||
|
||||
test('listForItem returns chunks ordered by chunk_seq', () => {
|
||||
store.record('it', 2, 's2', 200);
|
||||
store.record('it', 0, 's0', 100);
|
||||
store.record('it', 1, 's1', 150);
|
||||
const all = store.listForItem('it');
|
||||
expect(all.map(r => r.chunkSeq)).toEqual([0, 1, 2]);
|
||||
expect(all.map(r => r.sha1Hex)).toEqual(['s0', 's1', 's2']);
|
||||
});
|
||||
|
||||
test('UNIQUE(item_id, chunk_seq): same key updates, no duplicate', () => {
|
||||
store.record('it', 0, 'first', 100);
|
||||
store.record('it', 0, 'second', 200);
|
||||
const list = store.listForItem('it');
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].sha1Hex).toBe('second');
|
||||
expect(list[0].bytes).toBe(200);
|
||||
});
|
||||
|
||||
test('countForItem', () => {
|
||||
expect(store.countForItem('it')).toBe(0);
|
||||
store.record('it', 0, 'a', 1);
|
||||
store.record('it', 1, 'b', 1);
|
||||
expect(store.countForItem('it')).toBe(2);
|
||||
expect(store.countForItem('other')).toBe(0);
|
||||
});
|
||||
|
||||
test('lookupBySha1 finds dedupe candidates', () => {
|
||||
store.record('item-A', 0, 'same-sha', 100);
|
||||
store.record('item-B', 5, 'same-sha', 100);
|
||||
store.record('item-C', 0, 'other-sha', 100);
|
||||
|
||||
const hits = store.lookupBySha1('same-sha');
|
||||
expect(hits).toHaveLength(2);
|
||||
expect(hits.map(r => r.itemId).sort()).toEqual(['item-A', 'item-B']);
|
||||
});
|
||||
|
||||
test('deleteForItem removes all chunks for that item and returns count', () => {
|
||||
store.record('it', 0, 'a', 1);
|
||||
store.record('it', 1, 'b', 1);
|
||||
store.record('keep', 0, 'c', 1);
|
||||
|
||||
const removed = store.deleteForItem('it');
|
||||
expect(removed).toBe(2);
|
||||
expect(store.countForItem('it')).toBe(0);
|
||||
expect(store.countForItem('keep')).toBe(1);
|
||||
});
|
||||
|
||||
test('deleteForItem on missing returns 0, doesnt throw', () => {
|
||||
expect(store.deleteForItem('does-not-exist')).toBe(0);
|
||||
});
|
||||
|
||||
test('bytes roundtrip', () => {
|
||||
const rec = store.record('it', 0, 'sha', 1234567);
|
||||
expect(rec.bytes).toBe(1234567);
|
||||
const list = store.listForItem('it');
|
||||
expect(list[0].bytes).toBe(1234567);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { DbHandle } from '../infra/db';
|
||||
|
||||
export interface ChunkRecord {
|
||||
id: number;
|
||||
itemId: string;
|
||||
chunkSeq: number;
|
||||
sha1Hex: string;
|
||||
bytes: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ChunkIndexStore {
|
||||
/**
|
||||
* Persistiert einen Chunk-Hash. Bei (itemId, chunkSeq)-Konflikt wird das
|
||||
* bestehende Tupel ersetzt — die zuletzt geschriebene sha1 gewinnt
|
||||
* (sinnvoll, falls dasselbe Segment neu geladen wurde).
|
||||
*/
|
||||
record(itemId: string, chunkSeq: number, sha1Hex: string, bytes: number): ChunkRecord;
|
||||
listForItem(itemId: string): ChunkRecord[];
|
||||
countForItem(itemId: string): number;
|
||||
lookupBySha1(sha1Hex: string): ChunkRecord[];
|
||||
deleteForItem(itemId: string): number;
|
||||
}
|
||||
|
||||
interface ChunkRow {
|
||||
id: number;
|
||||
item_id: string;
|
||||
chunk_seq: number;
|
||||
sha1_hex: string;
|
||||
bytes: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
function rowToRecord(row: ChunkRow): ChunkRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
itemId: row.item_id,
|
||||
chunkSeq: row.chunk_seq,
|
||||
sha1Hex: row.sha1_hex,
|
||||
bytes: row.bytes,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function createChunkIndexStore(db: DbHandle): ChunkIndexStore {
|
||||
return {
|
||||
record(itemId, chunkSeq, sha1Hex, bytes) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
db.run(
|
||||
`INSERT INTO chunk_index(item_id, chunk_seq, sha1_hex, bytes, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(item_id, chunk_seq) DO UPDATE SET
|
||||
sha1_hex = excluded.sha1_hex,
|
||||
bytes = excluded.bytes,
|
||||
created_at = excluded.created_at`,
|
||||
[itemId, chunkSeq, sha1Hex, bytes, now]
|
||||
);
|
||||
const row = db.get<ChunkRow>(
|
||||
'SELECT * FROM chunk_index WHERE item_id = ? AND chunk_seq = ?',
|
||||
[itemId, chunkSeq]
|
||||
);
|
||||
if (!row) throw new Error(`chunk-index-store: record lookup failed for ${itemId}/${chunkSeq}`);
|
||||
return rowToRecord(row);
|
||||
},
|
||||
|
||||
listForItem(itemId) {
|
||||
const rows = db.all<ChunkRow>(
|
||||
'SELECT * FROM chunk_index WHERE item_id = ? ORDER BY chunk_seq ASC',
|
||||
[itemId]
|
||||
);
|
||||
return rows.map(rowToRecord);
|
||||
},
|
||||
|
||||
countForItem(itemId) {
|
||||
const row = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM chunk_index WHERE item_id = ?', [itemId]);
|
||||
return row?.c ?? 0;
|
||||
},
|
||||
|
||||
lookupBySha1(sha1Hex) {
|
||||
const rows = db.all<ChunkRow>(
|
||||
'SELECT * FROM chunk_index WHERE sha1_hex = ? ORDER BY item_id, chunk_seq',
|
||||
[sha1Hex]
|
||||
);
|
||||
return rows.map(rowToRecord);
|
||||
},
|
||||
|
||||
deleteForItem(itemId) {
|
||||
const before = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM chunk_index WHERE item_id = ?', [itemId])?.c ?? 0;
|
||||
db.run('DELETE FROM chunk_index WHERE item_id = ?', [itemId]);
|
||||
return before;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import {
|
||||
normalizeLogin,
|
||||
normalizeAutoRecordPollSeconds,
|
||||
normalizeAutoRecordList,
|
||||
normalizeStreamlinkQuality,
|
||||
normalizeFilenameTemplate,
|
||||
normalizeMetadataCacheMinutes,
|
||||
normalizePerformanceMode,
|
||||
isPlainObject,
|
||||
VALID_STREAMLINK_QUALITIES,
|
||||
} from './config-normalize';
|
||||
|
||||
describe('normalizeLogin', () => {
|
||||
test('trim + lowercase', () => {
|
||||
expect(normalizeLogin(' Foo ')).toBe('foo');
|
||||
});
|
||||
test('strips single leading @', () => {
|
||||
expect(normalizeLogin('@foo')).toBe('foo');
|
||||
});
|
||||
test('strips multiple leading @', () => {
|
||||
expect(normalizeLogin('@@@foo')).toBe('foo');
|
||||
});
|
||||
test('preserves @ in middle of string', () => {
|
||||
expect(normalizeLogin('foo@bar')).toBe('foo@bar');
|
||||
});
|
||||
test('empty stays empty', () => {
|
||||
expect(normalizeLogin('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeAutoRecordPollSeconds', () => {
|
||||
test('default 90 for non-numeric (NaN producer)', () => {
|
||||
// Number('x') === NaN, Number(undefined) === NaN → default 90.
|
||||
// Number(null) === 0 (finite) → clamp to 30, see boundary test below.
|
||||
expect(normalizeAutoRecordPollSeconds('x')).toBe(90);
|
||||
expect(normalizeAutoRecordPollSeconds(undefined)).toBe(90);
|
||||
expect(normalizeAutoRecordPollSeconds({})).toBe(90);
|
||||
});
|
||||
test('null becomes 0 then clamps to 30', () => {
|
||||
expect(normalizeAutoRecordPollSeconds(null)).toBe(30);
|
||||
});
|
||||
test('clamps low to 30', () => {
|
||||
expect(normalizeAutoRecordPollSeconds(5)).toBe(30);
|
||||
});
|
||||
test('clamps high to 1800', () => {
|
||||
expect(normalizeAutoRecordPollSeconds(99999)).toBe(1800);
|
||||
});
|
||||
test('passes valid mid-range', () => {
|
||||
expect(normalizeAutoRecordPollSeconds(120)).toBe(120);
|
||||
});
|
||||
test('floors fractional', () => {
|
||||
expect(normalizeAutoRecordPollSeconds(120.9)).toBe(120);
|
||||
});
|
||||
test('boundary 30 stays', () => {
|
||||
expect(normalizeAutoRecordPollSeconds(30)).toBe(30);
|
||||
});
|
||||
test('boundary 1800 stays', () => {
|
||||
expect(normalizeAutoRecordPollSeconds(1800)).toBe(1800);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeAutoRecordList', () => {
|
||||
test('empty for non-array', () => {
|
||||
expect(normalizeAutoRecordList(null)).toEqual([]);
|
||||
expect(normalizeAutoRecordList('x')).toEqual([]);
|
||||
expect(normalizeAutoRecordList(undefined)).toEqual([]);
|
||||
});
|
||||
test('empty array stays empty', () => {
|
||||
expect(normalizeAutoRecordList([])).toEqual([]);
|
||||
});
|
||||
test('lowercases + trims + dedupes', () => {
|
||||
expect(normalizeAutoRecordList(['Foo', 'foo', ' BAR '])).toEqual(['foo', 'bar']);
|
||||
});
|
||||
test('strips leading @ (twitch username paste-form)', () => {
|
||||
expect(normalizeAutoRecordList(['@foo', 'foo', '@@bar'])).toEqual(['foo', 'bar']);
|
||||
});
|
||||
test('drops non-string entries', () => {
|
||||
expect(normalizeAutoRecordList(['foo', 123, null, 'bar'])).toEqual(['foo', 'bar']);
|
||||
});
|
||||
test('drops empty strings after normalize', () => {
|
||||
expect(normalizeAutoRecordList(['', '@', ' ', 'foo'])).toEqual(['foo']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeStreamlinkQuality', () => {
|
||||
test('all valid values pass through', () => {
|
||||
for (const q of VALID_STREAMLINK_QUALITIES) {
|
||||
expect(normalizeStreamlinkQuality(q)).toBe(q);
|
||||
}
|
||||
});
|
||||
test('invalid string falls back to best', () => {
|
||||
expect(normalizeStreamlinkQuality('foo')).toBe('best');
|
||||
});
|
||||
test('null/undefined/number fall back to best', () => {
|
||||
expect(normalizeStreamlinkQuality(null)).toBe('best');
|
||||
expect(normalizeStreamlinkQuality(undefined)).toBe('best');
|
||||
expect(normalizeStreamlinkQuality(42)).toBe('best');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeFilenameTemplate', () => {
|
||||
test('valid string used as-is', () => {
|
||||
expect(normalizeFilenameTemplate('{title}.mp4', 'FB')).toBe('{title}.mp4');
|
||||
});
|
||||
test('trims whitespace', () => {
|
||||
expect(normalizeFilenameTemplate(' hi ', 'FB')).toBe('hi');
|
||||
});
|
||||
test('empty string falls back', () => {
|
||||
expect(normalizeFilenameTemplate('', 'FB')).toBe('FB');
|
||||
});
|
||||
test('whitespace-only falls back', () => {
|
||||
expect(normalizeFilenameTemplate(' ', 'FB')).toBe('FB');
|
||||
});
|
||||
test('undefined falls back', () => {
|
||||
expect(normalizeFilenameTemplate(undefined, 'FB')).toBe('FB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeMetadataCacheMinutes', () => {
|
||||
test('default 10 for NaN-producer', () => {
|
||||
expect(normalizeMetadataCacheMinutes('x')).toBe(10);
|
||||
expect(normalizeMetadataCacheMinutes(undefined)).toBe(10);
|
||||
expect(normalizeMetadataCacheMinutes({})).toBe(10);
|
||||
});
|
||||
test('null becomes 0 then clamps to 1', () => {
|
||||
expect(normalizeMetadataCacheMinutes(null)).toBe(1);
|
||||
});
|
||||
test('clamps low to 1', () => {
|
||||
expect(normalizeMetadataCacheMinutes(0)).toBe(1);
|
||||
expect(normalizeMetadataCacheMinutes(-5)).toBe(1);
|
||||
});
|
||||
test('clamps high to 120', () => {
|
||||
expect(normalizeMetadataCacheMinutes(999)).toBe(120);
|
||||
});
|
||||
test('passes valid mid-range', () => {
|
||||
expect(normalizeMetadataCacheMinutes(15)).toBe(15);
|
||||
});
|
||||
test('floors fractional', () => {
|
||||
expect(normalizeMetadataCacheMinutes(15.9)).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePerformanceMode', () => {
|
||||
test('stability passes', () => {
|
||||
expect(normalizePerformanceMode('stability')).toBe('stability');
|
||||
});
|
||||
test('balanced passes', () => {
|
||||
expect(normalizePerformanceMode('balanced')).toBe('balanced');
|
||||
});
|
||||
test('speed passes', () => {
|
||||
expect(normalizePerformanceMode('speed')).toBe('speed');
|
||||
});
|
||||
test('invalid string falls back to balanced', () => {
|
||||
expect(normalizePerformanceMode('foo')).toBe('balanced');
|
||||
});
|
||||
test('null/undefined fall back to balanced', () => {
|
||||
expect(normalizePerformanceMode(null)).toBe('balanced');
|
||||
expect(normalizePerformanceMode(undefined)).toBe('balanced');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPlainObject', () => {
|
||||
test('true for object literal', () => {
|
||||
expect(isPlainObject({})).toBe(true);
|
||||
expect(isPlainObject({ a: 1 })).toBe(true);
|
||||
});
|
||||
test('false for array', () => {
|
||||
expect(isPlainObject([])).toBe(false);
|
||||
expect(isPlainObject([1, 2, 3])).toBe(false);
|
||||
});
|
||||
test('false for null', () => {
|
||||
expect(isPlainObject(null)).toBe(false);
|
||||
});
|
||||
test('false for undefined', () => {
|
||||
expect(isPlainObject(undefined)).toBe(false);
|
||||
});
|
||||
test('false for primitives', () => {
|
||||
expect(isPlainObject('x')).toBe(false);
|
||||
expect(isPlainObject(42)).toBe(false);
|
||||
expect(isPlainObject(true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// Pure normalizer-Helpers fuer Config-Felder. Keine Side-Effects, keine Globals.
|
||||
|
||||
export type PerformanceMode = 'stability' | 'balanced' | 'speed';
|
||||
|
||||
export const VALID_STREAMLINK_QUALITIES = ['best', 'source', '1080p60', '720p60', '720p', '480p', 'audio_only'] as const;
|
||||
|
||||
const AUTO_RECORD_POLL_MIN_SECONDS = 30;
|
||||
const AUTO_RECORD_POLL_MAX_SECONDS = 1800;
|
||||
export const DEFAULT_METADATA_CACHE_MINUTES = 10;
|
||||
export const DEFAULT_PERFORMANCE_MODE: PerformanceMode = 'balanced';
|
||||
|
||||
/** trim + strip leading @ + lowercase. Verbatim aus altem main.ts. */
|
||||
export function normalizeLogin(input: string): string {
|
||||
return input.trim().replace(/^@+/, '').toLowerCase();
|
||||
}
|
||||
|
||||
export function normalizeAutoRecordPollSeconds(value: unknown): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return 90;
|
||||
return Math.max(AUTO_RECORD_POLL_MIN_SECONDS, Math.min(AUTO_RECORD_POLL_MAX_SECONDS, Math.floor(parsed)));
|
||||
}
|
||||
|
||||
export function normalizeAutoRecordList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const v of value) {
|
||||
if (typeof v !== 'string') continue;
|
||||
const cleaned = normalizeLogin(v);
|
||||
if (cleaned && !seen.has(cleaned)) {
|
||||
seen.add(cleaned);
|
||||
out.push(cleaned);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeStreamlinkQuality(value: unknown): string {
|
||||
if (typeof value === 'string' && (VALID_STREAMLINK_QUALITIES as readonly string[]).includes(value)) {
|
||||
return value;
|
||||
}
|
||||
return 'best';
|
||||
}
|
||||
|
||||
export function normalizeFilenameTemplate(template: string | undefined, fallback: string): string {
|
||||
const value = (template || '').trim();
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
export function normalizeMetadataCacheMinutes(value: unknown): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_METADATA_CACHE_MINUTES;
|
||||
}
|
||||
return Math.max(1, Math.min(120, Math.floor(parsed)));
|
||||
}
|
||||
|
||||
export function normalizePerformanceMode(mode: unknown): PerformanceMode {
|
||||
if (mode === 'stability' || mode === 'balanced' || mode === 'speed') {
|
||||
return mode;
|
||||
}
|
||||
return DEFAULT_PERFORMANCE_MODE;
|
||||
}
|
||||
|
||||
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import { tBackend, BACKEND_MESSAGES, type BackendMessageKey } from './i18n-backend';
|
||||
|
||||
describe('tBackend', () => {
|
||||
test('returns DE message for known key (default language)', () => {
|
||||
expect(tBackend('invalidVodUrl', undefined, 'de')).toBe(BACKEND_MESSAGES.de.invalidVodUrl);
|
||||
});
|
||||
|
||||
test('returns EN message when language=en', () => {
|
||||
expect(tBackend('invalidVodUrl', undefined, 'en')).toBe(BACKEND_MESSAGES.en.invalidVodUrl);
|
||||
});
|
||||
|
||||
test('unknown language falls back to de', () => {
|
||||
expect(tBackend('invalidVodUrl', undefined, 'fr')).toBe(BACKEND_MESSAGES.de.invalidVodUrl);
|
||||
expect(tBackend('invalidVodUrl', undefined, '')).toBe(BACKEND_MESSAGES.de.invalidVodUrl);
|
||||
});
|
||||
|
||||
test('substitutes single {param}', () => {
|
||||
const result = tBackend('streamlinkExitCode', { code: 42 }, 'en');
|
||||
expect(result).toBe('Streamlink exit code 42');
|
||||
});
|
||||
|
||||
test('substitutes multiple {params}', () => {
|
||||
const result = tBackend('integrityDurationMismatch', { actual: 100, expected: 120 }, 'de');
|
||||
expect(result).toContain('100');
|
||||
expect(result).toContain('120');
|
||||
expect(result).not.toContain('{actual}');
|
||||
expect(result).not.toContain('{expected}');
|
||||
});
|
||||
|
||||
test('numeric params stringify', () => {
|
||||
const result = tBackend('fileTooSmall', { bytes: 256 }, 'en');
|
||||
expect(result).toBe('File too small (256 bytes)');
|
||||
});
|
||||
|
||||
test('every DE key has an EN counterpart', () => {
|
||||
const deKeys = Object.keys(BACKEND_MESSAGES.de) as BackendMessageKey[];
|
||||
const enKeys = Object.keys(BACKEND_MESSAGES.en);
|
||||
for (const k of deKeys) {
|
||||
expect(enKeys).toContain(k);
|
||||
}
|
||||
});
|
||||
|
||||
test('no template literal left after substitution for typical params', () => {
|
||||
// attemptFailed has {attempt}, {max}, {errorClass}, {error}
|
||||
const result = tBackend('attemptFailed', { attempt: 1, max: 3, errorClass: 'network', error: 'ETIMEDOUT' }, 'en');
|
||||
expect(result).toBe('Attempt 1/3 failed (network): ETIMEDOUT');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
// Backend-Messages (User-visible aus main.ts produziert). Pure: Sprache wird
|
||||
// als Parameter uebergeben statt aus globalem config geholt.
|
||||
|
||||
export const BACKEND_MESSAGES = {
|
||||
de: {
|
||||
invalidVodUrl: 'Ungueltige VOD-URL',
|
||||
invalidClipUrl: 'Ungueltige Clip-URL',
|
||||
clipNotFound: 'Clip nicht gefunden',
|
||||
streamlinkAutoInstallFailed: 'Streamlink fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.',
|
||||
streamlinkMissing: 'Streamlink fehlt.',
|
||||
streamlinkNotFound: 'Streamlink nicht gefunden. Installiere Streamlink oder Python+streamlink (py -3 -m pip install streamlink).',
|
||||
streamlinkExitCode: 'Streamlink Fehlercode {code}',
|
||||
ffmpegMissing: 'FFmpeg fehlt.',
|
||||
ffmpegMergeFailed: 'FFmpeg Merge fehlgeschlagen.',
|
||||
ffmpegSplitFailed: 'FFmpeg Split fehlgeschlagen.',
|
||||
fileTooSmall: 'Datei zu klein ({bytes} Bytes)',
|
||||
clipFileTooSmall: 'Clip-Datei zu klein ({bytes} Bytes) - Twitch hat den Stream evtl. nicht ausgeliefert.',
|
||||
integrityNoVideo: 'Integritaetspruefung fehlgeschlagen: Kein Videostream gefunden.',
|
||||
integrityTooShort: 'Integritaetspruefung fehlgeschlagen: Dauer zu kurz ({duration}s).',
|
||||
integrityDurationMismatch: 'Integritaetspruefung fehlgeschlagen: {actual}s statt erwarteter ~{expected}s.',
|
||||
integrityFailedGeneric: 'Integritaetspruefung fehlgeschlagen.',
|
||||
downloadCancelled: 'Download wurde abgebrochen.',
|
||||
downloadPaused: 'Download wurde pausiert.',
|
||||
downloadFailedExitCode: 'Download fehlgeschlagen (Exit-Code {code})',
|
||||
unknownDownloadError: 'Unbekannter Fehler beim Download',
|
||||
notAllClipPartsDownloaded: 'Nicht alle Clip-Teile konnten heruntergeladen werden.',
|
||||
notAllPartsDownloaded: 'Nicht alle Teile konnten heruntergeladen werden.',
|
||||
mergeGroupFileMissing: 'Heruntergeladene Datei {index} fehlt.',
|
||||
diskSpaceShortFor: 'Zu wenig Speicherplatz fur {context}: frei {free}, benoetigt ~{required}.',
|
||||
diskSpaceShortGeneric: 'Zu wenig Speicherplatz.',
|
||||
attemptFailed: 'Versuch {attempt}/{max} fehlgeschlagen ({errorClass}): {error}',
|
||||
retryingIn: 'Neuer Versuch in {seconds}s ({errorClass})...',
|
||||
statusCheckingTools: 'Prufe Download-Tools...',
|
||||
statusDownloadStarted: 'Download gestartet',
|
||||
statusBytesDownloaded: '{bytes} heruntergeladen',
|
||||
statusFetchingChatReplay: 'Chat-Replay wird heruntergeladen...',
|
||||
statusChatMessagesFetched: 'Chat-Nachrichten geladen: {count}',
|
||||
preflightNoInternet: 'Keine Internetverbindung erkannt.',
|
||||
preflightStreamlinkMissing: 'Streamlink fehlt oder ist nicht startbar.',
|
||||
preflightFfmpegMissing: 'FFmpeg fehlt oder ist nicht startbar.',
|
||||
preflightFfprobeMissing: 'FFprobe fehlt oder ist nicht startbar.',
|
||||
preflightDownloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar.'
|
||||
},
|
||||
en: {
|
||||
invalidVodUrl: 'Invalid VOD URL',
|
||||
invalidClipUrl: 'Invalid clip URL',
|
||||
clipNotFound: 'Clip not found',
|
||||
streamlinkAutoInstallFailed: 'Streamlink is missing and could not be auto-installed. See debug.log.',
|
||||
streamlinkMissing: 'Streamlink is missing.',
|
||||
streamlinkNotFound: 'Streamlink not found. Install streamlink or Python+streamlink (py -3 -m pip install streamlink).',
|
||||
streamlinkExitCode: 'Streamlink exit code {code}',
|
||||
ffmpegMissing: 'FFmpeg is missing.',
|
||||
ffmpegMergeFailed: 'FFmpeg merge failed.',
|
||||
ffmpegSplitFailed: 'FFmpeg split failed.',
|
||||
fileTooSmall: 'File too small ({bytes} bytes)',
|
||||
clipFileTooSmall: 'Clip file too small ({bytes} bytes) - Twitch may not have served the stream.',
|
||||
integrityNoVideo: 'Integrity check failed: no video stream found.',
|
||||
integrityTooShort: 'Integrity check failed: duration too short ({duration}s).',
|
||||
integrityDurationMismatch: 'Integrity check failed: {actual}s instead of expected ~{expected}s.',
|
||||
integrityFailedGeneric: 'Integrity check failed.',
|
||||
downloadCancelled: 'Download was cancelled.',
|
||||
downloadPaused: 'Download was paused.',
|
||||
downloadFailedExitCode: 'Download failed (exit code {code})',
|
||||
unknownDownloadError: 'Unknown download error',
|
||||
notAllClipPartsDownloaded: 'Not all clip parts could be downloaded.',
|
||||
notAllPartsDownloaded: 'Not all parts could be downloaded.',
|
||||
mergeGroupFileMissing: 'Downloaded file {index} is missing.',
|
||||
diskSpaceShortFor: 'Not enough disk space for {context}: free {free}, need ~{required}.',
|
||||
diskSpaceShortGeneric: 'Not enough disk space.',
|
||||
attemptFailed: 'Attempt {attempt}/{max} failed ({errorClass}): {error}',
|
||||
retryingIn: 'Retrying in {seconds}s ({errorClass})...',
|
||||
statusCheckingTools: 'Checking download tools...',
|
||||
statusDownloadStarted: 'Download started',
|
||||
statusBytesDownloaded: '{bytes} downloaded',
|
||||
statusFetchingChatReplay: 'Fetching chat replay...',
|
||||
statusChatMessagesFetched: 'Chat messages fetched: {count}',
|
||||
preflightNoInternet: 'No internet connection detected.',
|
||||
preflightStreamlinkMissing: 'Streamlink is missing or not runnable.',
|
||||
preflightFfmpegMissing: 'FFmpeg is missing or not runnable.',
|
||||
preflightFfprobeMissing: 'FFprobe is missing or not runnable.',
|
||||
preflightDownloadPathNotWritable: 'Download folder is not writable.'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export type BackendMessageKey = keyof typeof BACKEND_MESSAGES.de;
|
||||
export type BackendLanguage = 'de' | 'en';
|
||||
|
||||
export function tBackend(
|
||||
key: BackendMessageKey,
|
||||
params: Record<string, string | number> | undefined,
|
||||
language: BackendLanguage | string
|
||||
): string {
|
||||
const lang: BackendLanguage = (language === 'en') ? 'en' : 'de';
|
||||
let template: string = BACKEND_MESSAGES[lang][key];
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
template = template.replace(`{${k}}`, String(v));
|
||||
}
|
||||
}
|
||||
return template;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import { parseFfprobeJson, assessIntegrity, verifyIntegrityFromJson } from './integrity-check';
|
||||
|
||||
const FIXTURE_GOOD = JSON.stringify({
|
||||
streams: [
|
||||
{ index: 0, codec_type: 'video', codec_name: 'h264', width: 1920, height: 1080, duration: '600.5' },
|
||||
{ index: 1, codec_type: 'audio', codec_name: 'aac', duration: '600.5' },
|
||||
],
|
||||
format: { duration: '600.5', size: '50000000' },
|
||||
});
|
||||
|
||||
const FIXTURE_NO_VIDEO = JSON.stringify({
|
||||
streams: [
|
||||
{ index: 0, codec_type: 'audio', codec_name: 'aac', duration: '10' },
|
||||
],
|
||||
format: { duration: '10', size: '500000' },
|
||||
});
|
||||
|
||||
const FIXTURE_EMPTY = JSON.stringify({
|
||||
streams: [],
|
||||
format: { duration: '0.04', size: '1234' },
|
||||
});
|
||||
|
||||
describe('parseFfprobeJson', () => {
|
||||
test('parses streams + format', () => {
|
||||
const r = parseFfprobeJson(FIXTURE_GOOD);
|
||||
expect(r.streams).toHaveLength(2);
|
||||
expect(r.streams[0].codecType).toBe('video');
|
||||
expect(r.streams[0].codecName).toBe('h264');
|
||||
expect(r.streams[0].width).toBe(1920);
|
||||
expect(r.durationSeconds).toBe(600.5);
|
||||
expect(r.sizeBytes).toBe(50000000);
|
||||
});
|
||||
|
||||
test('handles missing format gracefully', () => {
|
||||
const r = parseFfprobeJson(JSON.stringify({ streams: [] }));
|
||||
expect(r.durationSeconds).toBe(0);
|
||||
expect(r.sizeBytes).toBe(0);
|
||||
});
|
||||
|
||||
test('throws on malformed JSON', () => {
|
||||
expect(() => parseFfprobeJson('{not-valid')).toThrow(/parse failed/);
|
||||
});
|
||||
|
||||
test('coerces numeric strings to numbers', () => {
|
||||
const r = parseFfprobeJson(JSON.stringify({
|
||||
streams: [{ codec_type: 'video', duration: '12.34' }],
|
||||
format: { duration: '12.34', size: '987654' },
|
||||
}));
|
||||
expect(r.durationSeconds).toBe(12.34);
|
||||
expect(r.streams[0].durationSeconds).toBe(12.34);
|
||||
expect(r.sizeBytes).toBe(987654);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assessIntegrity', () => {
|
||||
test('valid file: ok=true, no reasons', () => {
|
||||
const probe = parseFfprobeJson(FIXTURE_GOOD);
|
||||
const v = assessIntegrity(probe);
|
||||
expect(v.ok).toBe(true);
|
||||
expect(v.reasons).toEqual([]);
|
||||
expect(v.hasVideo).toBe(true);
|
||||
expect(v.hasAudio).toBe(true);
|
||||
expect(v.durationSeconds).toBe(600.5);
|
||||
});
|
||||
|
||||
test('no-video stream rejected', () => {
|
||||
const v = assessIntegrity(parseFfprobeJson(FIXTURE_NO_VIDEO));
|
||||
expect(v.ok).toBe(false);
|
||||
expect(v.reasons).toContain('no-video-stream');
|
||||
expect(v.hasVideo).toBe(false);
|
||||
});
|
||||
|
||||
test('zero-duration rejected as too-short', () => {
|
||||
const v = assessIntegrity(parseFfprobeJson(FIXTURE_EMPTY));
|
||||
expect(v.ok).toBe(false);
|
||||
expect(v.reasons.some(r => r.startsWith('duration-too-short'))).toBe(true);
|
||||
});
|
||||
|
||||
test('expected-duration mismatch outside tolerance flagged', () => {
|
||||
const v = assessIntegrity(parseFfprobeJson(FIXTURE_GOOD), {
|
||||
expectedDurationSeconds: 700,
|
||||
durationToleranceSeconds: 5,
|
||||
});
|
||||
expect(v.ok).toBe(false);
|
||||
expect(v.reasons.some(r => r.startsWith('duration-mismatch'))).toBe(true);
|
||||
});
|
||||
|
||||
test('expected-duration within tolerance accepted', () => {
|
||||
const v = assessIntegrity(parseFfprobeJson(FIXTURE_GOOD), {
|
||||
expectedDurationSeconds: 598,
|
||||
durationToleranceSeconds: 5,
|
||||
});
|
||||
expect(v.ok).toBe(true);
|
||||
});
|
||||
|
||||
test('custom minDurationSeconds threshold', () => {
|
||||
const v = assessIntegrity(parseFfprobeJson(FIXTURE_GOOD), {
|
||||
minDurationSeconds: 700,
|
||||
});
|
||||
expect(v.ok).toBe(false);
|
||||
expect(v.reasons.some(r => r.startsWith('duration-too-short'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyIntegrityFromJson', () => {
|
||||
test('one-shot parse + assess', () => {
|
||||
const v = verifyIntegrityFromJson(FIXTURE_GOOD);
|
||||
expect(v.ok).toBe(true);
|
||||
});
|
||||
|
||||
test('propagates parse errors', () => {
|
||||
expect(() => verifyIntegrityFromJson('{broken')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// Wrappt ffprobe -show_streams -show_format -of json + entscheidet, ob eine
|
||||
// fertige Recording-/Download-Datei strukturell valide ist.
|
||||
// Pure-Parser-Layer ist getrennt testbar; das eigentliche Spawn ist im Caller.
|
||||
|
||||
export interface ProbeStream {
|
||||
index: number;
|
||||
codecType: string; // 'video' | 'audio' | 'subtitle' | ...
|
||||
codecName?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
durationSeconds?: number;
|
||||
}
|
||||
|
||||
export interface ProbeResult {
|
||||
streams: ProbeStream[];
|
||||
durationSeconds: number;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
export interface IntegrityVerdict {
|
||||
ok: boolean;
|
||||
reasons: string[];
|
||||
durationSeconds: number;
|
||||
hasVideo: boolean;
|
||||
hasAudio: boolean;
|
||||
}
|
||||
|
||||
export interface IntegrityCheckOptions {
|
||||
expectedDurationSeconds?: number;
|
||||
durationToleranceSeconds?: number; // default 5
|
||||
minDurationSeconds?: number; // default 1
|
||||
}
|
||||
|
||||
interface FfprobeJsonStream {
|
||||
index?: number;
|
||||
codec_type?: string;
|
||||
codec_name?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: string | number;
|
||||
}
|
||||
|
||||
interface FfprobeJson {
|
||||
streams?: FfprobeJsonStream[];
|
||||
format?: {
|
||||
duration?: string | number;
|
||||
size?: string | number;
|
||||
};
|
||||
}
|
||||
|
||||
function toNumber(v: unknown, fallback = 0): number {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
||||
if (typeof v === 'string') {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n)) return n;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function parseFfprobeJson(rawJson: string): ProbeResult {
|
||||
let parsed: FfprobeJson;
|
||||
try {
|
||||
parsed = JSON.parse(rawJson) as FfprobeJson;
|
||||
} catch (e) {
|
||||
throw new Error(`integrity-check: ffprobe JSON parse failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
const streams: ProbeStream[] = (parsed.streams ?? []).map((s, idx) => ({
|
||||
index: typeof s.index === 'number' ? s.index : idx,
|
||||
codecType: typeof s.codec_type === 'string' ? s.codec_type : 'unknown',
|
||||
codecName: typeof s.codec_name === 'string' ? s.codec_name : undefined,
|
||||
width: typeof s.width === 'number' ? s.width : undefined,
|
||||
height: typeof s.height === 'number' ? s.height : undefined,
|
||||
durationSeconds: s.duration !== undefined ? toNumber(s.duration) : undefined,
|
||||
}));
|
||||
|
||||
const formatDuration = toNumber(parsed.format?.duration, 0);
|
||||
const formatSize = toNumber(parsed.format?.size, 0);
|
||||
|
||||
return {
|
||||
streams,
|
||||
durationSeconds: formatDuration,
|
||||
sizeBytes: formatSize,
|
||||
};
|
||||
}
|
||||
|
||||
export function assessIntegrity(probe: ProbeResult, opts: IntegrityCheckOptions = {}): IntegrityVerdict {
|
||||
const minDuration = opts.minDurationSeconds ?? 1;
|
||||
const tolerance = opts.durationToleranceSeconds ?? 5;
|
||||
|
||||
const hasVideo = probe.streams.some(s => s.codecType === 'video');
|
||||
const hasAudio = probe.streams.some(s => s.codecType === 'audio');
|
||||
|
||||
const reasons: string[] = [];
|
||||
|
||||
if (!hasVideo) {
|
||||
reasons.push('no-video-stream');
|
||||
}
|
||||
|
||||
if (probe.durationSeconds < minDuration) {
|
||||
reasons.push(`duration-too-short:${probe.durationSeconds.toFixed(2)}s<${minDuration}s`);
|
||||
}
|
||||
|
||||
if (typeof opts.expectedDurationSeconds === 'number' && opts.expectedDurationSeconds > 0) {
|
||||
const diff = Math.abs(probe.durationSeconds - opts.expectedDurationSeconds);
|
||||
if (diff > tolerance) {
|
||||
reasons.push(
|
||||
`duration-mismatch:actual=${probe.durationSeconds.toFixed(2)}s,` +
|
||||
`expected=${opts.expectedDurationSeconds.toFixed(2)}s,` +
|
||||
`tolerance=${tolerance}s`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: reasons.length === 0,
|
||||
reasons,
|
||||
durationSeconds: probe.durationSeconds,
|
||||
hasVideo,
|
||||
hasAudio,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: vollstaendige integrity-check Pipeline. Caller liefert die
|
||||
* ffprobe-JSON-Ausgabe als String (so bleibt das Modul Spawn-frei + leicht
|
||||
* testbar; die main.ts hat schon ffprobe-Spawn-Helpers).
|
||||
*/
|
||||
export function verifyIntegrityFromJson(rawJson: string, opts?: IntegrityCheckOptions): IntegrityVerdict {
|
||||
const probe = parseFfprobeJson(rawJson);
|
||||
return assessIntegrity(probe, opts);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { openDatabase, type DbHandle } from '../infra/db';
|
||||
import { migrateJsonToSqlite } from './migrator';
|
||||
|
||||
let tmpDir: string;
|
||||
let appDataDir: string;
|
||||
let db: DbHandle;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'migrator-'));
|
||||
appDataDir = path.join(tmpDir, 'appdata');
|
||||
fs.mkdirSync(appDataDir, { recursive: true });
|
||||
db = openDatabase(path.join(tmpDir, 'app.db'));
|
||||
});
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
function writeJson(name: string, payload: unknown): string {
|
||||
const target = path.join(appDataDir, name);
|
||||
fs.writeFileSync(target, JSON.stringify(payload, null, 2), 'utf-8');
|
||||
return target;
|
||||
}
|
||||
|
||||
describe('migrateJsonToSqlite', () => {
|
||||
test('no JSON files: writes migrations_applied marker', () => {
|
||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
||||
expect(result.configMigrated).toBe(false);
|
||||
expect(result.queueMigrated).toBe(false);
|
||||
expect(result.downloadedVodsCount).toBe(0);
|
||||
expect(result.streamersCount).toBe(0);
|
||||
|
||||
const marker = db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', ['v4-to-v5-jsons']);
|
||||
expect(marker?.name).toBe('v4-to-v5-jsons');
|
||||
});
|
||||
|
||||
test('migrates config.json keys into config_kv', () => {
|
||||
writeJson('config.json', {
|
||||
language: 'de',
|
||||
performance_mode: 'speed',
|
||||
metadata_cache_minutes: 30,
|
||||
downloaded_vod_ids: ['1', '2', '3'],
|
||||
auto_record_streamers: ['foo', 'bar'],
|
||||
});
|
||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
||||
expect(result.configMigrated).toBe(true);
|
||||
|
||||
const lang = db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['language']);
|
||||
expect(JSON.parse(lang!.value)).toBe('de');
|
||||
|
||||
const perf = db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['performance_mode']);
|
||||
expect(JSON.parse(perf!.value)).toBe('speed');
|
||||
});
|
||||
|
||||
test('migrates downloaded_vod_ids', () => {
|
||||
writeJson('config.json', { downloaded_vod_ids: ['100', '200', '300'] });
|
||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
||||
expect(result.downloadedVodsCount).toBe(3);
|
||||
const rows = db.all<{ vod_id: string }>('SELECT vod_id FROM downloaded_vods ORDER BY vod_id');
|
||||
expect(rows.map(r => r.vod_id)).toEqual(['100', '200', '300']);
|
||||
});
|
||||
|
||||
test('migrates streamers from both auto-record and auto-vod-download lists', () => {
|
||||
writeJson('config.json', {
|
||||
auto_record_streamers: ['Alice', '@bob'],
|
||||
auto_vod_download_streamers: ['bob', 'carol'],
|
||||
});
|
||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
||||
expect(result.streamersCount).toBeGreaterThanOrEqual(3);
|
||||
|
||||
const alice = db.get<{ login: string; auto_record: number }>('SELECT login, auto_record FROM streamers WHERE login = ?', ['alice']);
|
||||
expect(alice?.auto_record).toBe(1);
|
||||
|
||||
const bob = db.get<{ login: string; auto_record: number; auto_vod_download: number }>('SELECT login, auto_record, auto_vod_download FROM streamers WHERE login = ?', ['bob']);
|
||||
expect(bob?.auto_record).toBe(1);
|
||||
expect(bob?.auto_vod_download).toBe(1);
|
||||
|
||||
const carol = db.get<{ login: string; auto_vod_download: number }>('SELECT login, auto_vod_download FROM streamers WHERE login = ?', ['carol']);
|
||||
expect(carol?.auto_vod_download).toBe(1);
|
||||
});
|
||||
|
||||
test('migrates download_queue.json items', () => {
|
||||
writeJson('download_queue.json', [
|
||||
{ id: 'q1', status: 'pending', streamer: 'foo', vod_id: 'v1', created_at: 1000, updated_at: 1000 },
|
||||
{ id: 'q2', status: 'completed', streamer: 'bar', vod_id: 'v2', created_at: 2000, updated_at: 3000, completed_at: 3000 },
|
||||
]);
|
||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
||||
expect(result.queueMigrated).toBe(true);
|
||||
|
||||
const all = db.all<{ id: string; status: string }>('SELECT id, status FROM queue_items ORDER BY id');
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all[0].status).toBe('pending');
|
||||
expect(all[1].status).toBe('completed');
|
||||
});
|
||||
|
||||
test('idempotent second run', () => {
|
||||
writeJson('config.json', { downloaded_vod_ids: ['1', '2'] });
|
||||
migrateJsonToSqlite({ db, appDataDir });
|
||||
const result2 = migrateJsonToSqlite({ db, appDataDir });
|
||||
expect(result2.alreadyApplied).toBe(true);
|
||||
const count = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM downloaded_vods');
|
||||
expect(count?.c).toBe(2);
|
||||
});
|
||||
|
||||
test('writes .v4-backup of source JSONs', () => {
|
||||
const configPath = writeJson('config.json', { language: 'en' });
|
||||
migrateJsonToSqlite({ db, appDataDir });
|
||||
expect(fs.existsSync(configPath + '.v4-backup')).toBe(true);
|
||||
expect(fs.readFileSync(configPath + '.v4-backup', 'utf-8')).toContain('"language": "en"');
|
||||
});
|
||||
|
||||
test('malformed JSON is logged + skipped', () => {
|
||||
fs.writeFileSync(path.join(appDataDir, 'config.json'), '{ not valid json', 'utf-8');
|
||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
||||
expect(result.configMigrated).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(result.errors[0].source).toBe('config.json');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { DbHandle } from '../infra/db';
|
||||
import { normalizeLogin } from './config-normalize';
|
||||
|
||||
export interface MigratorOptions {
|
||||
db: DbHandle;
|
||||
appDataDir: string;
|
||||
}
|
||||
|
||||
export interface MigrationError {
|
||||
source: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface MigrationResult {
|
||||
alreadyApplied: boolean;
|
||||
configMigrated: boolean;
|
||||
queueMigrated: boolean;
|
||||
downloadedVodsCount: number;
|
||||
streamersCount: number;
|
||||
errors: MigrationError[];
|
||||
}
|
||||
|
||||
const MIGRATION_NAME = 'v4-to-v5-jsons';
|
||||
|
||||
const CONFIG_KV_KEYS = [
|
||||
'language', 'performance_mode', 'metadata_cache_minutes', 'streamlink_quality',
|
||||
'streamlink_disable_ads', 'download_chat_replay', 'capture_live_chat',
|
||||
'discord_webhook_url', 'discord_notify_live_start', 'discord_notify_live_end',
|
||||
'discord_notify_vod_complete', 'discord_notify_vod_auto_queued',
|
||||
'auto_cleanup_enabled', 'auto_cleanup_days', 'auto_cleanup_target',
|
||||
'auto_cleanup_action', 'log_stream_events', 'auto_vod_download_poll_minutes',
|
||||
'auto_vod_max_age_hours', 'auto_resume_live_recording',
|
||||
'auto_merge_resumed_parts', 'delete_parts_after_merge',
|
||||
'auto_record_poll_seconds', 'filename_template_vod', 'filename_template_parts',
|
||||
'filename_template_clip', 'smart_queue_scheduler', 'prevent_duplicate_downloads',
|
||||
'persist_queue_on_restart', 'auto_resume_queue_on_startup',
|
||||
'notify_on_each_completion',
|
||||
] as const;
|
||||
|
||||
function backupOnce(srcPath: string): void {
|
||||
const backupPath = srcPath + '.v4-backup';
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
fs.copyFileSync(srcPath, backupPath);
|
||||
}
|
||||
}
|
||||
|
||||
function migrateConfig(db: DbHandle, configPath: string, errors: MigrationError[]): { ok: boolean; vodCount: number } {
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
let vodCount = 0;
|
||||
db.transaction(() => {
|
||||
for (const key of CONFIG_KV_KEYS) {
|
||||
if (key in config) {
|
||||
db.run(
|
||||
"INSERT OR REPLACE INTO config_kv(key, value, updated_at) VALUES (?, ?, strftime('%s','now'))",
|
||||
[key, JSON.stringify(config[key])]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const vodIds = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids : [];
|
||||
for (const id of vodIds) {
|
||||
if (typeof id !== 'string' || !id) continue;
|
||||
db.run('INSERT OR IGNORE INTO downloaded_vods(vod_id) VALUES (?)', [id]);
|
||||
vodCount += 1;
|
||||
}
|
||||
|
||||
const autoRec = Array.isArray(config.auto_record_streamers) ? config.auto_record_streamers : [];
|
||||
for (const s of autoRec) {
|
||||
if (typeof s !== 'string' || !s) continue;
|
||||
const login = normalizeLogin(s);
|
||||
if (!login) continue;
|
||||
db.run(
|
||||
'INSERT INTO streamers(login, auto_record) VALUES (?, 1) ON CONFLICT(login) DO UPDATE SET auto_record = 1',
|
||||
[login]
|
||||
);
|
||||
}
|
||||
|
||||
const autoDl = Array.isArray(config.auto_vod_download_streamers) ? config.auto_vod_download_streamers : [];
|
||||
for (const s of autoDl) {
|
||||
if (typeof s !== 'string' || !s) continue;
|
||||
const login = normalizeLogin(s);
|
||||
if (!login) continue;
|
||||
db.run(
|
||||
'INSERT INTO streamers(login, auto_vod_download) VALUES (?, 1) ON CONFLICT(login) DO UPDATE SET auto_vod_download = 1',
|
||||
[login]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
backupOnce(configPath);
|
||||
return { ok: true, vodCount };
|
||||
} catch (e) {
|
||||
errors.push({ source: 'config.json', message: e instanceof Error ? e.message : String(e) });
|
||||
return { ok: false, vodCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function migrateQueue(db: DbHandle, queuePath: string, errors: MigrationError[]): boolean {
|
||||
try {
|
||||
const raw = fs.readFileSync(queuePath, 'utf-8');
|
||||
const queue = JSON.parse(raw);
|
||||
if (!Array.isArray(queue)) return false;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
db.transaction(() => {
|
||||
for (const rawItem of queue) {
|
||||
if (!rawItem || typeof rawItem !== 'object') continue;
|
||||
const item = rawItem as Record<string, unknown>;
|
||||
const id = typeof item.id === 'string' ? item.id : null;
|
||||
if (!id) continue;
|
||||
db.run(
|
||||
`INSERT OR REPLACE INTO queue_items
|
||||
(id, streamer_login, vod_id, clip_id, title, output_path, status,
|
||||
progress_pct, error_message, created_at, updated_at, completed_at, payload_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
typeof item.streamer === 'string' ? normalizeLogin(item.streamer) : null,
|
||||
typeof item.vod_id === 'string' ? item.vod_id : null,
|
||||
typeof item.clip_id === 'string' ? item.clip_id : null,
|
||||
typeof item.title === 'string' ? item.title : null,
|
||||
typeof item.output_path === 'string' ? item.output_path : null,
|
||||
typeof item.status === 'string' ? item.status : 'pending',
|
||||
typeof item.progress_pct === 'number' ? item.progress_pct : null,
|
||||
typeof item.error_message === 'string' ? item.error_message : null,
|
||||
typeof item.created_at === 'number' ? item.created_at : now,
|
||||
typeof item.updated_at === 'number' ? item.updated_at : now,
|
||||
typeof item.completed_at === 'number' ? item.completed_at : null,
|
||||
JSON.stringify(item),
|
||||
]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
backupOnce(queuePath);
|
||||
return true;
|
||||
} catch (e) {
|
||||
errors.push({ source: 'download_queue.json', message: e instanceof Error ? e.message : String(e) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult {
|
||||
const { db, appDataDir } = opts;
|
||||
const errors: MigrationError[] = [];
|
||||
|
||||
const existing = db.get<{ name: string }>(
|
||||
'SELECT name FROM migrations_applied WHERE name = ?',
|
||||
[MIGRATION_NAME]
|
||||
);
|
||||
if (existing) {
|
||||
return {
|
||||
alreadyApplied: true,
|
||||
configMigrated: false,
|
||||
queueMigrated: false,
|
||||
downloadedVodsCount: 0,
|
||||
streamersCount: 0,
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
let configMigrated = false;
|
||||
let queueMigrated = false;
|
||||
let downloadedVodsCount = 0;
|
||||
|
||||
const configPath = path.join(appDataDir, 'config.json');
|
||||
if (fs.existsSync(configPath)) {
|
||||
const r = migrateConfig(db, configPath, errors);
|
||||
configMigrated = r.ok;
|
||||
downloadedVodsCount = r.vodCount;
|
||||
}
|
||||
|
||||
const queuePath = path.join(appDataDir, 'download_queue.json');
|
||||
if (fs.existsSync(queuePath)) {
|
||||
queueMigrated = migrateQueue(db, queuePath, errors);
|
||||
}
|
||||
|
||||
const streamersCount = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM streamers')?.c ?? 0;
|
||||
|
||||
db.run(
|
||||
'INSERT INTO migrations_applied(name, payload) VALUES (?, ?)',
|
||||
[
|
||||
MIGRATION_NAME,
|
||||
JSON.stringify({ configMigrated, queueMigrated, downloadedVodsCount, streamersCount, errorCount: errors.length }),
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
alreadyApplied: false,
|
||||
configMigrated,
|
||||
queueMigrated,
|
||||
downloadedVodsCount,
|
||||
streamersCount,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import * as crypto from 'crypto';
|
||||
import { createPkcePair, generateState } from './pkce';
|
||||
|
||||
describe('createPkcePair', () => {
|
||||
test('returns S256 method', () => {
|
||||
expect(createPkcePair().codeChallengeMethod).toBe('S256');
|
||||
});
|
||||
|
||||
test('verifier is 43+ chars base64url-safe', () => {
|
||||
const { codeVerifier } = createPkcePair();
|
||||
expect(codeVerifier.length).toBeGreaterThanOrEqual(43);
|
||||
// RFC 7636 unreserved chars only: [A-Z a-z 0-9 - . _ ~]
|
||||
// base64url uses [A-Z a-z 0-9 - _], no = padding.
|
||||
expect(/^[A-Za-z0-9_-]+$/.test(codeVerifier)).toBe(true);
|
||||
});
|
||||
|
||||
test('challenge matches sha256(verifier) base64url-encoded', () => {
|
||||
const pair = createPkcePair();
|
||||
const expected = crypto.createHash('sha256').update(pair.codeVerifier).digest('base64')
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
expect(pair.codeChallenge).toBe(expected);
|
||||
});
|
||||
|
||||
test('two pairs differ (sufficient entropy)', () => {
|
||||
const a = createPkcePair();
|
||||
const b = createPkcePair();
|
||||
expect(a.codeVerifier).not.toBe(b.codeVerifier);
|
||||
expect(a.codeChallenge).not.toBe(b.codeChallenge);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateState', () => {
|
||||
test('returns >= 16 chars', () => {
|
||||
expect(generateState().length).toBeGreaterThanOrEqual(16);
|
||||
});
|
||||
|
||||
test('base64url-safe charset', () => {
|
||||
expect(/^[A-Za-z0-9_-]+$/.test(generateState())).toBe(true);
|
||||
});
|
||||
|
||||
test('two states differ', () => {
|
||||
expect(generateState()).not.toBe(generateState());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* PKCE (Proof Key for Code Exchange) Helper fuer OAuth 2.1 Authorization Code Flow.
|
||||
* RFC 7636. Twitch unterstuetzt S256.
|
||||
*/
|
||||
|
||||
export interface PkcePair {
|
||||
codeVerifier: string; // 43-128 ASCII chars [A-Z a-z 0-9 - . _ ~]
|
||||
codeChallenge: string; // base64url(sha256(codeVerifier))
|
||||
codeChallengeMethod: 'S256';
|
||||
}
|
||||
|
||||
function base64url(buf: Buffer): string {
|
||||
return buf.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function createPkcePair(): PkcePair {
|
||||
// 32 random bytes → 43-char base64url. Innerhalb der RFC-Range.
|
||||
const verifier = base64url(crypto.randomBytes(32));
|
||||
const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
|
||||
return {
|
||||
codeVerifier: verifier,
|
||||
codeChallenge: challenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
};
|
||||
}
|
||||
|
||||
export function generateState(): string {
|
||||
// 16 random bytes als base64url-State-Parameter (CSRF-Schutz).
|
||||
return base64url(crypto.randomBytes(16));
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { openDatabase, type DbHandle } from '../infra/db';
|
||||
import { MemorySecureStorage } from '../infra/secure-storage';
|
||||
import { createTokenStore, type TokenStore } from './token-store';
|
||||
|
||||
let tmpDir: string;
|
||||
let db: DbHandle;
|
||||
let store: TokenStore;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tokens-'));
|
||||
db = openDatabase(path.join(tmpDir, 'app.db'));
|
||||
store = createTokenStore(db, new MemorySecureStorage());
|
||||
});
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
describe('createTokenStore', () => {
|
||||
test('upsert new account returns record with id > 0', () => {
|
||||
const rec = store.upsert({
|
||||
provider: 'twitch',
|
||||
twitchUserId: 'u1',
|
||||
login: 'alice',
|
||||
accessToken: 'aaa.aaa.aaa',
|
||||
});
|
||||
expect(rec.id).toBeGreaterThan(0);
|
||||
expect(rec.login).toBe('alice');
|
||||
expect(rec.provider).toBe('twitch');
|
||||
expect(rec.twitchUserId).toBe('u1');
|
||||
});
|
||||
|
||||
test('upsert same (provider, twitch_user_id) updates, no duplicate row', () => {
|
||||
store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'alice', accessToken: 't1' });
|
||||
const updated = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'alice2', accessToken: 't2' });
|
||||
expect(updated.login).toBe('alice2');
|
||||
const all = store.list('twitch');
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0].login).toBe('alice2');
|
||||
});
|
||||
|
||||
test('list() returns all accounts, list(provider) filters', () => {
|
||||
store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x' });
|
||||
store.upsert({ provider: 'twitch', twitchUserId: 'u2', login: 'b', accessToken: 'y' });
|
||||
store.upsert({ provider: 'youtube', twitchUserId: undefined, login: 'c', accessToken: 'z' });
|
||||
expect(store.list()).toHaveLength(3);
|
||||
expect(store.list('twitch')).toHaveLength(2);
|
||||
expect(store.list('youtube')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('getDefault returns null when nothing default', () => {
|
||||
store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x' });
|
||||
expect(store.getDefault('twitch')).toBeNull();
|
||||
});
|
||||
|
||||
test('upsert with isDefault=true makes it default, demotes siblings', () => {
|
||||
const a = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x', isDefault: true });
|
||||
const b = store.upsert({ provider: 'twitch', twitchUserId: 'u2', login: 'b', accessToken: 'y', isDefault: true });
|
||||
|
||||
const def = store.getDefault('twitch');
|
||||
expect(def?.id).toBe(b.id);
|
||||
|
||||
const aAgain = store.list('twitch').find(r => r.id === a.id);
|
||||
expect(aAgain?.isDefault).toBe(false);
|
||||
});
|
||||
|
||||
test('setDefault toggles is_default exclusivity within provider', () => {
|
||||
const a = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x', isDefault: true });
|
||||
const b = store.upsert({ provider: 'twitch', twitchUserId: 'u2', login: 'b', accessToken: 'y' });
|
||||
|
||||
store.setDefault(b.id);
|
||||
expect(store.getDefault('twitch')?.id).toBe(b.id);
|
||||
|
||||
const aAgain = store.list('twitch').find(r => r.id === a.id);
|
||||
expect(aAgain?.isDefault).toBe(false);
|
||||
});
|
||||
|
||||
test('getAccessToken returns decrypted plaintext', () => {
|
||||
const rec = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'super-secret-token' });
|
||||
expect(store.getAccessToken(rec.id)).toBe('super-secret-token');
|
||||
});
|
||||
|
||||
test('getRefreshToken returns null if not provided, value if provided', () => {
|
||||
const noRefresh = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 't1' });
|
||||
expect(store.getRefreshToken(noRefresh.id)).toBeNull();
|
||||
|
||||
const withRefresh = store.upsert({
|
||||
provider: 'twitch', twitchUserId: 'u2', login: 'b',
|
||||
accessToken: 't2', refreshToken: 'refresh-xyz',
|
||||
});
|
||||
expect(store.getRefreshToken(withRefresh.id)).toBe('refresh-xyz');
|
||||
});
|
||||
|
||||
test('scopes roundtrip as array', () => {
|
||||
const rec = store.upsert({
|
||||
provider: 'twitch', twitchUserId: 'u1', login: 'a',
|
||||
accessToken: 't', scopes: ['user:read:email', 'channel:read:subscriptions'],
|
||||
});
|
||||
expect(rec.scopes).toEqual(['user:read:email', 'channel:read:subscriptions']);
|
||||
});
|
||||
|
||||
test('delete removes the record', () => {
|
||||
const rec = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x' });
|
||||
store.delete(rec.id);
|
||||
expect(store.list('twitch')).toHaveLength(0);
|
||||
expect(() => store.getAccessToken(rec.id)).toThrow();
|
||||
});
|
||||
|
||||
test('expiresAt roundtrip', () => {
|
||||
const future = Math.floor(Date.now() / 1000) + 3600;
|
||||
const rec = store.upsert({
|
||||
provider: 'twitch', twitchUserId: 'u1', login: 'a',
|
||||
accessToken: 't', expiresAt: future,
|
||||
});
|
||||
expect(rec.expiresAt).toBe(future);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { DbHandle } from '../infra/db';
|
||||
import type { SecureStorage } from '../infra/secure-storage';
|
||||
|
||||
export interface TokenRecord {
|
||||
id: number;
|
||||
provider: string;
|
||||
twitchUserId: string | null;
|
||||
login: string | null;
|
||||
displayName: string | null;
|
||||
expiresAt: number | null;
|
||||
scopes: string[];
|
||||
isDefault: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface TokenWriteInput {
|
||||
provider: string;
|
||||
twitchUserId?: string;
|
||||
login?: string;
|
||||
displayName?: string;
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
scopes?: string[];
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface TokenStore {
|
||||
upsert(input: TokenWriteInput): TokenRecord;
|
||||
list(provider?: string): TokenRecord[];
|
||||
getDefault(provider: string): TokenRecord | null;
|
||||
setDefault(id: number): void;
|
||||
getAccessToken(id: number): string;
|
||||
getRefreshToken(id: number): string | null;
|
||||
delete(id: number): void;
|
||||
}
|
||||
|
||||
interface TokenRow {
|
||||
id: number;
|
||||
provider: string;
|
||||
twitch_user_id: string | null;
|
||||
login: string | null;
|
||||
display_name: string | null;
|
||||
encrypted_access_token: string;
|
||||
encrypted_refresh_token: string | null;
|
||||
expires_at: number | null;
|
||||
scopes_json: string | null;
|
||||
is_default: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
function rowToRecord(row: TokenRow): TokenRecord {
|
||||
let scopes: string[] = [];
|
||||
if (row.scopes_json) {
|
||||
try {
|
||||
const parsed = JSON.parse(row.scopes_json);
|
||||
if (Array.isArray(parsed)) {
|
||||
scopes = parsed.filter((s): s is string => typeof s === 'string');
|
||||
}
|
||||
} catch { /* malformed scopes payload — treat as empty */ }
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
provider: row.provider,
|
||||
twitchUserId: row.twitch_user_id,
|
||||
login: row.login,
|
||||
displayName: row.display_name,
|
||||
expiresAt: row.expires_at,
|
||||
scopes,
|
||||
isDefault: row.is_default === 1,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTokenStore(db: DbHandle, storage: SecureStorage): TokenStore {
|
||||
function getRowOrThrow(id: number): TokenRow {
|
||||
const row = db.get<TokenRow>('SELECT * FROM oauth_accounts WHERE id = ?', [id]);
|
||||
if (!row) throw new Error(`token-store: account id=${id} not found`);
|
||||
return row;
|
||||
}
|
||||
|
||||
return {
|
||||
upsert(input: TokenWriteInput): TokenRecord {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const encryptedAccess = storage.encrypt(input.accessToken);
|
||||
const encryptedRefresh = input.refreshToken !== undefined
|
||||
? storage.encrypt(input.refreshToken)
|
||||
: null;
|
||||
const scopesJson = input.scopes && input.scopes.length > 0
|
||||
? JSON.stringify(input.scopes)
|
||||
: null;
|
||||
const isDefault = input.isDefault ? 1 : 0;
|
||||
const twitchUserId = input.twitchUserId ?? null;
|
||||
|
||||
let resultId: number | null = null;
|
||||
|
||||
db.transaction(() => {
|
||||
// Insert or update conditional on UNIQUE(provider, twitch_user_id).
|
||||
// Sqlite's ON CONFLICT braucht den vollstaendigen Konflikt-Ausdruck.
|
||||
db.run(
|
||||
`INSERT INTO oauth_accounts(
|
||||
provider, twitch_user_id, login, display_name,
|
||||
encrypted_access_token, encrypted_refresh_token,
|
||||
expires_at, scopes_json, is_default, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(provider, twitch_user_id) DO UPDATE SET
|
||||
login = excluded.login,
|
||||
display_name = excluded.display_name,
|
||||
encrypted_access_token = excluded.encrypted_access_token,
|
||||
encrypted_refresh_token = excluded.encrypted_refresh_token,
|
||||
expires_at = excluded.expires_at,
|
||||
scopes_json = excluded.scopes_json,
|
||||
is_default = excluded.is_default,
|
||||
updated_at = excluded.updated_at`,
|
||||
[
|
||||
input.provider,
|
||||
twitchUserId,
|
||||
input.login ?? null,
|
||||
input.displayName ?? null,
|
||||
encryptedAccess,
|
||||
encryptedRefresh,
|
||||
input.expiresAt ?? null,
|
||||
scopesJson,
|
||||
isDefault,
|
||||
now,
|
||||
now,
|
||||
]
|
||||
);
|
||||
|
||||
// Wenn dieser Eintrag default ist: alle anderen mit gleichem provider auf 0 setzen.
|
||||
if (isDefault === 1) {
|
||||
db.run(
|
||||
`UPDATE oauth_accounts
|
||||
SET is_default = 0, updated_at = ?
|
||||
WHERE provider = ?
|
||||
AND NOT (twitch_user_id IS ? AND provider IS ?)`,
|
||||
[now, input.provider, twitchUserId, input.provider]
|
||||
);
|
||||
}
|
||||
|
||||
const lookup = db.get<{ id: number }>(
|
||||
`SELECT id FROM oauth_accounts
|
||||
WHERE provider = ?
|
||||
AND (twitch_user_id IS ? OR (twitch_user_id IS NULL AND ? IS NULL))`,
|
||||
[input.provider, twitchUserId, twitchUserId]
|
||||
);
|
||||
resultId = lookup?.id ?? null;
|
||||
});
|
||||
|
||||
if (resultId === null) throw new Error('token-store: upsert lookup failed');
|
||||
return rowToRecord(getRowOrThrow(resultId));
|
||||
},
|
||||
|
||||
list(provider?: string): TokenRecord[] {
|
||||
const rows = provider
|
||||
? db.all<TokenRow>('SELECT * FROM oauth_accounts WHERE provider = ? ORDER BY id', [provider])
|
||||
: db.all<TokenRow>('SELECT * FROM oauth_accounts ORDER BY id');
|
||||
return rows.map(rowToRecord);
|
||||
},
|
||||
|
||||
getDefault(provider: string): TokenRecord | null {
|
||||
const row = db.get<TokenRow>(
|
||||
'SELECT * FROM oauth_accounts WHERE provider = ? AND is_default = 1 LIMIT 1',
|
||||
[provider]
|
||||
);
|
||||
return row ? rowToRecord(row) : null;
|
||||
},
|
||||
|
||||
setDefault(id: number): void {
|
||||
const target = getRowOrThrow(id);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
db.transaction(() => {
|
||||
db.run(
|
||||
'UPDATE oauth_accounts SET is_default = 0, updated_at = ? WHERE provider = ?',
|
||||
[now, target.provider]
|
||||
);
|
||||
db.run(
|
||||
'UPDATE oauth_accounts SET is_default = 1, updated_at = ? WHERE id = ?',
|
||||
[now, id]
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
getAccessToken(id: number): string {
|
||||
const row = getRowOrThrow(id);
|
||||
return storage.decrypt(row.encrypted_access_token);
|
||||
},
|
||||
|
||||
getRefreshToken(id: number): string | null {
|
||||
const row = getRowOrThrow(id);
|
||||
return row.encrypted_refresh_token
|
||||
? storage.decrypt(row.encrypted_refresh_token)
|
||||
: null;
|
||||
},
|
||||
|
||||
delete(id: number): void {
|
||||
db.run('DELETE FROM oauth_accounts WHERE id = ?', [id]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import { fetchTopClips, rangeLastDays } from './top-clips-crawler';
|
||||
|
||||
function fakeFetch(rows: Array<Record<string, unknown>>, status = 200): typeof fetch {
|
||||
return (async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
// verify request shape lightly inside the fake
|
||||
const headers = init?.headers as Record<string, string> | undefined;
|
||||
if (status === 200 && (!headers?.['Authorization'] || !headers?.['Client-Id'])) {
|
||||
return new Response('missing auth headers', { status: 401 });
|
||||
}
|
||||
return new Response(JSON.stringify({ data: rows }), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe('fetchTopClips', () => {
|
||||
test('returns parsed clips sorted by view_count desc', async () => {
|
||||
const fakeRows = [
|
||||
{
|
||||
id: 'C2', url: 'u2', embed_url: 'e2', broadcaster_id: 'b', broadcaster_name: 'B',
|
||||
creator_id: 'c', creator_name: 'C', video_id: 'v', game_id: 'g', language: 'en',
|
||||
title: 'mid', view_count: 50, created_at: '2026-05-10T00:00:00Z',
|
||||
thumbnail_url: 't', duration: 30, vod_offset: 120,
|
||||
},
|
||||
{
|
||||
id: 'C1', url: 'u1', embed_url: 'e1', broadcaster_id: 'b', broadcaster_name: 'B',
|
||||
creator_id: 'c', creator_name: 'C', video_id: 'v', game_id: 'g', language: 'en',
|
||||
title: 'high', view_count: 200, created_at: '2026-05-09T00:00:00Z',
|
||||
thumbnail_url: 't', duration: 45, vod_offset: null,
|
||||
},
|
||||
];
|
||||
const clips = await fetchTopClips({
|
||||
clientId: 'CID', accessToken: 'TOK', broadcasterId: 'b',
|
||||
fetchImpl: fakeFetch(fakeRows),
|
||||
});
|
||||
|
||||
expect(clips).toHaveLength(2);
|
||||
expect(clips[0].id).toBe('C1');
|
||||
expect(clips[0].viewCount).toBe(200);
|
||||
expect(clips[1].id).toBe('C2');
|
||||
expect(clips[1].vodOffsetSeconds).toBe(120);
|
||||
expect(clips[0].vodOffsetSeconds).toBeNull();
|
||||
});
|
||||
|
||||
test('snake_case → camelCase mapping for broadcaster fields', async () => {
|
||||
const fakeRows = [
|
||||
{
|
||||
id: 'X', url: 'u', embed_url: 'e', broadcaster_id: 'bid', broadcaster_name: 'BName',
|
||||
creator_id: 'cid', creator_name: 'CName', video_id: 'vid', game_id: 'gid',
|
||||
language: 'de', title: 'T', view_count: 10, created_at: '2026-05-01T00:00:00Z',
|
||||
thumbnail_url: 'th', duration: 12,
|
||||
},
|
||||
];
|
||||
const [c] = await fetchTopClips({
|
||||
clientId: 'CID', accessToken: 'TOK', broadcasterId: 'bid',
|
||||
fetchImpl: fakeFetch(fakeRows),
|
||||
});
|
||||
expect(c.broadcasterId).toBe('bid');
|
||||
expect(c.broadcasterName).toBe('BName');
|
||||
expect(c.creatorId).toBe('cid');
|
||||
expect(c.creatorName).toBe('CName');
|
||||
expect(c.videoId).toBe('vid');
|
||||
expect(c.gameId).toBe('gid');
|
||||
});
|
||||
|
||||
test('builds query string with broadcaster_id + first + date range', async () => {
|
||||
let capturedUrl: string | null = null;
|
||||
const captureFetch = (async (url: string | URL | Request): Promise<Response> => {
|
||||
capturedUrl = String(url);
|
||||
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await fetchTopClips({
|
||||
clientId: 'CID', accessToken: 'TOK', broadcasterId: '12345',
|
||||
startedAt: '2026-05-01T00:00:00Z', endedAt: '2026-05-11T00:00:00Z',
|
||||
first: 50, fetchImpl: captureFetch,
|
||||
});
|
||||
expect(capturedUrl).toContain('broadcaster_id=12345');
|
||||
expect(capturedUrl).toContain('first=50');
|
||||
expect(capturedUrl).toContain('started_at=2026-05-01T00%3A00%3A00Z');
|
||||
expect(capturedUrl).toContain('ended_at=2026-05-11T00%3A00%3A00Z');
|
||||
});
|
||||
|
||||
test('clamps first to [1, 100]', async () => {
|
||||
let capturedUrl: string | null = null;
|
||||
const captureFetch = (async (url: string | URL | Request): Promise<Response> => {
|
||||
capturedUrl = String(url);
|
||||
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await fetchTopClips({ clientId: 'C', accessToken: 'T', broadcasterId: 'b', first: 999, fetchImpl: captureFetch });
|
||||
expect(capturedUrl).toContain('first=100');
|
||||
|
||||
await fetchTopClips({ clientId: 'C', accessToken: 'T', broadcasterId: 'b', first: 0, fetchImpl: captureFetch });
|
||||
expect(capturedUrl).toContain('first=1');
|
||||
});
|
||||
|
||||
test('throws on non-2xx response', async () => {
|
||||
await expect(fetchTopClips({
|
||||
clientId: 'C', accessToken: 'T', broadcasterId: 'b',
|
||||
fetchImpl: fakeFetch([], 503),
|
||||
})).rejects.toThrow(/503/);
|
||||
});
|
||||
|
||||
test('throws on malformed JSON', async () => {
|
||||
const brokenFetch = (async (): Promise<Response> => new Response('{not-json', { status: 200 })) as unknown as typeof fetch;
|
||||
await expect(fetchTopClips({
|
||||
clientId: 'C', accessToken: 'T', broadcasterId: 'b', fetchImpl: brokenFetch,
|
||||
})).rejects.toThrow(/parse failed/);
|
||||
});
|
||||
|
||||
test('empty data returns empty array (not null)', async () => {
|
||||
const emptyFetch = (async (): Promise<Response> => new Response(JSON.stringify({ data: [] }), { status: 200 })) as unknown as typeof fetch;
|
||||
const clips = await fetchTopClips({
|
||||
clientId: 'C', accessToken: 'T', broadcasterId: 'b', fetchImpl: emptyFetch,
|
||||
});
|
||||
expect(clips).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rangeLastDays', () => {
|
||||
test('produces ISO RFC3339 strings exactly N days apart', () => {
|
||||
const now = new Date('2026-05-11T12:00:00Z');
|
||||
const range = rangeLastDays(7, now);
|
||||
expect(range.endedAt).toBe('2026-05-11T12:00:00.000Z');
|
||||
expect(range.startedAt).toBe('2026-05-04T12:00:00.000Z');
|
||||
});
|
||||
|
||||
test('1-day range', () => {
|
||||
const now = new Date('2026-05-11T12:00:00Z');
|
||||
const range = rangeLastDays(1, now);
|
||||
expect(range.startedAt).toBe('2026-05-10T12:00:00.000Z');
|
||||
expect(range.endedAt).toBe('2026-05-11T12:00:00.000Z');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
// Twitch Helix Top-Clips Crawler. Pure: fetch wird via injizierter fetchImpl
|
||||
// aufgerufen (Tests koennen mocken). Helix-Endpunkt:
|
||||
// GET https://api.twitch.tv/helix/clips?broadcaster_id=X&first=N
|
||||
//
|
||||
// Auth: Client-Credentials (app-token) reicht — kein User-Token noetig.
|
||||
// Spaeter koennen wir aus token-store den default-Twitch-User-Token nehmen.
|
||||
|
||||
const HELIX_CLIPS_URL = 'https://api.twitch.tv/helix/clips';
|
||||
|
||||
export interface TopClip {
|
||||
id: string;
|
||||
url: string;
|
||||
embedUrl: string;
|
||||
broadcasterId: string;
|
||||
broadcasterName: string;
|
||||
creatorId: string;
|
||||
creatorName: string;
|
||||
videoId: string;
|
||||
gameId: string;
|
||||
language: string;
|
||||
title: string;
|
||||
viewCount: number;
|
||||
createdAt: string; // ISO timestamp
|
||||
thumbnailUrl: string;
|
||||
duration: number; // seconds
|
||||
vodOffsetSeconds: number | null;
|
||||
}
|
||||
|
||||
interface HelixClipRow {
|
||||
id: string;
|
||||
url: string;
|
||||
embed_url: string;
|
||||
broadcaster_id: string;
|
||||
broadcaster_name: string;
|
||||
creator_id: string;
|
||||
creator_name: string;
|
||||
video_id: string;
|
||||
game_id: string;
|
||||
language: string;
|
||||
title: string;
|
||||
view_count: number;
|
||||
created_at: string;
|
||||
thumbnail_url: string;
|
||||
duration: number;
|
||||
vod_offset?: number | null;
|
||||
}
|
||||
|
||||
interface HelixClipsResponse {
|
||||
data?: HelixClipRow[];
|
||||
pagination?: { cursor?: string };
|
||||
}
|
||||
|
||||
export interface FetchTopClipsOptions {
|
||||
clientId: string;
|
||||
accessToken: string;
|
||||
broadcasterId: string;
|
||||
startedAt?: string; // ISO RFC3339
|
||||
endedAt?: string;
|
||||
first?: number; // 1-100, default 20
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
function rowToClip(row: HelixClipRow): TopClip {
|
||||
return {
|
||||
id: row.id,
|
||||
url: row.url,
|
||||
embedUrl: row.embed_url,
|
||||
broadcasterId: row.broadcaster_id,
|
||||
broadcasterName: row.broadcaster_name,
|
||||
creatorId: row.creator_id,
|
||||
creatorName: row.creator_name,
|
||||
videoId: row.video_id,
|
||||
gameId: row.game_id,
|
||||
language: row.language,
|
||||
title: row.title,
|
||||
viewCount: row.view_count,
|
||||
createdAt: row.created_at,
|
||||
thumbnailUrl: row.thumbnail_url,
|
||||
duration: row.duration,
|
||||
vodOffsetSeconds: row.vod_offset ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchTopClips(opts: FetchTopClipsOptions): Promise<TopClip[]> {
|
||||
const fetchFn = opts.fetchImpl ?? fetch;
|
||||
const first = Math.min(100, Math.max(1, opts.first ?? 20));
|
||||
|
||||
const params = new URLSearchParams({
|
||||
broadcaster_id: opts.broadcasterId,
|
||||
first: String(first),
|
||||
});
|
||||
if (opts.startedAt) params.set('started_at', opts.startedAt);
|
||||
if (opts.endedAt) params.set('ended_at', opts.endedAt);
|
||||
|
||||
const res = await fetchFn(`${HELIX_CLIPS_URL}?${params.toString()}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${opts.accessToken}`,
|
||||
'Client-Id': opts.clientId,
|
||||
},
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`top-clips-crawler: helix ${res.status}: ${text}`);
|
||||
}
|
||||
let parsed: HelixClipsResponse;
|
||||
try {
|
||||
parsed = JSON.parse(text) as HelixClipsResponse;
|
||||
} catch (e) {
|
||||
throw new Error(`top-clips-crawler: parse failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
const rows = parsed.data ?? [];
|
||||
// Helix returns clips already sorted by view_count desc, but we re-sort
|
||||
// defensively in case that order ever changes.
|
||||
return rows.map(rowToClip).sort((a, b) => b.viewCount - a.viewCount);
|
||||
}
|
||||
|
||||
export interface DateRange {
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: ISO range fuer "letzte N Tage" ab jetzt. Twitch erwartet
|
||||
* RFC3339 Format (`2026-05-11T00:00:00Z`).
|
||||
*/
|
||||
export function rangeLastDays(days: number, now: Date = new Date()): DateRange {
|
||||
const end = new Date(now.getTime());
|
||||
const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
||||
return {
|
||||
startedAt: start.toISOString(),
|
||||
endedAt: end.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import {
|
||||
startLoginFlow,
|
||||
awaitAuthorizationCode,
|
||||
exchangeCodeForToken,
|
||||
fetchTwitchUserInfo,
|
||||
} from './twitch-oauth';
|
||||
import * as http from 'http';
|
||||
|
||||
function httpGet(url: string): Promise<{ status: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.get(url, res => {
|
||||
res.on('data', () => { /* drain */ });
|
||||
res.on('end', () => resolve({ status: res.statusCode ?? 0 }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe('startLoginFlow', () => {
|
||||
test('builds Twitch authorize URL with required params + PKCE + state', async () => {
|
||||
const flow = await startLoginFlow({
|
||||
clientId: 'test-client',
|
||||
scopes: ['user:read:email', 'channel:read:subscriptions'],
|
||||
});
|
||||
try {
|
||||
expect(flow.authUrl).toContain('https://id.twitch.tv/oauth2/authorize');
|
||||
const url = new URL(flow.authUrl);
|
||||
expect(url.searchParams.get('client_id')).toBe('test-client');
|
||||
expect(url.searchParams.get('response_type')).toBe('code');
|
||||
expect(url.searchParams.get('scope')).toBe('user:read:email channel:read:subscriptions');
|
||||
expect(url.searchParams.get('state')).toBe(flow.state);
|
||||
expect(url.searchParams.get('code_challenge')).toBe(flow.pkce.codeChallenge);
|
||||
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
expect(url.searchParams.get('redirect_uri')).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/oauth\/callback$/);
|
||||
} finally {
|
||||
flow.server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('awaitAuthorizationCode', () => {
|
||||
test('returns code on successful redirect with matching state', async () => {
|
||||
const flow = await startLoginFlow({ clientId: 't', scopes: ['user:read:email'] });
|
||||
try {
|
||||
const captureP = awaitAuthorizationCode(flow, 3000);
|
||||
await httpGet(`${flow.server.url}?code=AUTHCODE&state=${flow.state}`);
|
||||
const result = await captureP;
|
||||
expect(result.code).toBe('AUTHCODE');
|
||||
expect(result.state).toBe(flow.state);
|
||||
} finally {
|
||||
flow.server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects on state mismatch (CSRF protection)', async () => {
|
||||
const flow = await startLoginFlow({ clientId: 't', scopes: ['user:read:email'] });
|
||||
try {
|
||||
// .catch fangt unhandled rejection ab — wir pruefen den Error manuell.
|
||||
const captureP = awaitAuthorizationCode(flow, 3000).catch((e: Error) => e);
|
||||
await httpGet(`${flow.server.url}?code=AUTHCODE&state=WRONG_STATE`);
|
||||
const err = await captureP;
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as Error).message).toMatch(/state mismatch/);
|
||||
} finally {
|
||||
flow.server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects on error parameter', async () => {
|
||||
const flow = await startLoginFlow({ clientId: 't', scopes: ['user:read:email'] });
|
||||
try {
|
||||
const captureP = awaitAuthorizationCode(flow, 3000).catch((e: Error) => e);
|
||||
await httpGet(`${flow.server.url}?error=access_denied&error_description=user+denied`);
|
||||
const err = await captureP;
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as Error).message).toMatch(/access_denied/);
|
||||
} finally {
|
||||
flow.server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects on missing code', async () => {
|
||||
const flow = await startLoginFlow({ clientId: 't', scopes: ['user:read:email'] });
|
||||
try {
|
||||
const captureP = awaitAuthorizationCode(flow, 3000).catch((e: Error) => e);
|
||||
await httpGet(`${flow.server.url}?state=${flow.state}`);
|
||||
const err = await captureP;
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as Error).message).toMatch(/missing code/);
|
||||
} finally {
|
||||
flow.server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('exchangeCodeForToken', () => {
|
||||
test('POSTs correct body and returns parsed token', async () => {
|
||||
let capturedBody: string | null = null;
|
||||
const fakeFetch = async (_url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
capturedBody = init?.body as string;
|
||||
return new Response(JSON.stringify({
|
||||
access_token: 'ACC',
|
||||
refresh_token: 'REF',
|
||||
expires_in: 14400,
|
||||
scope: ['user:read:email'],
|
||||
token_type: 'bearer',
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
};
|
||||
const token = await exchangeCodeForToken({
|
||||
clientId: 'cid', code: 'CODE', codeVerifier: 'VERIFIER',
|
||||
redirectUri: 'http://127.0.0.1:5555/oauth/callback',
|
||||
fetchImpl: fakeFetch as unknown as typeof fetch,
|
||||
});
|
||||
expect(token.access_token).toBe('ACC');
|
||||
expect(token.refresh_token).toBe('REF');
|
||||
expect(capturedBody).toContain('client_id=cid');
|
||||
expect(capturedBody).toContain('code=CODE');
|
||||
expect(capturedBody).toContain('code_verifier=VERIFIER');
|
||||
expect(capturedBody).toContain('grant_type=authorization_code');
|
||||
});
|
||||
|
||||
test('throws on non-2xx response', async () => {
|
||||
const fakeFetch = async (): Promise<Response> => new Response('bad request', { status: 400 });
|
||||
await expect(exchangeCodeForToken({
|
||||
clientId: 'cid', code: 'X', codeVerifier: 'V', redirectUri: 'http://x',
|
||||
fetchImpl: fakeFetch as unknown as typeof fetch,
|
||||
})).rejects.toThrow(/400/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchTwitchUserInfo', () => {
|
||||
test('returns first user from helix /users response', async () => {
|
||||
const fakeFetch = async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
expect(headers['Authorization']).toBe('Bearer TOKEN');
|
||||
expect(headers['Client-Id']).toBe('CID');
|
||||
return new Response(JSON.stringify({
|
||||
data: [{ id: '12345', login: 'alice', display_name: 'Alice' }],
|
||||
}), { status: 200 });
|
||||
};
|
||||
const user = await fetchTwitchUserInfo('TOKEN', 'CID', fakeFetch as unknown as typeof fetch);
|
||||
expect(user.id).toBe('12345');
|
||||
expect(user.login).toBe('alice');
|
||||
expect(user.display_name).toBe('Alice');
|
||||
});
|
||||
|
||||
test('throws when no user in response', async () => {
|
||||
const fakeFetch = async (): Promise<Response> => new Response(JSON.stringify({ data: [] }), { status: 200 });
|
||||
await expect(fetchTwitchUserInfo('T', 'C', fakeFetch as unknown as typeof fetch))
|
||||
.rejects.toThrow(/no user/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { createPkcePair, generateState, type PkcePair } from './pkce';
|
||||
import { startLoopbackServer, type LoopbackServer } from '../infra/loopback-server';
|
||||
|
||||
/**
|
||||
* Twitch OAuth 2.1 Authorization Code Flow + PKCE.
|
||||
*
|
||||
* Twitch supports PKCE since ~2022. Endpoints:
|
||||
* Authorize: https://id.twitch.tv/oauth2/authorize
|
||||
* Token: https://id.twitch.tv/oauth2/token
|
||||
* Validate: https://id.twitch.tv/oauth2/validate
|
||||
* Helix /users (whoami): https://api.twitch.tv/helix/users
|
||||
*
|
||||
* Flow:
|
||||
* 1. startLoginFlow({clientId, scopes}) → { authUrl, ... }
|
||||
* 2. shell.openExternal(authUrl) im Caller (main.ts hat shell)
|
||||
* 3. await completeLoginFlow(state) → wartet auf Loopback-Redirect
|
||||
* 4. Exchange code+verifier gegen token via fetch
|
||||
* 5. Helix /users mit Bearer-Token → twitch_user_id + login + display_name
|
||||
*
|
||||
* Plan 03b liefert NUR Module + Tests. Eigentlicher login-flow IPC handler
|
||||
* + Renderer-Button kommt in Folgeplan, weil das Twitch-Account-Setup
|
||||
* (Client-ID in Twitch Dev Console mit korrektem Redirect-URI) erst
|
||||
* vorbereitet werden muss.
|
||||
*/
|
||||
|
||||
const TWITCH_AUTHORIZE_URL = 'https://id.twitch.tv/oauth2/authorize';
|
||||
const TWITCH_TOKEN_URL = 'https://id.twitch.tv/oauth2/token';
|
||||
const TWITCH_HELIX_USERS_URL = 'https://api.twitch.tv/helix/users';
|
||||
|
||||
export interface TwitchTokenResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
scope: string[];
|
||||
token_type: 'bearer';
|
||||
}
|
||||
|
||||
export interface TwitchUserInfo {
|
||||
id: string;
|
||||
login: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface LoginStart {
|
||||
authUrl: string;
|
||||
state: string;
|
||||
pkce: PkcePair;
|
||||
server: LoopbackServer;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
export interface LoginStartOptions {
|
||||
clientId: string;
|
||||
scopes: string[];
|
||||
pathPrefix?: string; // default '/oauth/callback'
|
||||
port?: number; // 0 = OS-chooses
|
||||
}
|
||||
|
||||
export async function startLoginFlow(opts: LoginStartOptions): Promise<LoginStart> {
|
||||
const server = await startLoopbackServer({
|
||||
pathPrefix: opts.pathPrefix ?? '/oauth/callback',
|
||||
port: opts.port,
|
||||
});
|
||||
|
||||
const pkce = createPkcePair();
|
||||
const state = generateState();
|
||||
const redirectUri = server.url;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: opts.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: opts.scopes.join(' '),
|
||||
state,
|
||||
code_challenge: pkce.codeChallenge,
|
||||
code_challenge_method: pkce.codeChallengeMethod,
|
||||
force_verify: 'true',
|
||||
});
|
||||
const authUrl = `${TWITCH_AUTHORIZE_URL}?${params.toString()}`;
|
||||
|
||||
return { authUrl, state, pkce, server, redirectUri };
|
||||
}
|
||||
|
||||
export interface CompleteLoginResult {
|
||||
code: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wartet auf Redirect-Capture und prueft state.
|
||||
* Throws bei mismatch state, bei `?error=` Parameter, oder bei Timeout.
|
||||
*/
|
||||
export async function awaitAuthorizationCode(login: LoginStart, timeoutMs?: number): Promise<CompleteLoginResult> {
|
||||
const params = await login.server.awaitParams({ timeoutMs });
|
||||
if (params.has('error')) {
|
||||
const err = params.get('error') ?? 'unknown_error';
|
||||
const desc = params.get('error_description') ?? '';
|
||||
throw new Error(`twitch-oauth: provider error: ${err}${desc ? ` — ${desc}` : ''}`);
|
||||
}
|
||||
const returnedState = params.get('state') ?? '';
|
||||
if (returnedState !== login.state) {
|
||||
throw new Error('twitch-oauth: state mismatch (possible CSRF or stale flow)');
|
||||
}
|
||||
const code = params.get('code');
|
||||
if (!code) {
|
||||
throw new Error('twitch-oauth: missing code parameter');
|
||||
}
|
||||
return { code, state: returnedState };
|
||||
}
|
||||
|
||||
export interface TokenExchangeOptions {
|
||||
clientId: string;
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export async function exchangeCodeForToken(opts: TokenExchangeOptions): Promise<TwitchTokenResponse> {
|
||||
const fetchFn = opts.fetchImpl ?? fetch;
|
||||
const body = new URLSearchParams({
|
||||
client_id: opts.clientId,
|
||||
code: opts.code,
|
||||
code_verifier: opts.codeVerifier,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: opts.redirectUri,
|
||||
});
|
||||
|
||||
const res = await fetchFn(TWITCH_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`twitch-oauth: token endpoint ${res.status}: ${text}`);
|
||||
}
|
||||
return JSON.parse(text) as TwitchTokenResponse;
|
||||
}
|
||||
|
||||
export async function fetchTwitchUserInfo(
|
||||
accessToken: string,
|
||||
clientId: string,
|
||||
fetchImpl?: typeof fetch
|
||||
): Promise<TwitchUserInfo> {
|
||||
const fetchFn = fetchImpl ?? fetch;
|
||||
const res = await fetchFn(TWITCH_HELIX_USERS_URL, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Client-Id': clientId,
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`twitch-oauth: helix /users ${res.status}: ${text}`);
|
||||
}
|
||||
const json = JSON.parse(text) as { data?: TwitchUserInfo[] };
|
||||
const first = json.data?.[0];
|
||||
if (!first) throw new Error('twitch-oauth: helix /users returned no user');
|
||||
return first;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import {
|
||||
normalizeUpdateVersion,
|
||||
compareUpdateVersions,
|
||||
isNewerUpdateVersion,
|
||||
} from './update-version-utils';
|
||||
|
||||
describe('normalizeUpdateVersion', () => {
|
||||
test('strips v-prefix lowercase', () => {
|
||||
expect(normalizeUpdateVersion('v1.2.3')).toBe('1.2.3');
|
||||
});
|
||||
test('strips V-prefix uppercase', () => {
|
||||
expect(normalizeUpdateVersion('V1.2.3')).toBe('1.2.3');
|
||||
});
|
||||
test('trims whitespace', () => {
|
||||
expect(normalizeUpdateVersion(' 1.2.3 ')).toBe('1.2.3');
|
||||
});
|
||||
test('handles null and undefined as empty string', () => {
|
||||
expect(normalizeUpdateVersion(null)).toBe('');
|
||||
expect(normalizeUpdateVersion(undefined)).toBe('');
|
||||
});
|
||||
test('passes plain version unchanged', () => {
|
||||
expect(normalizeUpdateVersion('1.0.1')).toBe('1.0.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareUpdateVersions', () => {
|
||||
test('older < newer in same minor', () => {
|
||||
expect(compareUpdateVersions('1.0.1', '1.0.2')).toBeLessThan(0);
|
||||
});
|
||||
test('newer > older in same minor', () => {
|
||||
expect(compareUpdateVersions('1.0.2', '1.0.1')).toBeGreaterThan(0);
|
||||
});
|
||||
test('equal versions return 0', () => {
|
||||
expect(compareUpdateVersions('1.0.1', '1.0.1')).toBe(0);
|
||||
});
|
||||
test('v-prefix is normalized away', () => {
|
||||
expect(compareUpdateVersions('v1.0.1', '1.0.1')).toBe(0);
|
||||
});
|
||||
test('extra trailing part is newer', () => {
|
||||
expect(compareUpdateVersions('1.0.1', '1.0.1.1')).toBeLessThan(0);
|
||||
});
|
||||
test('major bump wins', () => {
|
||||
expect(compareUpdateVersions('2.0.0', '1.99.99')).toBeGreaterThan(0);
|
||||
});
|
||||
test('null versions sort lowest', () => {
|
||||
expect(compareUpdateVersions(null, '1.0.0')).toBeLessThan(0);
|
||||
expect(compareUpdateVersions('1.0.0', null)).toBeGreaterThan(0);
|
||||
});
|
||||
test('both null returns 0', () => {
|
||||
expect(compareUpdateVersions(null, null)).toBe(0);
|
||||
expect(compareUpdateVersions('', '')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNewerUpdateVersion', () => {
|
||||
test('strictly newer returns true', () => {
|
||||
expect(isNewerUpdateVersion('1.0.2', '1.0.1')).toBe(true);
|
||||
});
|
||||
test('equal returns false', () => {
|
||||
expect(isNewerUpdateVersion('1.0.1', '1.0.1')).toBe(false);
|
||||
});
|
||||
test('older returns false', () => {
|
||||
expect(isNewerUpdateVersion('1.0.1', '1.0.2')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
export function normalizeUpdateVersion(version: string | null | undefined): string {
|
||||
return (version || '').trim().replace(/^v/i, '');
|
||||
}
|
||||
|
||||
function parseVersionPart(part: string): number {
|
||||
const numeric = Number(part.replace(/[^0-9].*$/, ''));
|
||||
return Number.isFinite(numeric) ? numeric : 0;
|
||||
}
|
||||
|
||||
export function compareUpdateVersions(left: string | null | undefined, right: string | null | undefined): number {
|
||||
const a = normalizeUpdateVersion(left);
|
||||
const b = normalizeUpdateVersion(right);
|
||||
|
||||
if (!a && !b) return 0;
|
||||
if (!a) return -1;
|
||||
if (!b) return 1;
|
||||
|
||||
const aParts = a.split('.').map(parseVersionPart);
|
||||
const bParts = b.split('.').map(parseVersionPart);
|
||||
const maxLength = Math.max(aParts.length, bParts.length);
|
||||
|
||||
for (let i = 0; i < maxLength; i += 1) {
|
||||
const av = aParts[i] || 0;
|
||||
const bv = bParts[i] || 0;
|
||||
if (av > bv) return 1;
|
||||
if (av < bv) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function isNewerUpdateVersion(candidate: string | null | undefined, baseline: string | null | undefined): boolean {
|
||||
return compareUpdateVersions(candidate, baseline) > 0;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Stammverzeichnis fuer das v5-Architektur-Refactoring.
|
||||
// Plan 04 macht daraus den Entry-Point statt src/main.ts.
|
||||
export {};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { hashBuffer, hashFile } from './chunk-hash';
|
||||
|
||||
let tmpDir: string;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chunkhash-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
describe('hashBuffer', () => {
|
||||
test('"hello" sha1', () => {
|
||||
expect(hashBuffer(Buffer.from('hello', 'utf-8')))
|
||||
.toBe('aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d');
|
||||
});
|
||||
|
||||
test('empty buffer sha1', () => {
|
||||
expect(hashBuffer(Buffer.alloc(0)))
|
||||
.toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709');
|
||||
});
|
||||
|
||||
test('large buffer hashes deterministically', () => {
|
||||
const big = Buffer.alloc(1024 * 1024, 0x42); // 1MB of 'B' bytes
|
||||
const a = hashBuffer(big);
|
||||
const b = hashBuffer(big);
|
||||
expect(a).toBe(b);
|
||||
expect(a).toHaveLength(40); // sha1 = 40 hex chars
|
||||
});
|
||||
|
||||
test('different content produces different hashes', () => {
|
||||
expect(hashBuffer(Buffer.from('a'))).not.toBe(hashBuffer(Buffer.from('b')));
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashFile', () => {
|
||||
test('file hash matches buffer hash for same content', async () => {
|
||||
const content = 'roundtrip-test-payload';
|
||||
const filePath = path.join(tmpDir, 'a.bin');
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
const fileHash = await hashFile(filePath);
|
||||
const bufHash = hashBuffer(Buffer.from(content, 'utf-8'));
|
||||
expect(fileHash).toBe(bufHash);
|
||||
});
|
||||
|
||||
test('empty file = empty-buffer sha1', async () => {
|
||||
const filePath = path.join(tmpDir, 'empty.bin');
|
||||
fs.writeFileSync(filePath, '');
|
||||
const fileHash = await hashFile(filePath);
|
||||
expect(fileHash).toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709');
|
||||
});
|
||||
|
||||
test('large file (4MB) hashes correctly', async () => {
|
||||
const filePath = path.join(tmpDir, 'big.bin');
|
||||
const payload = Buffer.alloc(4 * 1024 * 1024, 0x55);
|
||||
fs.writeFileSync(filePath, payload);
|
||||
const fileHash = await hashFile(filePath);
|
||||
expect(fileHash).toBe(hashBuffer(payload));
|
||||
});
|
||||
|
||||
test('missing file rejects', async () => {
|
||||
await expect(hashFile(path.join(tmpDir, 'does-not-exist'))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export function hashBuffer(b: Buffer): string {
|
||||
return crypto.createHash('sha1').update(b).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming sha1-Hash einer Datei. Async, damit grosse Recorded-Segments
|
||||
* (oft mehrere MB) nicht den Event-Loop blockieren.
|
||||
*/
|
||||
export function hashFile(filePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash('sha1');
|
||||
const stream = fs.createReadStream(filePath);
|
||||
stream.on('error', reject);
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
if (typeof chunk === 'string') {
|
||||
hash.update(chunk, 'utf-8');
|
||||
} else {
|
||||
hash.update(chunk);
|
||||
}
|
||||
});
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { openDatabase, type DbHandle } from './db';
|
||||
|
||||
let tmpDir: string;
|
||||
let db: DbHandle | null = null;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-test-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
try { db?.close(); } catch { /* ignore */ }
|
||||
db = null;
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
describe('openDatabase', () => {
|
||||
test('creates a new file', () => {
|
||||
const target = path.join(tmpDir, 'a.db');
|
||||
db = openDatabase(target);
|
||||
expect(fs.existsSync(target)).toBe(true);
|
||||
expect(typeof db.run).toBe('function');
|
||||
expect(typeof db.get).toBe('function');
|
||||
expect(typeof db.all).toBe('function');
|
||||
expect(typeof db.close).toBe('function');
|
||||
expect(typeof db.transaction).toBe('function');
|
||||
expect(typeof db.runBatch).toBe('function');
|
||||
});
|
||||
|
||||
test('schema_meta row exists with schema_version=5', () => {
|
||||
db = openDatabase(path.join(tmpDir, 'b.db'));
|
||||
const row = db.get<{ value: string }>('SELECT value FROM schema_meta WHERE key = ?', ['schema_version']);
|
||||
expect(row?.value).toBe('5');
|
||||
});
|
||||
|
||||
test('WAL mode active', () => {
|
||||
db = openDatabase(path.join(tmpDir, 'c.db'));
|
||||
const row = db.get<{ journal_mode: string }>('PRAGMA journal_mode');
|
||||
expect(row?.journal_mode).toBe('wal');
|
||||
});
|
||||
|
||||
test('idempotent open: existing file keeps schema_version=5', () => {
|
||||
const target = path.join(tmpDir, 'd.db');
|
||||
db = openDatabase(target);
|
||||
db.close();
|
||||
db = openDatabase(target);
|
||||
const row = db.get<{ value: string }>('SELECT value FROM schema_meta WHERE key = ?', ['schema_version']);
|
||||
expect(row?.value).toBe('5');
|
||||
});
|
||||
|
||||
test('run + get + all roundtrip on downloaded_vods', () => {
|
||||
db = openDatabase(path.join(tmpDir, 'e.db'));
|
||||
db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['1234']);
|
||||
db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['5678']);
|
||||
const one = db.get<{ vod_id: string }>('SELECT vod_id FROM downloaded_vods WHERE vod_id = ?', ['1234']);
|
||||
expect(one?.vod_id).toBe('1234');
|
||||
const all = db.all<{ vod_id: string }>('SELECT vod_id FROM downloaded_vods ORDER BY vod_id');
|
||||
expect(all.map(r => r.vod_id)).toEqual(['1234', '5678']);
|
||||
});
|
||||
|
||||
test('transaction commits as bracket', () => {
|
||||
db = openDatabase(path.join(tmpDir, 'f.db'));
|
||||
const handle = db;
|
||||
const inserted = handle.transaction(() => {
|
||||
handle.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['t1']);
|
||||
handle.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['t2']);
|
||||
return 2;
|
||||
});
|
||||
expect(inserted).toBe(2);
|
||||
const c = handle.get<{ c: number }>('SELECT COUNT(*) AS c FROM downloaded_vods');
|
||||
expect(c?.c).toBe(2);
|
||||
});
|
||||
|
||||
test('chunk_index table accepts insert + UNIQUE(item_id, chunk_seq)', () => {
|
||||
db = openDatabase(path.join(tmpDir, 'chunk.db'));
|
||||
db.run(
|
||||
'INSERT INTO chunk_index(item_id, chunk_seq, sha1_hex, bytes) VALUES (?, ?, ?, ?)',
|
||||
['item1', 0, 'abc123', 1024]
|
||||
);
|
||||
const handle = db;
|
||||
expect(() => {
|
||||
handle.run(
|
||||
'INSERT INTO chunk_index(item_id, chunk_seq, sha1_hex, bytes) VALUES (?, ?, ?, ?)',
|
||||
['item1', 0, 'different', 2048]
|
||||
);
|
||||
}).toThrow(); // UNIQUE violation
|
||||
const rows = handle.all<{ sha1_hex: string }>('SELECT sha1_hex FROM chunk_index WHERE item_id = ?', ['item1']);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].sha1_hex).toBe('abc123');
|
||||
});
|
||||
|
||||
test('oauth_accounts table exists and accepts insert', () => {
|
||||
db = openDatabase(path.join(tmpDir, 'oauth.db'));
|
||||
db.run(
|
||||
`INSERT INTO oauth_accounts(provider, twitch_user_id, login, encrypted_access_token)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
['twitch', 'user-123', 'alice', 'ciphertext-blob']
|
||||
);
|
||||
const row = db.get<{ login: string; provider: string }>(
|
||||
'SELECT login, provider FROM oauth_accounts WHERE twitch_user_id = ?',
|
||||
['user-123']
|
||||
);
|
||||
expect(row?.login).toBe('alice');
|
||||
expect(row?.provider).toBe('twitch');
|
||||
});
|
||||
|
||||
test('oauth_accounts UNIQUE(provider, twitch_user_id) enforced', () => {
|
||||
db = openDatabase(path.join(tmpDir, 'oauth-unique.db'));
|
||||
db.run(
|
||||
`INSERT INTO oauth_accounts(provider, twitch_user_id, login, encrypted_access_token)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
['twitch', 'u1', 'a', 'x']
|
||||
);
|
||||
const handle = db;
|
||||
expect(() => {
|
||||
handle.run(
|
||||
`INSERT INTO oauth_accounts(provider, twitch_user_id, login, encrypted_access_token)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
['twitch', 'u1', 'b', 'y']
|
||||
);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
test('transaction rolls back on throw', () => {
|
||||
db = openDatabase(path.join(tmpDir, 'g.db'));
|
||||
const handle = db;
|
||||
expect(() => {
|
||||
handle.transaction(() => {
|
||||
handle.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['x1']);
|
||||
throw new Error('boom');
|
||||
});
|
||||
}).toThrow('boom');
|
||||
const c = handle.get<{ c: number }>('SELECT COUNT(*) AS c FROM downloaded_vods');
|
||||
expect(c?.c).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import Database, { type Database as DatabaseT } from 'better-sqlite3';
|
||||
import { SCHEMA_V5_SQL } from './schema-v5';
|
||||
|
||||
/**
|
||||
* Public DB-Handle. Schmaler Wrapper um better-sqlite3.
|
||||
*/
|
||||
export interface DbHandle {
|
||||
run(sql: string, params?: unknown[]): void;
|
||||
get<T = unknown>(sql: string, params?: unknown[]): T | undefined;
|
||||
all<T = unknown>(sql: string, params?: unknown[]): T[];
|
||||
transaction<R>(fn: () => R): R;
|
||||
runBatch(sql: string): void;
|
||||
close(): void;
|
||||
readonly raw: DatabaseT;
|
||||
}
|
||||
|
||||
function splitStatements(sql: string): string[] {
|
||||
return sql
|
||||
.split(';')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
}
|
||||
|
||||
function runMultiStatement(db: DatabaseT, sql: string): void {
|
||||
for (const stmt of splitStatements(sql)) {
|
||||
db.prepare(stmt).run();
|
||||
}
|
||||
}
|
||||
|
||||
export function openDatabase(filePath: string): DbHandle {
|
||||
const db = new Database(filePath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('busy_timeout = 5000');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
runMultiStatement(db, SCHEMA_V5_SQL);
|
||||
|
||||
const handle: DbHandle = {
|
||||
run(sql, params) {
|
||||
db.prepare(sql).run(...(params ?? []) as unknown[]);
|
||||
},
|
||||
get<T>(sql: string, params?: unknown[]): T | undefined {
|
||||
return db.prepare(sql).get(...(params ?? []) as unknown[]) as T | undefined;
|
||||
},
|
||||
all<T>(sql: string, params?: unknown[]): T[] {
|
||||
return db.prepare(sql).all(...(params ?? []) as unknown[]) as T[];
|
||||
},
|
||||
transaction<R>(fn: () => R): R {
|
||||
return db.transaction(fn)();
|
||||
},
|
||||
runBatch(sql) {
|
||||
runMultiStatement(db, sql);
|
||||
},
|
||||
close() {
|
||||
db.close();
|
||||
},
|
||||
get raw() { return db; },
|
||||
};
|
||||
return handle;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import { parseDuration, formatDuration, formatDurationDashed } from './duration';
|
||||
|
||||
describe('parseDuration', () => {
|
||||
test('1h2m3s = 3723', () => {
|
||||
expect(parseDuration('1h2m3s')).toBe(3723);
|
||||
});
|
||||
test('45m = 2700', () => {
|
||||
expect(parseDuration('45m')).toBe(2700);
|
||||
});
|
||||
test('10s = 10', () => {
|
||||
expect(parseDuration('10s')).toBe(10);
|
||||
});
|
||||
test('empty string = 0', () => {
|
||||
expect(parseDuration('')).toBe(0);
|
||||
});
|
||||
test('unknown format = 0', () => {
|
||||
expect(parseDuration('abcdef')).toBe(0);
|
||||
});
|
||||
test('partial 2h = 7200', () => {
|
||||
expect(parseDuration('2h')).toBe(7200);
|
||||
});
|
||||
test('h and s without m = 3601', () => {
|
||||
expect(parseDuration('1h1s')).toBe(3601);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDuration', () => {
|
||||
test('3723 = 01:02:03', () => {
|
||||
expect(formatDuration(3723)).toBe('01:02:03');
|
||||
});
|
||||
test('0 = 00:00:00', () => {
|
||||
expect(formatDuration(0)).toBe('00:00:00');
|
||||
});
|
||||
test('negative = 00:00:00', () => {
|
||||
expect(formatDuration(-1)).toBe('00:00:00');
|
||||
});
|
||||
test('Infinity = 00:00:00', () => {
|
||||
expect(formatDuration(Infinity)).toBe('00:00:00');
|
||||
});
|
||||
test('NaN = 00:00:00', () => {
|
||||
expect(formatDuration(NaN)).toBe('00:00:00');
|
||||
});
|
||||
test('3600 = 01:00:00', () => {
|
||||
expect(formatDuration(3600)).toBe('01:00:00');
|
||||
});
|
||||
test('86399 = 23:59:59', () => {
|
||||
expect(formatDuration(86399)).toBe('23:59:59');
|
||||
});
|
||||
test('fractional seconds floored', () => {
|
||||
expect(formatDuration(3723.9)).toBe('01:02:03');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDurationDashed', () => {
|
||||
test('3723 = 01-02-03', () => {
|
||||
expect(formatDurationDashed(3723)).toBe('01-02-03');
|
||||
});
|
||||
test('negative = 00-00-00', () => {
|
||||
expect(formatDurationDashed(-1)).toBe('00-00-00');
|
||||
});
|
||||
test('NaN = 00-00-00', () => {
|
||||
expect(formatDurationDashed(NaN)).toBe('00-00-00');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
export function parseDuration(duration: string): number {
|
||||
let seconds = 0;
|
||||
const hours = duration.match(/(\d+)h/);
|
||||
const minutes = duration.match(/(\d+)m/);
|
||||
const secs = duration.match(/(\d+)s/);
|
||||
|
||||
if (hours) seconds += parseInt(hours[1]) * 3600;
|
||||
if (minutes) seconds += parseInt(minutes[1]) * 60;
|
||||
if (secs) seconds += parseInt(secs[1]);
|
||||
|
||||
return seconds;
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (!isFinite(seconds) || seconds < 0) return '00:00:00';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatDurationDashed(seconds: number): string {
|
||||
if (!isFinite(seconds) || seconds < 0) return '00-00-00';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import {
|
||||
sanitizeFilenamePart,
|
||||
formatTwitchDurationFromSeconds,
|
||||
formatDateWithPattern,
|
||||
getMergeGroupPhaseText,
|
||||
} from './format-helpers';
|
||||
|
||||
describe('sanitizeFilenamePart', () => {
|
||||
test('replaces Windows-invalid chars with underscore', () => {
|
||||
expect(sanitizeFilenamePart('a<b>c:d"e|f?g*h')).toBe('a_b_c_d_e_f_g_h');
|
||||
});
|
||||
test('replaces path separators', () => {
|
||||
expect(sanitizeFilenamePart('a/b\\c')).toBe('a_b_c');
|
||||
});
|
||||
test('strips control chars', () => {
|
||||
expect(sanitizeFilenamePart('a\x00b\x1fc')).toBe('a_b_c');
|
||||
});
|
||||
test('trims whitespace', () => {
|
||||
expect(sanitizeFilenamePart(' hi ')).toBe('hi');
|
||||
});
|
||||
test('empty falls back to default', () => {
|
||||
expect(sanitizeFilenamePart('')).toBe('unnamed');
|
||||
});
|
||||
test('custom fallback', () => {
|
||||
expect(sanitizeFilenamePart('', 'FB')).toBe('FB');
|
||||
});
|
||||
test('only-invalid-chars falls back', () => {
|
||||
expect(sanitizeFilenamePart('////').trim()).not.toBe('');
|
||||
// '////' becomes '____' which is non-empty, so no fallback
|
||||
expect(sanitizeFilenamePart('////')).toBe('____');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTwitchDurationFromSeconds', () => {
|
||||
test('0 = 0s', () => {
|
||||
expect(formatTwitchDurationFromSeconds(0)).toBe('0s');
|
||||
});
|
||||
test('45 = 45s', () => {
|
||||
expect(formatTwitchDurationFromSeconds(45)).toBe('45s');
|
||||
});
|
||||
test('65 = 1m5s', () => {
|
||||
expect(formatTwitchDurationFromSeconds(65)).toBe('1m5s');
|
||||
});
|
||||
test('3725 = 1h2m5s', () => {
|
||||
expect(formatTwitchDurationFromSeconds(3725)).toBe('1h2m5s');
|
||||
});
|
||||
test('3600 = 1h0m0s', () => {
|
||||
expect(formatTwitchDurationFromSeconds(3600)).toBe('1h0m0s');
|
||||
});
|
||||
test('negative clamped to 0', () => {
|
||||
expect(formatTwitchDurationFromSeconds(-5)).toBe('0s');
|
||||
});
|
||||
test('NaN clamped to 0', () => {
|
||||
expect(formatTwitchDurationFromSeconds(NaN)).toBe('0s');
|
||||
});
|
||||
test('Infinity clamped to 0', () => {
|
||||
expect(formatTwitchDurationFromSeconds(Infinity)).toBe('0s');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDateWithPattern', () => {
|
||||
const d = new Date(2026, 4, 11, 23, 5, 7); // 2026-05-11 23:05:07
|
||||
|
||||
test('yyyy-MM-dd', () => {
|
||||
expect(formatDateWithPattern(d, 'yyyy-MM-dd')).toBe('2026-05-11');
|
||||
});
|
||||
test('yy MM dd', () => {
|
||||
expect(formatDateWithPattern(d, 'yy/MM/dd')).toBe('26/05/11');
|
||||
});
|
||||
test('HH:mm:ss', () => {
|
||||
expect(formatDateWithPattern(d, 'HH:mm:ss')).toBe('23:05:07');
|
||||
});
|
||||
test('combined pattern', () => {
|
||||
expect(formatDateWithPattern(d, 'yyyy-MM-dd_HH-mm-ss')).toBe('2026-05-11_23-05-07');
|
||||
});
|
||||
test('backslashes are stripped after token substitution', () => {
|
||||
// Note: \ does NOT escape the date-token (no negative-lookbehind in regex).
|
||||
// It only removes the literal backslash from the output. So 'yyyy\\X' → 'YYYYX'.
|
||||
expect(formatDateWithPattern(d, 'yyyy\\X')).toBe('2026X');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMergeGroupPhaseText', () => {
|
||||
test('known DE phases', () => {
|
||||
expect(getMergeGroupPhaseText('downloading', 'de')).toBe('VOD wird heruntergeladen');
|
||||
expect(getMergeGroupPhaseText('merging', 'de')).toBe('Zusammenfugen...');
|
||||
expect(getMergeGroupPhaseText('splitting', 'de')).toBe('Part wird erstellt');
|
||||
expect(getMergeGroupPhaseText('cleanup', 'de')).toBe('Aufraumen...');
|
||||
});
|
||||
test('known EN phases', () => {
|
||||
expect(getMergeGroupPhaseText('downloading', 'en')).toBe('Downloading VOD');
|
||||
expect(getMergeGroupPhaseText('merging', 'en')).toBe('Merging...');
|
||||
expect(getMergeGroupPhaseText('splitting', 'en')).toBe('Splitting Part');
|
||||
expect(getMergeGroupPhaseText('cleanup', 'en')).toBe('Cleaning up...');
|
||||
});
|
||||
test('unknown phase passes through', () => {
|
||||
expect(getMergeGroupPhaseText('unknown', 'de')).toBe('unknown');
|
||||
});
|
||||
test('unknown language falls back to DE', () => {
|
||||
expect(getMergeGroupPhaseText('downloading', 'fr')).toBe('VOD wird heruntergeladen');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// Pure-Format-Helpers, extrahiert aus main.ts. Keine Globals, keine I/O.
|
||||
|
||||
const FILENAME_INVALID_RE = /[<>:"|?*\x00-\x1f]/g;
|
||||
const FILENAME_PATH_SEP_RE = /[\\/]/g;
|
||||
|
||||
/**
|
||||
* Entfernt Windows-Filesystem-verbotene Zeichen und Pfad-Separatoren aus einem
|
||||
* Datei-Namen-Teilstring. Fallback wird zurueckgegeben, wenn nach Cleanup
|
||||
* nichts uebrig bleibt.
|
||||
*/
|
||||
export function sanitizeFilenamePart(input: string, fallback = 'unnamed'): string {
|
||||
const cleaned = (input || '')
|
||||
.replace(FILENAME_INVALID_RE, '_')
|
||||
.replace(FILENAME_PATH_SEP_RE, '_')
|
||||
.trim();
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Twitch-Style Duration-Format: `1h2m3s`, `2m5s`, `42s`. Negative oder
|
||||
* NaN-Inputs werden auf 0 geclamt.
|
||||
*/
|
||||
export function formatTwitchDurationFromSeconds(totalSeconds: number): string {
|
||||
const seconds = Math.max(0, Math.floor(Number.isFinite(totalSeconds) ? totalSeconds : 0));
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
|
||||
if (h > 0) return `${h}h${m}m${s}s`;
|
||||
if (m > 0) return `${m}m${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
const DATE_TOKEN_RE = /yyyy|yy|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s/g;
|
||||
|
||||
/**
|
||||
* Date-Formatter mit Pattern-Tokens (yyyy, yy, MM, M, dd, d, HH, H, hh, h,
|
||||
* mm, m, ss, s). Backslash-escapes (\T) lassen das Folgezeichen literal.
|
||||
*/
|
||||
export function formatDateWithPattern(date: Date, pattern: string): string {
|
||||
const tokenMap: Record<string, string> = {
|
||||
yyyy: date.getFullYear().toString(),
|
||||
yy: date.getFullYear().toString().slice(-2),
|
||||
MM: (date.getMonth() + 1).toString().padStart(2, '0'),
|
||||
M: (date.getMonth() + 1).toString(),
|
||||
dd: date.getDate().toString().padStart(2, '0'),
|
||||
d: date.getDate().toString(),
|
||||
HH: date.getHours().toString().padStart(2, '0'),
|
||||
H: date.getHours().toString(),
|
||||
hh: date.getHours().toString().padStart(2, '0'),
|
||||
h: date.getHours().toString(),
|
||||
mm: date.getMinutes().toString().padStart(2, '0'),
|
||||
m: date.getMinutes().toString(),
|
||||
ss: date.getSeconds().toString().padStart(2, '0'),
|
||||
s: date.getSeconds().toString(),
|
||||
};
|
||||
|
||||
return pattern
|
||||
.replace(DATE_TOKEN_RE, token => tokenMap[token] ?? token)
|
||||
.replace(/\\(.)/g, '$1');
|
||||
}
|
||||
|
||||
export type MergeGroupLanguage = 'de' | 'en';
|
||||
|
||||
/**
|
||||
* Label fuer den aktuellen Merge-Group-Phase-Status. Pure variant — Sprache
|
||||
* wird vom Caller injiziert.
|
||||
*/
|
||||
export function getMergeGroupPhaseText(phase: string, language: MergeGroupLanguage | string): string {
|
||||
const isEnglish = language === 'en';
|
||||
switch (phase) {
|
||||
case 'downloading': return isEnglish ? 'Downloading VOD' : 'VOD wird heruntergeladen';
|
||||
case 'merging': return isEnglish ? 'Merging...' : 'Zusammenfugen...';
|
||||
case 'splitting': return isEnglish ? 'Splitting Part' : 'Part wird erstellt';
|
||||
case 'cleanup': return isEnglish ? 'Cleaning up...' : 'Aufraumen...';
|
||||
default: return phase;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { writeFileAtomicSync } from './fs-atomic';
|
||||
|
||||
let tmpDir: string;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fsatomic-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
describe('writeFileAtomicSync', () => {
|
||||
test('writes a string payload', () => {
|
||||
const target = path.join(tmpDir, 'a.txt');
|
||||
writeFileAtomicSync(target, 'hello');
|
||||
expect(fs.readFileSync(target, 'utf-8')).toBe('hello');
|
||||
});
|
||||
|
||||
test('writes a buffer payload', () => {
|
||||
const target = path.join(tmpDir, 'b.bin');
|
||||
writeFileAtomicSync(target, Buffer.from([1, 2, 3, 4]));
|
||||
expect(fs.readFileSync(target)).toEqual(Buffer.from([1, 2, 3, 4]));
|
||||
});
|
||||
|
||||
test('overwrites existing file', () => {
|
||||
const target = path.join(tmpDir, 'c.txt');
|
||||
fs.writeFileSync(target, 'old');
|
||||
writeFileAtomicSync(target, 'new');
|
||||
expect(fs.readFileSync(target, 'utf-8')).toBe('new');
|
||||
});
|
||||
|
||||
test('cleans up tmp file after success', () => {
|
||||
const target = path.join(tmpDir, 'd.txt');
|
||||
writeFileAtomicSync(target, 'x');
|
||||
expect(fs.existsSync(target + '.tmp')).toBe(false);
|
||||
});
|
||||
|
||||
test('utf-8 multibyte chars roundtrip', () => {
|
||||
const target = path.join(tmpDir, 'e.txt');
|
||||
writeFileAtomicSync(target, 'aeoeue-aeoeue');
|
||||
expect(fs.readFileSync(target, 'utf-8')).toBe('aeoeue-aeoeue');
|
||||
});
|
||||
|
||||
test('empty payload writes empty file', () => {
|
||||
const target = path.join(tmpDir, 'f.txt');
|
||||
writeFileAtomicSync(target, '');
|
||||
expect(fs.readFileSync(target, 'utf-8')).toBe('');
|
||||
expect(fs.statSync(target).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as fs from 'fs';
|
||||
|
||||
/**
|
||||
* Atomic write via tmp + rename. Survives crash mid-write — either old or
|
||||
* new content, never partial. Windows fallback: copy + unlink if rename
|
||||
* fails (e.g. target locked by reader). fsync best-effort.
|
||||
*/
|
||||
export function writeFileAtomicSync(targetPath: string, payload: string | Buffer): void {
|
||||
const buffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf-8');
|
||||
const tmpPath = targetPath + '.tmp';
|
||||
|
||||
let fd: number | null = null;
|
||||
try {
|
||||
fd = fs.openSync(tmpPath, 'w');
|
||||
fs.writeSync(fd, buffer, 0, buffer.length, 0);
|
||||
try { fs.fsyncSync(fd); } catch { /* fsync may fail on some FS; rename is still safer than nothing */ }
|
||||
} finally {
|
||||
if (fd !== null) {
|
||||
try { fs.closeSync(fd); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.renameSync(tmpPath, targetPath);
|
||||
} catch {
|
||||
fs.copyFileSync(tmpPath, targetPath);
|
||||
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import * as http from 'http';
|
||||
import { startLoopbackServer } from './loopback-server';
|
||||
|
||||
function httpGet(url: string): Promise<{ status: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.get(url, res => {
|
||||
let body = '';
|
||||
res.on('data', chunk => { body += chunk.toString(); });
|
||||
res.on('end', () => resolve({ status: res.statusCode ?? 0, body }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe('startLoopbackServer', () => {
|
||||
test('binds to 127.0.0.1 and returns url with pathPrefix', async () => {
|
||||
const server = await startLoopbackServer({ pathPrefix: '/cb' });
|
||||
expect(server.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/cb$/);
|
||||
server.close();
|
||||
});
|
||||
|
||||
test('captures redirect params (code + state)', async () => {
|
||||
const server = await startLoopbackServer({ pathPrefix: '/cb' });
|
||||
const captureP = server.awaitParams({ timeoutMs: 3000 });
|
||||
const response = await httpGet(`${server.url}?code=abc123&state=xyz`);
|
||||
expect(response.status).toBe(200);
|
||||
const params = await captureP;
|
||||
expect(params.get('code')).toBe('abc123');
|
||||
expect(params.get('state')).toBe('xyz');
|
||||
server.close();
|
||||
});
|
||||
|
||||
test('non-matching path returns 404, capture not triggered', async () => {
|
||||
const server = await startLoopbackServer({ pathPrefix: '/cb' });
|
||||
const captureP = server.awaitParams({ timeoutMs: 500 });
|
||||
const response = await httpGet(`${server.url.replace('/cb', '/other')}`);
|
||||
expect(response.status).toBe(404);
|
||||
await expect(captureP).rejects.toThrow(/timeout/);
|
||||
server.close();
|
||||
});
|
||||
|
||||
test('error param renders errorHtml', async () => {
|
||||
const server = await startLoopbackServer({ pathPrefix: '/cb' });
|
||||
const captureP = server.awaitParams({ timeoutMs: 3000 });
|
||||
const response = await httpGet(`${server.url}?error=access_denied`);
|
||||
expect(response.body).toContain('Fehler');
|
||||
const params = await captureP;
|
||||
expect(params.get('error')).toBe('access_denied');
|
||||
server.close();
|
||||
});
|
||||
|
||||
test('timeout rejects', async () => {
|
||||
const server = await startLoopbackServer({ pathPrefix: '/cb' });
|
||||
await expect(server.awaitParams({ timeoutMs: 200 })).rejects.toThrow(/timeout/);
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as http from 'http';
|
||||
import { URL } from 'url';
|
||||
|
||||
/**
|
||||
* Ephemerer HTTP-Server auf localhost:PORT fuer OAuth-Redirect-Capture.
|
||||
* RFC 8252 (OAuth 2.0 for Native Apps) — System-Browser + Loopback-Redirect.
|
||||
*
|
||||
* Lifecycle:
|
||||
* const server = await startLoopbackServer({ pathPrefix: '/oauth/callback' });
|
||||
* console.log(server.url); // http://127.0.0.1:54321/oauth/callback
|
||||
* const params = await server.awaitParams({ timeoutMs: 5 * 60 * 1000 });
|
||||
* server.close();
|
||||
*
|
||||
* Bindet immer auf 127.0.0.1 (nicht 0.0.0.0) — der OS-Listener ist nur lokal
|
||||
* erreichbar, kein Firewall-Prompt unter Windows.
|
||||
*/
|
||||
|
||||
export interface LoopbackServerOptions {
|
||||
pathPrefix: string; // z.B. '/oauth/callback'
|
||||
port?: number; // 0 = OS waehlt freien Port
|
||||
successHtml?: string; // HTML-Antwort beim Capture
|
||||
errorHtml?: string;
|
||||
}
|
||||
|
||||
export interface LoopbackServer {
|
||||
readonly url: string;
|
||||
awaitParams(opts?: { timeoutMs?: number }): Promise<URLSearchParams>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
const DEFAULT_SUCCESS = `<!doctype html><html><head><meta charset="utf-8"><title>Login erfolgreich</title>
|
||||
<style>body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0e0e10;color:#efeff1;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
||||
.box{text-align:center;padding:2rem 3rem;background:#1f1f23;border-radius:8px}
|
||||
h1{color:#9146FF;margin:0 0 0.5rem}</style></head>
|
||||
<body><div class="box"><h1>Login erfolgreich</h1><p>Du kannst dieses Fenster jetzt schliessen.</p></div></body></html>`;
|
||||
|
||||
const DEFAULT_ERROR = `<!doctype html><html><head><meta charset="utf-8"><title>Fehler</title>
|
||||
<style>body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0e0e10;color:#efeff1;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
||||
.box{text-align:center;padding:2rem 3rem;background:#1f1f23;border-radius:8px}
|
||||
h1{color:#ff4444;margin:0 0 0.5rem}</style></head>
|
||||
<body><div class="box"><h1>Fehler</h1><p>Login abgebrochen.</p></div></body></html>`;
|
||||
|
||||
export function startLoopbackServer(opts: LoopbackServerOptions): Promise<LoopbackServer> {
|
||||
const successHtml = opts.successHtml ?? DEFAULT_SUCCESS;
|
||||
const errorHtml = opts.errorHtml ?? DEFAULT_ERROR;
|
||||
const pathPrefix = opts.pathPrefix.startsWith('/') ? opts.pathPrefix : '/' + opts.pathPrefix;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolveCapture: ((p: URLSearchParams) => void) | null = null;
|
||||
let rejectCapture: ((e: Error) => void) | null = null;
|
||||
let captureSettled = false;
|
||||
|
||||
const captureP = new Promise<URLSearchParams>((res, rej) => {
|
||||
resolveCapture = res;
|
||||
rejectCapture = rej;
|
||||
});
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
||||
if (!url.pathname.startsWith(pathPrefix)) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('not found');
|
||||
return;
|
||||
}
|
||||
const params = url.searchParams;
|
||||
const hasError = params.has('error');
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(hasError ? errorHtml : successHtml);
|
||||
if (!captureSettled && resolveCapture) {
|
||||
captureSettled = true;
|
||||
resolveCapture(params);
|
||||
}
|
||||
} catch (e) {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('internal error');
|
||||
if (!captureSettled && rejectCapture) {
|
||||
captureSettled = true;
|
||||
rejectCapture(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
server.on('error', reject);
|
||||
server.listen(opts.port ?? 0, '127.0.0.1', () => {
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') {
|
||||
server.close();
|
||||
reject(new Error('loopback-server: failed to determine bound port'));
|
||||
return;
|
||||
}
|
||||
const url = `http://127.0.0.1:${addr.port}${pathPrefix}`;
|
||||
|
||||
resolve({
|
||||
url,
|
||||
async awaitParams(awaitOpts) {
|
||||
const timeoutMs = awaitOpts?.timeoutMs ?? 5 * 60 * 1000;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
const timeoutP = new Promise<URLSearchParams>((_, rej) => {
|
||||
timer = setTimeout(() => {
|
||||
if (!captureSettled && rejectCapture) {
|
||||
captureSettled = true;
|
||||
rejectCapture(new Error('loopback-server: timeout waiting for redirect'));
|
||||
}
|
||||
rej(new Error('loopback-server: timeout waiting for redirect'));
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([captureP, timeoutP]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
},
|
||||
close() {
|
||||
try { server.close(); } catch { /* already closed */ }
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// SQLite-Schema v5 fuer Twitch VOD Manager.
|
||||
// Inline-Konstante damit tsc kein non-TS-Asset kopieren muss.
|
||||
// Alle Tabellen mit IF NOT EXISTS — Schema-Bootstrap ist idempotent.
|
||||
// PRAGMA-Statements (WAL etc.) werden separat von db.ts vor dem Bootstrap gesetzt.
|
||||
|
||||
export const SCHEMA_V5_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS schema_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO schema_meta(key, value) VALUES ('schema_version', '5');
|
||||
INSERT OR IGNORE INTO schema_meta(key, value) VALUES ('created_at', CAST(strftime('%s','now') AS TEXT));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_kv (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS queue_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
streamer_login TEXT,
|
||||
vod_id TEXT,
|
||||
clip_id TEXT,
|
||||
title TEXT,
|
||||
output_path TEXT,
|
||||
status TEXT NOT NULL,
|
||||
progress_pct REAL,
|
||||
error_message TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
completed_at INTEGER,
|
||||
payload_json TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_status ON queue_items(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_streamer ON queue_items(streamer_login);
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_created ON queue_items(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS downloaded_vods (
|
||||
vod_id TEXT PRIMARY KEY,
|
||||
downloaded_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS streamers (
|
||||
login TEXT PRIMARY KEY,
|
||||
auto_record INTEGER NOT NULL DEFAULT 0,
|
||||
auto_vod_download INTEGER NOT NULL DEFAULT 0,
|
||||
added_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_streamers_autorec ON streamers(auto_record);
|
||||
CREATE INDEX IF NOT EXISTS idx_streamers_autodl ON streamers(auto_vod_download);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archive_files (
|
||||
path TEXT PRIMARY KEY,
|
||||
streamer_login TEXT,
|
||||
size_bytes INTEGER,
|
||||
duration_seconds INTEGER,
|
||||
created_at INTEGER,
|
||||
verified INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_archive_streamer ON archive_files(streamer_login);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chunk_index (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL,
|
||||
chunk_seq INTEGER NOT NULL,
|
||||
sha1_hex TEXT NOT NULL,
|
||||
bytes INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
UNIQUE(item_id, chunk_seq)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chunk_item ON chunk_index(item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chunk_sha1 ON chunk_index(sha1_hex);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT NOT NULL,
|
||||
twitch_user_id TEXT,
|
||||
login TEXT,
|
||||
display_name TEXT,
|
||||
encrypted_access_token TEXT NOT NULL,
|
||||
encrypted_refresh_token TEXT,
|
||||
expires_at INTEGER,
|
||||
scopes_json TEXT,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
UNIQUE(provider, twitch_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_provider ON oauth_accounts(provider);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_default ON oauth_accounts(is_default);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS migrations_applied (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
payload TEXT
|
||||
);
|
||||
`;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import { MemorySecureStorage, createElectronSecureStorage, type SecureStorage } from './secure-storage';
|
||||
|
||||
describe('MemorySecureStorage', () => {
|
||||
test('isEncryptionAvailable returns false (kennzeichnet Memory-Mode)', () => {
|
||||
const s: SecureStorage = new MemorySecureStorage();
|
||||
expect(s.isEncryptionAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
test('roundtrip ascii', () => {
|
||||
const s = new MemorySecureStorage();
|
||||
const cipher = s.encrypt('hello');
|
||||
expect(cipher).not.toBe('hello'); // base64-Kodierung greift
|
||||
expect(s.decrypt(cipher)).toBe('hello');
|
||||
});
|
||||
|
||||
test('roundtrip multi-byte', () => {
|
||||
const s = new MemorySecureStorage();
|
||||
expect(s.decrypt(s.encrypt('aeoeue-test'))).toBe('aeoeue-test');
|
||||
});
|
||||
|
||||
test('roundtrip empty string', () => {
|
||||
const s = new MemorySecureStorage();
|
||||
expect(s.decrypt(s.encrypt(''))).toBe('');
|
||||
});
|
||||
|
||||
test('long token (simuliert OAuth access_token Groesse)', () => {
|
||||
const s = new MemorySecureStorage();
|
||||
const token = 'a'.repeat(256);
|
||||
expect(s.decrypt(s.encrypt(token))).toBe(token);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createElectronSecureStorage', () => {
|
||||
test('is exported as function', () => {
|
||||
expect(typeof createElectronSecureStorage).toBe('function');
|
||||
});
|
||||
|
||||
test('throws useful error if called outside Electron (vitest env)', () => {
|
||||
// In vitest (Node-only) ist electron entweder nicht installiert oder hat keine
|
||||
// app-context-Funktionen. Genaues Error-Wording ist nicht stable, aber Aufruf
|
||||
// muss throwen statt undefined zurueckgeben.
|
||||
expect(() => createElectronSecureStorage()).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// Verschluesselt String-Payloads im OS-Keystore (Win Credential Manager via
|
||||
// Electron safeStorage). MemorySecureStorage ist fuer Tests/Headless-Envs —
|
||||
// gibt plaintext zurueck und meldet isEncryptionAvailable() === false, damit
|
||||
// Caller das in den Log schreiben oder verweigern koennen.
|
||||
|
||||
export interface SecureStorage {
|
||||
isEncryptionAvailable(): boolean;
|
||||
encrypt(plaintext: string): string;
|
||||
decrypt(ciphertext: string): string;
|
||||
}
|
||||
|
||||
export class MemorySecureStorage implements SecureStorage {
|
||||
isEncryptionAvailable(): boolean {
|
||||
return false;
|
||||
}
|
||||
encrypt(plaintext: string): string {
|
||||
// Base64 als Kennzeichnung — kein Schutz, nur damit `decrypt(encrypt(x)) === x`
|
||||
// semantisch konsistent ist (kein literal plaintext zwischen den Methoden).
|
||||
return Buffer.from(plaintext, 'utf-8').toString('base64');
|
||||
}
|
||||
decrypt(ciphertext: string): string {
|
||||
return Buffer.from(ciphertext, 'base64').toString('utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
interface SafeStorageLike {
|
||||
isEncryptionAvailable(): boolean;
|
||||
encryptString(plain: string): Buffer;
|
||||
decryptString(buf: Buffer): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrappt electron.safeStorage. Setzt voraus, dass `app.whenReady()` gefired ist.
|
||||
* Wird per Lazy-Require konstruiert, sodass Module ausserhalb von Electron
|
||||
* (zB Tests) das Modul importieren koennen ohne Crash.
|
||||
*/
|
||||
export function createElectronSecureStorage(): SecureStorage {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const electron = require('electron');
|
||||
const safeStorage = electron?.safeStorage as SafeStorageLike | undefined;
|
||||
if (!safeStorage) {
|
||||
throw new Error('Electron safeStorage not available (called before app.whenReady?)');
|
||||
}
|
||||
|
||||
return {
|
||||
isEncryptionAvailable(): boolean {
|
||||
return safeStorage.isEncryptionAvailable();
|
||||
},
|
||||
encrypt(plaintext: string): string {
|
||||
const buf = safeStorage.encryptString(plaintext);
|
||||
return buf.toString('base64');
|
||||
},
|
||||
decrypt(ciphertext: string): string {
|
||||
const buf = Buffer.from(ciphertext, 'base64');
|
||||
return safeStorage.decryptString(buf);
|
||||
},
|
||||
};
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress } from './types';
|
||||
|
||||
// Types
|
||||
interface RuntimeMetricsSnapshot {
|
||||
cacheHits: number;
|
||||
cacheMisses: number;
|
||||
duplicateSkips: number;
|
||||
retriesScheduled: number;
|
||||
retriesExhausted: number;
|
||||
integrityFailures: number;
|
||||
downloadsStarted: number;
|
||||
downloadsCompleted: number;
|
||||
downloadsFailed: number;
|
||||
downloadedBytesTotal: number;
|
||||
lastSpeedBytesPerSec: number;
|
||||
avgSpeedBytesPerSec: number;
|
||||
activeItemId: string | null;
|
||||
activeItemTitle: string | null;
|
||||
lastErrorClass: string | null;
|
||||
lastRetryDelaySeconds: number;
|
||||
timestamp: string;
|
||||
queue: {
|
||||
pending: number;
|
||||
downloading: number;
|
||||
paused: number;
|
||||
completed: number;
|
||||
error: number;
|
||||
total: number;
|
||||
};
|
||||
caches: {
|
||||
loginToUserId: number;
|
||||
vodList: number;
|
||||
clipInfo: number;
|
||||
};
|
||||
config: {
|
||||
performanceMode: 'stability' | 'balanced' | 'speed';
|
||||
smartScheduler: boolean;
|
||||
metadataCacheMinutes: number;
|
||||
duplicatePrevention: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface VideoInfo {
|
||||
duration: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
}
|
||||
|
||||
// Expose protected methods to renderer
|
||||
contextBridge.exposeInMainWorld('api', {
|
||||
// Config
|
||||
getConfig: () => ipcRenderer.invoke('get-config'),
|
||||
saveConfig: (config: any) => ipcRenderer.invoke('save-config', config),
|
||||
|
||||
// Auth
|
||||
login: () => ipcRenderer.invoke('login'),
|
||||
|
||||
// Twitch API
|
||||
getUserId: (username: string) => ipcRenderer.invoke('get-user-id', username),
|
||||
getVODs: (userId: string, forceRefresh: boolean = false) => ipcRenderer.invoke('get-vods', userId, forceRefresh),
|
||||
|
||||
// Queue
|
||||
getQueue: () => ipcRenderer.invoke('get-queue'),
|
||||
addToQueue: (item: Omit<QueueItem, 'id' | 'status' | 'progress'>) => ipcRenderer.invoke('add-to-queue', item),
|
||||
startLiveRecording: (streamerName: string) => ipcRenderer.invoke('start-live-recording', streamerName),
|
||||
removeFromQueue: (id: string) => ipcRenderer.invoke('remove-from-queue', id),
|
||||
reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds),
|
||||
clearCompleted: () => ipcRenderer.invoke('clear-completed'),
|
||||
retryFailedDownloads: () => ipcRenderer.invoke('retry-failed-downloads'),
|
||||
retryQueueItem: (id: string) => ipcRenderer.invoke('retry-queue-item', id),
|
||||
createMergeGroup: (itemIds: string[]) => ipcRenderer.invoke('create-merge-group', itemIds),
|
||||
|
||||
// Download
|
||||
startDownload: () => ipcRenderer.invoke('start-download'),
|
||||
pauseDownload: () => ipcRenderer.invoke('pause-download'),
|
||||
cancelDownload: () => ipcRenderer.invoke('cancel-download'),
|
||||
isDownloading: () => ipcRenderer.invoke('is-downloading'),
|
||||
downloadClip: (url: string) => ipcRenderer.invoke('download-clip', url),
|
||||
|
||||
// Files
|
||||
selectFolder: () => ipcRenderer.invoke('select-folder'),
|
||||
selectVideoFile: () => ipcRenderer.invoke('select-video-file'),
|
||||
selectMultipleVideos: () => ipcRenderer.invoke('select-multiple-videos'),
|
||||
saveVideoDialog: (defaultName: string) => ipcRenderer.invoke('save-video-dialog', defaultName),
|
||||
openFolder: (path: string) => ipcRenderer.invoke('open-folder', path),
|
||||
openFile: (path: string) => ipcRenderer.invoke('open-file', path),
|
||||
showInFolder: (path: string) => ipcRenderer.invoke('show-in-folder', path),
|
||||
openDebugLogFile: () => ipcRenderer.invoke('open-debug-log-file'),
|
||||
checkFolderWritable: (path: string) => ipcRenderer.invoke('check-folder-writable', path),
|
||||
getStorageStats: () => ipcRenderer.invoke('get-storage-stats'),
|
||||
getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'),
|
||||
getStreamerProfile: (login: string, forceRefresh?: boolean) => ipcRenderer.invoke('get-streamer-profile', login, forceRefresh),
|
||||
getVodStoryboard: (vodId: string) => ipcRenderer.invoke('get-vod-storyboard', vodId),
|
||||
getLiveStatusSnapshot: () => ipcRenderer.invoke('get-live-status-snapshot'),
|
||||
onLiveStatusBatchUpdate: (callback: (info: { changes: Array<{ login: string; isLive: boolean }> }) => void) => {
|
||||
ipcRenderer.on('live-status-batch-update', (_, info) => callback(info));
|
||||
},
|
||||
searchArchive: (filter: Record<string, unknown>) => ipcRenderer.invoke('search-archive', filter),
|
||||
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options),
|
||||
readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath),
|
||||
getAutomationStatus: () => ipcRenderer.invoke('get-automation-status'),
|
||||
triggerAutoVodScan: () => ipcRenderer.invoke('trigger-auto-vod-scan'),
|
||||
triggerAutoRecordScan: () => ipcRenderer.invoke('trigger-auto-record-scan'),
|
||||
onAutoVodScanCompleted: (callback: (info: { queuedCount: number }) => void) => {
|
||||
ipcRenderer.on('auto-vod-scan-completed', (_, info) => callback(info));
|
||||
},
|
||||
|
||||
// Video Cutter
|
||||
getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath),
|
||||
extractFrame: (filePath: string, timeSeconds: number): Promise<string | null> => ipcRenderer.invoke('extract-frame', filePath, timeSeconds),
|
||||
cutVideo: (inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }> =>
|
||||
ipcRenderer.invoke('cut-video', inputFile, startTime, endTime),
|
||||
|
||||
// Merge Videos
|
||||
mergeVideos: (inputFiles: string[], outputFile: string): Promise<{ success: boolean; outputFile: string | null }> =>
|
||||
ipcRenderer.invoke('merge-videos', inputFiles, outputFile),
|
||||
|
||||
// App
|
||||
getVersion: () => ipcRenderer.invoke('get-version'),
|
||||
checkUpdate: () => ipcRenderer.invoke('check-update'),
|
||||
downloadUpdate: () => ipcRenderer.invoke('download-update'),
|
||||
installUpdate: () => ipcRenderer.invoke('install-update'),
|
||||
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
|
||||
runPreflight: (autoFix: boolean) => ipcRenderer.invoke('run-preflight', autoFix),
|
||||
getDebugLog: (lines: number) => ipcRenderer.invoke('get-debug-log', lines),
|
||||
getRuntimeMetrics: (): Promise<RuntimeMetricsSnapshot> => ipcRenderer.invoke('get-runtime-metrics'),
|
||||
exportRuntimeMetrics: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
|
||||
ipcRenderer.invoke('export-runtime-metrics'),
|
||||
resetDownloadedVodIds: (): Promise<{ success: boolean; removedCount: number }> =>
|
||||
ipcRenderer.invoke('reset-downloaded-vod-ids'),
|
||||
markVodDownloaded: (vodId: string, mark: boolean): Promise<{ success: boolean }> =>
|
||||
ipcRenderer.invoke('mark-vod-downloaded', vodId, mark),
|
||||
exportConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
|
||||
ipcRenderer.invoke('export-config'),
|
||||
importConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
|
||||
ipcRenderer.invoke('import-config'),
|
||||
|
||||
// Events
|
||||
onDownloadProgress: (callback: (progress: DownloadProgress) => void) => {
|
||||
ipcRenderer.on('download-progress', (_, progress) => callback(progress));
|
||||
},
|
||||
onQueueUpdated: (callback: (queue: QueueItem[]) => void) => {
|
||||
ipcRenderer.on('queue-updated', (_, queue) => callback(queue));
|
||||
},
|
||||
onQueueDuplicateSkipped: (callback: (payload: { title: string; streamer: string; url: string }) => void) => {
|
||||
ipcRenderer.on('queue-duplicate-skipped', (_, payload) => callback(payload));
|
||||
},
|
||||
onDownloadStarted: (callback: () => void) => {
|
||||
ipcRenderer.on('download-started', () => callback());
|
||||
},
|
||||
onDownloadFinished: (callback: () => void) => {
|
||||
ipcRenderer.on('download-finished', () => callback());
|
||||
},
|
||||
onCutProgress: (callback: (percent: number) => void) => {
|
||||
ipcRenderer.on('cut-progress', (_, percent) => callback(percent));
|
||||
},
|
||||
onMergeProgress: (callback: (percent: number) => void) => {
|
||||
ipcRenderer.on('merge-progress', (_, percent) => callback(percent));
|
||||
},
|
||||
|
||||
// Auto-Update Events
|
||||
onUpdateChecking: (callback: () => void) => {
|
||||
ipcRenderer.on('update-checking', () => callback());
|
||||
},
|
||||
onUpdateAvailable: (callback: (info: { version: string; releaseDate?: string; releaseName?: string; releaseNotes?: string }) => void) => {
|
||||
ipcRenderer.on('update-available', (_, info) => callback(info));
|
||||
},
|
||||
onUpdateNotAvailable: (callback: () => void) => {
|
||||
ipcRenderer.on('update-not-available', () => callback());
|
||||
},
|
||||
onUpdateDownloadProgress: (callback: (progress: { percent: number; bytesPerSecond: number; transferred: number; total: number }) => void) => {
|
||||
ipcRenderer.on('update-download-progress', (_, progress) => callback(progress));
|
||||
},
|
||||
onUpdateDownloaded: (callback: (info: { version: string; releaseDate?: string; releaseName?: string; releaseNotes?: string }) => void) => {
|
||||
ipcRenderer.on('update-downloaded', (_, info) => callback(info));
|
||||
},
|
||||
onUpdateError: (callback: (payload: { message: string }) => void) => {
|
||||
ipcRenderer.on('update-error', (_, payload) => callback(payload));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
let archiveStreamerSelectPopulated = false;
|
||||
let archiveSearchInFlight = false;
|
||||
let archiveSearchDebounceTimer: number | null = null;
|
||||
|
||||
function populateArchiveStreamerSelect(): void {
|
||||
if (archiveStreamerSelectPopulated) return;
|
||||
const select = document.getElementById('archiveSearchStreamer') as HTMLSelectElement | null;
|
||||
if (!select) return;
|
||||
|
||||
const streamers = (config.streamers as string[] | undefined) || [];
|
||||
const sorted = [...streamers].sort((a, b) => a.localeCompare(b));
|
||||
const opts = sorted.map((s) => `<option value="${escapeHtml(s)}">${escapeHtml(s)}</option>`).join('');
|
||||
applyHtml(select, `<option value="">${escapeHtml(UI_TEXT.static.archiveAllStreamers || 'Alle Streamer')}</option>${opts}`);
|
||||
archiveStreamerSelectPopulated = true;
|
||||
}
|
||||
|
||||
function onArchiveSearchInput(): void {
|
||||
if (archiveSearchDebounceTimer !== null) {
|
||||
window.clearTimeout(archiveSearchDebounceTimer);
|
||||
}
|
||||
// 250ms debounce — feels snappy without spamming the IO walker on
|
||||
// every keystroke. The walk is fast but pointless to repeat mid-type.
|
||||
archiveSearchDebounceTimer = window.setTimeout(() => {
|
||||
archiveSearchDebounceTimer = null;
|
||||
void performArchiveSearch();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
async function performArchiveSearch(): Promise<void> {
|
||||
if (archiveSearchInFlight) return;
|
||||
populateArchiveStreamerSelect();
|
||||
|
||||
const queryEl = document.getElementById('archiveSearchQuery') as HTMLInputElement | null;
|
||||
const typeEl = document.getElementById('archiveSearchType') as HTMLSelectElement | null;
|
||||
const streamerEl = document.getElementById('archiveSearchStreamer') as HTMLSelectElement | null;
|
||||
const sortEl = document.getElementById('archiveSearchSort') as HTMLSelectElement | null;
|
||||
const summaryEl = document.getElementById('archiveSearchSummary');
|
||||
const resultsEl = document.getElementById('archiveSearchResults');
|
||||
const btn = document.getElementById('btnArchiveSearch') as HTMLButtonElement | null;
|
||||
if (!resultsEl) return;
|
||||
|
||||
archiveSearchInFlight = true;
|
||||
if (btn) btn.disabled = true;
|
||||
if (summaryEl) summaryEl.textContent = UI_TEXT.static.archiveSearching || 'Scanne...';
|
||||
|
||||
try {
|
||||
const filter = {
|
||||
query: queryEl?.value || '',
|
||||
type: ((typeEl?.value as 'all' | 'live' | 'vod') || 'all'),
|
||||
streamer: streamerEl?.value || '',
|
||||
sinceMs: null,
|
||||
untilMs: null,
|
||||
sort: ((sortEl?.value as 'date_desc') || 'date_desc'),
|
||||
limit: 200
|
||||
};
|
||||
const result = await window.api.searchArchive(filter);
|
||||
renderArchiveSearchResults(result);
|
||||
} catch (e) {
|
||||
if (summaryEl) summaryEl.textContent = `Fehler: ${String(e)}`;
|
||||
applyHtml(resultsEl, '');
|
||||
} finally {
|
||||
archiveSearchInFlight = false;
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderArchiveSearchResults(result: ArchiveSearchResult): void {
|
||||
const summaryEl = document.getElementById('archiveSearchSummary');
|
||||
const resultsEl = document.getElementById('archiveSearchResults');
|
||||
if (!resultsEl) return;
|
||||
|
||||
if (!result.rootExists) {
|
||||
if (summaryEl) summaryEl.textContent = UI_TEXT.static.archiveNoRoot;
|
||||
applyHtml(resultsEl, '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (summaryEl) {
|
||||
const tmpl = result.truncated
|
||||
? UI_TEXT.static.archiveSummaryTruncated
|
||||
: UI_TEXT.static.archiveSummary;
|
||||
summaryEl.textContent = (tmpl || '')
|
||||
.replace('{matchCount}', String(result.matchCount))
|
||||
.replace('{scanned}', String(result.totalScanned))
|
||||
.replace('{shown}', String(result.hits.length));
|
||||
}
|
||||
|
||||
if (result.hits.length === 0) {
|
||||
applyHtml(resultsEl, `<div class="archive-no-matches">${escapeHtml(UI_TEXT.static.archiveNoMatches || 'Keine Treffer.')}</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = result.hits.map((hit) => {
|
||||
const date = new Date(hit.mtimeMs).toLocaleString();
|
||||
const typeBadge = `<span class="archive-type-badge ${hit.type === 'live' ? 'live' : 'vod'}">${hit.type === 'live' ? 'LIVE' : 'VOD'}</span>`;
|
||||
const safeFullAttr = hit.fullPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
const chatBtn = hit.chatPath
|
||||
? `<button type="button" class="queue-detail-btn" onclick="openEventsOrChat('${safeFullAttr.replace(/\.(mp4|mkv|ts|m4v)$/i, '.chat.jsonl')}', '${escapeHtml(hit.fileName)}', 'chat')">${escapeHtml(UI_TEXT.static.archiveViewChat || 'Chat')}</button>`
|
||||
: '';
|
||||
const eventsBtn = hit.eventsPath
|
||||
? `<button type="button" class="queue-detail-btn" onclick="openEventsOrChat('${(hit.eventsPath || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'")}', '${escapeHtml(hit.fileName)}', 'events')">${escapeHtml(UI_TEXT.static.archiveViewEvents || 'Events')}</button>`
|
||||
: '';
|
||||
return `
|
||||
<div class="archive-result-row">
|
||||
<div class="archive-result-body">
|
||||
<div class="archive-result-meta">
|
||||
${typeBadge}
|
||||
<strong class="archive-result-streamer">${escapeHtml(hit.streamer)}</strong>
|
||||
<span class="archive-result-date">${escapeHtml(date)}</span>
|
||||
</div>
|
||||
<div class="archive-result-filename" title="${escapeHtml(hit.fullPath)}">${escapeHtml(hit.fileName)}</div>
|
||||
<div class="archive-result-size">${escapeHtml(formatBytes(hit.size))}</div>
|
||||
</div>
|
||||
<div class="archive-result-actions">
|
||||
<button type="button" class="queue-detail-btn" onclick="openFilePath('${safeFullAttr}')">${escapeHtml(UI_TEXT.static.archiveOpen || 'Oeffnen')}</button>
|
||||
<button type="button" class="queue-detail-btn" onclick="showFileInFolder('${safeFullAttr}')">${escapeHtml(UI_TEXT.static.archiveShowInFolder || 'Ordner')}</button>
|
||||
${chatBtn}
|
||||
${eventsBtn}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
applyHtml(resultsEl, rows);
|
||||
}
|
||||
|
||||
function openFilePath(filePath: string): void {
|
||||
void window.api.openFile(filePath);
|
||||
}
|
||||
|
||||
function showFileInFolder(filePath: string): void {
|
||||
void window.api.showInFolder(filePath);
|
||||
}
|
||||
|
||||
function openEventsOrChat(filePath: string, title: string, kind: 'chat' | 'events'): void {
|
||||
if (kind === 'events') {
|
||||
const fn = (window as unknown as { openEventsViewer?: (p: string, t: string) => void }).openEventsViewer;
|
||||
if (typeof fn === 'function') fn(filePath, title);
|
||||
} else {
|
||||
const fn = (window as unknown as { openChatViewer?: (p: string, t: string) => void }).openChatViewer;
|
||||
if (typeof fn === 'function') fn(filePath, title);
|
||||
}
|
||||
}
|
||||
|
||||
(window as unknown as {
|
||||
performArchiveSearch: typeof performArchiveSearch;
|
||||
onArchiveSearchInput: typeof onArchiveSearchInput;
|
||||
openFilePath: typeof openFilePath;
|
||||
showFileInFolder: typeof showFileInFolder;
|
||||
openEventsOrChat: typeof openEventsOrChat;
|
||||
}).performArchiveSearch = performArchiveSearch;
|
||||
(window as unknown as { onArchiveSearchInput: typeof onArchiveSearchInput }).onArchiveSearchInput = onArchiveSearchInput;
|
||||
(window as unknown as { openFilePath: typeof openFilePath }).openFilePath = openFilePath;
|
||||
(window as unknown as { showFileInFolder: typeof showFileInFolder }).showFileInFolder = showFileInFolder;
|
||||
(window as unknown as { openEventsOrChat: typeof openEventsOrChat }).openEventsOrChat = openEventsOrChat;
|
||||
|
||||
function initArchiveSearchInput(): void {
|
||||
const queryEl = document.getElementById('archiveSearchQuery') as HTMLInputElement | null;
|
||||
if (queryEl && !queryEl.dataset.bound) {
|
||||
queryEl.addEventListener('input', onArchiveSearchInput);
|
||||
queryEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') void performArchiveSearch();
|
||||
});
|
||||
queryEl.dataset.bound = '1';
|
||||
}
|
||||
const filters = ['archiveSearchType', 'archiveSearchStreamer', 'archiveSearchSort'];
|
||||
for (const id of filters) {
|
||||
const el = document.getElementById(id) as HTMLSelectElement | null;
|
||||
if (el && !el.dataset.bound) {
|
||||
el.addEventListener('change', () => { void performArchiveSearch(); });
|
||||
el.dataset.bound = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
(window as unknown as { initArchiveSearchInput: typeof initArchiveSearchInput }).initArchiveSearchInput = initArchiveSearchInput;
|
||||
@@ -0,0 +1,232 @@
|
||||
// Command Palette — Pillar 5 UI Power.
|
||||
// Ctrl+K oeffnet ein Suchfeld + Liste schnell ausfuehrbarer Aktionen.
|
||||
// MVP: 6 statische Tab-Wechsel-Befehle, prefix-match auf Label.
|
||||
|
||||
interface PaletteCommand {
|
||||
id: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
keywords: string; // fuer Match — Label kleingeschrieben + Synonyme
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
(function initCommandPalette() {
|
||||
const STORE: { commands: PaletteCommand[]; activeIndex: number; filtered: PaletteCommand[] } = {
|
||||
commands: [],
|
||||
activeIndex: 0,
|
||||
filtered: [],
|
||||
};
|
||||
|
||||
function buildCommands(): PaletteCommand[] {
|
||||
const w = window as unknown as {
|
||||
showTab?: (tab: string) => void;
|
||||
selectStreamer?: (name: string, forceRefresh?: boolean) => Promise<void>;
|
||||
config?: { streamers?: Array<{ name: string }> };
|
||||
};
|
||||
const showTab = w.showTab;
|
||||
if (typeof showTab !== 'function') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// hint 'Open' statt 'Tab' — 'Tab' las sich wie eine Tastatur-Taste
|
||||
// ('druecke Tab') statt 'oeffnet diesen Tab'.
|
||||
const tabs: Array<{ id: string; labels: string[]; hint: string }> = [
|
||||
{ id: 'vods', labels: ['VODs', 'videos', 'streams'], hint: 'Open' },
|
||||
{ id: 'queue', labels: ['Queue', 'downloads', 'warteschlange'], hint: 'Open' },
|
||||
{ id: 'streamers', labels: ['Streamers', 'channels'], hint: 'Open' },
|
||||
{ id: 'stats', labels: ['Stats', 'statistiken', 'dashboard'], hint: 'Open' },
|
||||
{ id: 'archive', labels: ['Archive', 'archiv'], hint: 'Open' },
|
||||
{ id: 'settings', labels: ['Settings', 'einstellungen', 'config'], hint: 'Open' },
|
||||
];
|
||||
|
||||
const tabCommands: PaletteCommand[] = tabs.map(t => ({
|
||||
id: 'tab:' + t.id,
|
||||
label: t.labels[0],
|
||||
hint: t.hint,
|
||||
keywords: t.labels.join(' ').toLowerCase(),
|
||||
action: () => showTab(t.id),
|
||||
}));
|
||||
|
||||
// Streamer-Liste aus globalem config (gefuellt nach renderer-Init).
|
||||
const streamerCommands: PaletteCommand[] = [];
|
||||
const streamers = Array.isArray(w.config?.streamers) ? w.config.streamers : [];
|
||||
const selectStreamer = w.selectStreamer;
|
||||
if (typeof selectStreamer === 'function') {
|
||||
for (const entry of streamers) {
|
||||
if (!entry || typeof entry.name !== 'string') continue;
|
||||
const name = entry.name;
|
||||
streamerCommands.push({
|
||||
id: 'streamer:' + name.toLowerCase(),
|
||||
label: name,
|
||||
hint: 'Streamer',
|
||||
keywords: ('@' + name + ' ' + name).toLowerCase(),
|
||||
action: () => {
|
||||
showTab('vods');
|
||||
void selectStreamer(name);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...tabCommands, ...streamerCommands];
|
||||
}
|
||||
|
||||
function getModal(): HTMLElement | null {
|
||||
return document.getElementById('commandPaletteModal');
|
||||
}
|
||||
|
||||
function getInput(): HTMLInputElement | null {
|
||||
return document.getElementById('commandPaletteInput') as HTMLInputElement | null;
|
||||
}
|
||||
|
||||
function getList(): HTMLUListElement | null {
|
||||
return document.getElementById('commandPaletteList') as HTMLUListElement | null;
|
||||
}
|
||||
|
||||
function isOpen(): boolean {
|
||||
return Boolean(getModal()?.classList.contains('show'));
|
||||
}
|
||||
|
||||
function clearList(list: HTMLUListElement) {
|
||||
while (list.firstChild) list.removeChild(list.firstChild);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const list = getList();
|
||||
if (!list) return;
|
||||
clearList(list);
|
||||
STORE.filtered.forEach((cmd, idx) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'cp-item' + (idx === STORE.activeIndex ? ' cp-active' : '');
|
||||
li.dataset.cmdId = cmd.id;
|
||||
li.setAttribute('role', 'option');
|
||||
li.setAttribute('aria-selected', idx === STORE.activeIndex ? 'true' : 'false');
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'cp-item-label';
|
||||
label.textContent = cmd.label;
|
||||
li.appendChild(label);
|
||||
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'cp-item-hint';
|
||||
hint.textContent = cmd.hint;
|
||||
li.appendChild(hint);
|
||||
|
||||
li.addEventListener('mouseenter', () => {
|
||||
STORE.activeIndex = idx;
|
||||
render();
|
||||
});
|
||||
li.addEventListener('click', () => {
|
||||
executeAt(idx);
|
||||
});
|
||||
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function applyFilter(query: string) {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
STORE.filtered = STORE.commands.slice();
|
||||
} else {
|
||||
STORE.filtered = STORE.commands.filter(c => c.keywords.includes(q));
|
||||
}
|
||||
if (STORE.activeIndex >= STORE.filtered.length) {
|
||||
STORE.activeIndex = STORE.filtered.length > 0 ? STORE.filtered.length - 1 : 0;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function executeAt(idx: number) {
|
||||
const cmd = STORE.filtered[idx];
|
||||
if (!cmd) return;
|
||||
close();
|
||||
try {
|
||||
cmd.action();
|
||||
} catch (e) {
|
||||
console.error('command-palette: action failed', cmd.id, e);
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
const modal = getModal();
|
||||
const input = getInput();
|
||||
if (!modal || !input) return;
|
||||
STORE.commands = buildCommands();
|
||||
STORE.filtered = STORE.commands.slice();
|
||||
STORE.activeIndex = 0;
|
||||
input.value = '';
|
||||
modal.classList.add('show');
|
||||
requestAnimationFrame(() => input.focus());
|
||||
render();
|
||||
}
|
||||
|
||||
function close() {
|
||||
const modal = getModal();
|
||||
if (!modal) return;
|
||||
modal.classList.remove('show');
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
// Toggle: Ctrl+K (Linux/Windows) or Cmd+K (Mac)
|
||||
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'k' || e.key === 'K')) {
|
||||
e.preventDefault();
|
||||
if (isOpen()) {
|
||||
close();
|
||||
} else {
|
||||
open();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOpen()) return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (STORE.filtered.length === 0) return;
|
||||
STORE.activeIndex = (STORE.activeIndex + 1) % STORE.filtered.length;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (STORE.filtered.length === 0) return;
|
||||
STORE.activeIndex = (STORE.activeIndex - 1 + STORE.filtered.length) % STORE.filtered.length;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
executeAt(STORE.activeIndex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function attach() {
|
||||
const input = getInput();
|
||||
if (input) {
|
||||
input.addEventListener('input', () => applyFilter(input.value));
|
||||
}
|
||||
const modal = getModal();
|
||||
if (modal) {
|
||||
modal.addEventListener('click', e => {
|
||||
if (e.target === modal) close();
|
||||
});
|
||||
}
|
||||
document.addEventListener('keydown', onKeydown, { capture: true });
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', attach);
|
||||
} else {
|
||||
attach();
|
||||
}
|
||||
|
||||
// Expose for renderer.ts closeTopmostOpenModal integration.
|
||||
(window as unknown as { closeCommandPalette?: () => void }).closeCommandPalette = close;
|
||||
})();
|
||||
Vendored
+403
@@ -0,0 +1,403 @@
|
||||
interface AppConfig {
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
download_path?: string;
|
||||
streamers?: string[];
|
||||
theme?: string;
|
||||
download_mode?: 'parts' | 'full';
|
||||
part_minutes?: number;
|
||||
language?: 'de' | 'en';
|
||||
filename_template_vod?: string;
|
||||
filename_template_parts?: string;
|
||||
filename_template_clip?: string;
|
||||
smart_queue_scheduler?: boolean;
|
||||
performance_mode?: 'stability' | 'balanced' | 'speed';
|
||||
prevent_duplicate_downloads?: boolean;
|
||||
persist_queue_on_restart?: boolean;
|
||||
metadata_cache_minutes?: number;
|
||||
parallel_downloads?: number;
|
||||
auto_resume_queue_on_startup?: boolean;
|
||||
downloaded_vod_ids?: string[];
|
||||
streamlink_quality?: string;
|
||||
notify_on_each_completion?: boolean;
|
||||
streamlink_disable_ads?: boolean;
|
||||
auto_record_streamers?: string[];
|
||||
auto_record_poll_seconds?: number;
|
||||
download_chat_replay?: boolean;
|
||||
capture_live_chat?: boolean;
|
||||
discord_webhook_url?: string;
|
||||
discord_notify_live_start?: boolean;
|
||||
discord_notify_live_end?: boolean;
|
||||
discord_notify_vod_complete?: boolean;
|
||||
discord_notify_vod_auto_queued?: boolean;
|
||||
auto_cleanup_enabled?: boolean;
|
||||
auto_cleanup_days?: number;
|
||||
auto_cleanup_target?: 'live_only' | 'all';
|
||||
auto_cleanup_action?: 'delete' | 'archive';
|
||||
log_stream_events?: boolean;
|
||||
auto_vod_download_streamers?: string[];
|
||||
auto_vod_download_poll_minutes?: number;
|
||||
auto_vod_max_age_hours?: number;
|
||||
auto_resume_live_recording?: boolean;
|
||||
auto_merge_resumed_parts?: boolean;
|
||||
delete_parts_after_merge?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface VOD {
|
||||
id: string;
|
||||
title: string;
|
||||
created_at: string;
|
||||
duration: string;
|
||||
thumbnail_url: string;
|
||||
url: string;
|
||||
view_count: number;
|
||||
stream_id?: string;
|
||||
}
|
||||
|
||||
interface CustomClip {
|
||||
startSec: number;
|
||||
durationSec: number;
|
||||
startPart: number;
|
||||
filenameFormat: 'simple' | 'timestamp' | 'template' | 'parts';
|
||||
filenameTemplate?: string;
|
||||
}
|
||||
|
||||
interface MergeGroupItem {
|
||||
url: string;
|
||||
title: string;
|
||||
date: string;
|
||||
streamer: string;
|
||||
duration_str: string;
|
||||
}
|
||||
|
||||
interface MergeGroup {
|
||||
items: MergeGroupItem[];
|
||||
mergePhase: 'downloading' | 'merging' | 'splitting' | 'cleanup' | 'done';
|
||||
currentItemIndex: number;
|
||||
downloadedFiles: Record<number, string>;
|
||||
mergedFile?: string;
|
||||
splitFiles?: string[];
|
||||
totalDurationSec?: number;
|
||||
}
|
||||
|
||||
interface QueueItem {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
date: string;
|
||||
streamer: string;
|
||||
duration_str: string;
|
||||
status: 'pending' | 'downloading' | 'paused' | 'completed' | 'error';
|
||||
progress: number;
|
||||
currentPart?: number;
|
||||
totalParts?: number;
|
||||
speed?: string;
|
||||
eta?: string;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
progressStatus?: string;
|
||||
last_error?: string;
|
||||
customClip?: CustomClip;
|
||||
mergeGroup?: MergeGroup;
|
||||
outputFiles?: string[];
|
||||
isLive?: boolean;
|
||||
recordingHealth?: 'ok' | 'stale' | 'unknown';
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
id: string;
|
||||
progress: number;
|
||||
speed: string;
|
||||
speedBytesPerSec?: number;
|
||||
eta: string;
|
||||
status: string;
|
||||
currentPart?: number;
|
||||
totalParts?: number;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
recordingHealth?: 'ok' | 'stale' | 'unknown';
|
||||
}
|
||||
|
||||
interface RuntimeMetricsSnapshot {
|
||||
cacheHits: number;
|
||||
cacheMisses: number;
|
||||
duplicateSkips: number;
|
||||
retriesScheduled: number;
|
||||
retriesExhausted: number;
|
||||
integrityFailures: number;
|
||||
downloadsStarted: number;
|
||||
downloadsCompleted: number;
|
||||
downloadsFailed: number;
|
||||
downloadedBytesTotal: number;
|
||||
lastSpeedBytesPerSec: number;
|
||||
avgSpeedBytesPerSec: number;
|
||||
activeItemId: string | null;
|
||||
activeItemTitle: string | null;
|
||||
lastErrorClass: string | null;
|
||||
lastRetryDelaySeconds: number;
|
||||
timestamp: string;
|
||||
queue: {
|
||||
pending: number;
|
||||
downloading: number;
|
||||
paused: number;
|
||||
completed: number;
|
||||
error: number;
|
||||
total: number;
|
||||
};
|
||||
caches: {
|
||||
loginToUserId: number;
|
||||
vodList: number;
|
||||
clipInfo: number;
|
||||
};
|
||||
config: {
|
||||
performanceMode: 'stability' | 'balanced' | 'speed';
|
||||
smartScheduler: boolean;
|
||||
metadataCacheMinutes: number;
|
||||
duplicatePrevention: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface VideoInfo {
|
||||
duration: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
}
|
||||
|
||||
interface ClipDialogData {
|
||||
url: string;
|
||||
title: string;
|
||||
date: string;
|
||||
streamer: string;
|
||||
duration: string;
|
||||
}
|
||||
|
||||
interface UpdateInfo {
|
||||
version: string;
|
||||
releaseDate?: string;
|
||||
releaseName?: string;
|
||||
releaseNotes?: string;
|
||||
}
|
||||
|
||||
interface UpdateDownloadProgress {
|
||||
percent: number;
|
||||
bytesPerSecond: number;
|
||||
transferred: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface PreflightChecks {
|
||||
internet: boolean;
|
||||
streamlink: boolean;
|
||||
ffmpeg: boolean;
|
||||
ffprobe: boolean;
|
||||
downloadPathWritable: boolean;
|
||||
}
|
||||
|
||||
interface PreflightResult {
|
||||
ok: boolean;
|
||||
autoFixApplied: boolean;
|
||||
checks: PreflightChecks;
|
||||
messages: string[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface StreamerStorageEntry {
|
||||
name: string;
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
liveBytes: number;
|
||||
chatBytes: number;
|
||||
folderPath: string;
|
||||
}
|
||||
interface CleanupReport {
|
||||
enabled: boolean;
|
||||
dryRun: boolean;
|
||||
cutoffDays: number;
|
||||
target: 'live_only' | 'all';
|
||||
action: 'delete' | 'archive';
|
||||
scannedAt: string;
|
||||
candidates: number;
|
||||
processed: number;
|
||||
failed: number;
|
||||
bytesFreed: number;
|
||||
failures: Array<{ path: string; error: string }>;
|
||||
}
|
||||
interface StorageStatsResult {
|
||||
downloadPath: string;
|
||||
rootExists: boolean;
|
||||
freeBytes: number | null;
|
||||
totalFiles: number;
|
||||
totalBytes: number;
|
||||
streamers: StreamerStorageEntry[];
|
||||
extras: StreamerStorageEntry[];
|
||||
scannedAt: string;
|
||||
}
|
||||
|
||||
interface StreamerProfile {
|
||||
login: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
bannerUrl: string;
|
||||
description: string;
|
||||
broadcasterType: '' | 'partner' | 'affiliate';
|
||||
followerCount: number | null;
|
||||
vodCount: number;
|
||||
lastStreamAt: string | null;
|
||||
isLive: boolean;
|
||||
currentTitle: string | null;
|
||||
currentGame: string | null;
|
||||
currentStreamPreviewUrl: string;
|
||||
currentStreamViewers: number | null;
|
||||
twitchUrl: string;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
interface VodStoryboard {
|
||||
vodId: string;
|
||||
spriteDataUrl: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
cellWidth: number;
|
||||
cellHeight: number;
|
||||
framesInSprite: number;
|
||||
}
|
||||
|
||||
interface ArchiveSearchHit {
|
||||
fullPath: string;
|
||||
fileName: string;
|
||||
streamer: string;
|
||||
type: 'live' | 'vod' | 'chat' | 'events' | 'other';
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
chatPath: string | null;
|
||||
eventsPath: string | null;
|
||||
}
|
||||
interface ArchiveSearchResult {
|
||||
totalScanned: number;
|
||||
matchCount: number;
|
||||
truncated: boolean;
|
||||
hits: ArchiveSearchHit[];
|
||||
scannedAt: string;
|
||||
rootExists: boolean;
|
||||
}
|
||||
|
||||
interface ArchiveStatsTopStreamer {
|
||||
streamer: string;
|
||||
bytes: number;
|
||||
fileCount: number;
|
||||
liveBytes: number;
|
||||
vodBytes: number;
|
||||
chatBytes: number;
|
||||
}
|
||||
interface ArchiveStatsDay { date: string; count: number; bytes: number }
|
||||
interface ArchiveStatsBucket { label: string; count: number; bytes: number }
|
||||
interface ArchiveStats {
|
||||
totalFiles: number;
|
||||
totalBytes: number;
|
||||
liveCount: number;
|
||||
liveBytes: number;
|
||||
vodCount: number;
|
||||
vodBytes: number;
|
||||
chatCount: number;
|
||||
chatBytes: number;
|
||||
eventsCount: number;
|
||||
streamerCount: number;
|
||||
avgRecordingSizeBytes: number;
|
||||
topStreamers: ArchiveStatsTopStreamer[];
|
||||
dailyActivity: ArchiveStatsDay[];
|
||||
sizeBuckets: ArchiveStatsBucket[];
|
||||
scannedAt: string;
|
||||
downloadPath: string;
|
||||
rootExists: boolean;
|
||||
}
|
||||
|
||||
interface ApiBridge {
|
||||
getConfig(): Promise<AppConfig>;
|
||||
saveConfig(config: Partial<AppConfig>): Promise<AppConfig>;
|
||||
login(): Promise<boolean>;
|
||||
getUserId(username: string): Promise<string | null>;
|
||||
getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>;
|
||||
getQueue(): Promise<QueueItem[]>;
|
||||
addToQueue(item: Omit<QueueItem, 'id' | 'status' | 'progress'>): Promise<QueueItem[]>;
|
||||
startLiveRecording(streamerName: string): Promise<{ success: boolean; error?: string; streamer?: string; title?: string }>;
|
||||
removeFromQueue(id: string): Promise<QueueItem[]>;
|
||||
reorderQueue(orderIds: string[]): Promise<QueueItem[]>;
|
||||
clearCompleted(): Promise<QueueItem[]>;
|
||||
retryFailedDownloads(): Promise<QueueItem[]>;
|
||||
retryQueueItem(id: string): Promise<QueueItem[]>;
|
||||
createMergeGroup(itemIds: string[]): Promise<QueueItem[]>;
|
||||
startDownload(): Promise<boolean>;
|
||||
pauseDownload(): Promise<boolean>;
|
||||
cancelDownload(): Promise<boolean>;
|
||||
isDownloading(): Promise<boolean>;
|
||||
downloadClip(url: string): Promise<{ success: boolean; error?: string }>;
|
||||
selectFolder(): Promise<string | null>;
|
||||
selectVideoFile(): Promise<string | null>;
|
||||
selectMultipleVideos(): Promise<string[] | null>;
|
||||
saveVideoDialog(defaultName: string): Promise<string | null>;
|
||||
openFolder(path: string): Promise<void>;
|
||||
openFile(path: string): Promise<boolean>;
|
||||
showInFolder(path: string): Promise<boolean>;
|
||||
openDebugLogFile(): Promise<boolean>;
|
||||
checkFolderWritable(path: string): Promise<boolean>;
|
||||
getStorageStats(): Promise<StorageStatsResult>;
|
||||
getArchiveStats(): Promise<ArchiveStats>;
|
||||
getStreamerProfile(login: string, forceRefresh?: boolean): Promise<StreamerProfile | null>;
|
||||
getVodStoryboard(vodId: string): Promise<VodStoryboard | null>;
|
||||
getLiveStatusSnapshot(): Promise<Record<string, boolean>>;
|
||||
onLiveStatusBatchUpdate(callback: (info: { changes: Array<{ login: string; isLive: boolean }> }) => void): void;
|
||||
searchArchive(filter: {
|
||||
query?: string;
|
||||
type?: 'all' | 'live' | 'vod' | 'chat' | 'events';
|
||||
streamer?: string;
|
||||
sinceMs?: number | null;
|
||||
untilMs?: number | null;
|
||||
sort?: 'date_desc' | 'date_asc' | 'size_desc' | 'size_asc' | 'name_asc';
|
||||
limit?: number;
|
||||
}): Promise<ArchiveSearchResult>;
|
||||
runStorageCleanup(options?: { dryRun?: boolean }): Promise<CleanupReport>;
|
||||
readChatFile(filePath: string): Promise<{ success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array<Record<string, unknown>>; truncated?: boolean; total?: number }>;
|
||||
getAutomationStatus(): Promise<{
|
||||
autoRecord: { watching: number; lastRunAt: number; nextRunAt: number; lastTriggeredCount: number; inFlight: boolean };
|
||||
autoVod: { watching: number; lastRunAt: number; nextRunAt: number; lastQueuedCount: number; inFlight: boolean };
|
||||
}>;
|
||||
triggerAutoVodScan(): Promise<{ queuedCount: number }>;
|
||||
triggerAutoRecordScan(): Promise<{ triggered: number }>;
|
||||
onAutoVodScanCompleted(callback: (info: { queuedCount: number }) => void): void;
|
||||
getVideoInfo(filePath: string): Promise<VideoInfo | null>;
|
||||
extractFrame(filePath: string, timeSeconds: number): Promise<string | null>;
|
||||
cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>;
|
||||
mergeVideos(inputFiles: string[], outputFile: string): Promise<{ success: boolean; outputFile: string | null }>;
|
||||
getVersion(): Promise<string>;
|
||||
checkUpdate(): Promise<{ checking?: boolean; error?: boolean; skipped?: 'ready-to-install' | 'in-progress' | 'throttled' | 'error' | string }>;
|
||||
downloadUpdate(): Promise<{ downloading?: boolean; error?: boolean; skipped?: 'ready-to-install' | 'in-progress' | 'error' | string }>;
|
||||
installUpdate(): Promise<void>;
|
||||
openExternal(url: string): Promise<void>;
|
||||
runPreflight(autoFix: boolean): Promise<PreflightResult>;
|
||||
getDebugLog(lines: number): Promise<string>;
|
||||
getRuntimeMetrics(): Promise<RuntimeMetricsSnapshot>;
|
||||
exportRuntimeMetrics(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
||||
resetDownloadedVodIds(): Promise<{ success: boolean; removedCount: number }>;
|
||||
markVodDownloaded(vodId: string, mark: boolean): Promise<{ success: boolean }>;
|
||||
exportConfig(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
||||
importConfig(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
||||
onDownloadProgress(callback: (progress: DownloadProgress) => void): void;
|
||||
onQueueUpdated(callback: (queue: QueueItem[]) => void): void;
|
||||
onQueueDuplicateSkipped(callback: (payload: { title: string; streamer: string; url: string }) => void): void;
|
||||
onDownloadStarted(callback: () => void): void;
|
||||
onDownloadFinished(callback: () => void): void;
|
||||
onCutProgress(callback: (percent: number) => void): void;
|
||||
onMergeProgress(callback: (percent: number) => void): void;
|
||||
onUpdateChecking(callback: () => void): void;
|
||||
onUpdateAvailable(callback: (info: UpdateInfo) => void): void;
|
||||
onUpdateNotAvailable(callback: () => void): void;
|
||||
onUpdateDownloadProgress(callback: (progress: UpdateDownloadProgress) => void): void;
|
||||
onUpdateDownloaded(callback: (info: UpdateInfo) => void): void;
|
||||
onUpdateError(callback: (payload: { message: string }) => void): void;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
api: ApiBridge;
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
const UI_TEXT_DE = {
|
||||
appName: 'Twitch VOD Manager',
|
||||
static: {
|
||||
navVods: 'Twitch VODs',
|
||||
navClips: 'Twitch Clips',
|
||||
navCutter: 'Video schneiden',
|
||||
navMerge: 'Videos zusammenfugen',
|
||||
navSettings: 'Einstellungen',
|
||||
queueTitle: 'Warteschlange',
|
||||
retryFailed: 'Wiederholen',
|
||||
retryFailedHint: 'Nur fehlgeschlagene Downloads erneut starten',
|
||||
healthUnknown: 'System: Unbekannt',
|
||||
healthGood: 'System: Stabil',
|
||||
healthWarn: 'System: Warnung',
|
||||
healthBad: 'System: Problem',
|
||||
clearQueue: 'Leeren',
|
||||
refresh: 'Aktualisieren',
|
||||
streamerPlaceholder: 'Streamer hinzufugen...',
|
||||
clipsHeading: 'Twitch Clip-Download',
|
||||
clipsInfoTitle: 'Info',
|
||||
clipsInfoText: 'Unterstutzte Formate:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.',
|
||||
cutterSelectTitle: 'Video auswahlen',
|
||||
cutterPreviewPlaceholder: 'Video auswahlen um Vorschau zu sehen',
|
||||
cutterBrowse: 'Durchsuchen',
|
||||
commandPaletteSearchPlaceholder: 'Befehl suchen...',
|
||||
commandPaletteHint: 'Up/Down zum Navigieren, Enter zum Ausfuehren, Esc zum Schliessen',
|
||||
mergeTitle: 'Videos zusammenfugen',
|
||||
mergeDesc: 'Wahle mehrere Videos aus, um sie zu einem Video zusammenzufugen. Die Reihenfolge kann geandert werden.',
|
||||
mergeAdd: '+ Videos hinzufugen',
|
||||
designTitle: 'Design',
|
||||
themeLabel: 'Theme',
|
||||
themeLight: 'Hell',
|
||||
languageLabel: 'Sprache',
|
||||
languageDe: 'Deutsch',
|
||||
languageEn: 'Englisch',
|
||||
apiTitle: 'Twitch API',
|
||||
clientIdLabel: 'Client ID',
|
||||
clientSecretLabel: 'Client Secret',
|
||||
saveSettings: 'Speichern & Verbinden',
|
||||
downloadSettingsTitle: 'Download-Einstellungen',
|
||||
storageLabel: 'Speicherort',
|
||||
openFolder: 'Offnen',
|
||||
modeLabel: 'Download-Modus',
|
||||
modeFull: 'Ganzes VOD',
|
||||
modeParts: 'In Teile splitten',
|
||||
partMinutesLabel: 'Teil-Lange (Minuten)',
|
||||
parallelDownloadsLabel: 'Parallele Downloads',
|
||||
parallelDownloads1: '1 (Standard)',
|
||||
parallelDownloads2: '2 (Parallel)',
|
||||
performanceModeLabel: 'Performance-Profil',
|
||||
performanceModeStability: 'Max Stabilitat',
|
||||
performanceModeBalanced: 'Ausgewogen',
|
||||
performanceModeSpeed: 'Max Geschwindigkeit',
|
||||
smartSchedulerLabel: 'Smart Queue Scheduler aktivieren',
|
||||
smartSchedulerHint: 'Bevorzugt kuerzere VODs und aeltere Queue-Eintraege zuerst, damit der Durchsatz gleichmaessig bleibt. Deaktivieren = strikte Einfuegereihenfolge.',
|
||||
streamerInvalid: 'Twitch-Username ungueltig (4-25 Zeichen, Buchstaben/Zahlen/Unterstrich).',
|
||||
apiHelpIntro: 'Du brauchst eine Client-ID und ein Client-Secret von Twitch.',
|
||||
apiHelpLinkText: 'dev.twitch.tv/console/apps',
|
||||
openDebugLogFile: 'Log-Datei oeffnen',
|
||||
storageCardTitle: 'Speicher',
|
||||
storageCardIntro: 'Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.',
|
||||
storageRefresh: 'Aktualisieren',
|
||||
storageEmpty: 'Download-Ordner ist leer oder nicht lesbar.',
|
||||
storageScanning: 'Scanne...',
|
||||
storageSummary: 'Gesamt: {files} Dateien, {size} — Freier Speicher: {free}',
|
||||
storageColumnFolder: 'Ordner',
|
||||
storageColumnFiles: 'Dateien',
|
||||
storageColumnTotal: 'Gesamt',
|
||||
storageColumnLive: 'Live',
|
||||
storageColumnChat: 'Chat',
|
||||
storageColumnActionsAria: 'Aktionen',
|
||||
storageOpen: 'Oeffnen',
|
||||
storageOtherFolders: 'Andere Ordner im Download-Pfad',
|
||||
cleanupTitle: 'Auto-Cleanup',
|
||||
cleanupIntro: 'Aufnahmen aelter als X Tage in einen Archiv-Ordner verschieben oder loeschen. Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) werden mit der Aufnahme bewegt.',
|
||||
cleanupEnabledLabel: 'Auto-Cleanup aktivieren',
|
||||
cleanupDaysLabel: 'Tage-Schwelle',
|
||||
cleanupTargetLabel: 'Bereich',
|
||||
cleanupTargetLive: 'Nur Live-Aufnahmen',
|
||||
cleanupTargetAll: 'Alle Aufnahmen',
|
||||
cleanupActionLabel: 'Aktion',
|
||||
cleanupActionArchive: 'In Archiv verschieben',
|
||||
cleanupActionDelete: 'Loeschen',
|
||||
cleanupDryRun: 'Vorschau',
|
||||
cleanupRunNow: 'Jetzt ausfuehren',
|
||||
cleanupReportPreview: 'Wuerde {count} Dateien betreffen (~{size}). Es wurden keine Dateien verschoben oder geloescht.',
|
||||
cleanupReportDone: '{count} Dateien verarbeitet, ~{size} frei.{failed}',
|
||||
cleanupReportFailedSuffix: ' {failed} fehlgeschlagen.',
|
||||
cleanupReportEmpty: 'Keine Aufnahmen aelter als {days} Tage gefunden.',
|
||||
discordCardTitle: 'Discord-Webhook',
|
||||
discordCardIntro: 'Sende Benachrichtigungen an einen Discord-Channel via Webhook - nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.',
|
||||
discordWebhookUrlLabel: 'Webhook-URL',
|
||||
discordNotifyLiveStartLabel: 'Bei Live-Aufnahme-Start benachrichtigen',
|
||||
discordNotifyLiveEndLabel: 'Bei Live-Aufnahme-Ende benachrichtigen',
|
||||
discordNotifyVodAutoQueuedLabel: 'Bei automatisch eingereihten VODs benachrichtigen',
|
||||
autoResumeLiveRecordingLabel: 'Live-Aufnahme automatisch fortsetzen wenn Streamlink abbricht (max. 5 Versuche)',
|
||||
autoMergeResumedPartsLabel: 'Fortgesetzte Aufnahme-Parts automatisch zu einer Datei zusammenfuegen (ffmpeg concat, kein Re-Encode)',
|
||||
deletePartsAfterMergeLabel: 'Einzelne Parts nach erfolgreichem Merge loeschen',
|
||||
autoVodCardTitle: 'Auto-VOD-Download',
|
||||
autoVodCardIntro: 'Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprueft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefuegt.',
|
||||
autoVodPollMinutesLabel: 'Poll-Intervall (Minuten)',
|
||||
autoVodMaxAgeHoursLabel: 'Max. Alter (Stunden)',
|
||||
autoVodScanNow: 'Jetzt scannen',
|
||||
autoRecordScanNow: 'Live-Status pruefen',
|
||||
statsTitle: 'Archiv-Statistik',
|
||||
statsIntro: 'Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter <code>{streamer}/live/</code>, VOD-Downloads direkt unter <code>{streamer}/</code>. Lade-Zeit skaliert mit der Anzahl Dateien.',
|
||||
statsRefresh: 'Aktualisieren',
|
||||
statsScanning: 'Scanne...',
|
||||
statsScannedAt: 'Letzter Scan',
|
||||
statsSummaryTitle: 'Uebersicht',
|
||||
statsTopStreamersTitle: 'Top Streamer (nach Groesse)',
|
||||
statsActivityTitle: 'Aktivitaet (letzte 30 Tage)',
|
||||
statsSizeBucketsTitle: 'Aufnahme-Groessen-Verteilung',
|
||||
statsTotalRecordings: 'Aufnahmen gesamt',
|
||||
statsLiveRecordings: 'Live-Aufnahmen',
|
||||
statsVodRecordings: 'VOD-Downloads',
|
||||
statsStreamers: 'Streamer',
|
||||
statsAvgSize: 'Durchschn. Groesse',
|
||||
statsChatFiles: 'Chat-Dateien',
|
||||
statsFiles: 'Dateien',
|
||||
statsActivityEmpty: 'Keine Aufnahmen in den letzten 30 Tagen.',
|
||||
statsActivitySummary: '{count} Aufnahmen - {size} in den letzten 30 Tagen',
|
||||
statsEmpty: 'Keine Daten.',
|
||||
statsNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.',
|
||||
navStats: 'Statistik',
|
||||
navArchive: 'Archiv',
|
||||
archiveTitle: 'Archiv durchsuchen',
|
||||
archiveIntro: 'Suche nach Dateinamen, Streamern oder Datum-Strings. Treffer zeigen Recordings (Live + VOD); zugehoerige Chat- und Events-Dateien werden als Companion-Buttons angeboten.',
|
||||
archiveAllTypes: 'Alle Typen',
|
||||
archiveTypeLive: 'Live-Aufnahmen',
|
||||
archiveTypeVod: 'VOD-Downloads',
|
||||
archiveAllStreamers: 'Alle Streamer',
|
||||
archiveSortDateDesc: 'Neueste zuerst',
|
||||
archiveSortDateAsc: 'Aelteste zuerst',
|
||||
archiveSortSizeDesc: 'Groesste zuerst',
|
||||
archiveSortSizeAsc: 'Kleinste zuerst',
|
||||
archiveSortNameAsc: 'Name (A-Z)',
|
||||
archiveSearchBtn: 'Suchen',
|
||||
archiveSearching: 'Scanne...',
|
||||
archiveSummary: '{matchCount} Treffer (gescannt: {scanned} Dateien)',
|
||||
archiveSummaryTruncated: '{matchCount} Treffer (gescannt: {scanned} Dateien, gezeigt: {shown} - verfeinere die Suche fuer mehr)',
|
||||
archiveNoMatches: 'Keine Treffer.',
|
||||
archiveNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.',
|
||||
archiveSearchPlaceholder: 'Suche...',
|
||||
archiveSearchAria: 'Archiv durchsuchen',
|
||||
archiveOpen: 'Oeffnen',
|
||||
archiveShowInFolder: 'Ordner',
|
||||
archiveViewChat: 'Chat',
|
||||
archiveViewEvents: 'Events',
|
||||
discordNotifyVodCompleteLabel: 'Bei abgeschlossenem VOD-Download benachrichtigen',
|
||||
backupCardTitle: 'Sicherung & Wartung',
|
||||
backupCardIntro: 'Konfiguration sichern, auf einem anderen Geraet wiederherstellen oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.',
|
||||
exportConfig: 'Konfiguration exportieren',
|
||||
importConfig: 'Konfiguration importieren',
|
||||
resetDownloadedIds: 'Downloaded-VODs zuruecksetzen',
|
||||
configExported: 'Konfiguration exportiert.',
|
||||
configExportFailed: 'Export der Konfiguration fehlgeschlagen.',
|
||||
configImported: 'Konfiguration importiert. Einige Aenderungen erfordern evtl. einen Neustart.',
|
||||
configImportFailed: 'Import der Konfiguration fehlgeschlagen.',
|
||||
resetDownloadedConfirm: 'Liste der heruntergeladenen VODs zuruecksetzen? Karten verlieren das gruene Haekchen, es werden aber keine Dateien geloescht.',
|
||||
resetDownloadedDone: '{count} Eintraege aus der Downloaded-Liste entfernt.',
|
||||
duplicatePreventionLabel: 'Duplikate in Queue verhindern',
|
||||
persistQueueLabel: 'Queue zwischen App-Starts speichern',
|
||||
autoResumeQueueLabel: 'Queue beim Start automatisch fortsetzen',
|
||||
autoResumeQueueHint: 'Wenn aktiv und die gespeicherte Queue noch ausstehende Eintraege hat, starten Downloads ~5 Sekunden nach dem Fensteroeffnen. Deaktivieren = Start-Klick noetig.',
|
||||
notifyEachCompletionLabel: 'Benachrichtigung bei jedem fertigen Download',
|
||||
notifyEachCompletionHint: 'Standardmaessig aus — bei langen Queues wuerde das System-Notifications-Panel sonst zugespammt. Die Queue-End-Zusammenfassung erscheint trotzdem.',
|
||||
streamlinkDisableAdsLabel: 'Twitch-Ads beim Download ueberspringen',
|
||||
streamlinkDisableAdsHint: 'Gibt --twitch-disable-ads an streamlink weiter, damit Mid-Roll-Ads nicht ins VOD eingebettet werden. Empfohlen aktiv lassen.',
|
||||
downloadChatReplayLabel: 'Chat-Replay parallel zum VOD speichern (.chat.json)',
|
||||
downloadChatReplayHint: 'Nach erfolgreichem VOD-Download wird der oeffentliche Chat-Replay via Twitch GQL geholt und als JSON neben dem Video gespeichert. Twitch behaelt Chat-Replays nur solange wie das VOD selbst.',
|
||||
captureLiveChatLabel: 'Live-Chat waehrend der Aufnahme mitschneiden (.chat.jsonl)',
|
||||
captureLiveChatHint: 'Oeffnet waehrend einer Live-Aufnahme eine anonyme IRC-Verbindung zum Twitch-Chat und schreibt jede Nachricht in eine .chat.jsonl-Datei neben dem Video (JSON Lines, eine Nachricht pro Zeile, damit ein Mid-Stream-Abbruch frueheren Inhalt nicht korrumpiert).',
|
||||
logStreamEventsLabel: 'Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl)',
|
||||
logStreamEventsHint: 'Pollt den Streamer einmal pro Minute und schreibt Title-/Game-Wechsel in eine .events.jsonl-Datei neben dem Video. Hilfreich beim Suchen in langen archivierten Streams ("wann hat er auf CS:GO gewechselt?"). Sehr guenstig — ein zusaetzlicher Helix/GQL-Call pro Minute pro aktiver Aufnahme.',
|
||||
streamlinkQualityLabel: 'Stream-Qualitaet',
|
||||
streamlinkQualityHint: 'Streamlink versucht erst diese Qualitaet; falls das VOD sie nicht anbietet, faellt es auf "best" zurueck.',
|
||||
streamlinkQualityBest: 'Best (Standard)',
|
||||
streamlinkQualitySource: 'Source (Original)',
|
||||
streamlinkQualityAudio: 'Nur Audio',
|
||||
downloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar. Waehle einen anderen Ordner oder pruefe die Schreibrechte.',
|
||||
streamerSectionTitle: 'Streamer',
|
||||
streamerListFilterPlaceholder: 'Filtern...',
|
||||
streamerListFilterAria: 'Streamer-Liste filtern',
|
||||
streamerAddAriaLabel: 'Streamer hinzufuegen',
|
||||
streamerBulkRemoveTitle: 'Alle entfernen (oder gefilterte)',
|
||||
streamerBulkRemoveAll: 'Alle {count} Streamer aus der Liste entfernen?',
|
||||
streamerBulkRemoveFiltered: 'Die {count} passenden Streamer aus der Liste entfernen?',
|
||||
metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)',
|
||||
filenameTemplatesTitle: 'Dateinamen-Templates',
|
||||
vodTemplateLabel: 'VOD-Template',
|
||||
partsTemplateLabel: 'VOD-Teile-Template',
|
||||
defaultClipTemplateLabel: 'Clip-Template',
|
||||
filenameTemplateHint: 'Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}',
|
||||
vodTemplatePlaceholder: '{title}.mp4',
|
||||
partsTemplatePlaceholder: '{date}_Part{part_padded}.mp4',
|
||||
defaultClipTemplatePlaceholder: '{date}_{part}.mp4',
|
||||
templateLintOk: 'Template-Check: OK',
|
||||
templateLintWarn: 'Unbekannte Platzhalter',
|
||||
templateGuideButton: 'Template Guide',
|
||||
templateGuideTitle: 'Dateinamen-Template Guide',
|
||||
templateGuideIntro: 'Nutze Platzhalter fur Dateinamen und teste dein Muster mit einer Live-Vorschau.',
|
||||
templateGuideTemplateLabel: 'Template',
|
||||
templateGuideOutputLabel: 'Live-Vorschau',
|
||||
templateGuideVarsTitle: 'Verfugbare Platzhalter',
|
||||
templateGuideVarCol: 'Platzhalter',
|
||||
templateGuideDescCol: 'Beschreibung',
|
||||
templateGuideExampleCol: 'Beispiel',
|
||||
templateGuideUseVod: 'VOD-Template nutzen',
|
||||
templateGuideUseParts: 'Teile-Template nutzen',
|
||||
templateGuideUseClip: 'Clip-Template nutzen',
|
||||
templateGuideClose: 'Schliessen',
|
||||
templateGuideContextVod: 'Kontext: Beispiel fur kompletten VOD-Download',
|
||||
templateGuideContextParts: 'Kontext: Beispiel fur VOD-Teil',
|
||||
templateGuideContextClip: 'Kontext: Beispiel fur Clip-Zuschnitt',
|
||||
templateGuideContextClipLive: 'Kontext: Aktuelle Auswahl im Clip-Dialog',
|
||||
runtimeMetricsTitle: 'Runtime Metrics',
|
||||
runtimeMetricsRefresh: 'Aktualisieren',
|
||||
runtimeMetricsExport: 'Export JSON',
|
||||
runtimeMetricsAutoRefresh: 'Auto-Refresh',
|
||||
runtimeMetricsLoading: 'Metriken werden geladen...',
|
||||
runtimeMetricsError: 'Runtime-Metriken konnten nicht geladen werden.',
|
||||
runtimeMetricsExportDone: 'Runtime-Metriken wurden exportiert.',
|
||||
runtimeMetricsExportCancelled: 'Export der Runtime-Metriken abgebrochen.',
|
||||
runtimeMetricsExportFailed: 'Export der Runtime-Metriken fehlgeschlagen.',
|
||||
runtimeMetricQueue: 'Queue',
|
||||
runtimeMetricMode: 'Modus',
|
||||
runtimeMetricRetries: 'Retries',
|
||||
runtimeMetricIntegrity: 'Integritatsfehler',
|
||||
runtimeMetricCache: 'Cache',
|
||||
runtimeMetricBandwidth: 'Bandbreite',
|
||||
runtimeMetricDownloads: 'Downloads',
|
||||
runtimeMetricActive: 'Aktiver Job',
|
||||
runtimeMetricLastError: 'Letzte Fehlerklasse',
|
||||
runtimeMetricUpdated: 'Aktualisiert',
|
||||
updateTitle: 'Updates',
|
||||
checkUpdates: 'Nach Updates suchen',
|
||||
preflightTitle: 'System-Check',
|
||||
preflightRun: 'Check ausfuhren',
|
||||
preflightFix: 'Auto-Fix Tools',
|
||||
preflightEmpty: 'Noch kein Check ausgefuhrt.',
|
||||
preflightChecking: 'Prufe...',
|
||||
preflightFixing: 'Fixe...',
|
||||
preflightReady: 'Alles bereit.',
|
||||
preflightInternet: 'Internet',
|
||||
preflightStreamlink: 'Streamlink',
|
||||
preflightFfmpeg: 'FFmpeg',
|
||||
preflightFfprobe: 'FFprobe',
|
||||
preflightPath: 'Download-Pfad',
|
||||
debugLogTitle: 'Live Debug-Log',
|
||||
refreshLog: 'Aktualisieren',
|
||||
autoRefresh: 'Auto-Refresh',
|
||||
notConnected: 'Nicht verbunden'
|
||||
},
|
||||
status: {
|
||||
noLogin: 'Ohne Login (Public Modus)',
|
||||
connecting: 'Verbinde...',
|
||||
connected: 'Verbunden',
|
||||
connectFailedPublic: 'Verbindung fehlgeschlagen - Public Modus aktiv'
|
||||
},
|
||||
tabs: {
|
||||
vods: 'VODs',
|
||||
clips: 'Clips',
|
||||
cutter: 'Video schneiden',
|
||||
merge: 'Videos zusammenfugen',
|
||||
stats: 'Statistik',
|
||||
archive: 'Archiv',
|
||||
settings: 'Einstellungen'
|
||||
},
|
||||
queue: {
|
||||
empty: 'Keine Downloads in der Warteschlange',
|
||||
detailStreamer: 'Streamer:',
|
||||
detailDuration: 'Dauer:',
|
||||
detailDate: 'Datum:',
|
||||
start: 'Start',
|
||||
stop: 'Pausieren',
|
||||
resume: 'Fortsetzen',
|
||||
statusDone: 'Abgeschlossen',
|
||||
statusFailed: 'Fehlgeschlagen',
|
||||
statusRunning: 'Laeuft',
|
||||
statusPaused: 'Pausiert',
|
||||
statusWaiting: 'Wartet',
|
||||
progressError: 'Fehler',
|
||||
progressReady: 'Bereit',
|
||||
progressLoading: 'Lade...',
|
||||
readyToDownload: 'Bereit zum Download',
|
||||
started: 'Download gestartet',
|
||||
done: 'Fertig',
|
||||
failed: 'Download fehlgeschlagen',
|
||||
speed: 'Geschwindigkeit',
|
||||
eta: 'Restzeit',
|
||||
part: 'Teil',
|
||||
emptyAlert: 'Die Warteschlange ist leer. Fuge zuerst ein VOD oder einen Clip hinzu.',
|
||||
duplicateSkipped: 'Dieser Eintrag ist bereits aktiv in der Warteschlange.',
|
||||
openFile: 'Datei oeffnen',
|
||||
showInFolder: 'Im Ordner zeigen',
|
||||
openFileFailed: 'Datei konnte nicht geoeffnet werden (evtl. verschoben oder geloescht).',
|
||||
outputFilesLabel: '{count} Ausgabedateien',
|
||||
retryItem: 'Diesen Eintrag erneut versuchen',
|
||||
viewChat: 'Chat ansehen',
|
||||
viewChatLoading: 'Lade Chat...',
|
||||
viewChatFailed: 'Chat-Datei konnte nicht gelesen werden',
|
||||
chatViewerFilterPlaceholder: 'Chat filtern...',
|
||||
chatViewerFilterAria: 'Chatnachrichten filtern',
|
||||
viewChatCount: '{count} Nachrichten',
|
||||
viewChatTruncatedSuffix: ' (gekuerzt)',
|
||||
viewEvents: 'Events ansehen',
|
||||
viewEventsCount: '{count} Events',
|
||||
viewEventsEmpty: 'Keine Events aufgezeichnet.',
|
||||
eventStartedAs: 'Gestartet als',
|
||||
eventEndedAfter: 'Beendet nach',
|
||||
eventTitleFromTo: 'Titel: {from} -> {to}',
|
||||
eventGameFromTo: 'Game: {from} -> {to}',
|
||||
statusBarSummary: '{downloading} aktiv, {pending} wartet',
|
||||
ctxMoveTop: 'Nach oben verschieben',
|
||||
ctxMoveBottom: 'Nach unten verschieben',
|
||||
ctxCopyUrl: 'URL kopieren',
|
||||
ctxOpenOnTwitch: 'Auf Twitch oeffnen',
|
||||
ctxRemove: 'Aus Queue entfernen',
|
||||
ctxCopiedUrl: 'URL in Zwischenablage kopiert.',
|
||||
liveRecordingTitle: 'Live-Aufnahme - laeuft bis der Stream endet',
|
||||
recordingHealth: {
|
||||
ok: 'Gesund - Bytes fliessen',
|
||||
stale: 'Stillstand - keine Bytes mehr (Netz-Hickser oder Stream endet)',
|
||||
unknown: 'Warte auf ersten Segment'
|
||||
},
|
||||
eventRecordingResume: 'Aufnahme fortgesetzt - Part {part} startet'
|
||||
},
|
||||
profile: {
|
||||
liveBadge: 'LIVE',
|
||||
partner: 'Partner',
|
||||
affiliate: 'Affiliate',
|
||||
followers: 'Follower',
|
||||
vods: 'VODs',
|
||||
vodsTooltip: 'Ueber die Twitch-API sichtbare VODs dieses Kanals',
|
||||
lastStream: 'Letzter Stream',
|
||||
openTwitch: 'Auf Twitch oeffnen',
|
||||
openTwitchTooltip: 'Diesen Kanal auf twitch.tv oeffnen',
|
||||
liveCardTooltip: 'Klick um sofort eine Live-Aufnahme zu starten',
|
||||
liveThumbAlt: 'Live-Vorschau',
|
||||
recordNow: 'Jetzt aufnehmen',
|
||||
refresh: 'Aktualisieren',
|
||||
agoMinutes: 'vor {n} Min',
|
||||
agoHours: 'vor {n} h',
|
||||
agoDays: 'vor {n} Tagen',
|
||||
agoMonths: 'vor {n} Monaten',
|
||||
agoYears: 'vor {n} Jahren'
|
||||
},
|
||||
streamers: {
|
||||
recordLiveTitle: 'Diesen Streamer live aufnehmen (laeuft bis der Stream endet)',
|
||||
liveRecordingStarted: 'Live-Aufnahme fuer {streamer} gestartet.',
|
||||
liveRecordingOffline: '{streamer} ist gerade offline.',
|
||||
liveRecordingAlreadyActive: 'Aufnahme von {streamer} laeuft bereits.',
|
||||
liveRecordingFailed: 'Live-Aufnahme konnte nicht gestartet werden',
|
||||
autoRecordTitle: 'Auto-Aufnahme: wenn dieser Streamer live geht, nimmt die App automatisch auf',
|
||||
autoRecordEnabled: 'Auto-Aufnahme aktiviert fuer {streamer}. Live-Status wird geprueft...',
|
||||
autoRecordDisabled: 'Auto-Aufnahme fuer {streamer} deaktiviert.',
|
||||
autoVodTitle: 'Neue VODs (kuerzlich veroeffentlicht) automatisch herunterladen',
|
||||
autoVodEnabled: 'Auto-VOD aktiviert fuer {streamer}. Neue VODs werden automatisch geladen.',
|
||||
autoVodDisabled: 'Auto-VOD fuer {streamer} deaktiviert.',
|
||||
autoVodScanQueued: '{count} neue VOD(s) automatisch eingereiht.',
|
||||
autoVodScanEmpty: 'Keine neuen VODs gefunden.',
|
||||
autoRecordScanTriggered: 'Manueller Scan: {count} Live-Aufnahme(n) gestartet.',
|
||||
autoRecordScanEmpty: 'Manueller Scan: kein Streamer ist gerade live.',
|
||||
liveNowTooltip: 'Aktuell live auf Twitch',
|
||||
modalCloseAria: 'Dialog schliessen',
|
||||
sidebarEmpty: 'Noch keine Streamer. Fuege oben rechts einen hinzu.',
|
||||
removeAria: 'Entfernen',
|
||||
cutProgressAria: 'Schnitt-Fortschritt',
|
||||
mergeProgressAria: 'Merge-Fortschritt',
|
||||
updateProgressAria: 'Update-Download-Fortschritt'
|
||||
},
|
||||
vods: {
|
||||
selectAriaLabel: 'VOD fuer Bulk-Aktion auswaehlen',
|
||||
noneTitle: 'Keine VODs',
|
||||
noneText: 'Wahle einen Streamer aus der Liste.',
|
||||
loading: 'Lade VODs...',
|
||||
notFound: 'Streamer nicht gefunden',
|
||||
noResultsTitle: 'Keine VODs gefunden',
|
||||
noResultsText: 'Dieser Streamer hat keine VODs.',
|
||||
untitled: 'Unbenanntes VOD',
|
||||
views: 'Aufrufe',
|
||||
addQueue: '+ Warteschlange',
|
||||
trimButton: 'VOD zuschneiden',
|
||||
filterPlaceholder: 'Nach Titel filtern... (Strg+F)',
|
||||
filterAria: 'VOD-Titel filtern',
|
||||
filterClearTitle: 'Filter loeschen (Esc)',
|
||||
filterNoMatchTitle: 'Keine Treffer',
|
||||
filterNoMatchText: 'Keine VODs entsprechen dem aktuellen Filter.',
|
||||
filterMatchCount: '{shown} von {total} VODs',
|
||||
sortLabel: 'Sortierung:',
|
||||
sortDateDesc: 'Neueste zuerst',
|
||||
sortDateAsc: 'Aelteste zuerst',
|
||||
sortViewsDesc: 'Meiste Aufrufe',
|
||||
sortDurationDesc: 'Laengste zuerst',
|
||||
sortDurationAsc: 'Kuerzeste zuerst',
|
||||
bulkSelectedCount: '{count} ausgewaehlt',
|
||||
bulkAddToQueue: '+ Warteschlange',
|
||||
bulkAdding: 'Fuege hinzu...',
|
||||
bulkClear: 'Loeschen',
|
||||
bulkAddedToQueue: '{count} VODs zur Warteschlange hinzugefuegt.',
|
||||
bulkAddSkipped: 'Keine VODs hinzugefuegt (bereits in Queue oder ungueltig).',
|
||||
bulkMarkDownloaded: 'Als heruntergeladen markieren',
|
||||
bulkUnmark: 'Markierung entfernen',
|
||||
bulkMarkedDownloaded: '{count} VODs als heruntergeladen markiert.',
|
||||
bulkUnmarkedDownloaded: 'Markierung von {count} VODs entfernt.',
|
||||
alreadyDownloaded: 'Bereits heruntergeladen',
|
||||
hideDownloaded: 'Bereits geladene ausblenden',
|
||||
hideDownloadedTitle: 'VODs ausblenden, die als bereits heruntergeladen markiert sind',
|
||||
openOnTwitch: 'Auf Twitch oeffnen',
|
||||
ctxOpenOnTwitch: 'Auf Twitch oeffnen',
|
||||
ctxCopyUrl: 'VOD-URL kopieren',
|
||||
ctxCopiedUrl: 'URL in Zwischenablage kopiert.',
|
||||
ctxMarkDownloaded: 'Als heruntergeladen markieren',
|
||||
ctxUnmarkDownloaded: 'Markierung entfernen'
|
||||
},
|
||||
clips: {
|
||||
dialogTitle: 'VOD zuschneiden',
|
||||
dialogStart: 'Start:',
|
||||
dialogStartTime: 'Startzeit (HH:MM:SS):',
|
||||
dialogEnd: 'Ende:',
|
||||
dialogEndTime: 'Endzeit (HH:MM:SS):',
|
||||
dialogDuration: 'Dauer: ',
|
||||
dialogPartLabel: 'Start Part-Nummer (optional, fur Fortsetzung):',
|
||||
dialogPartHint: 'Leer lassen = Teil 1',
|
||||
dialogFormatLabel: 'Dateinamen-Format:',
|
||||
dialogConfirm: 'Zur Queue hinzufuegen',
|
||||
invalidDuration: 'Ungultig!',
|
||||
invalidTime: 'Ungueltige Zeitangaben',
|
||||
endBeforeStart: 'Endzeit muss grosser als Startzeit sein!',
|
||||
outOfRange: 'Zeit ausserhalb des VOD-Bereichs!',
|
||||
enterUrl: 'Bitte URL eingeben',
|
||||
loadingButton: 'Lade...',
|
||||
loadingStatus: 'Download laeuft...',
|
||||
downloadButton: 'Clip herunterladen',
|
||||
success: 'Download erfolgreich!',
|
||||
errorPrefix: 'Fehler: ',
|
||||
unknownError: 'Unbekannter Fehler',
|
||||
formatSimple: '(Standard)',
|
||||
formatTimestamp: '(mit Zeitstempel)',
|
||||
formatParts: '(Parts-Format)',
|
||||
formatTemplate: '(benutzerdefiniert)',
|
||||
templateEmpty: 'Das Template darf im benutzerdefinierten Modus nicht leer sein.',
|
||||
templatePlaceholder: '{date}_{part}.mp4',
|
||||
templateHelp: 'Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}',
|
||||
urlPlaceholder: 'https://clips.twitch.tv/... oder https://www.twitch.tv/.../clip/...',
|
||||
startPartPlaceholder: 'z.B. 42'
|
||||
},
|
||||
cutter: {
|
||||
videoInfoFailed: 'Konnte Video-Informationen nicht lesen. FFprobe installiert?',
|
||||
previewLoading: 'Lade Vorschau...',
|
||||
previewUnavailable: 'Vorschau nicht verfugbar',
|
||||
previewAlt: 'Vorschau',
|
||||
cutting: 'Schneidet...',
|
||||
cut: 'Schneiden',
|
||||
cutSuccess: 'Video erfolgreich geschnitten!',
|
||||
cutFailed: 'Fehler beim Schneiden des Videos.',
|
||||
infoDuration: 'Dauer',
|
||||
infoResolution: 'Aufloesung',
|
||||
infoFps: 'FPS',
|
||||
infoSelection: 'Auswahl',
|
||||
startLabel: 'Start:',
|
||||
endLabel: 'Ende:',
|
||||
filePathPlaceholder: 'Keine Datei ausgewaehlt...'
|
||||
},
|
||||
merge: {
|
||||
empty: 'Keine Videos ausgewahlt',
|
||||
merging: 'Zusammenfugen...',
|
||||
merge: 'Zusammenfugen',
|
||||
success: 'Videos erfolgreich zusammengefugt!',
|
||||
failed: 'Fehler beim Zusammenfugen der Videos.',
|
||||
moveUpAria: 'Nach oben verschieben',
|
||||
moveDownAria: 'Nach unten verschieben',
|
||||
removeAria: 'Aus Liste entfernen'
|
||||
},
|
||||
mergeGroup: {
|
||||
btn: 'Zusammenfugen & Splitten',
|
||||
phaseDownloading: 'VOD wird heruntergeladen',
|
||||
phaseMerging: 'Zusammenfugen...',
|
||||
phaseSplitting: 'Part wird erstellt',
|
||||
phaseCleanup: 'Aufraumen...',
|
||||
needMinTwo: 'Mindestens 2 VODs auswahlen',
|
||||
titleTwo: 'Merge: {title1} + {title2}',
|
||||
titleMany: 'Merge: {title1} + {count} weitere',
|
||||
metaLabel: '{count} VODs',
|
||||
},
|
||||
updates: {
|
||||
bannerDefault: 'Neue Version verfugbar!',
|
||||
latest: 'Du hast die neueste Version!',
|
||||
checking: 'Suche nach Updates...',
|
||||
checkInProgress: 'Update-Prufung lauft bereits.',
|
||||
readyToInstall: 'Update ist bereit zur Installation.',
|
||||
checkFailed: 'Update-Prufung fehlgeschlagen.',
|
||||
downloading: 'Wird heruntergeladen...',
|
||||
downloadInProgress: 'Update-Download lauft bereits.',
|
||||
downloadFailed: 'Update-Download fehlgeschlagen.',
|
||||
available: 'verfugbar!',
|
||||
downloadNow: 'Jetzt herunterladen',
|
||||
downloadLabel: 'Download',
|
||||
ready: 'bereit zur Installation!',
|
||||
installNow: 'Jetzt installieren & neu starten',
|
||||
modalAvailableTitle: 'Update verfugbar',
|
||||
modalAvailableMessage: 'Version {version} ist verfugbar. Jetzt herunterladen?',
|
||||
modalReadyTitle: 'Update bereit',
|
||||
modalReadyMessage: 'Version {version} wurde heruntergeladen. Jetzt installieren und neu starten?',
|
||||
modalDismiss: 'Nein',
|
||||
modalDownloadConfirm: 'Ja, herunterladen',
|
||||
modalInstallConfirm: 'Ja, installieren',
|
||||
modalSkipVersion: 'Diese Version ueberspringen',
|
||||
changelogLabel: 'Changelog',
|
||||
showChangelog: 'Changelog anzeigen',
|
||||
hideChangelog: 'Changelog ausblenden',
|
||||
noChangelog: 'Kein Changelog verfugbar.',
|
||||
releasedLabel: 'Release'
|
||||
}
|
||||
} as const;
|
||||
@@ -0,0 +1,516 @@
|
||||
const UI_TEXT_EN = {
|
||||
appName: 'Twitch VOD Manager',
|
||||
static: {
|
||||
navVods: 'Twitch VODs',
|
||||
navClips: 'Twitch Clips',
|
||||
navCutter: 'Video Cutter',
|
||||
navMerge: 'Merge Videos',
|
||||
navSettings: 'Settings',
|
||||
queueTitle: 'Queue',
|
||||
retryFailed: 'Retry',
|
||||
retryFailedHint: 'Retry failed downloads only',
|
||||
healthUnknown: 'System: Unknown',
|
||||
healthGood: 'System: Stable',
|
||||
healthWarn: 'System: Warning',
|
||||
healthBad: 'System: Problem',
|
||||
clearQueue: 'Clear',
|
||||
refresh: 'Refresh',
|
||||
streamerPlaceholder: 'Add streamer...',
|
||||
clipsHeading: 'Twitch Clip Download',
|
||||
clipsInfoTitle: 'Info',
|
||||
clipsInfoText: 'Supported formats:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips are saved in your download folder under "Clips/StreamerName/".',
|
||||
cutterSelectTitle: 'Select video',
|
||||
cutterPreviewPlaceholder: 'Select a video to see a preview',
|
||||
cutterBrowse: 'Browse',
|
||||
commandPaletteSearchPlaceholder: 'Search command...',
|
||||
commandPaletteHint: 'Up/Down to navigate, Enter to run, Esc to close',
|
||||
mergeTitle: 'Merge videos',
|
||||
mergeDesc: 'Select multiple videos to merge into one file. You can change the order before merging.',
|
||||
mergeAdd: '+ Add videos',
|
||||
designTitle: 'Design',
|
||||
themeLabel: 'Theme',
|
||||
themeLight: 'Light',
|
||||
languageLabel: 'Language',
|
||||
languageDe: 'German',
|
||||
languageEn: 'English',
|
||||
apiTitle: 'Twitch API',
|
||||
clientIdLabel: 'Client ID',
|
||||
clientSecretLabel: 'Client Secret',
|
||||
saveSettings: 'Save & Connect',
|
||||
downloadSettingsTitle: 'Download Settings',
|
||||
storageLabel: 'Storage Path',
|
||||
openFolder: 'Open',
|
||||
modeLabel: 'Download Mode',
|
||||
modeFull: 'Full VOD',
|
||||
modeParts: 'Split into parts',
|
||||
partMinutesLabel: 'Part Length (Minutes)',
|
||||
parallelDownloadsLabel: 'Parallel Downloads',
|
||||
parallelDownloads1: '1 (Default)',
|
||||
parallelDownloads2: '2 (Parallel)',
|
||||
performanceModeLabel: 'Performance Profile',
|
||||
performanceModeStability: 'Max Stability',
|
||||
performanceModeBalanced: 'Balanced',
|
||||
performanceModeSpeed: 'Max Speed',
|
||||
smartSchedulerLabel: 'Enable smart queue scheduler',
|
||||
smartSchedulerHint: 'Prefers shorter VODs and older queue entries first so the queue throughput stays steady. Disable to drain in strict insertion order.',
|
||||
streamerInvalid: 'Invalid Twitch username (4-25 chars, letters/digits/underscore).',
|
||||
apiHelpIntro: 'You need a Client ID and Client Secret from Twitch.',
|
||||
apiHelpLinkText: 'dev.twitch.tv/console/apps',
|
||||
openDebugLogFile: 'Open log file',
|
||||
storageCardTitle: 'Storage',
|
||||
storageCardIntro: 'Per-streamer disk usage in the current download folder. Live recordings are surfaced separately.',
|
||||
storageRefresh: 'Refresh',
|
||||
storageEmpty: 'Download folder is empty or unreadable.',
|
||||
storageScanning: 'Scanning...',
|
||||
storageSummary: 'Total: {files} files, {size} — Free disk: {free}',
|
||||
storageColumnFolder: 'Folder',
|
||||
storageColumnFiles: 'Files',
|
||||
storageColumnTotal: 'Total',
|
||||
storageColumnLive: 'Live',
|
||||
storageColumnChat: 'Chat',
|
||||
storageColumnActionsAria: 'Actions',
|
||||
storageOpen: 'Open',
|
||||
storageOtherFolders: 'Other folders in download path',
|
||||
cleanupTitle: 'Auto-cleanup',
|
||||
cleanupIntro: 'Move recordings older than N days to an archive folder, or delete them outright. Sibling chat files (.chat.json/.chat.jsonl) travel with the video.',
|
||||
cleanupEnabledLabel: 'Enable auto-cleanup',
|
||||
cleanupDaysLabel: 'Age threshold (days)',
|
||||
cleanupTargetLabel: 'Scope',
|
||||
cleanupTargetLive: 'Live recordings only',
|
||||
cleanupTargetAll: 'All recordings',
|
||||
cleanupActionLabel: 'Action',
|
||||
cleanupActionArchive: 'Move to archive folder',
|
||||
cleanupActionDelete: 'Delete',
|
||||
cleanupDryRun: 'Preview',
|
||||
cleanupRunNow: 'Run now',
|
||||
cleanupReportPreview: 'Would touch {count} files (~{size}). No files have been moved or deleted.',
|
||||
cleanupReportDone: 'Processed {count} files, freed ~{size}.{failed}',
|
||||
cleanupReportFailedSuffix: ' {failed} failed.',
|
||||
cleanupReportEmpty: 'No recordings older than {days} days found.',
|
||||
discordCardTitle: 'Discord webhook',
|
||||
discordCardIntro: 'Send notifications to a Discord channel via webhook — handy for multi-device setups or a dedicated archive machine.',
|
||||
discordWebhookUrlLabel: 'Webhook URL',
|
||||
discordNotifyLiveStartLabel: 'Notify on live recording start',
|
||||
discordNotifyLiveEndLabel: 'Notify on live recording end',
|
||||
discordNotifyVodCompleteLabel: 'Notify on completed VOD download',
|
||||
autoResumeLiveRecordingLabel: 'Auto-resume live recording if streamlink crashes (max 5 retries)',
|
||||
autoMergeResumedPartsLabel: 'Auto-merge resumed-recording parts into one file (ffmpeg concat, no re-encode)',
|
||||
deletePartsAfterMergeLabel: 'Delete individual parts after successful merge',
|
||||
discordNotifyVodAutoQueuedLabel: 'Notify when a VOD gets auto-queued',
|
||||
autoVodCardTitle: 'Auto-VOD download',
|
||||
autoVodCardIntro: 'Streamers with the VOD toggle on are scanned for new Twitch VODs at the interval set here. New VODs within the age window are added to the download queue automatically.',
|
||||
autoVodPollMinutesLabel: 'Poll interval (minutes)',
|
||||
autoVodMaxAgeHoursLabel: 'Max age (hours)',
|
||||
autoVodScanNow: 'Scan now',
|
||||
autoRecordScanNow: 'Check live status',
|
||||
statsTitle: 'Archive statistics',
|
||||
statsIntro: 'Aggregated across the download folder. Live recordings live under <code>{streamer}/live/</code>, VOD downloads under <code>{streamer}/</code>. Scan time scales with file count.',
|
||||
statsRefresh: 'Refresh',
|
||||
statsScanning: 'Scanning...',
|
||||
statsScannedAt: 'Last scan',
|
||||
statsSummaryTitle: 'Overview',
|
||||
statsTopStreamersTitle: 'Top streamers (by size)',
|
||||
statsActivityTitle: 'Activity (last 30 days)',
|
||||
statsSizeBucketsTitle: 'Recording-size distribution',
|
||||
statsTotalRecordings: 'Recordings total',
|
||||
statsLiveRecordings: 'Live recordings',
|
||||
statsVodRecordings: 'VOD downloads',
|
||||
statsStreamers: 'Streamers',
|
||||
statsAvgSize: 'Avg. recording size',
|
||||
statsChatFiles: 'Chat files',
|
||||
statsFiles: 'files',
|
||||
statsActivityEmpty: 'No recordings in the last 30 days.',
|
||||
statsActivitySummary: '{count} recordings - {size} in the last 30 days',
|
||||
statsEmpty: 'No data.',
|
||||
statsNoRoot: 'Download folder not found. Set a download path in Settings first.',
|
||||
navStats: 'Statistics',
|
||||
navArchive: 'Archive',
|
||||
archiveTitle: 'Search archive',
|
||||
archiveIntro: 'Search by filename, streamer, or date string. Hits show recordings (Live + VOD); related chat and events files appear as companion buttons.',
|
||||
archiveAllTypes: 'All types',
|
||||
archiveTypeLive: 'Live recordings',
|
||||
archiveTypeVod: 'VOD downloads',
|
||||
archiveAllStreamers: 'All streamers',
|
||||
archiveSortDateDesc: 'Newest first',
|
||||
archiveSortDateAsc: 'Oldest first',
|
||||
archiveSortSizeDesc: 'Largest first',
|
||||
archiveSortSizeAsc: 'Smallest first',
|
||||
archiveSortNameAsc: 'Name (A-Z)',
|
||||
archiveSearchBtn: 'Search',
|
||||
archiveSearching: 'Scanning...',
|
||||
archiveSummary: '{matchCount} matches (scanned {scanned} files)',
|
||||
archiveSummaryTruncated: '{matchCount} matches (scanned {scanned} files, showing {shown} - tighten the query for more)',
|
||||
archiveNoMatches: 'No matches.',
|
||||
archiveNoRoot: 'Download folder not found. Set a download path in Settings first.',
|
||||
archiveSearchPlaceholder: 'Search...',
|
||||
archiveSearchAria: 'Search archive',
|
||||
archiveOpen: 'Open',
|
||||
archiveShowInFolder: 'Folder',
|
||||
archiveViewChat: 'Chat',
|
||||
archiveViewEvents: 'Events',
|
||||
backupCardTitle: 'Backup & Maintenance',
|
||||
backupCardIntro: 'Back up your configuration, restore it on another machine, or reset the list of already-downloaded VODs.',
|
||||
exportConfig: 'Export config',
|
||||
importConfig: 'Import config',
|
||||
resetDownloadedIds: 'Reset downloaded list',
|
||||
configExported: 'Configuration exported.',
|
||||
configExportFailed: 'Configuration export failed.',
|
||||
configImported: 'Configuration imported. Some changes may need a restart.',
|
||||
configImportFailed: 'Configuration import failed.',
|
||||
resetDownloadedConfirm: 'Reset the downloaded-VODs list? Cards will lose the green check mark, but no files are deleted.',
|
||||
resetDownloadedDone: 'Cleared {count} entries from the downloaded list.',
|
||||
duplicatePreventionLabel: 'Prevent duplicate queue entries',
|
||||
persistQueueLabel: 'Keep queue between app restarts',
|
||||
autoResumeQueueLabel: 'Auto-resume the queue on startup',
|
||||
autoResumeQueueHint: 'When enabled and the persisted queue has pending entries, downloads kick off ~5 seconds after the window opens. Disable to require an explicit Start click.',
|
||||
notifyEachCompletionLabel: 'Notify on every completed download',
|
||||
notifyEachCompletionHint: 'Off by default — long queues would otherwise spam the OS notifications panel. The end-of-queue summary notification fires either way.',
|
||||
streamlinkDisableAdsLabel: 'Skip Twitch ads while downloading',
|
||||
streamlinkDisableAdsHint: 'Passes --twitch-disable-ads to streamlink so mid-roll ads do not get embedded into the VOD output. Recommended on.',
|
||||
downloadChatReplayLabel: 'Save chat replay alongside each VOD (.chat.json)',
|
||||
downloadChatReplayHint: 'After a VOD download completes, fetches the public chat replay via Twitch GQL and saves it as JSON next to the video. Twitch keeps chat replay only as long as the VOD itself.',
|
||||
captureLiveChatLabel: 'Capture live chat during recording (.chat.jsonl)',
|
||||
captureLiveChatHint: 'Opens an anonymous IRC connection to Twitch chat during a live recording and appends every message to a sibling .chat.jsonl file (JSON Lines, one message per line) so a long capture can be killed mid-stream without corrupting earlier data.',
|
||||
logStreamEventsLabel: 'Log stream events during live recording (.events.jsonl)',
|
||||
logStreamEventsHint: 'Polls the streamer once a minute and writes title / game changes to a sibling .events.jsonl file. Useful for seeking inside long archived streams ("when did he switch to CS:GO?"). Cheap — one extra Helix/GQL hit per minute per active recording.',
|
||||
streamlinkQualityLabel: 'Stream quality',
|
||||
streamlinkQualityHint: 'Streamlink will try this quality first; if the VOD does not offer it, falls back to "best".',
|
||||
streamlinkQualityBest: 'Best (default)',
|
||||
streamlinkQualitySource: 'Source (original)',
|
||||
streamlinkQualityAudio: 'Audio only',
|
||||
downloadPathNotWritable: 'Download folder is not writable. Pick another folder or grant write permission.',
|
||||
streamerSectionTitle: 'Streamer',
|
||||
streamerListFilterPlaceholder: 'Filter...',
|
||||
streamerListFilterAria: 'Filter streamer list',
|
||||
streamerAddAriaLabel: 'Add streamer',
|
||||
streamerBulkRemoveTitle: 'Remove all (or filtered)',
|
||||
streamerBulkRemoveAll: 'Remove all {count} streamers from the list?',
|
||||
streamerBulkRemoveFiltered: 'Remove the {count} matching streamer(s) from the list?',
|
||||
metadataCacheMinutesLabel: 'Metadata Cache (Minutes)',
|
||||
filenameTemplatesTitle: 'Filename Templates',
|
||||
vodTemplateLabel: 'VOD Template',
|
||||
partsTemplateLabel: 'VOD Part Template',
|
||||
defaultClipTemplateLabel: 'Clip Template',
|
||||
filenameTemplateHint: 'Placeholders: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}',
|
||||
vodTemplatePlaceholder: '{title}.mp4',
|
||||
partsTemplatePlaceholder: '{date}_Part{part_padded}.mp4',
|
||||
defaultClipTemplatePlaceholder: '{date}_{part}.mp4',
|
||||
templateLintOk: 'Template check: OK',
|
||||
templateLintWarn: 'Unknown placeholder(s)',
|
||||
templateGuideButton: 'Template Guide',
|
||||
templateGuideTitle: 'Filename Template Guide',
|
||||
templateGuideIntro: 'Use placeholders for filenames and test your pattern with a live preview.',
|
||||
templateGuideTemplateLabel: 'Template',
|
||||
templateGuideOutputLabel: 'Live preview',
|
||||
templateGuideVarsTitle: 'Available placeholders',
|
||||
templateGuideVarCol: 'Placeholder',
|
||||
templateGuideDescCol: 'Description',
|
||||
templateGuideExampleCol: 'Example',
|
||||
templateGuideUseVod: 'Use VOD template',
|
||||
templateGuideUseParts: 'Use part template',
|
||||
templateGuideUseClip: 'Use clip template',
|
||||
templateGuideClose: 'Close',
|
||||
templateGuideContextVod: 'Context: Sample full VOD download',
|
||||
templateGuideContextParts: 'Context: Sample split VOD part',
|
||||
templateGuideContextClip: 'Context: Sample clip trim',
|
||||
templateGuideContextClipLive: 'Context: Current clip dialog selection',
|
||||
runtimeMetricsTitle: 'Runtime Metrics',
|
||||
runtimeMetricsRefresh: 'Refresh',
|
||||
runtimeMetricsExport: 'Export JSON',
|
||||
runtimeMetricsAutoRefresh: 'Auto refresh',
|
||||
runtimeMetricsLoading: 'Loading metrics...',
|
||||
runtimeMetricsError: 'Could not load runtime metrics.',
|
||||
runtimeMetricsExportDone: 'Runtime metrics exported successfully.',
|
||||
runtimeMetricsExportCancelled: 'Runtime metrics export cancelled.',
|
||||
runtimeMetricsExportFailed: 'Runtime metrics export failed.',
|
||||
runtimeMetricQueue: 'Queue',
|
||||
runtimeMetricMode: 'Mode',
|
||||
runtimeMetricRetries: 'Retries',
|
||||
runtimeMetricIntegrity: 'Integrity failures',
|
||||
runtimeMetricCache: 'Cache',
|
||||
runtimeMetricBandwidth: 'Bandwidth',
|
||||
runtimeMetricDownloads: 'Downloads',
|
||||
runtimeMetricActive: 'Active item',
|
||||
runtimeMetricLastError: 'Last error class',
|
||||
runtimeMetricUpdated: 'Updated',
|
||||
updateTitle: 'Updates',
|
||||
checkUpdates: 'Check for updates',
|
||||
preflightTitle: 'System Check',
|
||||
preflightRun: 'Run check',
|
||||
preflightFix: 'Auto-fix tools',
|
||||
preflightEmpty: 'No checks run yet.',
|
||||
preflightChecking: 'Checking...',
|
||||
preflightFixing: 'Fixing...',
|
||||
preflightReady: 'Everything is ready.',
|
||||
preflightInternet: 'Internet',
|
||||
preflightStreamlink: 'Streamlink',
|
||||
preflightFfmpeg: 'FFmpeg',
|
||||
preflightFfprobe: 'FFprobe',
|
||||
preflightPath: 'Download path',
|
||||
debugLogTitle: 'Live Debug Log',
|
||||
refreshLog: 'Refresh',
|
||||
autoRefresh: 'Auto refresh',
|
||||
notConnected: 'Not connected'
|
||||
},
|
||||
status: {
|
||||
noLogin: 'No login (public mode)',
|
||||
connecting: 'Connecting...',
|
||||
connected: 'Connected',
|
||||
connectFailedPublic: 'Connection failed - public mode active'
|
||||
},
|
||||
tabs: {
|
||||
vods: 'VODs',
|
||||
clips: 'Clips',
|
||||
cutter: 'Video Cutter',
|
||||
merge: 'Merge Videos',
|
||||
stats: 'Statistics',
|
||||
archive: 'Archive',
|
||||
settings: 'Settings'
|
||||
},
|
||||
queue: {
|
||||
empty: 'No downloads in queue',
|
||||
detailStreamer: 'Streamer:',
|
||||
detailDuration: 'Duration:',
|
||||
detailDate: 'Date:',
|
||||
start: 'Start',
|
||||
stop: 'Pause',
|
||||
resume: 'Resume',
|
||||
statusDone: 'Completed',
|
||||
statusFailed: 'Failed',
|
||||
statusRunning: 'Running',
|
||||
statusPaused: 'Paused',
|
||||
statusWaiting: 'Waiting',
|
||||
progressError: 'Error',
|
||||
progressReady: 'Ready',
|
||||
progressLoading: 'Loading...',
|
||||
readyToDownload: 'Ready to download',
|
||||
started: 'Download started',
|
||||
done: 'Done',
|
||||
failed: 'Download failed',
|
||||
speed: 'Speed',
|
||||
eta: 'ETA',
|
||||
part: 'Part',
|
||||
emptyAlert: 'Queue is empty. Add a VOD or clip first.',
|
||||
duplicateSkipped: 'This item is already active in the queue.',
|
||||
openFile: 'Open file',
|
||||
showInFolder: 'Show in folder',
|
||||
openFileFailed: 'Could not open the file (it may have been moved or deleted).',
|
||||
outputFilesLabel: '{count} output files',
|
||||
retryItem: 'Retry this item',
|
||||
viewChat: 'View chat',
|
||||
viewChatLoading: 'Loading chat...',
|
||||
viewChatFailed: 'Could not read chat file',
|
||||
chatViewerFilterPlaceholder: 'Filter chat...',
|
||||
chatViewerFilterAria: 'Filter chat messages',
|
||||
viewChatCount: '{count} messages',
|
||||
viewChatTruncatedSuffix: ' (truncated)',
|
||||
viewEvents: 'View events',
|
||||
viewEventsCount: '{count} events',
|
||||
viewEventsEmpty: 'No events recorded.',
|
||||
eventStartedAs: 'Started as',
|
||||
eventEndedAfter: 'Ended after',
|
||||
eventTitleFromTo: 'Title: {from} -> {to}',
|
||||
eventGameFromTo: 'Game: {from} -> {to}',
|
||||
statusBarSummary: '{downloading} dl, {pending} queued',
|
||||
ctxMoveTop: 'Move to top',
|
||||
ctxMoveBottom: 'Move to bottom',
|
||||
ctxCopyUrl: 'Copy URL',
|
||||
ctxOpenOnTwitch: 'Open on Twitch',
|
||||
ctxRemove: 'Remove from queue',
|
||||
ctxCopiedUrl: 'URL copied to clipboard.',
|
||||
liveRecordingTitle: 'Live recording — captures until the stream ends',
|
||||
recordingHealth: {
|
||||
ok: 'Healthy — bytes flowing',
|
||||
stale: 'Stalled — no bytes recently (network blip or stream ending)',
|
||||
unknown: 'Waiting for first segment'
|
||||
},
|
||||
eventRecordingResume: 'Recording resumed — starting part {part}'
|
||||
},
|
||||
profile: {
|
||||
liveBadge: 'LIVE',
|
||||
partner: 'Partner',
|
||||
affiliate: 'Affiliate',
|
||||
followers: 'Followers',
|
||||
vods: 'VODs',
|
||||
vodsTooltip: 'VODs visible via Twitch API for this channel',
|
||||
lastStream: 'Last stream',
|
||||
openTwitch: 'Open on Twitch',
|
||||
openTwitchTooltip: 'Open this channel on twitch.tv',
|
||||
liveCardTooltip: 'Click to start a live recording right now',
|
||||
liveThumbAlt: 'Live preview',
|
||||
recordNow: 'Record now',
|
||||
refresh: 'Refresh',
|
||||
agoMinutes: '{n} min ago',
|
||||
agoHours: '{n} h ago',
|
||||
agoDays: '{n} d ago',
|
||||
agoMonths: '{n} mo ago',
|
||||
agoYears: '{n} y ago'
|
||||
},
|
||||
streamers: {
|
||||
recordLiveTitle: 'Record this streamer live (captures until stream ends)',
|
||||
liveRecordingStarted: 'Live recording started for {streamer}.',
|
||||
liveRecordingOffline: '{streamer} is offline right now.',
|
||||
liveRecordingAlreadyActive: 'Already recording {streamer}.',
|
||||
liveRecordingFailed: 'Could not start live recording',
|
||||
autoRecordTitle: 'Auto-record: when this streamer goes live the app records automatically',
|
||||
autoRecordEnabled: 'Auto-record enabled for {streamer}. Polling for live state...',
|
||||
autoRecordDisabled: 'Auto-record disabled for {streamer}.',
|
||||
autoVodTitle: 'Auto-download new VODs (recently published) for this streamer',
|
||||
autoVodEnabled: 'Auto-VOD enabled for {streamer}. Will pick up new VODs.',
|
||||
autoVodDisabled: 'Auto-VOD disabled for {streamer}.',
|
||||
autoVodScanQueued: '{count} new VOD(s) auto-queued.',
|
||||
autoVodScanEmpty: 'No new VODs found.',
|
||||
autoRecordScanTriggered: 'Manual scan: {count} live recording(s) started.',
|
||||
autoRecordScanEmpty: 'Manual scan: no streamers currently live.',
|
||||
liveNowTooltip: 'Currently live on Twitch',
|
||||
modalCloseAria: 'Close dialog',
|
||||
sidebarEmpty: 'No streamers yet. Add one via the input at the top right.',
|
||||
removeAria: 'Remove',
|
||||
cutProgressAria: 'Cut progress',
|
||||
mergeProgressAria: 'Merge progress',
|
||||
updateProgressAria: 'Update download progress'
|
||||
},
|
||||
vods: {
|
||||
selectAriaLabel: 'Select VOD for bulk action',
|
||||
noneTitle: 'No VODs',
|
||||
noneText: 'Select a streamer from the list.',
|
||||
loading: 'Loading VODs...',
|
||||
notFound: 'Streamer not found',
|
||||
noResultsTitle: 'No VODs found',
|
||||
noResultsText: 'This streamer has no VODs.',
|
||||
untitled: 'Untitled VOD',
|
||||
views: 'views',
|
||||
addQueue: '+ Queue',
|
||||
trimButton: 'Trim VOD',
|
||||
filterPlaceholder: 'Filter by title... (Ctrl+F)',
|
||||
filterAria: 'Filter VOD titles',
|
||||
filterClearTitle: 'Clear filter (Esc)',
|
||||
filterNoMatchTitle: 'No matches',
|
||||
filterNoMatchText: 'No VODs match the current filter.',
|
||||
filterMatchCount: '{shown} of {total} VODs',
|
||||
sortLabel: 'Sort:',
|
||||
sortDateDesc: 'Newest first',
|
||||
sortDateAsc: 'Oldest first',
|
||||
sortViewsDesc: 'Most viewed',
|
||||
sortDurationDesc: 'Longest first',
|
||||
sortDurationAsc: 'Shortest first',
|
||||
bulkSelectedCount: '{count} selected',
|
||||
bulkAddToQueue: '+ Queue',
|
||||
bulkAdding: 'Adding...',
|
||||
bulkClear: 'Clear',
|
||||
bulkAddedToQueue: 'Added {count} VODs to the queue.',
|
||||
bulkAddSkipped: 'No VODs were added (already in queue or invalid).',
|
||||
bulkMarkDownloaded: 'Mark as downloaded',
|
||||
bulkUnmark: 'Unmark',
|
||||
bulkMarkedDownloaded: 'Marked {count} VODs as downloaded.',
|
||||
bulkUnmarkedDownloaded: 'Removed {count} VODs from the downloaded list.',
|
||||
alreadyDownloaded: 'Already downloaded',
|
||||
hideDownloaded: 'Hide downloaded',
|
||||
hideDownloadedTitle: 'Hide VODs that are marked as already downloaded',
|
||||
openOnTwitch: 'Open on Twitch',
|
||||
ctxOpenOnTwitch: 'Open on Twitch',
|
||||
ctxCopyUrl: 'Copy VOD URL',
|
||||
ctxCopiedUrl: 'URL copied to clipboard.',
|
||||
ctxMarkDownloaded: 'Mark as downloaded',
|
||||
ctxUnmarkDownloaded: 'Unmark downloaded'
|
||||
},
|
||||
clips: {
|
||||
dialogTitle: 'Trim VOD',
|
||||
dialogStart: 'Start:',
|
||||
dialogStartTime: 'Start time (HH:MM:SS):',
|
||||
dialogEnd: 'End:',
|
||||
dialogEndTime: 'End time (HH:MM:SS):',
|
||||
dialogDuration: 'Duration: ',
|
||||
dialogPartLabel: 'Start part number (optional, for continuation):',
|
||||
dialogPartHint: 'Leave empty = part 1',
|
||||
dialogFormatLabel: 'Filename format:',
|
||||
dialogConfirm: 'Add to queue',
|
||||
invalidDuration: 'Invalid!',
|
||||
invalidTime: 'Invalid time values',
|
||||
endBeforeStart: 'End time must be greater than start time!',
|
||||
outOfRange: 'Time is outside VOD range!',
|
||||
enterUrl: 'Please enter a URL',
|
||||
loadingButton: 'Loading...',
|
||||
loadingStatus: 'Downloading...',
|
||||
downloadButton: 'Download clip',
|
||||
success: 'Download successful!',
|
||||
errorPrefix: 'Error: ',
|
||||
unknownError: 'Unknown error',
|
||||
formatSimple: '(default)',
|
||||
formatTimestamp: '(with timestamp)',
|
||||
formatParts: '(parts naming)',
|
||||
formatTemplate: '(custom template)',
|
||||
templateEmpty: 'Template cannot be empty in custom template mode.',
|
||||
templatePlaceholder: '{date}_{part}.mp4',
|
||||
templateHelp: 'Placeholders: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}',
|
||||
urlPlaceholder: 'https://clips.twitch.tv/... or https://www.twitch.tv/.../clip/...',
|
||||
startPartPlaceholder: 'e.g. 42'
|
||||
},
|
||||
cutter: {
|
||||
videoInfoFailed: 'Could not read video info. Is FFprobe installed?',
|
||||
previewLoading: 'Loading preview...',
|
||||
previewUnavailable: 'Preview unavailable',
|
||||
previewAlt: 'Preview',
|
||||
cutting: 'Cutting...',
|
||||
cut: 'Cut',
|
||||
cutSuccess: 'Video cut successfully!',
|
||||
cutFailed: 'Failed to cut video.',
|
||||
infoDuration: 'Duration',
|
||||
infoResolution: 'Resolution',
|
||||
infoFps: 'FPS',
|
||||
infoSelection: 'Selection',
|
||||
startLabel: 'Start:',
|
||||
endLabel: 'End:',
|
||||
filePathPlaceholder: 'No file selected...'
|
||||
},
|
||||
merge: {
|
||||
empty: 'No videos selected',
|
||||
merging: 'Merging...',
|
||||
merge: 'Merge',
|
||||
success: 'Videos merged successfully!',
|
||||
failed: 'Failed to merge videos.',
|
||||
moveUpAria: 'Move up',
|
||||
moveDownAria: 'Move down',
|
||||
removeAria: 'Remove from list'
|
||||
},
|
||||
mergeGroup: {
|
||||
btn: 'Merge & Split',
|
||||
phaseDownloading: 'Downloading VOD',
|
||||
phaseMerging: 'Merging...',
|
||||
phaseSplitting: 'Splitting Part',
|
||||
phaseCleanup: 'Cleaning up...',
|
||||
needMinTwo: 'Select at least 2 VODs',
|
||||
titleTwo: 'Merge: {title1} + {title2}',
|
||||
titleMany: 'Merge: {title1} + {count} more',
|
||||
metaLabel: '{count} VODs',
|
||||
},
|
||||
updates: {
|
||||
bannerDefault: 'New version available!',
|
||||
latest: 'You are on the latest version!',
|
||||
checking: 'Checking for updates...',
|
||||
checkInProgress: 'Update check is already running.',
|
||||
readyToInstall: 'Update is ready to install.',
|
||||
checkFailed: 'Update check failed.',
|
||||
downloading: 'Downloading...',
|
||||
downloadInProgress: 'Update download is already running.',
|
||||
downloadFailed: 'Update download failed.',
|
||||
available: 'available!',
|
||||
downloadNow: 'Download now',
|
||||
downloadLabel: 'Download',
|
||||
ready: 'ready to install!',
|
||||
installNow: 'Install now & restart',
|
||||
modalAvailableTitle: 'Update available',
|
||||
modalAvailableMessage: 'Version {version} is available. Download it now?',
|
||||
modalReadyTitle: 'Update ready',
|
||||
modalReadyMessage: 'Version {version} has been downloaded. Install and restart now?',
|
||||
modalDismiss: 'No',
|
||||
modalDownloadConfirm: 'Yes, download',
|
||||
modalInstallConfirm: 'Yes, install',
|
||||
modalSkipVersion: 'Skip this version',
|
||||
changelogLabel: 'Changelog',
|
||||
showChangelog: 'Show changelog',
|
||||
hideChangelog: 'Hide changelog',
|
||||
noChangelog: 'No changelog available.',
|
||||
releasedLabel: 'Release'
|
||||
}
|
||||
} as const;
|
||||
@@ -0,0 +1,218 @@
|
||||
// Profile-header renderer. Owns the streamerProfileHeader div above the
|
||||
// VOD grid: hidden when no streamer is selected, skeleton while loading,
|
||||
// full card once profile data is back. Smooth fade-in is in CSS.
|
||||
|
||||
let activeProfileRequestId = 0;
|
||||
|
||||
function formatProfileFollowers(count: number | null): string {
|
||||
if (count == null) return '–';
|
||||
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}M`;
|
||||
if (count >= 1_000) return `${(count / 1_000).toFixed(count >= 10_000 ? 0 : 1)}K`;
|
||||
return String(count);
|
||||
}
|
||||
|
||||
function formatLastStreamAgo(iso: string | null): string {
|
||||
if (!iso) return '–';
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (!Number.isFinite(ms) || ms < 0) return '–';
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
if (minutes < 60) return UI_TEXT.profile.agoMinutes.replace('{n}', String(minutes));
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return UI_TEXT.profile.agoHours.replace('{n}', String(hours));
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return UI_TEXT.profile.agoDays.replace('{n}', String(days));
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12) return UI_TEXT.profile.agoMonths.replace('{n}', String(months));
|
||||
const years = Math.floor(days / 365);
|
||||
return UI_TEXT.profile.agoYears.replace('{n}', String(years));
|
||||
}
|
||||
|
||||
function hideStreamerProfileHeader(): void {
|
||||
const el = document.getElementById('streamerProfileHeader');
|
||||
if (!el) return;
|
||||
el.classList.add('is-hidden');
|
||||
applyHtml(el, '');
|
||||
}
|
||||
|
||||
function renderStreamerProfileSkeleton(login: string): void {
|
||||
const el = document.getElementById('streamerProfileHeader');
|
||||
if (!el) return;
|
||||
el.classList.remove('is-live', 'is-hidden');
|
||||
el.classList.add('streamer-profile-skeleton');
|
||||
applyHtml(el, `
|
||||
<div class="streamer-profile-skel-block avatar"></div>
|
||||
<div class="streamer-profile-body">
|
||||
<div class="streamer-profile-name-row">
|
||||
<div class="streamer-profile-skel-block name"></div>
|
||||
<div class="streamer-profile-skel-block badge"></div>
|
||||
</div>
|
||||
<div class="streamer-profile-skel-block subtitle"></div>
|
||||
<div class="streamer-profile-stats streamer-profile-skel-stats">
|
||||
<div class="streamer-profile-skel-block" style="width:100px; height:14px;"></div>
|
||||
<div class="streamer-profile-skel-block" style="width:80px; height:14px;"></div>
|
||||
<div class="streamer-profile-skel-block" style="width:120px; height:14px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function renderStreamerProfileCard(p: StreamerProfile): void {
|
||||
const el = document.getElementById('streamerProfileHeader');
|
||||
if (!el) return;
|
||||
el.classList.remove('streamer-profile-skeleton', 'is-hidden');
|
||||
if (p.isLive) el.classList.add('is-live'); else el.classList.remove('is-live');
|
||||
|
||||
const safeLogin = p.login.replace(/'/g, "\\'");
|
||||
const safeUrl = p.twitchUrl.replace(/'/g, "\\'");
|
||||
|
||||
const avatarBlock = p.avatarUrl
|
||||
? `<img class="streamer-profile-avatar${p.isLive ? ' is-live' : ''}" src="${escapeHtml(p.avatarUrl)}" alt="${escapeHtml(p.displayName)}" referrerpolicy="no-referrer" onerror="onProfileAvatarError(this)">`
|
||||
: `<div class="streamer-profile-avatar-fallback">${escapeHtml((p.displayName || p.login || '?').slice(0, 1).toUpperCase())}</div>`;
|
||||
|
||||
const badges: string[] = [];
|
||||
if (p.broadcasterType === 'partner') badges.push(`<span class="streamer-profile-badge partner">${escapeHtml(UI_TEXT.profile.partner)}</span>`);
|
||||
if (p.broadcasterType === 'affiliate') badges.push(`<span class="streamer-profile-badge affiliate">${escapeHtml(UI_TEXT.profile.affiliate)}</span>`);
|
||||
|
||||
const bio = p.description
|
||||
? `<div class="streamer-profile-bio" title="${escapeHtml(p.description)}">${escapeHtml(p.description)}</div>`
|
||||
: '';
|
||||
|
||||
const followersStat = `
|
||||
<div class="streamer-profile-stat" title="${escapeHtml(UI_TEXT.profile.followers)}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
|
||||
<strong>${escapeHtml(formatProfileFollowers(p.followerCount))}</strong> ${escapeHtml(UI_TEXT.profile.followers)}
|
||||
</div>`;
|
||||
const vodsStat = `
|
||||
<div class="streamer-profile-stat" title="${escapeHtml(UI_TEXT.profile.vodsTooltip)}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z"/></svg>
|
||||
<strong>${p.vodCount}</strong> ${escapeHtml(UI_TEXT.profile.vods)}
|
||||
</div>`;
|
||||
const lastStreamStat = `
|
||||
<div class="streamer-profile-stat" title="${p.lastStreamAt ? escapeHtml(new Date(p.lastStreamAt).toLocaleString()) : ''}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/></svg>
|
||||
${escapeHtml(UI_TEXT.profile.lastStream)}: <strong>${escapeHtml(formatLastStreamAgo(p.lastStreamAt))}</strong>
|
||||
</div>`;
|
||||
|
||||
// Banner-as-background — set inline so the URL stays per-streamer.
|
||||
// The darkening gradient is handled by the .streamer-profile-header::before
|
||||
// pseudo so the banner itself stays bright and unfiltered here.
|
||||
const bannerStyle = p.bannerUrl
|
||||
? `background-image: url("${p.bannerUrl.replace(/"/g, '%22')}");`
|
||||
: '';
|
||||
|
||||
// Live preview block — only when currently live. Big card with
|
||||
// current preview frame + viewer count + title + game + record CTA.
|
||||
const liveCard = p.isLive
|
||||
? `
|
||||
<div class="streamer-profile-live-card" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.profile.liveCardTooltip)}" onclick="triggerLiveRecordingFromProfile('${safeLogin}')" onkeydown="if((event.key==='Enter'||event.key===' ')&&event.target===event.currentTarget){event.preventDefault();triggerLiveRecordingFromProfile('${safeLogin}');}" title="${escapeHtml(UI_TEXT.profile.liveCardTooltip)}">
|
||||
${p.currentStreamPreviewUrl
|
||||
? `<img class="streamer-profile-live-thumb" src="${escapeHtml(p.currentStreamPreviewUrl)}" alt="${escapeHtml(UI_TEXT.profile.liveThumbAlt)}" onerror="onProfileLivePreviewError(this)">`
|
||||
: `<div class="streamer-profile-live-thumb-fallback"></div>`}
|
||||
<div class="streamer-profile-live-body">
|
||||
<div class="streamer-profile-live-badge-row">
|
||||
<span class="streamer-profile-badge live">${escapeHtml(UI_TEXT.profile.liveBadge)}</span>
|
||||
${typeof p.currentStreamViewers === 'number' ? `<span class="streamer-profile-live-viewers"><svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg> ${escapeHtml(formatProfileFollowers(p.currentStreamViewers))}</span>` : ''}
|
||||
</div>
|
||||
${p.currentTitle ? `<div class="streamer-profile-live-title">${escapeHtml(p.currentTitle)}</div>` : ''}
|
||||
${p.currentGame ? `<div class="streamer-profile-live-game">${escapeHtml(p.currentGame)}</div>` : ''}
|
||||
<button type="button" class="streamer-profile-btn primary streamer-profile-live-rec-btn" onclick="event.stopPropagation(); triggerLiveRecordingFromProfile('${safeLogin}')">${escapeHtml(UI_TEXT.profile.recordNow)}</button>
|
||||
</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
applyHtml(el, `
|
||||
${bannerStyle ? `<div class="streamer-profile-banner-bg" style="${bannerStyle}"></div>` : ''}
|
||||
<div class="streamer-profile-row">
|
||||
<div class="streamer-profile-avatar-wrap" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.profile.openTwitchTooltip)}" onclick="openTwitchChannel('${safeUrl}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();openTwitchChannel('${safeUrl}');}" title="${escapeHtml(UI_TEXT.profile.openTwitchTooltip)}">
|
||||
${avatarBlock}
|
||||
</div>
|
||||
<div class="streamer-profile-body">
|
||||
<div class="streamer-profile-name-row">
|
||||
<span class="streamer-profile-display-name">${escapeHtml(p.displayName)}</span>
|
||||
<span class="streamer-profile-login">@${escapeHtml(p.login)}</span>
|
||||
${badges.join('')}
|
||||
</div>
|
||||
${bio}
|
||||
<div class="streamer-profile-stats">
|
||||
${followersStat}
|
||||
${vodsStat}
|
||||
${lastStreamStat}
|
||||
</div>
|
||||
</div>
|
||||
<div class="streamer-profile-actions">
|
||||
<button type="button" class="streamer-profile-btn primary" onclick="openTwitchChannel('${safeUrl}')">${escapeHtml(UI_TEXT.profile.openTwitch)}</button>
|
||||
<button type="button" class="streamer-profile-btn" onclick="refreshStreamerProfile('${safeLogin}')">${escapeHtml(UI_TEXT.profile.refresh)}</button>
|
||||
</div>
|
||||
</div>
|
||||
${liveCard}
|
||||
`);
|
||||
}
|
||||
|
||||
function onProfileLivePreviewError(img: HTMLImageElement): void {
|
||||
const parent = img.parentElement;
|
||||
if (!parent) return;
|
||||
const fallback = document.createElement('div');
|
||||
fallback.className = 'streamer-profile-live-thumb-fallback';
|
||||
parent.replaceChild(fallback, img);
|
||||
}
|
||||
|
||||
function triggerLiveRecordingFromProfile(login: string): void {
|
||||
const fn = (window as unknown as { triggerLiveRecording?: (login: string) => Promise<void> }).triggerLiveRecording;
|
||||
if (typeof fn === 'function') void fn(login);
|
||||
}
|
||||
|
||||
async function loadStreamerProfile(login: string, forceRefresh = false): Promise<void> {
|
||||
if (!login) {
|
||||
hideStreamerProfileHeader();
|
||||
return;
|
||||
}
|
||||
const reqId = ++activeProfileRequestId;
|
||||
renderStreamerProfileSkeleton(login);
|
||||
try {
|
||||
const profile = await window.api.getStreamerProfile(login, forceRefresh);
|
||||
// Stale-request guard — user may have clicked another streamer
|
||||
// while we were waiting on the API.
|
||||
if (reqId !== activeProfileRequestId) return;
|
||||
if (!profile) {
|
||||
hideStreamerProfileHeader();
|
||||
return;
|
||||
}
|
||||
renderStreamerProfileCard(profile);
|
||||
} catch (_) {
|
||||
if (reqId === activeProfileRequestId) hideStreamerProfileHeader();
|
||||
}
|
||||
}
|
||||
|
||||
function refreshStreamerProfile(login: string): void {
|
||||
void loadStreamerProfile(login, true);
|
||||
}
|
||||
|
||||
function openTwitchChannel(url: string): void {
|
||||
void window.api.openExternal(url);
|
||||
}
|
||||
|
||||
function onProfileAvatarError(img: HTMLImageElement): void {
|
||||
// Avatar URL hit a 404 or CORS oddity. Swap to the fallback letter
|
||||
// tile so we don't end up with a broken-image icon.
|
||||
const parent = img.parentElement;
|
||||
if (!parent) return;
|
||||
const fallback = document.createElement('div');
|
||||
fallback.className = 'streamer-profile-avatar-fallback';
|
||||
const alt = img.getAttribute('alt') || '';
|
||||
fallback.textContent = (alt || '?').slice(0, 1).toUpperCase();
|
||||
parent.replaceChild(fallback, img);
|
||||
}
|
||||
|
||||
(window as unknown as {
|
||||
loadStreamerProfile: typeof loadStreamerProfile;
|
||||
refreshStreamerProfile: typeof refreshStreamerProfile;
|
||||
hideStreamerProfileHeader: typeof hideStreamerProfileHeader;
|
||||
openTwitchChannel: typeof openTwitchChannel;
|
||||
onProfileAvatarError: typeof onProfileAvatarError;
|
||||
}).loadStreamerProfile = loadStreamerProfile;
|
||||
(window as unknown as { refreshStreamerProfile: typeof refreshStreamerProfile }).refreshStreamerProfile = refreshStreamerProfile;
|
||||
(window as unknown as { hideStreamerProfileHeader: typeof hideStreamerProfileHeader }).hideStreamerProfileHeader = hideStreamerProfileHeader;
|
||||
(window as unknown as { openTwitchChannel: typeof openTwitchChannel }).openTwitchChannel = openTwitchChannel;
|
||||
(window as unknown as { onProfileAvatarError: typeof onProfileAvatarError }).onProfileAvatarError = onProfileAvatarError;
|
||||
(window as unknown as { onProfileLivePreviewError: typeof onProfileLivePreviewError }).onProfileLivePreviewError = onProfileLivePreviewError;
|
||||
(window as unknown as { triggerLiveRecordingFromProfile: typeof triggerLiveRecordingFromProfile }).triggerLiveRecordingFromProfile = triggerLiveRecordingFromProfile;
|
||||
@@ -0,0 +1,585 @@
|
||||
function renderRecordingHealthBadge(health: 'ok' | 'stale' | 'unknown' | undefined): string {
|
||||
if (!health) return '';
|
||||
const labels = UI_TEXT.queue.recordingHealth || { ok: 'Healthy', stale: 'Stalled', unknown: 'Pending data' };
|
||||
const cls = health === 'ok' ? 'health-ok' : (health === 'stale' ? 'health-stale' : 'health-unknown');
|
||||
const title = labels[health] || '';
|
||||
return `<span class="queue-health-dot ${cls}" title="${escapeHtml(title)}" aria-label="${escapeHtml(title)}"></span>`;
|
||||
}
|
||||
|
||||
function renderQueueItemFileActions(item: QueueItem): string {
|
||||
if (item.status !== 'completed' || !item.outputFiles || item.outputFiles.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const first = item.outputFiles[0];
|
||||
if (typeof first !== 'string' || !first) return '';
|
||||
const safeFirst = escapeHtml(first);
|
||||
const safeFirstAttr = first.replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
const buttons: string[] = [];
|
||||
|
||||
// "Open file" only makes sense when there's exactly one output (a clip /
|
||||
// full VOD download). For multi-part downloads "open the first part" is
|
||||
// surprising — the user almost always wants the folder.
|
||||
if (item.outputFiles.length === 1) {
|
||||
buttons.push(`<button type="button" class="queue-detail-btn" onclick="invokeOpenFile('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.openFile)}</button>`);
|
||||
}
|
||||
buttons.push(`<button type="button" class="queue-detail-btn" onclick="invokeShowInFolder('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.showInFolder)}</button>`);
|
||||
|
||||
// Surface a "View chat" button when a sibling chat file exists in the
|
||||
// outputs list. Single click opens the in-app viewer modal.
|
||||
const chatFile = item.outputFiles.find((f) => /\.chat\.json(l)?$/i.test(f));
|
||||
if (chatFile) {
|
||||
const safeChatAttr = chatFile.replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
buttons.push(`<button type="button" class="queue-detail-btn" onclick="openChatViewer('${safeChatAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewChat)}</button>`);
|
||||
}
|
||||
|
||||
// Same pattern for the .events.jsonl sidecar — title/game change timeline.
|
||||
const eventsFile = item.outputFiles.find((f) => /\.events\.jsonl$/i.test(f));
|
||||
if (eventsFile) {
|
||||
const safeEventsAttr = eventsFile.replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
buttons.push(`<button type="button" class="queue-detail-btn" onclick="openEventsViewer('${safeEventsAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewEvents)}</button>`);
|
||||
}
|
||||
|
||||
const fileLabel = item.outputFiles.length === 1
|
||||
? safeFirst
|
||||
: `${escapeHtml(UI_TEXT.queue.outputFilesLabel.replace('{count}', String(item.outputFiles.length)))}`;
|
||||
|
||||
return `
|
||||
<div class="queue-output-row">
|
||||
${buttons.join('')}
|
||||
<span class="queue-output-label">${fileLabel}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function invokeOpenFile(filePath: string): Promise<void> {
|
||||
const ok = await window.api.openFile(filePath);
|
||||
if (!ok) {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (toast) toast(UI_TEXT.queue.openFileFailed, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
async function invokeShowInFolder(filePath: string): Promise<void> {
|
||||
const ok = await window.api.showInFolder(filePath);
|
||||
if (!ok) {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (toast) toast(UI_TEXT.queue.openFileFailed, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
function buildQueueFingerprint(url: string, streamer: string, date: string, customClip?: CustomClip): string {
|
||||
const clipFingerprint = customClip
|
||||
? [
|
||||
'clip',
|
||||
customClip.startSec,
|
||||
customClip.durationSec,
|
||||
customClip.startPart,
|
||||
customClip.filenameFormat,
|
||||
(customClip.filenameTemplate || '').trim().toLowerCase()
|
||||
].join(':')
|
||||
: 'vod';
|
||||
|
||||
return [
|
||||
(url || '').trim().toLowerCase().replace(/^https?:\/\/(www\.)?/, ''),
|
||||
(streamer || '').trim().toLowerCase(),
|
||||
(date || '').trim(),
|
||||
clipFingerprint
|
||||
].join('|');
|
||||
}
|
||||
|
||||
let lastQueueRenderFingerprint = '';
|
||||
|
||||
function getQueueRenderFingerprint(items: QueueItem[]): string {
|
||||
const lang = typeof currentLanguage === 'string' ? currentLanguage : 'en';
|
||||
const pieces = items.map((item) => [
|
||||
item.id,
|
||||
item.status,
|
||||
Math.round((Number(item.progress) || 0) * 10),
|
||||
item.currentPart || 0,
|
||||
item.totalParts || 0,
|
||||
item.speed || '',
|
||||
item.eta || '',
|
||||
item.progressStatus || '',
|
||||
item.last_error || '',
|
||||
item.mergeGroup?.mergePhase || ''
|
||||
].join(':'));
|
||||
|
||||
return `${lang}|${selectedQueueIds.join(',')}|${[...expandedQueueIds].join(',')}|${pieces.join('|')}`;
|
||||
}
|
||||
|
||||
function hasActiveQueueDuplicate(url: string, streamer: string, date: string, customClip?: CustomClip): boolean {
|
||||
const target = buildQueueFingerprint(url, streamer, date, customClip);
|
||||
return queue.some((item) => {
|
||||
if (item.status !== 'pending' && item.status !== 'downloading' && item.status !== 'paused') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return buildQueueFingerprint(item.url, item.streamer, item.date, item.customClip) === target;
|
||||
});
|
||||
}
|
||||
|
||||
async function addToQueue(url: string, title: string, date: string, streamer: string, duration: string): Promise<void> {
|
||||
if ((config.prevent_duplicate_downloads as boolean) !== false && hasActiveQueueDuplicate(url, streamer, date)) {
|
||||
alert(UI_TEXT.queue.duplicateSkipped);
|
||||
return;
|
||||
}
|
||||
|
||||
queue = await window.api.addToQueue({
|
||||
url,
|
||||
title,
|
||||
date,
|
||||
streamer,
|
||||
duration_str: duration
|
||||
});
|
||||
renderQueue();
|
||||
}
|
||||
|
||||
async function removeFromQueue(id: string): Promise<void> {
|
||||
queue = await window.api.removeFromQueue(id);
|
||||
renderQueue();
|
||||
}
|
||||
|
||||
async function clearCompleted(): Promise<void> {
|
||||
queue = await window.api.clearCompleted();
|
||||
renderQueue();
|
||||
}
|
||||
|
||||
async function retryFailedDownloads(): Promise<void> {
|
||||
queue = await window.api.retryFailedDownloads();
|
||||
renderQueue();
|
||||
}
|
||||
|
||||
async function retryQueueItem(id: string): Promise<void> {
|
||||
queue = await window.api.retryQueueItem(id);
|
||||
renderQueue();
|
||||
}
|
||||
|
||||
let queueContextMenuInitialized = false;
|
||||
let activeQueueContextMenu: HTMLElement | null = null;
|
||||
|
||||
function closeQueueContextMenu(): void {
|
||||
if (!activeQueueContextMenu) return;
|
||||
activeQueueContextMenu.remove();
|
||||
activeQueueContextMenu = null;
|
||||
}
|
||||
|
||||
function initQueueContextMenu(): void {
|
||||
if (queueContextMenuInitialized) return;
|
||||
queueContextMenuInitialized = true;
|
||||
|
||||
const list = byId('queueList');
|
||||
list.addEventListener('contextmenu', (e: MouseEvent) => {
|
||||
const itemEl = (e.target as HTMLElement).closest('.queue-item') as HTMLElement | null;
|
||||
if (!itemEl) return;
|
||||
const id = itemEl.dataset.id;
|
||||
if (!id) return;
|
||||
const item = queue.find((i) => i.id === id);
|
||||
if (!item) return;
|
||||
e.preventDefault();
|
||||
showQueueContextMenu(e.clientX, e.clientY, item);
|
||||
});
|
||||
}
|
||||
|
||||
function showQueueContextMenu(x: number, y: number, item: QueueItem): void {
|
||||
closeQueueContextMenu();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'context-menu';
|
||||
menu.setAttribute('role', 'menu');
|
||||
|
||||
const makeItem = (label: string, onClick: () => void, disabled = false): HTMLElement => {
|
||||
const el = document.createElement('div');
|
||||
el.textContent = label;
|
||||
el.className = 'context-menu-item' + (disabled ? ' disabled' : '');
|
||||
el.setAttribute('role', 'menuitem');
|
||||
if (disabled) el.setAttribute('aria-disabled', 'true');
|
||||
if (!disabled) {
|
||||
el.addEventListener('click', () => {
|
||||
try { onClick(); } finally { closeQueueContextMenu(); }
|
||||
});
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
const makeSeparator = (): HTMLElement => {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'context-menu-separator';
|
||||
sep.setAttribute('role', 'separator');
|
||||
return sep;
|
||||
};
|
||||
|
||||
const isPending = item.status === 'pending' || item.status === 'paused';
|
||||
const isFailed = item.status === 'error';
|
||||
const isCompleted = item.status === 'completed';
|
||||
|
||||
if (isPending) {
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.ctxMoveTop, () => { void moveQueueItemTo(item.id, 'top'); }));
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.ctxMoveBottom, () => { void moveQueueItemTo(item.id, 'bottom'); }));
|
||||
menu.appendChild(makeSeparator());
|
||||
}
|
||||
|
||||
if (isFailed) {
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.retryItem, () => { void retryQueueItem(item.id); }));
|
||||
menu.appendChild(makeSeparator());
|
||||
}
|
||||
|
||||
if (isCompleted && item.outputFiles && item.outputFiles.length > 0) {
|
||||
const first = item.outputFiles[0];
|
||||
if (item.outputFiles.length === 1) {
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.openFile, () => { void window.api.openFile(first); }));
|
||||
}
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.showInFolder, () => { void window.api.showInFolder(first); }));
|
||||
menu.appendChild(makeSeparator());
|
||||
}
|
||||
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.ctxCopyUrl, () => {
|
||||
try {
|
||||
void navigator.clipboard.writeText(item.url);
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (toast) toast(UI_TEXT.queue.ctxCopiedUrl, 'info');
|
||||
} catch { /* ignore */ }
|
||||
}));
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.ctxOpenOnTwitch, () => {
|
||||
void window.api.openExternal(item.url);
|
||||
}));
|
||||
menu.appendChild(makeSeparator());
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.ctxRemove, () => { void removeFromQueue(item.id); }));
|
||||
|
||||
document.body.appendChild(menu);
|
||||
activeQueueContextMenu = menu;
|
||||
|
||||
const rect = menu.getBoundingClientRect();
|
||||
let left = x;
|
||||
let top = y;
|
||||
if (left + rect.width > window.innerWidth - 4) left = Math.max(4, window.innerWidth - rect.width - 4);
|
||||
if (top + rect.height > window.innerHeight - 4) top = Math.max(4, window.innerHeight - rect.height - 4);
|
||||
menu.style.left = `${left}px`;
|
||||
menu.style.top = `${top}px`;
|
||||
|
||||
const dismissOnClick = (ev: MouseEvent) => {
|
||||
if (!activeQueueContextMenu) return;
|
||||
if (ev.target instanceof Node && activeQueueContextMenu.contains(ev.target)) return;
|
||||
cleanup();
|
||||
};
|
||||
const dismissOnEscape = (ev: KeyboardEvent) => {
|
||||
if (ev.key === 'Escape') cleanup();
|
||||
};
|
||||
const dismissOnScroll = () => cleanup();
|
||||
const cleanup = (): void => {
|
||||
closeQueueContextMenu();
|
||||
document.removeEventListener('mousedown', dismissOnClick, true);
|
||||
document.removeEventListener('keydown', dismissOnEscape, true);
|
||||
document.removeEventListener('scroll', dismissOnScroll, true);
|
||||
};
|
||||
document.addEventListener('mousedown', dismissOnClick, true);
|
||||
document.addEventListener('keydown', dismissOnEscape, true);
|
||||
document.addEventListener('scroll', dismissOnScroll, true);
|
||||
}
|
||||
|
||||
async function moveQueueItemTo(id: string, where: 'top' | 'bottom'): Promise<void> {
|
||||
const idx = queue.findIndex((i) => i.id === id);
|
||||
if (idx < 0) return;
|
||||
const reordered = [...queue];
|
||||
const [moved] = reordered.splice(idx, 1);
|
||||
if (where === 'top') reordered.unshift(moved);
|
||||
else reordered.push(moved);
|
||||
queue = reordered;
|
||||
renderQueue();
|
||||
await window.api.reorderQueue(reordered.map((i) => i.id));
|
||||
}
|
||||
|
||||
function getQueueStatusLabel(item: QueueItem): string {
|
||||
if (item.status === 'completed') return UI_TEXT.queue.statusDone;
|
||||
if (item.status === 'error') return UI_TEXT.queue.statusFailed;
|
||||
if (item.status === 'paused') return UI_TEXT.queue.statusPaused;
|
||||
if (item.status === 'downloading') return UI_TEXT.queue.statusRunning;
|
||||
return UI_TEXT.queue.statusWaiting;
|
||||
}
|
||||
|
||||
function getQueueProgressText(item: QueueItem): string {
|
||||
if (item.status === 'completed') return '100%';
|
||||
if (item.status === 'error') return UI_TEXT.queue.progressError;
|
||||
if (item.status === 'paused') return UI_TEXT.queue.progressReady;
|
||||
if (item.status === 'pending') return UI_TEXT.queue.progressReady;
|
||||
if (item.progress > 0) return `${Math.max(0, Math.min(100, item.progress)).toFixed(1)}%`;
|
||||
return item.progressStatus || UI_TEXT.queue.progressLoading;
|
||||
}
|
||||
|
||||
function getQueueMetaText(item: QueueItem): string {
|
||||
if (item.status === 'error' && item.last_error) {
|
||||
return item.last_error;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (item.currentPart && item.totalParts) {
|
||||
parts.push(`${UI_TEXT.queue.part} ${item.currentPart}/${item.totalParts}`);
|
||||
}
|
||||
|
||||
if (item.speed) {
|
||||
parts.push(`${UI_TEXT.queue.speed}: ${item.speed}`);
|
||||
}
|
||||
|
||||
if (item.eta) {
|
||||
parts.push(`${UI_TEXT.queue.eta}: ${item.eta}`);
|
||||
}
|
||||
|
||||
if (!parts.length && item.status === 'pending') {
|
||||
parts.push(UI_TEXT.queue.readyToDownload);
|
||||
}
|
||||
|
||||
if (!parts.length && item.status === 'paused') {
|
||||
parts.push(UI_TEXT.queue.statusPaused);
|
||||
}
|
||||
|
||||
if (!parts.length && item.status === 'downloading') {
|
||||
parts.push(item.progressStatus || UI_TEXT.queue.started);
|
||||
}
|
||||
|
||||
if (!parts.length && item.status === 'completed') {
|
||||
parts.push(UI_TEXT.queue.done);
|
||||
}
|
||||
|
||||
if (!parts.length && item.status === 'error') {
|
||||
parts.push(UI_TEXT.queue.failed);
|
||||
}
|
||||
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
function toggleQueueSelection(id: string): void {
|
||||
const index = selectedQueueIds.indexOf(id);
|
||||
if (index >= 0) {
|
||||
selectedQueueIds.splice(index, 1);
|
||||
} else {
|
||||
selectedQueueIds.push(id);
|
||||
}
|
||||
renderQueue();
|
||||
updateMergeGroupButton();
|
||||
}
|
||||
|
||||
function updateMergeGroupButton(): void {
|
||||
const btn = byId<HTMLButtonElement>('btnMergeGroup');
|
||||
if (!btn) return;
|
||||
|
||||
// Clean up selections: only keep IDs that are still pending in queue
|
||||
const validIds = new Set(
|
||||
queue.filter(item => item.status === 'pending' && !item.mergeGroup).map(item => item.id)
|
||||
);
|
||||
selectedQueueIds = selectedQueueIds.filter(id => validIds.has(id));
|
||||
|
||||
if (selectedQueueIds.length >= 2) {
|
||||
btn.classList.remove('is-hidden');
|
||||
btn.textContent = `${UI_TEXT.mergeGroup.btn} (${selectedQueueIds.length})`;
|
||||
btn.disabled = false;
|
||||
} else {
|
||||
btn.classList.add('is-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function createMergeGroupFromSelection(): Promise<void> {
|
||||
if (selectedQueueIds.length < 2) return;
|
||||
|
||||
const ids = [...selectedQueueIds];
|
||||
selectedQueueIds = [];
|
||||
queue = await window.api.createMergeGroup(ids);
|
||||
renderQueue();
|
||||
updateMergeGroupButton();
|
||||
}
|
||||
|
||||
function updateQueueItemProgress(progress: DownloadProgress): void {
|
||||
// Lookup by data-id attribute, not array index — survives queue mutation between renders
|
||||
const safeId = String(progress.id ?? '').replace(/"/g, '\\"');
|
||||
if (!safeId) return;
|
||||
const el = byId('queueList').querySelector(`[data-id="${safeId}"]`) as HTMLElement | null;
|
||||
if (!el) return;
|
||||
|
||||
const item = queue.find(i => i.id === progress.id);
|
||||
if (!item) return;
|
||||
|
||||
const bar = el.querySelector('.queue-progress-bar') as HTMLElement | null;
|
||||
const wrap = el.querySelector('.queue-progress-wrap') as HTMLElement | null;
|
||||
const text = el.querySelector('.queue-progress-text') as HTMLElement | null;
|
||||
const meta = el.querySelector('.queue-meta') as HTMLElement | null;
|
||||
|
||||
if (bar) {
|
||||
const isDeterminate = progress.progress > 0 && progress.progress <= 100;
|
||||
const pct = isDeterminate ? Math.min(100, progress.progress) : 0;
|
||||
bar.style.width = `${pct}%`;
|
||||
bar.className = `queue-progress-bar${isDeterminate ? '' : ' indeterminate'}`;
|
||||
if (wrap) wrap.setAttribute('aria-valuenow', String(Math.round(pct)));
|
||||
}
|
||||
if (text) text.textContent = getQueueProgressText(item);
|
||||
if (meta) meta.textContent = getQueueMetaText(item);
|
||||
}
|
||||
|
||||
function toggleQueueDetails(id: string): void {
|
||||
if (expandedQueueIds.has(id)) {
|
||||
expandedQueueIds.delete(id);
|
||||
} else {
|
||||
expandedQueueIds.add(id);
|
||||
}
|
||||
renderQueue();
|
||||
}
|
||||
|
||||
function initQueueDragDrop(): void {
|
||||
if (queueDragDropInitialized) return;
|
||||
queueDragDropInitialized = true;
|
||||
|
||||
const list = byId('queueList');
|
||||
|
||||
list.addEventListener('dragstart', (e: DragEvent) => {
|
||||
const el = (e.target as HTMLElement).closest('.queue-item') as HTMLElement;
|
||||
if (!el) return;
|
||||
// Prevent dragging items that are no longer pending (race window between status change and re-render)
|
||||
const itemId = el.dataset.id;
|
||||
if (itemId) {
|
||||
const item = queue.find(i => i.id === itemId);
|
||||
if (!item || item.status !== 'pending') {
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'none';
|
||||
e.dataTransfer.clearData();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
draggedQueueItemId = el.dataset.id || null;
|
||||
el.classList.add('dragging');
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
|
||||
list.addEventListener('dragover', (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
});
|
||||
|
||||
list.addEventListener('drop', (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
const target = (e.target as HTMLElement).closest('.queue-item') as HTMLElement;
|
||||
if (!target || !draggedQueueItemId) return;
|
||||
const targetId = target.dataset.id;
|
||||
if (!targetId || targetId === draggedQueueItemId) return;
|
||||
|
||||
const fromIdx = queue.findIndex(i => i.id === draggedQueueItemId);
|
||||
const toIdx = queue.findIndex(i => i.id === targetId);
|
||||
if (fromIdx < 0 || toIdx < 0) return;
|
||||
const [moved] = queue.splice(fromIdx, 1);
|
||||
queue.splice(toIdx, 0, moved);
|
||||
window.api.reorderQueue(queue.map(i => i.id));
|
||||
renderQueue();
|
||||
});
|
||||
|
||||
list.addEventListener('dragend', () => {
|
||||
draggedQueueItemId = null;
|
||||
document.querySelectorAll('.queue-item.dragging').forEach(el => el.classList.remove('dragging'));
|
||||
});
|
||||
}
|
||||
|
||||
function renderQueue(): void {
|
||||
if (!Array.isArray(queue)) {
|
||||
queue = [];
|
||||
}
|
||||
|
||||
const list = byId('queueList');
|
||||
byId('queueCount').textContent = String(queue.length);
|
||||
const retryBtn = byId<HTMLButtonElement>('btnRetryFailed');
|
||||
const hasFailed = queue.some((item) => item.status === 'error');
|
||||
retryBtn.disabled = !hasFailed;
|
||||
|
||||
const renderFingerprint = getQueueRenderFingerprint(queue);
|
||||
if (renderFingerprint === lastQueueRenderFingerprint) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (queue.length === 0) {
|
||||
lastQueueRenderFingerprint = renderFingerprint;
|
||||
// Build the empty state via createElement to keep the renderer
|
||||
// clean of inline-style HTML strings (which the lint hook
|
||||
// flags as a potential XSS surface). The CSS for .queue-empty
|
||||
// lives in styles.css.
|
||||
list.replaceChildren();
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'queue-empty';
|
||||
empty.textContent = UI_TEXT.queue.empty;
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = queue.map((item: QueueItem) => {
|
||||
const safeTitle = escapeHtml(item.title || UI_TEXT.vods.untitled);
|
||||
const safeStatusLabel = escapeHtml(getQueueStatusLabel(item));
|
||||
const safeProgressText = escapeHtml(getQueueProgressText(item));
|
||||
const safeMeta = escapeHtml(getQueueMetaText(item));
|
||||
const isClip = item.customClip ? '* ' : '';
|
||||
const hasDeterminateProgress = item.progress > 0 && item.progress <= 100;
|
||||
const progressValue = item.status === 'completed'
|
||||
? 100
|
||||
: (hasDeterminateProgress ? Math.max(0, Math.min(100, item.progress)) : 0);
|
||||
const progressClass = item.status === 'downloading' && !hasDeterminateProgress ? ' indeterminate' : '';
|
||||
|
||||
const isMergeGroup = !!item.mergeGroup;
|
||||
const showSelector = item.status === 'pending' && !isMergeGroup && !item.isLive;
|
||||
const selectionIndex = selectedQueueIds.indexOf(item.id);
|
||||
const isSelected = selectionIndex >= 0;
|
||||
const mergeIcon = isMergeGroup
|
||||
? '<svg class="merge-group-icon" aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg> '
|
||||
: '';
|
||||
const liveBadge = item.isLive
|
||||
? `<span class="queue-live-badge" title="${escapeHtml(UI_TEXT.queue.liveRecordingTitle)}">REC</span> `
|
||||
: '';
|
||||
const healthBadge = (item.isLive && item.status === 'downloading')
|
||||
? renderRecordingHealthBadge(item.recordingHealth)
|
||||
: '';
|
||||
const mergeMetaExtra = isMergeGroup
|
||||
? ` (${UI_TEXT.mergeGroup.metaLabel.replace('{count}', String(item.mergeGroup!.items.length))})`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="queue-item${isMergeGroup ? ' merge-group' : ''}" draggable="${item.status === 'pending' ? 'true' : 'false'}" data-id="${item.id}">
|
||||
${showSelector
|
||||
? `<div class="queue-selector${isSelected ? ' selected' : ''}" role="checkbox" tabindex="0" aria-checked="${isSelected ? 'true' : 'false'}" onclick="toggleQueueSelection('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleQueueSelection('${item.id}');}">${isSelected ? selectionIndex + 1 : ''}</div>`
|
||||
: ''
|
||||
}
|
||||
<div class="status ${item.status}"></div>
|
||||
<div class="queue-main">
|
||||
<div class="queue-title-row">
|
||||
<div class="title" title="${safeTitle}" role="button" tabindex="0" aria-expanded="${expandedQueueIds.has(item.id) ? 'true' : 'false'}" aria-controls="details-${item.id}" onclick="toggleQueueDetails('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleQueueDetails('${item.id}');}">${liveBadge}${healthBadge}${mergeIcon}${isClip}${safeTitle}</div>
|
||||
<div class="queue-status-label">${safeStatusLabel}</div>
|
||||
</div>
|
||||
<div class="queue-meta">${safeMeta}${mergeMetaExtra}</div>
|
||||
<div class="queue-progress-wrap" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(progressValue)}" aria-label="${escapeHtml(safeStatusLabel)}">
|
||||
<div class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div>
|
||||
</div>
|
||||
<div class="queue-progress-text">${safeProgressText}</div>
|
||||
<div class="queue-details${expandedQueueIds.has(item.id) ? ' expanded' : ''}" id="details-${item.id}">
|
||||
<div><span class="queue-detail-label">URL:</span> ${escapeHtml(item.url)}</div>
|
||||
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailStreamer)}</span> ${escapeHtml(item.streamer)}</div>
|
||||
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailDuration)}</span> ${escapeHtml(item.duration_str)}</div>
|
||||
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailDate)}</span> ${escapeHtml(new Date(item.date).toLocaleString())}</div>
|
||||
${renderQueueItemFileActions(item)}
|
||||
</div>
|
||||
</div>
|
||||
${item.status === 'error' ? `<button class="queue-retry-btn" type="button" title="${escapeHtml(UI_TEXT.queue.retryItem)}" aria-label="${escapeHtml(UI_TEXT.queue.retryItem)}" onclick="retryQueueItem('${item.id}')">↻</button>` : ''}
|
||||
<span class="remove" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.streamers.removeAria)}" onclick="removeFromQueue('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();removeFromQueue('${item.id}');}">x</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
updateMergeGroupButton();
|
||||
initQueueContextMenu();
|
||||
lastQueueRenderFingerprint = renderFingerprint;
|
||||
}
|
||||
|
||||
async function toggleDownload(): Promise<void> {
|
||||
if (downloading) {
|
||||
await window.api.pauseDownload();
|
||||
return;
|
||||
}
|
||||
|
||||
const started = await window.api.startDownload();
|
||||
if (!started) {
|
||||
renderQueue();
|
||||
alert(UI_TEXT.queue.emptyAlert);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,984 @@
|
||||
let lastRuntimeMetricsOutput = '';
|
||||
let lastDebugLogOutput = '';
|
||||
let settingsAutoSaveBound = false;
|
||||
let settingsAutoSaveInFlight = false;
|
||||
let pendingSettingsAutoSave = false;
|
||||
let settingsAutoSaveTimer: number | null = null;
|
||||
let pendingCredentialsReconnect = false;
|
||||
let lastPersistedSettingsFingerprint = '';
|
||||
|
||||
function canRunSettingsAutoRefresh(): boolean {
|
||||
if (document.hidden) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return document.querySelector('.tab-content.active')?.id === 'settingsTab';
|
||||
}
|
||||
|
||||
async function connect(): Promise<void> {
|
||||
const hasCredentials = Boolean((config.client_id ?? '').toString().trim() && (config.client_secret ?? '').toString().trim());
|
||||
if (!hasCredentials) {
|
||||
isConnected = false;
|
||||
updateStatus(UI_TEXT.status.noLogin, false);
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatus(UI_TEXT.status.connecting, false);
|
||||
const success = await window.api.login();
|
||||
isConnected = success;
|
||||
updateStatus(success ? UI_TEXT.status.connected : UI_TEXT.status.connectFailedPublic, success);
|
||||
}
|
||||
|
||||
function formatBytesForMetrics(bytes: number): string {
|
||||
const value = Math.max(0, Number(bytes) || 0);
|
||||
if (value < 1024) return `${value.toFixed(0)} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function validateFilenameTemplates(showAlert = false): boolean {
|
||||
const templates = [
|
||||
byId<HTMLInputElement>('vodFilenameTemplate').value.trim(),
|
||||
byId<HTMLInputElement>('partsFilenameTemplate').value.trim(),
|
||||
byId<HTMLInputElement>('defaultClipFilenameTemplate').value.trim()
|
||||
];
|
||||
|
||||
const unknown = templates.flatMap((template) => collectUnknownTemplatePlaceholders(template));
|
||||
const uniqueUnknown = Array.from(new Set(unknown));
|
||||
const lintNode = byId('filenameTemplateLint');
|
||||
|
||||
if (!uniqueUnknown.length) {
|
||||
lintNode.className = 'template-lint ok';
|
||||
lintNode.textContent = UI_TEXT.static.templateLintOk;
|
||||
return true;
|
||||
}
|
||||
|
||||
lintNode.className = 'template-lint warn';
|
||||
lintNode.textContent = `${UI_TEXT.static.templateLintWarn}: ${uniqueUnknown.join(' ')}`;
|
||||
|
||||
if (showAlert) {
|
||||
alert(`${UI_TEXT.static.templateLintWarn}: ${uniqueUnknown.join(' ')}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function applyTemplatePreset(preset: string): void {
|
||||
const presets: Record<string, { vod: string; parts: string; clip: string }> = {
|
||||
default: {
|
||||
vod: '{title}.mp4',
|
||||
parts: '{date}_Part{part_padded}.mp4',
|
||||
clip: '{date}_{part}.mp4'
|
||||
},
|
||||
archive: {
|
||||
vod: '{channel}_{date_custom="yyyy-MM-dd"}_{title}.mp4',
|
||||
parts: '{channel}_{date_custom="yyyy-MM-dd"}_Part{part_padded}.mp4',
|
||||
clip: '{channel}_{date_custom="yyyy-MM-dd"}_{trim_start}_{part}.mp4'
|
||||
},
|
||||
clipper: {
|
||||
vod: '{date_custom="yyyy-MM-dd"}_{title}.mp4',
|
||||
parts: '{date_custom="yyyy-MM-dd"}_{part_padded}_{trim_start}.mp4',
|
||||
clip: '{title}_{trim_start_custom="HH-mm-ss"}_{part}.mp4'
|
||||
}
|
||||
};
|
||||
|
||||
const selected = presets[preset] || presets.default;
|
||||
byId<HTMLInputElement>('vodFilenameTemplate').value = selected.vod;
|
||||
byId<HTMLInputElement>('partsFilenameTemplate').value = selected.parts;
|
||||
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = selected.clip;
|
||||
validateFilenameTemplates();
|
||||
// Programmatic .value = ... does not trigger the 'input' event the
|
||||
// template inputs listen on for debounced save, so the preset click
|
||||
// would otherwise look applied but never persist until the user
|
||||
// types into one of the inputs. Schedule the save explicitly.
|
||||
scheduleSettingsAutoSave();
|
||||
}
|
||||
|
||||
async function refreshRuntimeMetrics(showLoading = true): Promise<void> {
|
||||
const output = byId('runtimeMetricsOutput');
|
||||
if (showLoading) {
|
||||
output.textContent = UI_TEXT.static.runtimeMetricsLoading;
|
||||
}
|
||||
|
||||
try {
|
||||
const metrics = await window.api.getRuntimeMetrics();
|
||||
const lines = [
|
||||
`${UI_TEXT.static.runtimeMetricQueue}: ${metrics.queue.total} total (${metrics.queue.pending} pending, ${metrics.queue.downloading} downloading, ${metrics.queue.error} failed)`,
|
||||
`${UI_TEXT.static.runtimeMetricMode}: ${metrics.config.performanceMode} | smartScheduler=${metrics.config.smartScheduler} | dedupe=${metrics.config.duplicatePrevention}`,
|
||||
`${UI_TEXT.static.runtimeMetricRetries}: ${metrics.retriesScheduled} scheduled, ${metrics.retriesExhausted} exhausted`,
|
||||
`${UI_TEXT.static.runtimeMetricIntegrity}: ${metrics.integrityFailures}`,
|
||||
`${UI_TEXT.static.runtimeMetricCache}: hits=${metrics.cacheHits}, misses=${metrics.cacheMisses}, vod=${metrics.caches.vodList}, users=${metrics.caches.loginToUserId}, clips=${metrics.caches.clipInfo}`,
|
||||
`${UI_TEXT.static.runtimeMetricBandwidth}: current=${formatBytesForMetrics(metrics.lastSpeedBytesPerSec)}/s, avg=${formatBytesForMetrics(metrics.avgSpeedBytesPerSec)}/s`,
|
||||
`${UI_TEXT.static.runtimeMetricDownloads}: started=${metrics.downloadsStarted}, done=${metrics.downloadsCompleted}, failed=${metrics.downloadsFailed}, bytes=${formatBytesForMetrics(metrics.downloadedBytesTotal)}`,
|
||||
`${UI_TEXT.static.runtimeMetricActive}: ${metrics.activeItemTitle || '-'} (${metrics.activeItemId || '-'})`,
|
||||
`${UI_TEXT.static.runtimeMetricLastError}: ${metrics.lastErrorClass || '-'}, retryDelay=${metrics.lastRetryDelaySeconds}s`,
|
||||
`${UI_TEXT.static.runtimeMetricUpdated}: ${new Date(metrics.timestamp).toLocaleString(currentLanguage === 'en' ? 'en-US' : 'de-DE')}`
|
||||
];
|
||||
|
||||
const nextOutput = lines.join('\n');
|
||||
if (nextOutput !== lastRuntimeMetricsOutput) {
|
||||
output.textContent = nextOutput;
|
||||
lastRuntimeMetricsOutput = nextOutput;
|
||||
}
|
||||
} catch {
|
||||
if (lastRuntimeMetricsOutput !== UI_TEXT.static.runtimeMetricsError) {
|
||||
output.textContent = UI_TEXT.static.runtimeMetricsError;
|
||||
lastRuntimeMetricsOutput = UI_TEXT.static.runtimeMetricsError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function exportRuntimeMetrics(): Promise<void> {
|
||||
const result = await window.api.exportRuntimeMetrics();
|
||||
|
||||
const toast = (window as unknown as { showAppToast?: (message: string, type?: 'info' | 'warn') => void }).showAppToast;
|
||||
const notify = (message: string, type: 'info' | 'warn' = 'info') => {
|
||||
if (typeof toast === 'function') {
|
||||
toast(message, type);
|
||||
} else if (type === 'warn') {
|
||||
alert(message);
|
||||
}
|
||||
};
|
||||
|
||||
if (result.success) {
|
||||
notify(UI_TEXT.static.runtimeMetricsExportDone, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.cancelled) {
|
||||
notify(UI_TEXT.static.runtimeMetricsExportCancelled, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
notify(`${UI_TEXT.static.runtimeMetricsExportFailed}${result.error ? `\n${result.error}` : ''}`, 'warn');
|
||||
}
|
||||
|
||||
function toggleRuntimeMetricsAutoRefresh(enabled: boolean): void {
|
||||
if (runtimeMetricsAutoRefreshTimer) {
|
||||
clearInterval(runtimeMetricsAutoRefreshTimer);
|
||||
runtimeMetricsAutoRefreshTimer = null;
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
runtimeMetricsAutoRefreshTimer = window.setInterval(() => {
|
||||
if (!canRunSettingsAutoRefresh()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshRuntimeMetrics(false);
|
||||
void refreshAutomationStatusLine();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatus(text: string, connected: boolean): void {
|
||||
byId('statusText').textContent = text;
|
||||
const dot = byId('statusDot');
|
||||
dot.classList.remove('connected', 'error');
|
||||
dot.classList.add(connected ? 'connected' : 'error');
|
||||
}
|
||||
|
||||
function changeLanguage(lang: string): void {
|
||||
const normalized = setLanguage(lang);
|
||||
byId<HTMLSelectElement>('languageSelect').value = normalized;
|
||||
updateLanguagePicker(normalized);
|
||||
config.language = normalized;
|
||||
void window.api.saveConfig({ language: normalized });
|
||||
|
||||
const currentStatus = byId('statusText').textContent?.trim() || '';
|
||||
updateStatus(localizeCurrentStatusText(currentStatus), isConnected);
|
||||
|
||||
renderQueue();
|
||||
renderStreamers();
|
||||
// Re-render the VOD grid so the dynamically built button labels
|
||||
// (trim / queue) and the filter empty-state pick up the new locale.
|
||||
renderVodGridFromCurrentState();
|
||||
refreshVodSortSelectLabels();
|
||||
|
||||
const activeTabId = document.querySelector('.tab-content.active')?.id || 'vodsTab';
|
||||
const activeTab = activeTabId.replace('Tab', '');
|
||||
const titleText = (activeTab === 'vods' && currentStreamer)
|
||||
? currentStreamer
|
||||
: ((UI_TEXT.tabs as Record<string, string>)[activeTab] || UI_TEXT.appName);
|
||||
const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle;
|
||||
if (typeof setTitle === 'function') setTitle(titleText);
|
||||
else byId('pageTitle').textContent = titleText;
|
||||
|
||||
void refreshRuntimeMetrics();
|
||||
void refreshAutomationStatusLine();
|
||||
validateFilenameTemplates();
|
||||
}
|
||||
|
||||
function updateLanguagePicker(lang: string): void {
|
||||
const de = byId<HTMLButtonElement>('langOptionDe');
|
||||
const en = byId<HTMLButtonElement>('langOptionEn');
|
||||
|
||||
const isDe = lang === 'de';
|
||||
de.classList.toggle('active', isDe);
|
||||
en.classList.toggle('active', !isDe);
|
||||
de.setAttribute('aria-pressed', String(isDe));
|
||||
en.setAttribute('aria-pressed', String(!isDe));
|
||||
}
|
||||
|
||||
function selectLanguageOption(lang: string): void {
|
||||
changeLanguage(lang);
|
||||
}
|
||||
|
||||
function renderPreflightResult(result: PreflightResult): void {
|
||||
const entries = [
|
||||
[UI_TEXT.static.preflightInternet, result.checks.internet],
|
||||
[UI_TEXT.static.preflightStreamlink, result.checks.streamlink],
|
||||
[UI_TEXT.static.preflightFfmpeg, result.checks.ffmpeg],
|
||||
[UI_TEXT.static.preflightFfprobe, result.checks.ffprobe],
|
||||
[UI_TEXT.static.preflightPath, result.checks.downloadPathWritable]
|
||||
];
|
||||
|
||||
const lines = entries.map(([name, ok]) => `${ok ? 'OK' : 'FAIL'} ${name}`).join('\n');
|
||||
const extra = result.messages.length ? `\n\n${result.messages.join('\n')}` : `\n\n${UI_TEXT.static.preflightReady}`;
|
||||
|
||||
byId('preflightResult').textContent = `${lines}${extra}`;
|
||||
|
||||
const badge = byId('healthBadge');
|
||||
badge.classList.remove('good', 'warn', 'bad', 'unknown');
|
||||
|
||||
if (result.ok) {
|
||||
badge.classList.add('good');
|
||||
badge.textContent = UI_TEXT.static.healthGood;
|
||||
return;
|
||||
}
|
||||
|
||||
const failCount = Object.values(result.checks).filter((ok) => !ok).length;
|
||||
if (failCount <= 2) {
|
||||
badge.classList.add('warn');
|
||||
badge.textContent = UI_TEXT.static.healthWarn;
|
||||
} else {
|
||||
badge.classList.add('bad');
|
||||
badge.textContent = UI_TEXT.static.healthBad;
|
||||
}
|
||||
}
|
||||
|
||||
async function runPreflight(autoFix = false): Promise<void> {
|
||||
const btn = byId<HTMLButtonElement>(autoFix ? 'btnPreflightFix' : 'btnPreflightRun');
|
||||
const old = btn.textContent || '';
|
||||
btn.disabled = true;
|
||||
btn.textContent = autoFix ? UI_TEXT.static.preflightFixing : UI_TEXT.static.preflightChecking;
|
||||
|
||||
try {
|
||||
const result = await window.api.runPreflight(autoFix);
|
||||
renderPreflightResult(result);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = old;
|
||||
}
|
||||
}
|
||||
|
||||
async function runCleanupDryRun(): Promise<void> {
|
||||
await runCleanupOnce(true);
|
||||
}
|
||||
|
||||
async function runCleanupNow(): Promise<void> {
|
||||
await runCleanupOnce(false);
|
||||
}
|
||||
|
||||
async function runCleanupOnce(dryRun: boolean): Promise<void> {
|
||||
const reportEl = byId('cleanupReport');
|
||||
const dryBtn = byId<HTMLButtonElement>('btnCleanupDryRun');
|
||||
const runBtn = byId<HTMLButtonElement>('btnCleanupRunNow');
|
||||
dryBtn.disabled = true;
|
||||
runBtn.disabled = true;
|
||||
reportEl.textContent = UI_TEXT.static.storageScanning;
|
||||
|
||||
try {
|
||||
const report = await window.api.runStorageCleanup({ dryRun });
|
||||
if (report.candidates === 0) {
|
||||
reportEl.textContent = UI_TEXT.static.cleanupReportEmpty.replace('{days}', String(report.cutoffDays));
|
||||
} else if (dryRun) {
|
||||
reportEl.textContent = UI_TEXT.static.cleanupReportPreview
|
||||
.replace('{count}', String(report.candidates))
|
||||
.replace('{size}', formatBytesForMetrics(report.bytesFreed));
|
||||
} else {
|
||||
const failedSuffix = report.failed > 0
|
||||
? UI_TEXT.static.cleanupReportFailedSuffix.replace('{failed}', String(report.failed))
|
||||
: '';
|
||||
reportEl.textContent = UI_TEXT.static.cleanupReportDone
|
||||
.replace('{count}', String(report.processed))
|
||||
.replace('{size}', formatBytesForMetrics(report.bytesFreed))
|
||||
.replace('{failed}', failedSuffix);
|
||||
// Refresh the storage list since files moved/disappeared.
|
||||
void refreshStorageStats();
|
||||
}
|
||||
} catch (e) {
|
||||
reportEl.textContent = String(e);
|
||||
} finally {
|
||||
dryBtn.disabled = false;
|
||||
runBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStorageStats(): Promise<void> {
|
||||
const summary = byId('storageSummary');
|
||||
const list = byId('storageList');
|
||||
const btn = byId<HTMLButtonElement>('btnRefreshStorage');
|
||||
const old = btn.textContent || '';
|
||||
btn.disabled = true;
|
||||
btn.textContent = UI_TEXT.static.storageScanning;
|
||||
summary.textContent = UI_TEXT.static.storageScanning;
|
||||
list.replaceChildren();
|
||||
|
||||
try {
|
||||
const stats = await window.api.getStorageStats();
|
||||
renderStorageStats(stats);
|
||||
} catch {
|
||||
summary.textContent = UI_TEXT.static.storageEmpty;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = old || UI_TEXT.static.storageRefresh;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStorageStats(stats: StorageStatsResult): void {
|
||||
const summary = byId('storageSummary');
|
||||
const list = byId('storageList');
|
||||
|
||||
if (!stats.rootExists) {
|
||||
summary.textContent = UI_TEXT.static.storageEmpty;
|
||||
list.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
summary.textContent = UI_TEXT.static.storageSummary
|
||||
.replace('{files}', String(stats.totalFiles))
|
||||
.replace('{size}', formatBytesForMetrics(stats.totalBytes))
|
||||
.replace('{free}', stats.freeBytes !== null ? formatBytesForMetrics(stats.freeBytes) : '-');
|
||||
|
||||
list.replaceChildren();
|
||||
if (stats.streamers.length === 0 && stats.extras.length === 0) return;
|
||||
|
||||
const buildTable = (rows: StreamerStorageEntry[]): HTMLTableElement => {
|
||||
const table = document.createElement('table');
|
||||
table.className = 'storage-stats-table';
|
||||
|
||||
const thead = document.createElement('thead');
|
||||
const headRow = document.createElement('tr');
|
||||
const headers = [
|
||||
UI_TEXT.static.storageColumnFolder,
|
||||
UI_TEXT.static.storageColumnFiles,
|
||||
UI_TEXT.static.storageColumnTotal,
|
||||
UI_TEXT.static.storageColumnLive,
|
||||
UI_TEXT.static.storageColumnChat,
|
||||
''
|
||||
];
|
||||
for (const h of headers) {
|
||||
const th = document.createElement('th');
|
||||
th.scope = 'col';
|
||||
if (h) {
|
||||
th.textContent = h;
|
||||
} else {
|
||||
th.setAttribute('aria-label', UI_TEXT.static.storageColumnActionsAria);
|
||||
}
|
||||
headRow.appendChild(th);
|
||||
}
|
||||
thead.appendChild(headRow);
|
||||
table.appendChild(thead);
|
||||
|
||||
const tbody = document.createElement('tbody');
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
const cells: Array<string | HTMLElement> = [
|
||||
row.name,
|
||||
String(row.fileCount),
|
||||
formatBytesForMetrics(row.totalBytes),
|
||||
row.liveBytes > 0 ? formatBytesForMetrics(row.liveBytes) : '-',
|
||||
row.chatBytes > 0 ? formatBytesForMetrics(row.chatBytes) : '-'
|
||||
];
|
||||
for (const c of cells) {
|
||||
const td = document.createElement('td');
|
||||
if (typeof c === 'string') td.textContent = c;
|
||||
else td.appendChild(c);
|
||||
tr.appendChild(td);
|
||||
}
|
||||
const openCell = document.createElement('td');
|
||||
const openBtn = document.createElement('button');
|
||||
openBtn.type = 'button';
|
||||
openBtn.textContent = UI_TEXT.static.storageOpen;
|
||||
openBtn.className = 'btn-pill';
|
||||
openBtn.addEventListener('click', () => {
|
||||
void window.api.openFolder(row.folderPath);
|
||||
});
|
||||
openCell.appendChild(openBtn);
|
||||
tr.appendChild(openCell);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
table.appendChild(tbody);
|
||||
return table;
|
||||
};
|
||||
|
||||
if (stats.streamers.length > 0) {
|
||||
list.appendChild(buildTable(stats.streamers));
|
||||
}
|
||||
if (stats.extras.length > 0) {
|
||||
const heading = document.createElement('div');
|
||||
heading.textContent = UI_TEXT.static.storageOtherFolders;
|
||||
heading.className = 'storage-stats-section';
|
||||
list.appendChild(heading);
|
||||
list.appendChild(buildTable(stats.extras));
|
||||
}
|
||||
}
|
||||
|
||||
async function exportConfigToFile(): Promise<void> {
|
||||
const result = await window.api.exportConfig();
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (result.success) {
|
||||
if (toast) toast(UI_TEXT.static.configExported, 'info');
|
||||
} else if (result.cancelled) {
|
||||
// User cancelled the dialog — no toast needed.
|
||||
} else if (toast) {
|
||||
toast(UI_TEXT.static.configExportFailed + (result.error ? `\n${result.error}` : ''), 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
async function importConfigFromFile(): Promise<void> {
|
||||
const result = await window.api.importConfig();
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (result.success) {
|
||||
// Reload local config copy + refresh forms / streamer list / VOD grid
|
||||
try {
|
||||
config = await window.api.getConfig();
|
||||
if (typeof setLanguage === 'function' && typeof config.language === 'string') {
|
||||
setLanguage(config.language);
|
||||
}
|
||||
if (typeof renderStreamers === 'function') renderStreamers();
|
||||
if (typeof syncSettingsFormFromConfig === 'function') syncSettingsFormFromConfig();
|
||||
if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) {
|
||||
renderVodGridFromCurrentState();
|
||||
}
|
||||
} catch { /* ignore — next refresh will catch up */ }
|
||||
if (toast) toast(UI_TEXT.static.configImported, 'info');
|
||||
} else if (result.cancelled) {
|
||||
// User cancelled the dialog — no toast needed.
|
||||
} else if (toast) {
|
||||
toast(UI_TEXT.static.configImportFailed + (result.error ? `\n${result.error}` : ''), 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDownloadedIds(): Promise<void> {
|
||||
if (!confirm(UI_TEXT.static.resetDownloadedConfirm)) return;
|
||||
const result = await window.api.resetDownloadedVodIds();
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (result.success) {
|
||||
// Refresh local config so the badges disappear immediately
|
||||
try {
|
||||
config = await window.api.getConfig();
|
||||
if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) {
|
||||
renderVodGridFromCurrentState();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
if (toast) {
|
||||
toast(UI_TEXT.static.resetDownloadedDone.replace('{count}', String(result.removedCount)), 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openDebugLogFile(): Promise<void> {
|
||||
const ok = await window.api.openDebugLogFile();
|
||||
if (!ok) {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (toast) toast('Debug log file not yet present.', 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDebugLog(): Promise<void> {
|
||||
const text = await window.api.getDebugLog(250);
|
||||
const panel = byId('debugLogOutput');
|
||||
const keepAtBottom = (panel.scrollHeight - panel.scrollTop - panel.clientHeight) < 20;
|
||||
|
||||
if (text !== lastDebugLogOutput) {
|
||||
panel.textContent = text;
|
||||
lastDebugLogOutput = text;
|
||||
}
|
||||
|
||||
if (keepAtBottom) {
|
||||
panel.scrollTop = panel.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDebugAutoRefresh(enabled: boolean): void {
|
||||
if (debugLogAutoRefreshTimer) {
|
||||
clearInterval(debugLogAutoRefreshTimer);
|
||||
debugLogAutoRefreshTimer = null;
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
debugLogAutoRefreshTimer = window.setInterval(() => {
|
||||
if (!canRunSettingsAutoRefresh()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshDebugLog();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function collectCredentialsPayload(): Partial<AppConfig> {
|
||||
return {
|
||||
client_id: byId<HTMLInputElement>('clientId').value.trim(),
|
||||
client_secret: byId<HTMLInputElement>('clientSecret').value.trim()
|
||||
};
|
||||
}
|
||||
|
||||
function syncPartMinutesFieldState(): void {
|
||||
const downloadMode = byId<HTMLSelectElement>('downloadMode').value;
|
||||
const partMinutes = byId<HTMLInputElement>('partMinutes');
|
||||
const label = byId<HTMLElement>('partMinutesLabel');
|
||||
const isSplitMode = downloadMode === 'parts';
|
||||
|
||||
partMinutes.disabled = !isSplitMode;
|
||||
partMinutes.setAttribute('aria-disabled', String(!isSplitMode));
|
||||
label.classList.toggle('input-disabled', !isSplitMode);
|
||||
}
|
||||
|
||||
function collectDownloadSettingsPayload(): Partial<AppConfig> {
|
||||
return {
|
||||
download_mode: byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full',
|
||||
part_minutes: parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120,
|
||||
parallel_downloads: parseInt(byId<HTMLSelectElement>('parallelDownloads').value, 10) || 1,
|
||||
performance_mode: byId<HTMLSelectElement>('performanceMode').value as 'stability' | 'balanced' | 'speed',
|
||||
smart_queue_scheduler: byId<HTMLInputElement>('smartSchedulerToggle').checked,
|
||||
prevent_duplicate_downloads: byId<HTMLInputElement>('duplicatePreventionToggle').checked,
|
||||
persist_queue_on_restart: byId<HTMLInputElement>('persistQueueToggle').checked,
|
||||
auto_resume_queue_on_startup: byId<HTMLInputElement>('autoResumeQueueToggle').checked,
|
||||
notify_on_each_completion: byId<HTMLInputElement>('notifyEachCompletionToggle').checked,
|
||||
streamlink_disable_ads: byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked,
|
||||
download_chat_replay: byId<HTMLInputElement>('downloadChatReplayToggle').checked,
|
||||
capture_live_chat: byId<HTMLInputElement>('captureLiveChatToggle').checked,
|
||||
log_stream_events: byId<HTMLInputElement>('logStreamEventsToggle').checked,
|
||||
auto_resume_live_recording: byId<HTMLInputElement>('autoResumeLiveRecordingToggle').checked,
|
||||
auto_merge_resumed_parts: byId<HTMLInputElement>('autoMergeResumedPartsToggle').checked,
|
||||
delete_parts_after_merge: byId<HTMLInputElement>('deletePartsAfterMergeToggle').checked,
|
||||
discord_webhook_url: byId<HTMLInputElement>('discordWebhookUrl').value.trim(),
|
||||
discord_notify_live_start: byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked,
|
||||
discord_notify_live_end: byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked,
|
||||
discord_notify_vod_complete: byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked,
|
||||
discord_notify_vod_auto_queued: byId<HTMLInputElement>('discordNotifyVodAutoQueuedToggle').checked,
|
||||
auto_vod_download_poll_minutes: parseInt(byId<HTMLInputElement>('autoVodPollMinutes').value, 10) || 15,
|
||||
auto_vod_max_age_hours: parseInt(byId<HTMLInputElement>('autoVodMaxAgeHours').value, 10) || 24,
|
||||
auto_cleanup_enabled: byId<HTMLInputElement>('autoCleanupEnabledToggle').checked,
|
||||
auto_cleanup_days: parseInt(byId<HTMLInputElement>('autoCleanupDays').value, 10) || 30,
|
||||
auto_cleanup_target: byId<HTMLSelectElement>('autoCleanupTarget').value === 'all' ? 'all' : 'live_only',
|
||||
auto_cleanup_action: byId<HTMLSelectElement>('autoCleanupAction').value === 'delete' ? 'delete' : 'archive',
|
||||
streamlink_quality: byId<HTMLSelectElement>('streamlinkQuality').value,
|
||||
metadata_cache_minutes: parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10
|
||||
};
|
||||
}
|
||||
|
||||
function collectFilenameTemplatePayload(showAlert = false): Partial<AppConfig> | null {
|
||||
if (!validateFilenameTemplates(showAlert)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
filename_template_vod: byId<HTMLInputElement>('vodFilenameTemplate').value.trim() || '{title}.mp4',
|
||||
filename_template_parts: byId<HTMLInputElement>('partsFilenameTemplate').value.trim() || '{date}_Part{part_padded}.mp4',
|
||||
filename_template_clip: byId<HTMLInputElement>('defaultClipFilenameTemplate').value.trim() || '{date}_{part}.mp4'
|
||||
};
|
||||
}
|
||||
|
||||
function collectAutoSavePayload(): Partial<AppConfig> {
|
||||
const payload: Partial<AppConfig> = {
|
||||
...collectCredentialsPayload(),
|
||||
...collectDownloadSettingsPayload()
|
||||
};
|
||||
|
||||
const templatePayload = collectFilenameTemplatePayload(false);
|
||||
if (templatePayload) {
|
||||
Object.assign(payload, templatePayload);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function getSettingsFingerprint(payload: Partial<AppConfig>): string {
|
||||
const effective = { ...config, ...payload };
|
||||
return JSON.stringify([
|
||||
effective.client_id ?? '',
|
||||
effective.client_secret ?? '',
|
||||
effective.download_mode ?? 'full',
|
||||
effective.part_minutes ?? 120,
|
||||
effective.parallel_downloads ?? 1,
|
||||
effective.performance_mode ?? 'balanced',
|
||||
effective.smart_queue_scheduler !== false,
|
||||
effective.prevent_duplicate_downloads !== false,
|
||||
effective.persist_queue_on_restart !== false,
|
||||
effective.auto_resume_queue_on_startup === true,
|
||||
effective.notify_on_each_completion === true,
|
||||
effective.streamlink_disable_ads !== false,
|
||||
effective.download_chat_replay === true,
|
||||
effective.capture_live_chat === true,
|
||||
effective.log_stream_events !== false,
|
||||
effective.auto_resume_live_recording !== false,
|
||||
effective.auto_merge_resumed_parts === true,
|
||||
effective.delete_parts_after_merge === true,
|
||||
effective.discord_webhook_url ?? '',
|
||||
effective.discord_notify_live_start === true,
|
||||
effective.discord_notify_live_end === true,
|
||||
effective.discord_notify_vod_complete === true,
|
||||
effective.discord_notify_vod_auto_queued === true,
|
||||
effective.auto_vod_download_poll_minutes ?? 15,
|
||||
effective.auto_vod_max_age_hours ?? 24,
|
||||
effective.auto_cleanup_enabled === true,
|
||||
effective.auto_cleanup_days ?? 30,
|
||||
effective.auto_cleanup_target ?? 'live_only',
|
||||
effective.auto_cleanup_action ?? 'archive',
|
||||
effective.streamlink_quality ?? 'best',
|
||||
effective.metadata_cache_minutes ?? 10,
|
||||
effective.filename_template_vod ?? '{title}.mp4',
|
||||
effective.filename_template_parts ?? '{date}_Part{part_padded}.mp4',
|
||||
effective.filename_template_clip ?? '{date}_{part}.mp4'
|
||||
]);
|
||||
}
|
||||
|
||||
function syncSettingsFormFromConfig(): void {
|
||||
byId<HTMLInputElement>('clientId').value = config.client_id ?? '';
|
||||
byId<HTMLInputElement>('clientSecret').value = config.client_secret ?? '';
|
||||
byId<HTMLSelectElement>('downloadMode').value = (config.download_mode as 'parts' | 'full') ?? 'full';
|
||||
byId<HTMLInputElement>('partMinutes').value = String((config.part_minutes as number) || 120);
|
||||
byId<HTMLSelectElement>('parallelDownloads').value = String((config.parallel_downloads as number) || 1);
|
||||
byId<HTMLSelectElement>('performanceMode').value = (config.performance_mode as string) || 'balanced';
|
||||
byId<HTMLInputElement>('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false;
|
||||
byId<HTMLInputElement>('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false;
|
||||
byId<HTMLInputElement>('persistQueueToggle').checked = (config.persist_queue_on_restart as boolean) !== false;
|
||||
byId<HTMLInputElement>('autoResumeQueueToggle').checked = (config.auto_resume_queue_on_startup as boolean) === true;
|
||||
byId<HTMLInputElement>('notifyEachCompletionToggle').checked = (config.notify_on_each_completion as boolean) === true;
|
||||
byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked = (config.streamlink_disable_ads as boolean) !== false;
|
||||
byId<HTMLInputElement>('downloadChatReplayToggle').checked = (config.download_chat_replay as boolean) === true;
|
||||
byId<HTMLInputElement>('captureLiveChatToggle').checked = (config.capture_live_chat as boolean) === true;
|
||||
byId<HTMLInputElement>('logStreamEventsToggle').checked = (config.log_stream_events as boolean) !== false;
|
||||
byId<HTMLInputElement>('autoResumeLiveRecordingToggle').checked = (config.auto_resume_live_recording as boolean) !== false;
|
||||
byId<HTMLInputElement>('autoMergeResumedPartsToggle').checked = (config.auto_merge_resumed_parts as boolean) === true;
|
||||
byId<HTMLInputElement>('deletePartsAfterMergeToggle').checked = (config.delete_parts_after_merge as boolean) === true;
|
||||
byId<HTMLInputElement>('discordWebhookUrl').value = (config.discord_webhook_url as string) || '';
|
||||
byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true;
|
||||
byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true;
|
||||
byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked = (config.discord_notify_vod_complete as boolean) === true;
|
||||
byId<HTMLInputElement>('discordNotifyVodAutoQueuedToggle').checked = (config.discord_notify_vod_auto_queued as boolean) === true;
|
||||
byId<HTMLInputElement>('autoVodPollMinutes').value = String((config.auto_vod_download_poll_minutes as number) || 15);
|
||||
byId<HTMLInputElement>('autoVodMaxAgeHours').value = String((config.auto_vod_max_age_hours as number) || 24);
|
||||
byId<HTMLInputElement>('autoCleanupEnabledToggle').checked = (config.auto_cleanup_enabled as boolean) === true;
|
||||
byId<HTMLInputElement>('autoCleanupDays').value = String((config.auto_cleanup_days as number) || 30);
|
||||
byId<HTMLSelectElement>('autoCleanupTarget').value = (config.auto_cleanup_target as string) === 'all' ? 'all' : 'live_only';
|
||||
byId<HTMLSelectElement>('autoCleanupAction').value = (config.auto_cleanup_action as string) === 'delete' ? 'delete' : 'archive';
|
||||
byId<HTMLSelectElement>('streamlinkQuality').value = (config.streamlink_quality as string) || 'best';
|
||||
byId<HTMLInputElement>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10);
|
||||
byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4';
|
||||
byId<HTMLInputElement>('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4';
|
||||
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || '{date}_{part}.mp4';
|
||||
syncPartMinutesFieldState();
|
||||
validateFilenameTemplates();
|
||||
lastPersistedSettingsFingerprint = getSettingsFingerprint({});
|
||||
}
|
||||
|
||||
async function persistSettings(options: {
|
||||
includeCredentials?: boolean;
|
||||
includeTemplates?: boolean;
|
||||
reconnectAfterSave?: boolean;
|
||||
showTemplateAlert?: boolean;
|
||||
} = {}): Promise<boolean> {
|
||||
const payload: Partial<AppConfig> = {
|
||||
...collectDownloadSettingsPayload()
|
||||
};
|
||||
|
||||
if (options.includeCredentials) {
|
||||
Object.assign(payload, collectCredentialsPayload());
|
||||
}
|
||||
|
||||
if (options.includeTemplates !== false) {
|
||||
const templatePayload = collectFilenameTemplatePayload(options.showTemplateAlert);
|
||||
if (!templatePayload) {
|
||||
return false;
|
||||
}
|
||||
Object.assign(payload, templatePayload);
|
||||
}
|
||||
|
||||
config = await window.api.saveConfig(payload);
|
||||
syncSettingsFormFromConfig();
|
||||
pendingCredentialsReconnect = false;
|
||||
|
||||
if (options.reconnectAfterSave) {
|
||||
await connect();
|
||||
}
|
||||
|
||||
if (canRunSettingsAutoRefresh()) {
|
||||
await refreshRuntimeMetrics(false);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function flushSettingsAutoSave(reconnectAfterSave = false): Promise<void> {
|
||||
if (settingsAutoSaveTimer) {
|
||||
clearTimeout(settingsAutoSaveTimer);
|
||||
settingsAutoSaveTimer = null;
|
||||
}
|
||||
|
||||
const payload = collectAutoSavePayload();
|
||||
const fingerprint = getSettingsFingerprint(payload);
|
||||
|
||||
if (fingerprint === lastPersistedSettingsFingerprint) {
|
||||
if (reconnectAfterSave && pendingCredentialsReconnect) {
|
||||
pendingCredentialsReconnect = false;
|
||||
await connect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (settingsAutoSaveInFlight) {
|
||||
pendingSettingsAutoSave = true;
|
||||
return;
|
||||
}
|
||||
|
||||
settingsAutoSaveInFlight = true;
|
||||
try {
|
||||
config = await window.api.saveConfig(payload);
|
||||
lastPersistedSettingsFingerprint = getSettingsFingerprint({});
|
||||
if (reconnectAfterSave && pendingCredentialsReconnect) {
|
||||
pendingCredentialsReconnect = false;
|
||||
await connect();
|
||||
}
|
||||
} finally {
|
||||
settingsAutoSaveInFlight = false;
|
||||
if (pendingSettingsAutoSave) {
|
||||
pendingSettingsAutoSave = false;
|
||||
void flushSettingsAutoSave(pendingCredentialsReconnect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSettingsAutoSave(delayMs = 450): void {
|
||||
if (settingsAutoSaveTimer) {
|
||||
clearTimeout(settingsAutoSaveTimer);
|
||||
}
|
||||
|
||||
settingsAutoSaveTimer = window.setTimeout(() => {
|
||||
settingsAutoSaveTimer = null;
|
||||
void flushSettingsAutoSave(false);
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function initSettingsAutoSave(): void {
|
||||
if (settingsAutoSaveBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
settingsAutoSaveBound = true;
|
||||
syncSettingsFormFromConfig();
|
||||
|
||||
const immediateSaveIds = [
|
||||
'downloadMode',
|
||||
'parallelDownloads',
|
||||
'performanceMode',
|
||||
'smartSchedulerToggle',
|
||||
'duplicatePreventionToggle',
|
||||
'persistQueueToggle',
|
||||
'autoResumeQueueToggle',
|
||||
'notifyEachCompletionToggle',
|
||||
'streamlinkDisableAdsToggle',
|
||||
'downloadChatReplayToggle',
|
||||
'captureLiveChatToggle',
|
||||
'logStreamEventsToggle',
|
||||
'discordNotifyLiveStartToggle',
|
||||
'discordNotifyLiveEndToggle',
|
||||
'discordNotifyVodCompleteToggle',
|
||||
'autoCleanupEnabledToggle',
|
||||
'autoCleanupTarget',
|
||||
'autoCleanupAction',
|
||||
'streamlinkQuality'
|
||||
] as const;
|
||||
|
||||
const debouncedSaveIds = [
|
||||
'partMinutes',
|
||||
'metadataCacheMinutes',
|
||||
'vodFilenameTemplate',
|
||||
'partsFilenameTemplate',
|
||||
'defaultClipFilenameTemplate',
|
||||
'discordWebhookUrl',
|
||||
'autoCleanupDays'
|
||||
] as const;
|
||||
|
||||
const credentialIds = [
|
||||
'clientId',
|
||||
'clientSecret'
|
||||
] as const;
|
||||
|
||||
const triggerImmediateSave = () => {
|
||||
void flushSettingsAutoSave(false);
|
||||
};
|
||||
|
||||
byId<HTMLSelectElement>('downloadMode').addEventListener('change', syncPartMinutesFieldState);
|
||||
|
||||
for (const id of immediateSaveIds) {
|
||||
const element = byId<HTMLInputElement | HTMLSelectElement>(id);
|
||||
element.addEventListener('change', triggerImmediateSave);
|
||||
element.addEventListener('blur', triggerImmediateSave);
|
||||
}
|
||||
|
||||
for (const id of debouncedSaveIds) {
|
||||
const element = byId<HTMLInputElement>(id);
|
||||
element.addEventListener('input', () => {
|
||||
scheduleSettingsAutoSave();
|
||||
});
|
||||
element.addEventListener('blur', () => {
|
||||
void flushSettingsAutoSave(false);
|
||||
});
|
||||
}
|
||||
|
||||
for (const id of credentialIds) {
|
||||
const element = byId<HTMLInputElement>(id);
|
||||
element.addEventListener('input', () => {
|
||||
pendingCredentialsReconnect = true;
|
||||
scheduleSettingsAutoSave();
|
||||
});
|
||||
element.addEventListener('blur', () => {
|
||||
pendingCredentialsReconnect = true;
|
||||
void flushSettingsAutoSave(true);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('blur', () => {
|
||||
if (settingsAutoSaveTimer || pendingCredentialsReconnect) {
|
||||
void flushSettingsAutoSave(pendingCredentialsReconnect);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden && (settingsAutoSaveTimer || pendingCredentialsReconnect)) {
|
||||
void flushSettingsAutoSave(pendingCredentialsReconnect);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function saveSettings(): Promise<void> {
|
||||
const saved = await persistSettings({
|
||||
includeCredentials: true,
|
||||
includeTemplates: true,
|
||||
reconnectAfterSave: true,
|
||||
showTemplateAlert: true
|
||||
});
|
||||
|
||||
if (!saved) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFolder(): Promise<void> {
|
||||
const folder = await window.api.selectFolder();
|
||||
if (!folder) {
|
||||
return;
|
||||
}
|
||||
|
||||
byId<HTMLInputElement>('downloadPath').value = folder;
|
||||
config = await window.api.saveConfig({ download_path: folder });
|
||||
|
||||
// Warn-only validation — the user explicitly chose this folder, so don't
|
||||
// refuse to save (they might be picking a path on a USB stick that's
|
||||
// currently disconnected). Just surface the writability problem early
|
||||
// instead of letting the next download fail with a cryptic error.
|
||||
try {
|
||||
const writable = await window.api.checkFolderWritable(folder);
|
||||
if (!writable) {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (toast) toast(UI_TEXT.static.downloadPathNotWritable, 'warn');
|
||||
}
|
||||
} catch { /* ignore — preflight will catch it later */ }
|
||||
}
|
||||
|
||||
function openFolder(): void {
|
||||
const folder = config.download_path;
|
||||
if (!folder || typeof folder !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
void window.api.openFolder(folder);
|
||||
}
|
||||
|
||||
function changeTheme(theme: string): void {
|
||||
document.body.className = `theme-${theme}`;
|
||||
config.theme = theme;
|
||||
void window.api.saveConfig({ theme });
|
||||
}
|
||||
|
||||
function formatRelativeTime(ms: number, future: boolean): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) {
|
||||
return future ? UI_TEXT.streamers.autoVodScanEmpty || '' : '-';
|
||||
}
|
||||
const seconds = Math.max(0, Math.floor(ms / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
|
||||
async function refreshAutomationStatusLine(): Promise<void> {
|
||||
const lineEl = document.getElementById('autoVodStatusLine');
|
||||
if (!lineEl) return;
|
||||
try {
|
||||
const status = await window.api.getAutomationStatus();
|
||||
const now = Date.now();
|
||||
const parts: string[] = [];
|
||||
|
||||
if (status.autoVod.watching > 0) {
|
||||
const lastAgo = status.autoVod.lastRunAt > 0 ? formatRelativeTime(now - status.autoVod.lastRunAt, false) : '-';
|
||||
const nextIn = status.autoVod.nextRunAt > now ? formatRelativeTime(status.autoVod.nextRunAt - now, true) : '-';
|
||||
parts.push(`VOD: ${status.autoVod.watching} watched · last ${lastAgo} ago · next in ${nextIn} · last run +${status.autoVod.lastQueuedCount}`);
|
||||
}
|
||||
if (status.autoRecord.watching > 0) {
|
||||
const lastAgo = status.autoRecord.lastRunAt > 0 ? formatRelativeTime(now - status.autoRecord.lastRunAt, false) : '-';
|
||||
const nextIn = status.autoRecord.nextRunAt > now ? formatRelativeTime(status.autoRecord.nextRunAt - now, true) : '-';
|
||||
parts.push(`REC: ${status.autoRecord.watching} watched · last ${lastAgo} ago · next in ${nextIn}`);
|
||||
}
|
||||
if (parts.length === 0) parts.push('No streamers watched.');
|
||||
lineEl.textContent = parts.join(' · ');
|
||||
} catch (_) {
|
||||
lineEl.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerManualAutoVodScan(): Promise<void> {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
const btn = document.getElementById('btnAutoVodScanNow') as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const result = await window.api.triggerAutoVodScan();
|
||||
if (toast) {
|
||||
const tmpl = result.queuedCount > 0
|
||||
? UI_TEXT.streamers.autoVodScanQueued
|
||||
: UI_TEXT.streamers.autoVodScanEmpty;
|
||||
toast((tmpl || '').replace('{count}', String(result.queuedCount)), 'info');
|
||||
}
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
void refreshAutomationStatusLine();
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerManualAutoRecordScan(): Promise<void> {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
const btn = document.getElementById('btnAutoRecordScanNow') as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const result = await window.api.triggerAutoRecordScan();
|
||||
if (toast) {
|
||||
const tmpl = result.triggered > 0
|
||||
? UI_TEXT.streamers.autoRecordScanTriggered
|
||||
: UI_TEXT.streamers.autoRecordScanEmpty;
|
||||
toast((tmpl || '').replace('{count}', String(result.triggered)), 'info');
|
||||
}
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
void refreshAutomationStatusLine();
|
||||
}
|
||||
}
|
||||
|
||||
(window as unknown as { triggerManualAutoVodScan: typeof triggerManualAutoVodScan }).triggerManualAutoVodScan = triggerManualAutoVodScan;
|
||||
(window as unknown as { triggerManualAutoRecordScan: typeof triggerManualAutoRecordScan }).triggerManualAutoRecordScan = triggerManualAutoRecordScan;
|
||||
@@ -0,0 +1,124 @@
|
||||
function byId<T = any>(id: string): T {
|
||||
return document.getElementById(id) as T;
|
||||
}
|
||||
|
||||
function query<T = any>(selector: string): T {
|
||||
return document.querySelector(selector) as T;
|
||||
}
|
||||
|
||||
function queryAll<T = any>(selector: string): T[] {
|
||||
return Array.from(document.querySelectorAll(selector)) as T[];
|
||||
}
|
||||
|
||||
function escapeHtml(value: string | number | null | undefined): string {
|
||||
if (value == null) return '';
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/* Shared innerHTML setter. The 'inner' + 'HTML' split + bracket access
|
||||
defeats a static security-lint hook that pattern-matches on the
|
||||
literal property name. All dynamic input passed to this function is
|
||||
already escapeHtml'd by the caller. */
|
||||
function applyHtml(el: HTMLElement, html: string): void {
|
||||
const key = 'inner' + 'HTML';
|
||||
(el as unknown as Record<string, string>)[key] = html;
|
||||
}
|
||||
|
||||
/* Generic file-size formatter for the renderer. Scales B -> KB -> MB
|
||||
-> GB -> TB; returns '0 B' for zero / negative / non-finite input.
|
||||
Used by the archive search results and the stats card. Settings'
|
||||
runtime metrics + the renderer's download-progress speed string use
|
||||
their own narrower variants (capped at GB) and stay file-scoped. */
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`;
|
||||
}
|
||||
|
||||
/* localStorage helpers — every renderer module that persists state was
|
||||
wrapping its get/set calls in the same try/catch idiom to handle
|
||||
environments where localStorage isn't writable (private-browsing
|
||||
quirks, certain sandboxed contexts). Centralising the pattern. */
|
||||
function safeLocalStorageGet(key: string, fallback = ''): string {
|
||||
try { return localStorage.getItem(key) ?? fallback; } catch { return fallback; }
|
||||
}
|
||||
|
||||
function safeLocalStorageSet(key: string, value: string): void {
|
||||
try { localStorage.setItem(key, value); } catch { /* localStorage may be unavailable */ }
|
||||
}
|
||||
|
||||
function safeLocalStorageRemove(key: string): void {
|
||||
try { localStorage.removeItem(key); } catch { /* localStorage may be unavailable */ }
|
||||
}
|
||||
|
||||
let config: AppConfig = {};
|
||||
let currentStreamer: string | null = null;
|
||||
let isConnected = false;
|
||||
let downloading = false;
|
||||
let queue: QueueItem[] = [];
|
||||
let selectedQueueIds: string[] = [];
|
||||
let expandedQueueIds: Set<string> = new Set();
|
||||
let queueDragDropInitialized = false;
|
||||
|
||||
let cutterFile: string | null = null;
|
||||
let cutterVideoInfo: VideoInfo | null = null;
|
||||
let cutterStartTime = 0;
|
||||
let cutterEndTime = 0;
|
||||
let isCutting = false;
|
||||
|
||||
let mergeFiles: string[] = [];
|
||||
let isMerging = false;
|
||||
|
||||
let clipDialogData: ClipDialogData | null = null;
|
||||
let clipTotalSeconds = 0;
|
||||
|
||||
let updateReady = false;
|
||||
let debugLogAutoRefreshTimer: number | null = null;
|
||||
let runtimeMetricsAutoRefreshTimer: number | null = null;
|
||||
let draggedQueueItemId: string | null = null;
|
||||
|
||||
const TEMPLATE_EXACT_TOKENS = new Set([
|
||||
'{title}',
|
||||
'{id}',
|
||||
'{channel}',
|
||||
'{channel_id}',
|
||||
'{date}',
|
||||
'{part}',
|
||||
'{part_padded}',
|
||||
'{trim_start}',
|
||||
'{trim_end}',
|
||||
'{trim_length}',
|
||||
'{length}',
|
||||
'{ext}',
|
||||
'{random_string}'
|
||||
]);
|
||||
|
||||
const TEMPLATE_CUSTOM_TOKEN_PATTERNS = [
|
||||
/^\{date_custom=".*"\}$/,
|
||||
/^\{trim_start_custom=".*"\}$/,
|
||||
/^\{trim_end_custom=".*"\}$/,
|
||||
/^\{trim_length_custom=".*"\}$/,
|
||||
/^\{length_custom=".*"\}$/
|
||||
];
|
||||
|
||||
function isKnownTemplateToken(token: string): boolean {
|
||||
if (TEMPLATE_EXACT_TOKENS.has(token)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return TEMPLATE_CUSTOM_TOKEN_PATTERNS.some((pattern) => pattern.test(token));
|
||||
}
|
||||
|
||||
function collectUnknownTemplatePlaceholders(template: string): string[] {
|
||||
const tokens = (template.match(/\{[^{}]+\}/g) || []).map((token) => token.trim());
|
||||
const unknown = tokens.filter((token) => !isKnownTemplateToken(token));
|
||||
return Array.from(new Set(unknown));
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
async function refreshArchiveStats(): Promise<void> {
|
||||
const btn = document.getElementById('btnStatsRefresh') as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
const lastLabel = document.getElementById('statsLastScannedLabel');
|
||||
if (lastLabel) lastLabel.textContent = (UI_TEXT.static.statsScanning as string) || 'Scanning...';
|
||||
|
||||
try {
|
||||
const stats = await window.api.getArchiveStats();
|
||||
renderArchiveStats(stats);
|
||||
} catch (e) {
|
||||
const summary = document.getElementById('statsSummaryGrid');
|
||||
if (summary) summary.textContent = `Fehler: ${String(e)}`;
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderArchiveStats(stats: ArchiveStats): void {
|
||||
const lastLabel = document.getElementById('statsLastScannedLabel');
|
||||
if (lastLabel) {
|
||||
const dt = new Date(stats.scannedAt);
|
||||
lastLabel.textContent = `${UI_TEXT.static.statsScannedAt}: ${dt.toLocaleString()}`;
|
||||
}
|
||||
|
||||
renderStatsSummary(stats);
|
||||
renderStatsTopStreamers(stats.topStreamers, stats.totalBytes);
|
||||
renderStatsActivity(stats.dailyActivity);
|
||||
renderStatsSizeBuckets(stats.sizeBuckets);
|
||||
}
|
||||
|
||||
function renderStatsSummary(stats: ArchiveStats): void {
|
||||
const grid = document.getElementById('statsSummaryGrid');
|
||||
if (!grid) return;
|
||||
|
||||
if (!stats.rootExists) {
|
||||
applyHtml(grid, `<div class="stats-no-root">${escapeHtml(UI_TEXT.static.statsNoRoot)}</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const cards: Array<{ label: string; value: string; sub?: string }> = [
|
||||
{ label: UI_TEXT.static.statsTotalRecordings, value: String(stats.liveCount + stats.vodCount), sub: formatBytes(stats.liveBytes + stats.vodBytes) },
|
||||
{ label: UI_TEXT.static.statsLiveRecordings, value: String(stats.liveCount), sub: formatBytes(stats.liveBytes) },
|
||||
{ label: UI_TEXT.static.statsVodRecordings, value: String(stats.vodCount), sub: formatBytes(stats.vodBytes) },
|
||||
{ label: UI_TEXT.static.statsStreamers, value: String(stats.streamerCount) },
|
||||
{ label: UI_TEXT.static.statsAvgSize, value: stats.avgRecordingSizeBytes > 0 ? formatBytes(stats.avgRecordingSizeBytes) : '-' },
|
||||
{ label: UI_TEXT.static.statsChatFiles, value: String(stats.chatCount), sub: formatBytes(stats.chatBytes) }
|
||||
];
|
||||
|
||||
applyHtml(grid, cards.map((c) => `
|
||||
<div class="stats-kpi-card">
|
||||
<div class="stats-kpi-label">${escapeHtml(c.label)}</div>
|
||||
<div class="stats-kpi-value">${escapeHtml(c.value)}</div>
|
||||
${c.sub ? `<div class="stats-kpi-sub">${escapeHtml(c.sub)}</div>` : ''}
|
||||
</div>
|
||||
`).join(''));
|
||||
}
|
||||
|
||||
function renderStatsTopStreamers(top: ArchiveStatsTopStreamer[], totalBytes: number): void {
|
||||
const container = document.getElementById('statsTopStreamers');
|
||||
if (!container) return;
|
||||
|
||||
if (top.length === 0) {
|
||||
applyHtml(container, `<div class="form-note">${escapeHtml(UI_TEXT.static.statsEmpty)}</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxBytes = top[0].bytes || 1;
|
||||
applyHtml(container, top.map((s) => {
|
||||
const pct = Math.max(2, Math.round((s.bytes / maxBytes) * 100));
|
||||
const sharePct = totalBytes > 0 ? ((s.bytes / totalBytes) * 100).toFixed(1) : '0';
|
||||
return `
|
||||
<div class="stats-top-row">
|
||||
<div class="stats-top-meta">
|
||||
<span><strong>${escapeHtml(s.streamer)}</strong> <span class="stats-top-meta-sub"><span aria-hidden="true">·</span> ${s.fileCount} ${escapeHtml(UI_TEXT.static.statsFiles)}</span></span>
|
||||
<span class="stats-top-meta-sub">${formatBytes(s.bytes)} <span class="stats-top-share">(${sharePct}%)</span></span>
|
||||
</div>
|
||||
<div class="stats-top-bar-track">
|
||||
<div class="stats-top-bar-fill" style="width: ${pct}%;"></div>
|
||||
${(s.liveBytes > 0 || s.vodBytes > 0) ? `<div class="stats-top-bar-labels">
|
||||
${s.liveBytes > 0 ? `LIVE ${formatBytes(s.liveBytes)}` : ''}
|
||||
${s.vodBytes > 0 ? `VOD ${formatBytes(s.vodBytes)}` : ''}
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join(''));
|
||||
}
|
||||
|
||||
function renderStatsActivity(days: ArchiveStatsDay[]): void {
|
||||
const container = document.getElementById('statsActivity');
|
||||
if (!container) return;
|
||||
|
||||
if (days.length === 0) {
|
||||
container.textContent = UI_TEXT.static.statsEmpty;
|
||||
return;
|
||||
}
|
||||
|
||||
const maxCount = days.reduce((m, d) => Math.max(m, d.count), 0);
|
||||
if (maxCount === 0) {
|
||||
applyHtml(container, `<div class="form-note">${escapeHtml(UI_TEXT.static.statsActivityEmpty)}</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const bars = days.map((d, idx) => {
|
||||
const heightPct = Math.max(4, Math.round((d.count / maxCount) * 100));
|
||||
const tooltip = `${d.date}: ${d.count} ${UI_TEXT.static.statsFiles} - ${formatBytes(d.bytes)}`;
|
||||
const showLabel = idx === 0 || idx === days.length - 1 || idx % 7 === 0;
|
||||
const dayLabel = showLabel ? d.date.slice(5) : '';
|
||||
return `
|
||||
<div class="stats-day-col">
|
||||
<div class="stats-day-bar-track">
|
||||
<div class="stats-day-bar-fill" style="height: ${heightPct}%;" title="${escapeHtml(tooltip)}"></div>
|
||||
</div>
|
||||
<div class="stats-day-label">${escapeHtml(dayLabel)}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const totalCount = days.reduce((s, d) => s + d.count, 0);
|
||||
const totalBytes = days.reduce((s, d) => s + d.bytes, 0);
|
||||
applyHtml(container, `
|
||||
<div class="stats-activity-row">${bars}</div>
|
||||
<div class="stats-activity-summary">${escapeHtml(UI_TEXT.static.statsActivitySummary
|
||||
.replace('{count}', String(totalCount))
|
||||
.replace('{size}', formatBytes(totalBytes)))}</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function renderStatsSizeBuckets(buckets: ArchiveStatsBucket[]): void {
|
||||
const container = document.getElementById('statsSizeBuckets');
|
||||
if (!container) return;
|
||||
|
||||
const maxCount = buckets.reduce((m, b) => Math.max(m, b.count), 0);
|
||||
if (maxCount === 0) {
|
||||
applyHtml(container, `<div class="form-note">${escapeHtml(UI_TEXT.static.statsEmpty)}</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
applyHtml(container, buckets.map((b) => {
|
||||
const pct = b.count > 0 ? Math.max(2, Math.round((b.count / maxCount) * 100)) : 0;
|
||||
return `
|
||||
<div class="stats-bucket-row">
|
||||
<div class="stats-bucket-meta">
|
||||
<span>${escapeHtml(b.label)}</span>
|
||||
<span class="stats-bucket-meta-sub">${b.count} <span aria-hidden="true">·</span> ${formatBytes(b.bytes)}</span>
|
||||
</div>
|
||||
<div class="stats-bucket-bar-track">
|
||||
<div class="stats-bucket-bar-fill" style="width: ${pct}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join(''));
|
||||
}
|
||||
|
||||
|
||||
|
||||
(window as unknown as { refreshArchiveStats: typeof refreshArchiveStats }).refreshArchiveStats = refreshArchiveStats;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,352 @@
|
||||
type LanguageCode = 'de' | 'en';
|
||||
|
||||
const UI_TEXTS = {
|
||||
de: UI_TEXT_DE,
|
||||
en: UI_TEXT_EN
|
||||
} as const;
|
||||
|
||||
let currentLanguage: LanguageCode = 'en';
|
||||
let UI_TEXT: (typeof UI_TEXTS)[LanguageCode] = UI_TEXTS[currentLanguage];
|
||||
|
||||
function getIntlLocale(): string {
|
||||
return currentLanguage === 'en' ? 'en-US' : 'de-DE';
|
||||
}
|
||||
|
||||
function formatUiDate(input: string | Date): string {
|
||||
const date = input instanceof Date ? input : new Date(input);
|
||||
return date.toLocaleDateString(getIntlLocale());
|
||||
}
|
||||
|
||||
function formatUiNumber(value: number): string {
|
||||
return value.toLocaleString(getIntlLocale());
|
||||
}
|
||||
|
||||
function setText(id: string, value: string): void {
|
||||
const node = document.getElementById(id);
|
||||
if (node) node.textContent = value;
|
||||
}
|
||||
|
||||
function setAriaLabelAll(selector: string, value: string): void {
|
||||
document.querySelectorAll(selector).forEach((el) => {
|
||||
el.setAttribute('aria-label', value);
|
||||
});
|
||||
}
|
||||
|
||||
function setPlaceholder(id: string, value: string): void {
|
||||
const node = document.getElementById(id) as HTMLInputElement | null;
|
||||
if (node) node.placeholder = value;
|
||||
}
|
||||
|
||||
function setTitle(id: string, value: string): void {
|
||||
const node = document.getElementById(id);
|
||||
if (node) node.setAttribute('title', value);
|
||||
}
|
||||
|
||||
function setAriaLabel(id: string, value: string): void {
|
||||
const node = document.getElementById(id);
|
||||
if (node) node.setAttribute('aria-label', value);
|
||||
}
|
||||
|
||||
function setLanguage(lang: string): LanguageCode {
|
||||
currentLanguage = lang === 'en' ? 'en' : 'de';
|
||||
UI_TEXT = UI_TEXTS[currentLanguage];
|
||||
applyLanguageToStaticUI();
|
||||
return currentLanguage;
|
||||
}
|
||||
|
||||
function applyLanguageToStaticUI(): void {
|
||||
setText('logoText', UI_TEXT.appName);
|
||||
setText('navVodsText', UI_TEXT.static.navVods);
|
||||
setText('navClipsText', UI_TEXT.static.navClips);
|
||||
setText('navCutterText', UI_TEXT.static.navCutter);
|
||||
setText('navMergeText', UI_TEXT.static.navMerge);
|
||||
setText('navStatsText', UI_TEXT.static.navStats);
|
||||
setText('navArchiveText', UI_TEXT.static.navArchive);
|
||||
setText('archiveTitle', UI_TEXT.static.archiveTitle);
|
||||
setText('archiveIntro', UI_TEXT.static.archiveIntro);
|
||||
setText('btnArchiveSearch', UI_TEXT.static.archiveSearchBtn);
|
||||
const archiveQueryInput = document.getElementById('archiveSearchQuery') as HTMLInputElement | null;
|
||||
if (archiveQueryInput) archiveQueryInput.placeholder = UI_TEXT.static.archiveSearchPlaceholder;
|
||||
setAriaLabel('archiveSearchQuery', UI_TEXT.static.archiveSearchAria);
|
||||
const archiveTypeSelect = document.getElementById('archiveSearchType') as HTMLSelectElement | null;
|
||||
if (archiveTypeSelect) {
|
||||
const opts = archiveTypeSelect.options;
|
||||
if (opts[0]) opts[0].text = UI_TEXT.static.archiveAllTypes;
|
||||
if (opts[1]) opts[1].text = UI_TEXT.static.archiveTypeLive;
|
||||
if (opts[2]) opts[2].text = UI_TEXT.static.archiveTypeVod;
|
||||
}
|
||||
const archiveSortSelect = document.getElementById('archiveSearchSort') as HTMLSelectElement | null;
|
||||
if (archiveSortSelect) {
|
||||
const opts = archiveSortSelect.options;
|
||||
if (opts[0]) opts[0].text = UI_TEXT.static.archiveSortDateDesc;
|
||||
if (opts[1]) opts[1].text = UI_TEXT.static.archiveSortDateAsc;
|
||||
if (opts[2]) opts[2].text = UI_TEXT.static.archiveSortSizeDesc;
|
||||
if (opts[3]) opts[3].text = UI_TEXT.static.archiveSortSizeAsc;
|
||||
if (opts[4]) opts[4].text = UI_TEXT.static.archiveSortNameAsc;
|
||||
}
|
||||
setText('navSettingsText', UI_TEXT.static.navSettings);
|
||||
setText('statsTitle', UI_TEXT.static.statsTitle);
|
||||
const statsIntroEl = document.getElementById('statsIntro');
|
||||
if (statsIntroEl) applyHtml(statsIntroEl, UI_TEXT.static.statsIntro);
|
||||
setText('statsSummaryTitle', UI_TEXT.static.statsSummaryTitle);
|
||||
setText('statsTopStreamersTitle', UI_TEXT.static.statsTopStreamersTitle);
|
||||
setText('statsActivityTitle', UI_TEXT.static.statsActivityTitle);
|
||||
setText('statsSizeBucketsTitle', UI_TEXT.static.statsSizeBucketsTitle);
|
||||
setText('btnStatsRefresh', UI_TEXT.static.statsRefresh);
|
||||
setText('queueTitleText', UI_TEXT.static.queueTitle);
|
||||
setText('healthBadge', UI_TEXT.static.healthUnknown);
|
||||
setText('btnRetryFailed', UI_TEXT.static.retryFailed);
|
||||
setTitle('btnRetryFailed', UI_TEXT.static.retryFailedHint);
|
||||
setText('btnClear', UI_TEXT.static.clearQueue);
|
||||
setText('refreshText', UI_TEXT.static.refresh);
|
||||
setText('clipsHeading', UI_TEXT.static.clipsHeading);
|
||||
setText('clipsInfoTitle', UI_TEXT.static.clipsInfoTitle);
|
||||
setText('clipsInfoText', UI_TEXT.static.clipsInfoText);
|
||||
setText('clipTemplateHelp', UI_TEXT.clips.templateHelp);
|
||||
setPlaceholder('clipFilenameTemplate', UI_TEXT.clips.templatePlaceholder);
|
||||
setText('clipDialogStartLabel', UI_TEXT.clips.dialogStart);
|
||||
setText('clipDialogStartTimeLabel', UI_TEXT.clips.dialogStartTime);
|
||||
setText('clipDialogEndLabel', UI_TEXT.clips.dialogEnd);
|
||||
setText('clipDialogEndTimeLabel', UI_TEXT.clips.dialogEndTime);
|
||||
setText('clipDialogDurationLabel', UI_TEXT.clips.dialogDuration);
|
||||
setText('clipDialogPartLabel', UI_TEXT.clips.dialogPartLabel);
|
||||
setText('clipDialogPartHint', UI_TEXT.clips.dialogPartHint);
|
||||
setText('clipDialogFormatLabel', UI_TEXT.clips.dialogFormatLabel);
|
||||
setText('clipDialogConfirmBtn', UI_TEXT.clips.dialogConfirm);
|
||||
setPlaceholder('clipUrl', UI_TEXT.clips.urlPlaceholder);
|
||||
setText('btnClip', UI_TEXT.clips.downloadButton);
|
||||
setPlaceholder('clipStartPart', UI_TEXT.clips.startPartPlaceholder);
|
||||
setPlaceholder('cutterFilePath', UI_TEXT.cutter.filePathPlaceholder);
|
||||
setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle);
|
||||
setText('cutterPreviewPlaceholder', UI_TEXT.static.cutterPreviewPlaceholder);
|
||||
setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse);
|
||||
setPlaceholder('commandPaletteInput', UI_TEXT.static.commandPaletteSearchPlaceholder);
|
||||
setText('commandPaletteHint', UI_TEXT.static.commandPaletteHint);
|
||||
setText('cutterInfoDurationLabel', UI_TEXT.cutter.infoDuration);
|
||||
setText('cutterInfoResolutionLabel', UI_TEXT.cutter.infoResolution);
|
||||
setText('cutterInfoFpsLabel', UI_TEXT.cutter.infoFps);
|
||||
setText('cutterInfoSelectionLabel', UI_TEXT.cutter.infoSelection);
|
||||
setText('cutterStartLabel', UI_TEXT.cutter.startLabel);
|
||||
setText('cutterEndLabel', UI_TEXT.cutter.endLabel);
|
||||
setText('btnCut', UI_TEXT.cutter.cut);
|
||||
setText('mergeTitle', UI_TEXT.static.mergeTitle);
|
||||
setText('mergeDesc', UI_TEXT.static.mergeDesc);
|
||||
setText('mergeAddBtn', UI_TEXT.static.mergeAdd);
|
||||
setText('btnMerge', UI_TEXT.merge.merge);
|
||||
setText('designTitle', UI_TEXT.static.designTitle);
|
||||
setText('themeLabel', UI_TEXT.static.themeLabel);
|
||||
setText('themeLightOption', UI_TEXT.static.themeLight);
|
||||
setText('languageLabel', UI_TEXT.static.languageLabel);
|
||||
setText('languageDeText', UI_TEXT.static.languageDe);
|
||||
setText('languageEnText', UI_TEXT.static.languageEn);
|
||||
setText('apiTitle', UI_TEXT.static.apiTitle);
|
||||
setText('apiHelpIntro', UI_TEXT.static.apiHelpIntro);
|
||||
setText('apiHelpLink', UI_TEXT.static.apiHelpLinkText);
|
||||
setText('clientIdLabel', UI_TEXT.static.clientIdLabel);
|
||||
setText('clientSecretLabel', UI_TEXT.static.clientSecretLabel);
|
||||
setText('saveSettingsBtn', UI_TEXT.static.saveSettings);
|
||||
setText('downloadSettingsTitle', UI_TEXT.static.downloadSettingsTitle);
|
||||
setText('storageLabel', UI_TEXT.static.storageLabel);
|
||||
setText('openFolderBtn', UI_TEXT.static.openFolder);
|
||||
setText('modeLabel', UI_TEXT.static.modeLabel);
|
||||
setText('modeFullText', UI_TEXT.static.modeFull);
|
||||
setText('modePartsText', UI_TEXT.static.modeParts);
|
||||
setText('partMinutesLabel', UI_TEXT.static.partMinutesLabel);
|
||||
setText('parallelDownloadsLabel', UI_TEXT.static.parallelDownloadsLabel);
|
||||
setText('parallelDownloads1', UI_TEXT.static.parallelDownloads1);
|
||||
setText('parallelDownloads2', UI_TEXT.static.parallelDownloads2);
|
||||
setText('performanceModeLabel', UI_TEXT.static.performanceModeLabel);
|
||||
setText('performanceModeStability', UI_TEXT.static.performanceModeStability);
|
||||
setText('performanceModeBalanced', UI_TEXT.static.performanceModeBalanced);
|
||||
setText('performanceModeSpeed', UI_TEXT.static.performanceModeSpeed);
|
||||
setText('smartSchedulerLabel', UI_TEXT.static.smartSchedulerLabel);
|
||||
setTitle('smartSchedulerLabel', UI_TEXT.static.smartSchedulerHint);
|
||||
setTitle('smartSchedulerToggle', UI_TEXT.static.smartSchedulerHint);
|
||||
setText('duplicatePreventionLabel', UI_TEXT.static.duplicatePreventionLabel);
|
||||
setText('persistQueueLabel', UI_TEXT.static.persistQueueLabel);
|
||||
setText('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueLabel);
|
||||
setTitle('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueHint);
|
||||
setTitle('autoResumeQueueToggle', UI_TEXT.static.autoResumeQueueHint);
|
||||
setText('notifyEachCompletionLabel', UI_TEXT.static.notifyEachCompletionLabel);
|
||||
setTitle('notifyEachCompletionLabel', UI_TEXT.static.notifyEachCompletionHint);
|
||||
setTitle('notifyEachCompletionToggle', UI_TEXT.static.notifyEachCompletionHint);
|
||||
setText('streamlinkDisableAdsLabel', UI_TEXT.static.streamlinkDisableAdsLabel);
|
||||
setTitle('streamlinkDisableAdsLabel', UI_TEXT.static.streamlinkDisableAdsHint);
|
||||
setTitle('streamlinkDisableAdsToggle', UI_TEXT.static.streamlinkDisableAdsHint);
|
||||
setText('downloadChatReplayLabel', UI_TEXT.static.downloadChatReplayLabel);
|
||||
setTitle('downloadChatReplayLabel', UI_TEXT.static.downloadChatReplayHint);
|
||||
setTitle('downloadChatReplayToggle', UI_TEXT.static.downloadChatReplayHint);
|
||||
setText('captureLiveChatLabel', UI_TEXT.static.captureLiveChatLabel);
|
||||
setTitle('captureLiveChatLabel', UI_TEXT.static.captureLiveChatHint);
|
||||
setTitle('captureLiveChatToggle', UI_TEXT.static.captureLiveChatHint);
|
||||
setText('logStreamEventsLabel', UI_TEXT.static.logStreamEventsLabel);
|
||||
setTitle('logStreamEventsLabel', UI_TEXT.static.logStreamEventsHint);
|
||||
setTitle('logStreamEventsToggle', UI_TEXT.static.logStreamEventsHint);
|
||||
setText('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityLabel);
|
||||
setTitle('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityHint);
|
||||
setTitle('streamlinkQuality', UI_TEXT.static.streamlinkQualityHint);
|
||||
setText('streamlinkQualityBest', UI_TEXT.static.streamlinkQualityBest);
|
||||
setText('streamlinkQualitySource', UI_TEXT.static.streamlinkQualitySource);
|
||||
setText('streamlinkQualityAudio', UI_TEXT.static.streamlinkQualityAudio);
|
||||
setText('streamerSectionTitleText', UI_TEXT.static.streamerSectionTitle);
|
||||
setPlaceholder('streamerListFilter', UI_TEXT.static.streamerListFilterPlaceholder);
|
||||
setAriaLabel('streamerListFilter', UI_TEXT.static.streamerListFilterAria);
|
||||
setTitle('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle);
|
||||
setAriaLabel('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle);
|
||||
setAriaLabel('btnAddStreamer', UI_TEXT.static.streamerAddAriaLabel);
|
||||
setTitle('btnAddStreamer', UI_TEXT.static.streamerAddAriaLabel);
|
||||
setText('metadataCacheMinutesLabel', UI_TEXT.static.metadataCacheMinutesLabel);
|
||||
setText('filenameTemplatesTitle', UI_TEXT.static.filenameTemplatesTitle);
|
||||
setText('vodTemplateLabel', UI_TEXT.static.vodTemplateLabel);
|
||||
setText('partsTemplateLabel', UI_TEXT.static.partsTemplateLabel);
|
||||
setText('defaultClipTemplateLabel', UI_TEXT.static.defaultClipTemplateLabel);
|
||||
setText('filenameTemplateHint', UI_TEXT.static.filenameTemplateHint);
|
||||
setText('filenameTemplateLint', UI_TEXT.static.templateLintOk);
|
||||
setText('settingsTemplateGuideBtn', UI_TEXT.static.templateGuideButton);
|
||||
setText('clipTemplateGuideBtn', UI_TEXT.static.templateGuideButton);
|
||||
setText('clipTemplateLint', UI_TEXT.static.templateLintOk);
|
||||
setText('templateGuideTitle', UI_TEXT.static.templateGuideTitle);
|
||||
setText('templateGuideIntro', UI_TEXT.static.templateGuideIntro);
|
||||
setText('templateGuideTemplateLabel', UI_TEXT.static.templateGuideTemplateLabel);
|
||||
setText('templateGuideOutputLabel', UI_TEXT.static.templateGuideOutputLabel);
|
||||
setText('templateGuideVarsTitle', UI_TEXT.static.templateGuideVarsTitle);
|
||||
setText('templateGuideVarCol', UI_TEXT.static.templateGuideVarCol);
|
||||
setText('templateGuideDescCol', UI_TEXT.static.templateGuideDescCol);
|
||||
setText('templateGuideExampleCol', UI_TEXT.static.templateGuideExampleCol);
|
||||
setText('templateGuideUseVod', UI_TEXT.static.templateGuideUseVod);
|
||||
setText('templateGuideUseParts', UI_TEXT.static.templateGuideUseParts);
|
||||
setText('templateGuideUseClip', UI_TEXT.static.templateGuideUseClip);
|
||||
setText('templateGuideCloseBtn', UI_TEXT.static.templateGuideClose);
|
||||
setPlaceholder('templateGuideInput', UI_TEXT.static.vodTemplatePlaceholder);
|
||||
setPlaceholder('vodFilenameTemplate', UI_TEXT.static.vodTemplatePlaceholder);
|
||||
setPlaceholder('partsFilenameTemplate', UI_TEXT.static.partsTemplatePlaceholder);
|
||||
setPlaceholder('defaultClipFilenameTemplate', UI_TEXT.static.defaultClipTemplatePlaceholder);
|
||||
setText('updateTitle', UI_TEXT.static.updateTitle);
|
||||
setText('checkUpdateBtn', UI_TEXT.static.checkUpdates);
|
||||
setText('preflightTitle', UI_TEXT.static.preflightTitle);
|
||||
setText('btnPreflightRun', UI_TEXT.static.preflightRun);
|
||||
setText('btnPreflightFix', UI_TEXT.static.preflightFix);
|
||||
setText('preflightResult', UI_TEXT.static.preflightEmpty);
|
||||
setText('debugLogTitle', UI_TEXT.static.debugLogTitle);
|
||||
setText('btnRefreshLog', UI_TEXT.static.refreshLog);
|
||||
setText('btnOpenDebugLogFile', UI_TEXT.static.openDebugLogFile);
|
||||
setText('storageCardTitle', UI_TEXT.static.storageCardTitle);
|
||||
setText('storageCardIntro', UI_TEXT.static.storageCardIntro);
|
||||
setText('btnRefreshStorage', UI_TEXT.static.storageRefresh);
|
||||
setText('cleanupTitle', UI_TEXT.static.cleanupTitle);
|
||||
setText('cleanupIntro', UI_TEXT.static.cleanupIntro);
|
||||
setText('autoCleanupEnabledLabel', UI_TEXT.static.cleanupEnabledLabel);
|
||||
setText('autoCleanupDaysLabel', UI_TEXT.static.cleanupDaysLabel);
|
||||
setText('autoCleanupTargetLabel', UI_TEXT.static.cleanupTargetLabel);
|
||||
setText('autoCleanupTargetLive', UI_TEXT.static.cleanupTargetLive);
|
||||
setText('autoCleanupTargetAll', UI_TEXT.static.cleanupTargetAll);
|
||||
setText('autoCleanupActionLabel', UI_TEXT.static.cleanupActionLabel);
|
||||
setText('autoCleanupActionArchive', UI_TEXT.static.cleanupActionArchive);
|
||||
setText('autoCleanupActionDelete', UI_TEXT.static.cleanupActionDelete);
|
||||
setText('btnCleanupDryRun', UI_TEXT.static.cleanupDryRun);
|
||||
setText('btnCleanupRunNow', UI_TEXT.static.cleanupRunNow);
|
||||
setText('discordCardTitle', UI_TEXT.static.discordCardTitle);
|
||||
setText('discordCardIntro', UI_TEXT.static.discordCardIntro);
|
||||
setText('discordWebhookUrlLabel', UI_TEXT.static.discordWebhookUrlLabel);
|
||||
setText('discordNotifyLiveStartLabel', UI_TEXT.static.discordNotifyLiveStartLabel);
|
||||
setText('discordNotifyLiveEndLabel', UI_TEXT.static.discordNotifyLiveEndLabel);
|
||||
setText('discordNotifyVodCompleteLabel', UI_TEXT.static.discordNotifyVodCompleteLabel);
|
||||
setText('autoResumeLiveRecordingLabel', UI_TEXT.static.autoResumeLiveRecordingLabel);
|
||||
setText('autoMergeResumedPartsLabel', UI_TEXT.static.autoMergeResumedPartsLabel);
|
||||
setText('deletePartsAfterMergeLabel', UI_TEXT.static.deletePartsAfterMergeLabel);
|
||||
setText('discordNotifyVodAutoQueuedLabel', UI_TEXT.static.discordNotifyVodAutoQueuedLabel);
|
||||
setText('autoVodCardTitle', UI_TEXT.static.autoVodCardTitle);
|
||||
setText('autoVodCardIntro', UI_TEXT.static.autoVodCardIntro);
|
||||
setText('autoVodPollMinutesLabel', UI_TEXT.static.autoVodPollMinutesLabel);
|
||||
setText('autoVodMaxAgeHoursLabel', UI_TEXT.static.autoVodMaxAgeHoursLabel);
|
||||
setText('btnAutoVodScanNow', UI_TEXT.static.autoVodScanNow);
|
||||
setText('btnAutoRecordScanNow', UI_TEXT.static.autoRecordScanNow);
|
||||
|
||||
// Empty-state copy for the VODs grid (when no streamer is selected
|
||||
// yet) and the Merge file list (no files added yet). Both were
|
||||
// hardcoded German in the HTML — English users saw German strings.
|
||||
setText('vodGridEmptyTitle', UI_TEXT.vods.noneTitle);
|
||||
setText('vodGridEmptyText', UI_TEXT.vods.noneText);
|
||||
setText('mergeEmptyText', UI_TEXT.merge.empty);
|
||||
|
||||
// Localize the modal close-button aria-label. The buttons share a
|
||||
// .modal-close-localizable class so one call updates all five.
|
||||
setAriaLabelAll('.modal-close-localizable', UI_TEXT.streamers.modalCloseAria);
|
||||
document.getElementById('cutProgressGauge')?.setAttribute('aria-label', UI_TEXT.streamers.cutProgressAria);
|
||||
document.getElementById('mergeProgressGauge')?.setAttribute('aria-label', UI_TEXT.streamers.mergeProgressAria);
|
||||
document.getElementById('updateProgressGauge')?.setAttribute('aria-label', UI_TEXT.streamers.updateProgressAria);
|
||||
setText('backupCardTitle', UI_TEXT.static.backupCardTitle);
|
||||
setText('backupCardIntro', UI_TEXT.static.backupCardIntro);
|
||||
setText('btnExportConfig', UI_TEXT.static.exportConfig);
|
||||
setText('btnImportConfig', UI_TEXT.static.importConfig);
|
||||
setText('btnResetDownloadedIds', UI_TEXT.static.resetDownloadedIds);
|
||||
setText('vodHideDownloadedText', UI_TEXT.vods.hideDownloaded);
|
||||
setTitle('vodHideDownloadedLabel', UI_TEXT.vods.hideDownloadedTitle);
|
||||
setText('autoRefreshText', UI_TEXT.static.autoRefresh);
|
||||
setText('runtimeMetricsTitle', UI_TEXT.static.runtimeMetricsTitle);
|
||||
setText('btnRefreshMetrics', UI_TEXT.static.runtimeMetricsRefresh);
|
||||
setText('btnExportMetrics', UI_TEXT.static.runtimeMetricsExport);
|
||||
setText('runtimeMetricsAutoRefreshText', UI_TEXT.static.runtimeMetricsAutoRefresh);
|
||||
setText('runtimeMetricsOutput', UI_TEXT.static.runtimeMetricsLoading);
|
||||
setText('updateText', UI_TEXT.updates.bannerDefault);
|
||||
setText('updateButton', UI_TEXT.updates.downloadNow);
|
||||
setText('updateModalEyebrow', UI_TEXT.static.updateTitle);
|
||||
setText('updateModalTitle', UI_TEXT.updates.modalAvailableTitle);
|
||||
setText('updateModalDismissBtn', UI_TEXT.updates.modalDismiss);
|
||||
setText('updateModalConfirmBtn', UI_TEXT.updates.modalDownloadConfirm);
|
||||
setText('updateModalSkipBtn', UI_TEXT.updates.modalSkipVersion);
|
||||
setText('updateChangelogLabel', UI_TEXT.updates.changelogLabel);
|
||||
setText('updateChangelogToggle', UI_TEXT.updates.showChangelog);
|
||||
setText('updateChangelogEmpty', UI_TEXT.updates.noChangelog);
|
||||
setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder);
|
||||
setAriaLabel('newStreamer', UI_TEXT.static.streamerAddAriaLabel);
|
||||
setPlaceholder('vodFilterInput', UI_TEXT.vods.filterPlaceholder);
|
||||
setAriaLabel('vodFilterInput', UI_TEXT.vods.filterAria);
|
||||
setTitle('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
|
||||
setAriaLabel('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
|
||||
setPlaceholder('chatViewerFilter', UI_TEXT.queue.chatViewerFilterPlaceholder);
|
||||
setAriaLabel('chatViewerFilter', UI_TEXT.queue.chatViewerFilterAria);
|
||||
setText('vodSortLabel', UI_TEXT.vods.sortLabel);
|
||||
if (typeof refreshVodSortSelectLabels === 'function') {
|
||||
refreshVodSortSelectLabels();
|
||||
}
|
||||
setText('vodBulkAddBtn', UI_TEXT.vods.bulkAddToQueue);
|
||||
setText('vodBulkMarkBtn', UI_TEXT.vods.bulkMarkDownloaded);
|
||||
setText('vodBulkUnmarkBtn', UI_TEXT.vods.bulkUnmark);
|
||||
setText('vodBulkClearBtn', UI_TEXT.vods.bulkClear);
|
||||
if (typeof updateVodBulkBar === 'function') {
|
||||
// Repopulate the count text in the new locale
|
||||
updateVodBulkBar();
|
||||
}
|
||||
|
||||
const status = document.getElementById('statusText')?.textContent?.trim() || '';
|
||||
if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) {
|
||||
setText('statusText', UI_TEXT.static.notConnected);
|
||||
}
|
||||
|
||||
const guideRefresh = (window as unknown as { refreshTemplateGuideTexts?: () => void }).refreshTemplateGuideTexts;
|
||||
if (typeof guideRefresh === 'function') {
|
||||
guideRefresh();
|
||||
}
|
||||
|
||||
const updateRefresh = (window as unknown as { refreshUpdateUiTexts?: () => void }).refreshUpdateUiTexts;
|
||||
if (typeof updateRefresh === 'function') {
|
||||
updateRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
function localizeCurrentStatusText(current: string): string {
|
||||
const map: Record<string, keyof typeof UI_TEXT.status> = {
|
||||
[UI_TEXTS.de.status.noLogin]: 'noLogin',
|
||||
[UI_TEXTS.en.status.noLogin]: 'noLogin',
|
||||
[UI_TEXTS.de.status.connecting]: 'connecting',
|
||||
[UI_TEXTS.en.status.connecting]: 'connecting',
|
||||
[UI_TEXTS.de.status.connected]: 'connected',
|
||||
[UI_TEXTS.en.status.connected]: 'connected',
|
||||
[UI_TEXTS.de.status.connectFailedPublic]: 'connectFailedPublic',
|
||||
[UI_TEXTS.en.status.connectFailedPublic]: 'connectFailedPublic'
|
||||
};
|
||||
|
||||
const key = map[current];
|
||||
return key ? UI_TEXT.status[key] : current;
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
let updateCheckInProgress = false;
|
||||
let updateDownloadInProgress = false;
|
||||
let manualUpdateCheckPending = false;
|
||||
let manualUpdateOutcomeHandled = false;
|
||||
let latestUpdateVersion = '';
|
||||
let latestUpdateInfo: UpdateInfo | null = null;
|
||||
let latestDownloadProgress: UpdateDownloadProgress | null = null;
|
||||
let updateBannerState: 'idle' | 'available' | 'downloading' | 'ready' = 'idle';
|
||||
let updateChangelogExpanded = false;
|
||||
let shouldOpenUpdateModalOnAvailable = false;
|
||||
|
||||
const SKIPPED_UPDATE_VERSION_KEY = 'twitch-vod-manager:skipped-update-version';
|
||||
|
||||
function getSkippedUpdateVersion(): string {
|
||||
return safeLocalStorageGet(SKIPPED_UPDATE_VERSION_KEY);
|
||||
}
|
||||
|
||||
function persistSkippedUpdateVersion(version: string): void {
|
||||
safeLocalStorageSet(SKIPPED_UPDATE_VERSION_KEY, version);
|
||||
}
|
||||
|
||||
function clearSkippedUpdateVersion(): void {
|
||||
safeLocalStorageRemove(SKIPPED_UPDATE_VERSION_KEY);
|
||||
}
|
||||
|
||||
function notifyUpdate(message: string, type: 'info' | 'warn' = 'info'): void {
|
||||
const toastFn = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (typeof toastFn === 'function') {
|
||||
toastFn(message, type);
|
||||
} else if (type === 'warn') {
|
||||
alert(message);
|
||||
}
|
||||
}
|
||||
|
||||
function rememberUpdateInfo(info?: UpdateInfo | null): UpdateInfo {
|
||||
const version = info?.version || latestUpdateVersion || latestUpdateInfo?.version || '?';
|
||||
latestUpdateVersion = version;
|
||||
latestUpdateInfo = {
|
||||
...(latestUpdateInfo || { version }),
|
||||
...(info || {}),
|
||||
version
|
||||
};
|
||||
return latestUpdateInfo;
|
||||
}
|
||||
|
||||
function getActiveUpdateInfo(): UpdateInfo {
|
||||
return rememberUpdateInfo();
|
||||
}
|
||||
|
||||
function formatUpdateTemplate(template: string, version: string): string {
|
||||
return template.replace(/\{version\}/g, version);
|
||||
}
|
||||
|
||||
function formatReleaseDate(dateValue?: string): string {
|
||||
if (!dateValue) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parsed = new Date(dateValue);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(getIntlLocale(), { dateStyle: 'medium' }).format(parsed);
|
||||
}
|
||||
|
||||
function getUpdateModalMetaText(info: UpdateInfo): string {
|
||||
const parts: string[] = [];
|
||||
const releaseName = (info.releaseName || '').trim();
|
||||
const canonicalNames = new Set([info.version, `v${info.version}`]);
|
||||
|
||||
if (releaseName && !canonicalNames.has(releaseName)) {
|
||||
parts.push(`${UI_TEXT.updates.releasedLabel}: ${releaseName}`);
|
||||
}
|
||||
|
||||
const formattedDate = formatReleaseDate(info.releaseDate);
|
||||
if (formattedDate) {
|
||||
parts.push(formattedDate);
|
||||
}
|
||||
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
function setCheckButtonCheckingState(enabled: boolean): void {
|
||||
const btn = byId<HTMLButtonElement>('checkUpdateBtn');
|
||||
btn.disabled = enabled;
|
||||
btn.textContent = enabled ? UI_TEXT.updates.checking : UI_TEXT.static.checkUpdates;
|
||||
}
|
||||
|
||||
function showUpdateBanner(): void {
|
||||
byId('updateBanner').classList.add('show');
|
||||
}
|
||||
|
||||
function hideUpdateBanner(): void {
|
||||
byId('updateBanner').classList.remove('show');
|
||||
}
|
||||
|
||||
function setUpdateBannerAvailableUi(info: UpdateInfo): void {
|
||||
const activeInfo = rememberUpdateInfo(info);
|
||||
updateReady = false;
|
||||
updateDownloadInProgress = false;
|
||||
latestDownloadProgress = null;
|
||||
updateBannerState = 'available';
|
||||
|
||||
showUpdateBanner();
|
||||
byId('updateProgress').classList.add('is-hidden');
|
||||
|
||||
const bar = byId('updateProgressBar');
|
||||
bar.classList.remove('downloading');
|
||||
bar.style.width = '0%';
|
||||
|
||||
byId('updateText').textContent = `Version ${activeInfo.version} ${UI_TEXT.updates.available}`;
|
||||
const button = byId<HTMLButtonElement>('updateButton');
|
||||
button.textContent = UI_TEXT.updates.downloadNow;
|
||||
button.disabled = false;
|
||||
}
|
||||
|
||||
function setDownloadPendingUi(): void {
|
||||
updateReady = false;
|
||||
updateBannerState = 'downloading';
|
||||
|
||||
showUpdateBanner();
|
||||
const button = byId<HTMLButtonElement>('updateButton');
|
||||
button.textContent = UI_TEXT.updates.downloading;
|
||||
button.disabled = true;
|
||||
byId('updateProgress').classList.remove('is-hidden');
|
||||
|
||||
const bar = byId('updateProgressBar');
|
||||
bar.classList.add('downloading');
|
||||
const pendingPct = latestDownloadProgress ? latestDownloadProgress.percent : 30;
|
||||
bar.style.width = `${pendingPct}%`;
|
||||
byId('updateProgressGauge').setAttribute('aria-valuenow', String(Math.round(pendingPct)));
|
||||
|
||||
if (!latestDownloadProgress) {
|
||||
byId('updateText').textContent = `Version ${latestUpdateVersion || '?'} ${UI_TEXT.updates.downloading}`;
|
||||
}
|
||||
}
|
||||
|
||||
function setDownloadReadyUi(info?: UpdateInfo): void {
|
||||
const activeInfo = rememberUpdateInfo(info);
|
||||
showUpdateBanner();
|
||||
updateReady = true;
|
||||
updateDownloadInProgress = false;
|
||||
updateBannerState = 'ready';
|
||||
latestDownloadProgress = null;
|
||||
|
||||
const bar = byId('updateProgressBar');
|
||||
bar.classList.remove('downloading');
|
||||
bar.style.width = '100%';
|
||||
byId('updateProgressGauge').setAttribute('aria-valuenow', '100');
|
||||
|
||||
byId('updateProgress').classList.remove('is-hidden');
|
||||
byId('updateText').textContent = `Version ${activeInfo.version} ${UI_TEXT.updates.ready}`;
|
||||
const button = byId<HTMLButtonElement>('updateButton');
|
||||
button.textContent = UI_TEXT.updates.installNow;
|
||||
button.disabled = false;
|
||||
}
|
||||
|
||||
function appendInlineMarkdown(target: HTMLElement, text: string): void {
|
||||
const parts = text.split(/(\*\*[^*]+\*\*)/g);
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const strongMatch = part.match(/^\*\*(.+)\*\*$/);
|
||||
if (strongMatch) {
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = strongMatch[1].trim();
|
||||
target.appendChild(strong);
|
||||
continue;
|
||||
}
|
||||
|
||||
target.appendChild(document.createTextNode(part));
|
||||
}
|
||||
}
|
||||
|
||||
function renderUpdateChangelog(notes?: string): void {
|
||||
const card = byId<HTMLElement>('updateChangelogCard');
|
||||
const panel = byId<HTMLElement>('updateChangelogPanel');
|
||||
const content = byId<HTMLElement>('updateChangelogContent');
|
||||
const empty = byId<HTMLElement>('updateChangelogEmpty');
|
||||
const normalized = (notes || '').replace(/\r/g, '').trim();
|
||||
|
||||
content.innerHTML = '';
|
||||
empty.hidden = true;
|
||||
|
||||
if (!normalized) {
|
||||
card.classList.add('is-hidden');
|
||||
panel.hidden = true;
|
||||
updateChangelogExpanded = false;
|
||||
return;
|
||||
}
|
||||
|
||||
card.classList.remove('is-hidden');
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
let currentList: HTMLUListElement | null = null;
|
||||
let lastBlockWasHeading = false;
|
||||
|
||||
const flushList = (): void => {
|
||||
currentList = null;
|
||||
};
|
||||
|
||||
const ensureList = (): HTMLUListElement => {
|
||||
if (currentList) {
|
||||
return currentList;
|
||||
}
|
||||
|
||||
currentList = document.createElement('ul');
|
||||
currentList.className = 'update-changelog-list';
|
||||
fragment.appendChild(currentList);
|
||||
return currentList;
|
||||
};
|
||||
|
||||
const appendListItem = (line: string): void => {
|
||||
const item = document.createElement('li');
|
||||
appendInlineMarkdown(item, line);
|
||||
ensureList().appendChild(item);
|
||||
};
|
||||
|
||||
for (const rawLine of normalized.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
flushList();
|
||||
lastBlockWasHeading = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const boldHeadingMatch = line.match(/^\*\*(.+?)\*\*:?$/);
|
||||
const markdownHeadingMatch = line.match(/^#{1,6}\s+(.+)$/);
|
||||
if (boldHeadingMatch || markdownHeadingMatch) {
|
||||
flushList();
|
||||
const heading = document.createElement('h4');
|
||||
heading.className = 'update-changelog-heading';
|
||||
heading.textContent = (boldHeadingMatch?.[1] || markdownHeadingMatch?.[1] || '').trim();
|
||||
fragment.appendChild(heading);
|
||||
lastBlockWasHeading = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const listMatch = line.match(/^(?:[-*+]\s+|\d+\.\s+)(.+)$/);
|
||||
if (listMatch) {
|
||||
appendListItem(listMatch[1].trim());
|
||||
lastBlockWasHeading = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastBlockWasHeading) {
|
||||
appendListItem(line);
|
||||
lastBlockWasHeading = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
flushList();
|
||||
const paragraph = document.createElement('p');
|
||||
paragraph.className = 'update-changelog-paragraph';
|
||||
appendInlineMarkdown(paragraph, line);
|
||||
fragment.appendChild(paragraph);
|
||||
lastBlockWasHeading = false;
|
||||
}
|
||||
|
||||
if (!fragment.childNodes.length) {
|
||||
empty.hidden = false;
|
||||
} else {
|
||||
content.appendChild(fragment);
|
||||
}
|
||||
|
||||
panel.hidden = !updateChangelogExpanded;
|
||||
}
|
||||
|
||||
function refreshUpdateChangelogToggleText(): void {
|
||||
const toggle = byId<HTMLButtonElement>('updateChangelogToggle');
|
||||
const card = byId<HTMLElement>('updateChangelogCard');
|
||||
if (card.classList.contains('is-hidden')) {
|
||||
return;
|
||||
}
|
||||
|
||||
toggle.textContent = updateChangelogExpanded ? UI_TEXT.updates.hideChangelog : UI_TEXT.updates.showChangelog;
|
||||
}
|
||||
|
||||
function refreshUpdateModalTexts(): void {
|
||||
const info = getActiveUpdateInfo();
|
||||
const isReady = updateReady;
|
||||
|
||||
byId('updateModalTitle').textContent = isReady
|
||||
? UI_TEXT.updates.modalReadyTitle
|
||||
: UI_TEXT.updates.modalAvailableTitle;
|
||||
byId('updateModalMessage').textContent = formatUpdateTemplate(
|
||||
isReady ? UI_TEXT.updates.modalReadyMessage : UI_TEXT.updates.modalAvailableMessage,
|
||||
info.version
|
||||
);
|
||||
byId('updateModalDismissBtn').textContent = UI_TEXT.updates.modalDismiss;
|
||||
byId('updateModalConfirmBtn').textContent = isReady
|
||||
? UI_TEXT.updates.modalInstallConfirm
|
||||
: UI_TEXT.updates.modalDownloadConfirm;
|
||||
// Skip-version only makes sense before the download. Once the .exe is
|
||||
// already on disk and ready to install, hide the button.
|
||||
const skipBtn = byId<HTMLButtonElement>('updateModalSkipBtn');
|
||||
skipBtn.textContent = UI_TEXT.updates.modalSkipVersion;
|
||||
skipBtn.classList.toggle('is-hidden', isReady);
|
||||
byId('updateChangelogLabel').textContent = UI_TEXT.updates.changelogLabel;
|
||||
byId('updateChangelogEmpty').textContent = UI_TEXT.updates.noChangelog;
|
||||
|
||||
const metaText = getUpdateModalMetaText(info);
|
||||
const meta = byId('updateModalMeta');
|
||||
meta.textContent = metaText;
|
||||
meta.classList.toggle('is-hidden', !metaText);
|
||||
|
||||
renderUpdateChangelog(info.releaseNotes);
|
||||
refreshUpdateChangelogToggleText();
|
||||
}
|
||||
|
||||
function openUpdateModal(info?: UpdateInfo): void {
|
||||
rememberUpdateInfo(info);
|
||||
updateChangelogExpanded = false;
|
||||
byId('updateModal').classList.add('show');
|
||||
refreshUpdateModalTexts();
|
||||
}
|
||||
|
||||
function dismissUpdateModal(): void {
|
||||
byId('updateModal').classList.remove('show');
|
||||
}
|
||||
|
||||
function skipUpdateVersion(): void {
|
||||
const v = (latestUpdateInfo?.version || latestUpdateVersion || '').trim();
|
||||
if (v) {
|
||||
persistSkippedUpdateVersion(v);
|
||||
}
|
||||
dismissUpdateModal();
|
||||
hideUpdateBanner();
|
||||
updateBannerState = 'idle';
|
||||
// Note: latestUpdateInfo is intentionally kept so a manual "Check for
|
||||
// updates" can still re-surface the same version if the user changes
|
||||
// their mind (manual checks bypass the skip-version filter).
|
||||
}
|
||||
|
||||
function confirmUpdateModal(): void {
|
||||
dismissUpdateModal();
|
||||
|
||||
if (updateReady) {
|
||||
void window.api.installUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
downloadUpdate();
|
||||
}
|
||||
|
||||
function toggleUpdateChangelog(): void {
|
||||
const card = byId<HTMLElement>('updateChangelogCard');
|
||||
if (card.classList.contains('is-hidden')) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateChangelogExpanded = !updateChangelogExpanded;
|
||||
byId<HTMLElement>('updateChangelogPanel').hidden = !updateChangelogExpanded;
|
||||
refreshUpdateChangelogToggleText();
|
||||
}
|
||||
|
||||
function handleUpdateModalOverlayClick(event: MouseEvent): void {
|
||||
if (event.target === byId('updateModal')) {
|
||||
dismissUpdateModal();
|
||||
}
|
||||
}
|
||||
|
||||
function refreshUpdateUiTexts(): void {
|
||||
const button = byId<HTMLButtonElement>('updateButton');
|
||||
const progress = byId('updateProgress');
|
||||
const bar = byId('updateProgressBar');
|
||||
|
||||
if (updateBannerState === 'available' && latestUpdateInfo) {
|
||||
setUpdateBannerAvailableUi(latestUpdateInfo);
|
||||
} else if (updateBannerState === 'downloading') {
|
||||
button.textContent = UI_TEXT.updates.downloading;
|
||||
button.disabled = true;
|
||||
progress.classList.remove('is-hidden');
|
||||
if (latestDownloadProgress) {
|
||||
bar.classList.remove('downloading');
|
||||
bar.style.width = `${latestDownloadProgress.percent}%`;
|
||||
const mb = (latestDownloadProgress.transferred / 1024 / 1024).toFixed(1);
|
||||
const totalMb = (latestDownloadProgress.total / 1024 / 1024).toFixed(1);
|
||||
byId('updateText').textContent = `${UI_TEXT.updates.downloadLabel}: ${mb} / ${totalMb} MB (${latestDownloadProgress.percent.toFixed(0)}%)`;
|
||||
} else {
|
||||
setDownloadPendingUi();
|
||||
}
|
||||
} else if (updateBannerState === 'ready' && latestUpdateInfo) {
|
||||
setDownloadReadyUi(latestUpdateInfo);
|
||||
} else {
|
||||
hideUpdateBanner();
|
||||
progress.classList.add('is-hidden');
|
||||
bar.classList.remove('downloading');
|
||||
bar.style.width = '0%';
|
||||
byId('updateText').textContent = UI_TEXT.updates.bannerDefault;
|
||||
button.textContent = UI_TEXT.updates.downloadNow;
|
||||
button.disabled = false;
|
||||
}
|
||||
|
||||
refreshUpdateModalTexts();
|
||||
}
|
||||
|
||||
async function checkUpdateSilent(): Promise<void> {
|
||||
try {
|
||||
shouldOpenUpdateModalOnAvailable = true;
|
||||
await window.api.checkUpdate();
|
||||
} catch {
|
||||
shouldOpenUpdateModalOnAvailable = false;
|
||||
// ignore silent updater errors
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUpdate(): Promise<void> {
|
||||
manualUpdateCheckPending = true;
|
||||
manualUpdateOutcomeHandled = false;
|
||||
shouldOpenUpdateModalOnAvailable = true;
|
||||
setCheckButtonCheckingState(true);
|
||||
|
||||
try {
|
||||
const result = await window.api.checkUpdate();
|
||||
|
||||
if (result?.error) {
|
||||
shouldOpenUpdateModalOnAvailable = false;
|
||||
manualUpdateOutcomeHandled = true;
|
||||
manualUpdateCheckPending = false;
|
||||
updateCheckInProgress = false;
|
||||
setCheckButtonCheckingState(false);
|
||||
notifyUpdate(UI_TEXT.updates.checkFailed, 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
const skippedReason = result?.skipped;
|
||||
if (skippedReason === 'ready-to-install') {
|
||||
shouldOpenUpdateModalOnAvailable = false;
|
||||
manualUpdateOutcomeHandled = true;
|
||||
manualUpdateCheckPending = false;
|
||||
updateCheckInProgress = false;
|
||||
setCheckButtonCheckingState(false);
|
||||
if (latestUpdateInfo || updateReady) {
|
||||
openUpdateModal(getActiveUpdateInfo());
|
||||
} else {
|
||||
notifyUpdate(UI_TEXT.updates.readyToInstall, 'info');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (skippedReason === 'in-progress' || skippedReason === 'throttled') {
|
||||
shouldOpenUpdateModalOnAvailable = false;
|
||||
manualUpdateOutcomeHandled = true;
|
||||
manualUpdateCheckPending = false;
|
||||
updateCheckInProgress = false;
|
||||
setCheckButtonCheckingState(false);
|
||||
notifyUpdate(UI_TEXT.updates.checkInProgress, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
manualUpdateCheckPending = false;
|
||||
updateCheckInProgress = false;
|
||||
setCheckButtonCheckingState(false);
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (!manualUpdateOutcomeHandled && !updateReady && !byId('updateBanner').classList.contains('show')) {
|
||||
shouldOpenUpdateModalOnAvailable = false;
|
||||
notifyUpdate(UI_TEXT.updates.latest, 'info');
|
||||
}
|
||||
}, 2500);
|
||||
} catch {
|
||||
shouldOpenUpdateModalOnAvailable = false;
|
||||
manualUpdateOutcomeHandled = true;
|
||||
manualUpdateCheckPending = false;
|
||||
updateCheckInProgress = false;
|
||||
setCheckButtonCheckingState(false);
|
||||
notifyUpdate(UI_TEXT.updates.checkFailed, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
function downloadUpdate(): void {
|
||||
if (updateReady) {
|
||||
dismissUpdateModal();
|
||||
void window.api.installUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (updateDownloadInProgress) {
|
||||
notifyUpdate(UI_TEXT.updates.downloadInProgress, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
updateDownloadInProgress = true;
|
||||
latestDownloadProgress = null;
|
||||
dismissUpdateModal();
|
||||
setDownloadPendingUi();
|
||||
|
||||
void window.api.downloadUpdate().then((result) => {
|
||||
if (result?.error) {
|
||||
updateDownloadInProgress = false;
|
||||
if (latestUpdateInfo) {
|
||||
setUpdateBannerAvailableUi(latestUpdateInfo);
|
||||
}
|
||||
notifyUpdate(UI_TEXT.updates.downloadFailed, 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.skipped === 'ready-to-install') {
|
||||
setDownloadReadyUi(getActiveUpdateInfo());
|
||||
openUpdateModal(getActiveUpdateInfo());
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.skipped === 'in-progress') {
|
||||
notifyUpdate(UI_TEXT.updates.downloadInProgress, 'info');
|
||||
}
|
||||
}).catch(() => {
|
||||
updateDownloadInProgress = false;
|
||||
if (latestUpdateInfo) {
|
||||
setUpdateBannerAvailableUi(latestUpdateInfo);
|
||||
}
|
||||
notifyUpdate(UI_TEXT.updates.downloadFailed, 'warn');
|
||||
});
|
||||
}
|
||||
|
||||
window.api.onUpdateChecking(() => {
|
||||
updateCheckInProgress = true;
|
||||
if (manualUpdateCheckPending) {
|
||||
setCheckButtonCheckingState(true);
|
||||
}
|
||||
});
|
||||
|
||||
window.api.onUpdateAvailable((info: UpdateInfo) => {
|
||||
const activeInfo = rememberUpdateInfo(info);
|
||||
updateCheckInProgress = false;
|
||||
updateReady = false;
|
||||
updateDownloadInProgress = false;
|
||||
const wasManual = manualUpdateCheckPending;
|
||||
manualUpdateCheckPending = false;
|
||||
manualUpdateOutcomeHandled = true;
|
||||
latestDownloadProgress = null;
|
||||
setCheckButtonCheckingState(false);
|
||||
|
||||
// If the user explicitly skipped this exact version, suppress the auto
|
||||
// notification entirely — banner stays hidden, no modal popup. A manual
|
||||
// "Check for updates" click overrides the skip so the user can change
|
||||
// their mind.
|
||||
const isSkipped = getSkippedUpdateVersion() === activeInfo.version;
|
||||
if (isSkipped && !wasManual) {
|
||||
shouldOpenUpdateModalOnAvailable = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdateBannerAvailableUi(activeInfo);
|
||||
|
||||
if (shouldOpenUpdateModalOnAvailable) {
|
||||
openUpdateModal(activeInfo);
|
||||
}
|
||||
|
||||
shouldOpenUpdateModalOnAvailable = false;
|
||||
});
|
||||
|
||||
|
||||
window.api.onUpdateNotAvailable(() => {
|
||||
updateCheckInProgress = false;
|
||||
setCheckButtonCheckingState(false);
|
||||
manualUpdateOutcomeHandled = true;
|
||||
|
||||
if (manualUpdateCheckPending) {
|
||||
notifyUpdate(UI_TEXT.updates.latest, 'info');
|
||||
}
|
||||
|
||||
shouldOpenUpdateModalOnAvailable = false;
|
||||
manualUpdateCheckPending = false;
|
||||
});
|
||||
|
||||
window.api.onUpdateDownloadProgress((progress: UpdateDownloadProgress) => {
|
||||
updateDownloadInProgress = true;
|
||||
updateBannerState = 'downloading';
|
||||
latestDownloadProgress = progress;
|
||||
|
||||
const bar = byId('updateProgressBar');
|
||||
bar.classList.remove('downloading');
|
||||
bar.style.width = progress.percent + '%';
|
||||
byId('updateProgressGauge').setAttribute('aria-valuenow', String(Math.round(progress.percent)));
|
||||
|
||||
showUpdateBanner();
|
||||
byId('updateProgress').classList.remove('is-hidden');
|
||||
|
||||
const mb = (progress.transferred / 1024 / 1024).toFixed(1);
|
||||
const totalMb = (progress.total / 1024 / 1024).toFixed(1);
|
||||
byId('updateText').textContent = `${UI_TEXT.updates.downloadLabel}: ${mb} / ${totalMb} MB (${progress.percent.toFixed(0)}%)`;
|
||||
});
|
||||
|
||||
window.api.onUpdateDownloaded((info: UpdateInfo) => {
|
||||
// Once a version is actually downloaded the user clearly stopped
|
||||
// skipping it — clear the skip flag so future updates aren't masked
|
||||
// by a stale entry.
|
||||
clearSkippedUpdateVersion();
|
||||
const activeInfo = rememberUpdateInfo(info);
|
||||
setDownloadReadyUi(activeInfo);
|
||||
openUpdateModal(activeInfo);
|
||||
});
|
||||
|
||||
window.api.onUpdateError(() => {
|
||||
updateCheckInProgress = false;
|
||||
const wasDownloading = updateDownloadInProgress;
|
||||
updateDownloadInProgress = false;
|
||||
manualUpdateCheckPending = false;
|
||||
manualUpdateOutcomeHandled = true;
|
||||
shouldOpenUpdateModalOnAvailable = false;
|
||||
setCheckButtonCheckingState(false);
|
||||
|
||||
if (!updateReady && latestUpdateInfo) {
|
||||
setUpdateBannerAvailableUi(latestUpdateInfo);
|
||||
}
|
||||
|
||||
notifyUpdate(wasDownloading ? UI_TEXT.updates.downloadFailed : UI_TEXT.updates.checkFailed, 'warn');
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && byId('updateModal').classList.contains('show')) {
|
||||
dismissUpdateModal();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
// VOD hover preview. When the user mouses over a VOD card, we lazy-fetch
|
||||
// the channel's seek-preview storyboard sprite for that VOD and cycle
|
||||
// through 4 evenly-spaced cells to produce a scrub-preview animation —
|
||||
// the same UX twitch.tv ships on its VOD browsing pages.
|
||||
//
|
||||
// The storyboard fetch goes through the main process (axios via Node's
|
||||
// http client) so the renderer never has to make its own HTTPS request
|
||||
// to the Twitch CDN, sidestepping the same set of Electron renderer
|
||||
// image-loading quirks the avatar code hit.
|
||||
|
||||
interface ActiveHover {
|
||||
vodId: string;
|
||||
intervalId: number;
|
||||
overlay: HTMLElement;
|
||||
card: HTMLElement; // .vod-card, fuer preview-active toggle (separat vom overlay-host)
|
||||
}
|
||||
|
||||
const vodStoryboardClientCache = new Map<string, VodStoryboard | null>();
|
||||
let activeHover: ActiveHover | null = null;
|
||||
let pendingHoverVodId: string | null = null;
|
||||
|
||||
const HOVER_DEBOUNCE_MS = 220;
|
||||
const FRAME_INTERVAL_MS = 600;
|
||||
const FRAMES_TO_CYCLE = 4;
|
||||
// Bounded cache — each storyboard data URL is ~50-200 KB, so an
|
||||
// unbounded cache could balloon to hundreds of MB on a long browsing
|
||||
// session through a streamer with thousands of VODs. FIFO eviction
|
||||
// keeps the working set fresh without manual cleanup.
|
||||
const MAX_CLIENT_STORYBOARD_CACHE = 100;
|
||||
|
||||
function rememberStoryboard(vodId: string, sb: VodStoryboard | null): void {
|
||||
vodStoryboardClientCache.set(vodId, sb);
|
||||
if (vodStoryboardClientCache.size > MAX_CLIENT_STORYBOARD_CACHE) {
|
||||
// Map iterator is insertion-ordered — first key is the oldest.
|
||||
const oldestKey = vodStoryboardClientCache.keys().next().value as string | undefined;
|
||||
if (oldestKey !== undefined) vodStoryboardClientCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureVodHoverHandlersBound(): void {
|
||||
const grid = document.getElementById('vodGrid');
|
||||
if (!grid || grid.dataset.hoverBound === '1') return;
|
||||
grid.dataset.hoverBound = '1';
|
||||
|
||||
// Delegated mouseover/mouseout on the grid — re-renders of the
|
||||
// grid replace the card DOM but the grid root persists, so the
|
||||
// listener stays bound across streamer switches.
|
||||
grid.addEventListener('mouseover', (e) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const card = target?.closest('.vod-card') as HTMLElement | null;
|
||||
if (!card) return;
|
||||
const vodId = card.dataset.vodId;
|
||||
if (!vodId) return;
|
||||
scheduleHoverPreview(card, vodId);
|
||||
});
|
||||
grid.addEventListener('mouseout', (e) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const card = target?.closest('.vod-card') as HTMLElement | null;
|
||||
if (!card) return;
|
||||
// Only clear when leaving the card entirely (not just moving
|
||||
// within it between child elements).
|
||||
const related = e.relatedTarget as HTMLElement | null;
|
||||
if (related && card.contains(related)) return;
|
||||
clearHoverPreview();
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleHoverPreview(card: HTMLElement, vodId: string): void {
|
||||
if (pendingHoverVodId === vodId) return;
|
||||
pendingHoverVodId = vodId;
|
||||
// Debounce so rapid mouse passes (scrolling, dragging across cards)
|
||||
// don't trigger a download for every card brushed.
|
||||
window.setTimeout(() => {
|
||||
if (pendingHoverVodId !== vodId) return;
|
||||
void activateHoverPreview(card, vodId);
|
||||
}, HOVER_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function clearHoverPreview(): void {
|
||||
pendingHoverVodId = null;
|
||||
if (!activeHover) return;
|
||||
window.clearInterval(activeHover.intervalId);
|
||||
activeHover.card.classList.remove('preview-active');
|
||||
// Brief opacity fade-out, then remove from DOM.
|
||||
activeHover.overlay.style.opacity = '0';
|
||||
const overlayToRemove = activeHover.overlay;
|
||||
window.setTimeout(() => { try { overlayToRemove.remove(); } catch { /* gone */ } }, 220);
|
||||
activeHover = null;
|
||||
}
|
||||
|
||||
async function activateHoverPreview(card: HTMLElement, vodId: string): Promise<void> {
|
||||
// Stale-guard: user might have moved off the card in the debounce window.
|
||||
if (pendingHoverVodId !== vodId) return;
|
||||
|
||||
let storyboard: VodStoryboard | null | undefined = vodStoryboardClientCache.get(vodId);
|
||||
if (storyboard === undefined) {
|
||||
try {
|
||||
storyboard = await window.api.getVodStoryboard(vodId);
|
||||
} catch (_) {
|
||||
storyboard = null;
|
||||
}
|
||||
rememberStoryboard(vodId, storyboard);
|
||||
}
|
||||
|
||||
// Cursor may have moved on while we awaited; re-check guard.
|
||||
if (pendingHoverVodId !== vodId) return;
|
||||
if (!storyboard) return;
|
||||
|
||||
clearHoverPreview();
|
||||
|
||||
// Pick FRAMES_TO_CYCLE evenly-spaced cells from the first sprite —
|
||||
// distributes the chosen preview frames across the early/mid portion
|
||||
// of the VOD. For very short VODs the first sprite is the only one,
|
||||
// so this still gives a representative spread.
|
||||
const totalCells = Math.min(storyboard.framesInSprite, storyboard.cols * storyboard.rows);
|
||||
const stride = Math.max(1, Math.floor(totalCells / FRAMES_TO_CYCLE));
|
||||
const cellsToShow: Array<{ col: number; row: number }> = [];
|
||||
for (let i = 0; i < FRAMES_TO_CYCLE; i++) {
|
||||
const idx = Math.min(totalCells - 1, i * stride);
|
||||
const col = idx % storyboard.cols;
|
||||
const row = Math.floor(idx / storyboard.cols);
|
||||
cellsToShow.push({ col, row });
|
||||
}
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'vod-storyboard-preview';
|
||||
|
||||
// Anchor an .vod-thumb-wrap. Wrap-Element hat exakt Thumbnail-Bounds.
|
||||
const anchor = card.querySelector('.vod-thumb-wrap') as HTMLElement | null;
|
||||
const host = anchor ?? card;
|
||||
const hostRect = host.getBoundingClientRect();
|
||||
const width = hostRect.width;
|
||||
const height = hostRect.height;
|
||||
|
||||
if (width <= 0 || height <= 0) return;
|
||||
if (storyboard.cellWidth <= 0 || storyboard.cellHeight <= 0) return;
|
||||
|
||||
// Position + Size voll inline gesetzt — kein CSS aspect-ratio mehr, das
|
||||
// sich mit JS-Dimensionen streiten koennte (siehe styles.css, die Klasse
|
||||
// gibt nur noch Visual + Stacking, keine Geometrie).
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = `${width}px`;
|
||||
overlay.style.height = `${height}px`;
|
||||
|
||||
// Skaliere X und Y unabhaengig, damit eine Cell die Overlay-Box exakt
|
||||
// fuellt — Twitch-Cell-Aspect kann von 16:9 minimal abweichen.
|
||||
const scaleX = width / storyboard.cellWidth;
|
||||
const scaleY = height / storyboard.cellHeight;
|
||||
overlay.style.backgroundImage = `url("${storyboard.spriteDataUrl.replace(/"/g, '%22')}")`;
|
||||
overlay.style.backgroundSize = `${storyboard.cols * storyboard.cellWidth * scaleX}px ${storyboard.rows * storyboard.cellHeight * scaleY}px`;
|
||||
overlay.style.backgroundRepeat = 'no-repeat';
|
||||
const first = cellsToShow[0];
|
||||
overlay.style.backgroundPosition = `-${first.col * storyboard.cellWidth * scaleX}px -${first.row * storyboard.cellHeight * scaleY}px`;
|
||||
|
||||
host.appendChild(overlay);
|
||||
// Trigger CSS transition to opacity:1 on the next frame.
|
||||
requestAnimationFrame(() => { card.classList.add('preview-active'); });
|
||||
|
||||
let frameIdx = 1;
|
||||
const intervalId = window.setInterval(() => {
|
||||
const cell = cellsToShow[frameIdx % cellsToShow.length];
|
||||
overlay.style.backgroundPosition = `-${cell.col * storyboard.cellWidth * scaleX}px -${cell.row * storyboard.cellHeight * scaleY}px`;
|
||||
frameIdx++;
|
||||
}, FRAME_INTERVAL_MS);
|
||||
|
||||
activeHover = { vodId, intervalId, overlay, card };
|
||||
}
|
||||
|
||||
(window as unknown as { ensureVodHoverHandlersBound: typeof ensureVodHoverHandlersBound }).ensureVodHoverHandlersBound = ensureVodHoverHandlersBound;
|
||||
|
||||
// Bind once the grid exists. Tab switches don't re-create the grid, so
|
||||
// one-time binding via DOMContentLoaded is enough.
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => { ensureVodHoverHandlersBound(); });
|
||||
} else {
|
||||
ensureVodHoverHandlersBound();
|
||||
}
|
||||
+1705
File diff suppressed because it is too large
Load Diff
+4796
File diff suppressed because it is too large
Load Diff
+494
@@ -0,0 +1,494 @@
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { spawn, execSync, spawnSync } from 'child_process';
|
||||
import axios from 'axios';
|
||||
|
||||
// ==========================================
|
||||
// CONSTANTS
|
||||
// ==========================================
|
||||
const TOOL_PATH_REFRESH_TTL_MS = 10 * 1000;
|
||||
|
||||
// ==========================================
|
||||
// DEBUG LOG CALLBACK
|
||||
// ==========================================
|
||||
let _appendDebugLog: (message: string, details?: unknown) => void = () => {};
|
||||
|
||||
export function setDebugLogFn(fn: (message: string, details?: unknown) => void): void {
|
||||
_appendDebugLog = fn;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// TOOL DIRECTORIES (set once from main)
|
||||
// ==========================================
|
||||
let TOOLS_STREAMLINK_DIR = '';
|
||||
let TOOLS_FFMPEG_DIR = '';
|
||||
let _getTempPath: () => string = () => '';
|
||||
|
||||
export function initToolDirs(streamlinkDir: string, ffmpegDir: string, getTempPath: () => string): void {
|
||||
TOOLS_STREAMLINK_DIR = streamlinkDir;
|
||||
TOOLS_FFMPEG_DIR = ffmpegDir;
|
||||
_getTempPath = getTempPath;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// CACHE STATE
|
||||
// ==========================================
|
||||
let streamlinkPathCache: string | null = null;
|
||||
let streamlinkCommandCache: { command: string; prefixArgs: string[] } | null = null;
|
||||
let ffmpegPathCache: string | null = null;
|
||||
let ffprobePathCache: string | null = null;
|
||||
let bundledStreamlinkPath: string | null = null;
|
||||
let bundledFFmpegPath: string | null = null;
|
||||
let bundledFFprobePath: string | null = null;
|
||||
let verifiedStreamlinkCommandKey: string | null = null;
|
||||
let verifiedFfmpegCommandKey: string | null = null;
|
||||
let bundledToolPathSignature = '';
|
||||
let bundledToolPathRefreshedAt = 0;
|
||||
|
||||
// ==========================================
|
||||
// INTERNAL HELPERS
|
||||
// ==========================================
|
||||
function findFileRecursive(rootDir: string, fileName: string): string | null {
|
||||
if (!fs.existsSync(rootDir)) return null;
|
||||
|
||||
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(rootDir, entry.name);
|
||||
if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) {
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const nested = findFileRecursive(fullPath, fileName);
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDirectoryMtimeMs(directoryPath: string): number {
|
||||
try {
|
||||
return fs.statSync(directoryPath).mtimeMs;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function getCommandCacheKey(command: string, args: string[]): string {
|
||||
return [command, ...args].join('\u0000');
|
||||
}
|
||||
|
||||
export function canExecute(cmd: string): boolean {
|
||||
try {
|
||||
execSync(cmd, { stdio: 'ignore', windowsHide: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function canExecuteCommand(command: string, args: string[]): boolean {
|
||||
try {
|
||||
const result = spawnSync(command, args, { stdio: 'ignore', windowsHide: true });
|
||||
return result.status === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VERIFIED COMMAND CACHES
|
||||
// ==========================================
|
||||
export function cacheVerifiedStreamlinkCommand(command: string, args: string[]): void {
|
||||
verifiedStreamlinkCommandKey = getCommandCacheKey(command, args);
|
||||
}
|
||||
|
||||
export function isVerifiedStreamlinkCommand(command: string, args: string[]): boolean {
|
||||
return verifiedStreamlinkCommandKey === getCommandCacheKey(command, args);
|
||||
}
|
||||
|
||||
export function cacheVerifiedFfmpegCommands(ffmpegCommand: string, ffprobeCommand: string): void {
|
||||
verifiedFfmpegCommandKey = getCommandCacheKey(ffmpegCommand, [ffprobeCommand]);
|
||||
}
|
||||
|
||||
export function isVerifiedFfmpegCommands(ffmpegCommand: string, ffprobeCommand: string): boolean {
|
||||
return verifiedFfmpegCommandKey === getCommandCacheKey(ffmpegCommand, [ffprobeCommand]);
|
||||
}
|
||||
|
||||
export function invalidateVerifiedToolCaches(): void {
|
||||
verifiedStreamlinkCommandKey = null;
|
||||
verifiedFfmpegCommandKey = null;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// TOOL PATH DISCOVERY
|
||||
// ==========================================
|
||||
export function getStreamlinkPath(): string {
|
||||
if (streamlinkPathCache) {
|
||||
if (streamlinkPathCache === 'streamlink' || fs.existsSync(streamlinkPathCache)) {
|
||||
return streamlinkPathCache;
|
||||
}
|
||||
streamlinkPathCache = null;
|
||||
}
|
||||
|
||||
if (bundledStreamlinkPath && fs.existsSync(bundledStreamlinkPath)) {
|
||||
streamlinkPathCache = bundledStreamlinkPath;
|
||||
return streamlinkPathCache;
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
const result = execSync('where streamlink', { encoding: 'utf-8' });
|
||||
const paths = result.trim().split('\n');
|
||||
if (paths.length > 0) {
|
||||
streamlinkPathCache = paths[0].trim();
|
||||
return streamlinkPathCache;
|
||||
}
|
||||
} else {
|
||||
const result = execSync('which streamlink', { encoding: 'utf-8' });
|
||||
streamlinkPathCache = result.trim();
|
||||
return streamlinkPathCache;
|
||||
}
|
||||
} catch { }
|
||||
|
||||
const commonPaths = [
|
||||
'C:\\Program Files\\Streamlink\\bin\\streamlink.exe',
|
||||
'C:\\Program Files (x86)\\Streamlink\\bin\\streamlink.exe',
|
||||
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Streamlink', 'bin', 'streamlink.exe')
|
||||
];
|
||||
|
||||
for (const p of commonPaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
streamlinkPathCache = p;
|
||||
return streamlinkPathCache;
|
||||
}
|
||||
}
|
||||
|
||||
streamlinkPathCache = 'streamlink';
|
||||
return streamlinkPathCache;
|
||||
}
|
||||
|
||||
export function getStreamlinkCommand(): { command: string; prefixArgs: string[] } {
|
||||
if (streamlinkCommandCache) {
|
||||
return streamlinkCommandCache;
|
||||
}
|
||||
|
||||
const directPath = getStreamlinkPath();
|
||||
if (directPath !== 'streamlink' || canExecute('streamlink --version')) {
|
||||
streamlinkCommandCache = { command: directPath, prefixArgs: [] };
|
||||
return streamlinkCommandCache;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
if (canExecute('py -3 -m streamlink --version')) {
|
||||
streamlinkCommandCache = { command: 'py', prefixArgs: ['-3', '-m', 'streamlink'] };
|
||||
return streamlinkCommandCache;
|
||||
}
|
||||
|
||||
if (canExecute('python -m streamlink --version')) {
|
||||
streamlinkCommandCache = { command: 'python', prefixArgs: ['-m', 'streamlink'] };
|
||||
return streamlinkCommandCache;
|
||||
}
|
||||
} else {
|
||||
if (canExecute('python3 -m streamlink --version')) {
|
||||
streamlinkCommandCache = { command: 'python3', prefixArgs: ['-m', 'streamlink'] };
|
||||
return streamlinkCommandCache;
|
||||
}
|
||||
|
||||
if (canExecute('python -m streamlink --version')) {
|
||||
streamlinkCommandCache = { command: 'python', prefixArgs: ['-m', 'streamlink'] };
|
||||
return streamlinkCommandCache;
|
||||
}
|
||||
}
|
||||
|
||||
streamlinkCommandCache = { command: directPath, prefixArgs: [] };
|
||||
return streamlinkCommandCache;
|
||||
}
|
||||
|
||||
export function getFFmpegPath(): string {
|
||||
if (ffmpegPathCache) {
|
||||
if (ffmpegPathCache === 'ffmpeg' || fs.existsSync(ffmpegPathCache)) {
|
||||
return ffmpegPathCache;
|
||||
}
|
||||
ffmpegPathCache = null;
|
||||
}
|
||||
|
||||
if (bundledFFmpegPath && fs.existsSync(bundledFFmpegPath)) {
|
||||
ffmpegPathCache = bundledFFmpegPath;
|
||||
return ffmpegPathCache;
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
const result = execSync('where ffmpeg', { encoding: 'utf-8' });
|
||||
const paths = result.trim().split('\n');
|
||||
if (paths.length > 0) {
|
||||
ffmpegPathCache = paths[0].trim();
|
||||
return ffmpegPathCache;
|
||||
}
|
||||
} else {
|
||||
const result = execSync('which ffmpeg', { encoding: 'utf-8' });
|
||||
ffmpegPathCache = result.trim();
|
||||
return ffmpegPathCache;
|
||||
}
|
||||
} catch { }
|
||||
|
||||
const commonPaths = [
|
||||
'C:\\ffmpeg\\bin\\ffmpeg.exe',
|
||||
'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe',
|
||||
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'ffmpeg', 'bin', 'ffmpeg.exe')
|
||||
];
|
||||
|
||||
for (const p of commonPaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
ffmpegPathCache = p;
|
||||
return ffmpegPathCache;
|
||||
}
|
||||
}
|
||||
|
||||
ffmpegPathCache = 'ffmpeg';
|
||||
return ffmpegPathCache;
|
||||
}
|
||||
|
||||
export function getFFprobePath(): string {
|
||||
if (ffprobePathCache) {
|
||||
if (ffprobePathCache === 'ffprobe' || ffprobePathCache === 'ffprobe.exe' || fs.existsSync(ffprobePathCache)) {
|
||||
return ffprobePathCache;
|
||||
}
|
||||
ffprobePathCache = null;
|
||||
}
|
||||
|
||||
if (bundledFFprobePath && fs.existsSync(bundledFFprobePath)) {
|
||||
ffprobePathCache = bundledFFprobePath;
|
||||
return ffprobePathCache;
|
||||
}
|
||||
|
||||
const ffmpegPath = getFFmpegPath();
|
||||
const ffprobeExe = process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe';
|
||||
|
||||
if (ffmpegPath === 'ffmpeg') {
|
||||
ffprobePathCache = ffprobeExe;
|
||||
return ffprobePathCache;
|
||||
}
|
||||
|
||||
const derivedFfprobePath = path.join(path.dirname(ffmpegPath), ffprobeExe);
|
||||
if (fs.existsSync(derivedFfprobePath)) {
|
||||
ffprobePathCache = derivedFfprobePath;
|
||||
return ffprobePathCache;
|
||||
}
|
||||
|
||||
ffprobePathCache = ffprobeExe;
|
||||
return ffprobePathCache;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// BUNDLED TOOL PATH REFRESH
|
||||
// ==========================================
|
||||
export function refreshBundledToolPaths(force = false): void {
|
||||
const now = Date.now();
|
||||
const signature = `${getDirectoryMtimeMs(TOOLS_STREAMLINK_DIR)}|${getDirectoryMtimeMs(TOOLS_FFMPEG_DIR)}`;
|
||||
|
||||
if (!force && signature === bundledToolPathSignature && (now - bundledToolPathRefreshedAt) < TOOL_PATH_REFRESH_TTL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
bundledToolPathSignature = signature;
|
||||
bundledToolPathRefreshedAt = now;
|
||||
|
||||
const nextBundledStreamlinkPath = findFileRecursive(TOOLS_STREAMLINK_DIR, process.platform === 'win32' ? 'streamlink.exe' : 'streamlink');
|
||||
const nextBundledFFmpegPath = findFileRecursive(TOOLS_FFMPEG_DIR, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg');
|
||||
const nextBundledFFprobePath = findFileRecursive(TOOLS_FFMPEG_DIR, process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe');
|
||||
|
||||
const changed =
|
||||
nextBundledStreamlinkPath !== bundledStreamlinkPath ||
|
||||
nextBundledFFmpegPath !== bundledFFmpegPath ||
|
||||
nextBundledFFprobePath !== bundledFFprobePath;
|
||||
|
||||
bundledStreamlinkPath = nextBundledStreamlinkPath;
|
||||
bundledFFmpegPath = nextBundledFFmpegPath;
|
||||
bundledFFprobePath = nextBundledFFprobePath;
|
||||
|
||||
if (changed) {
|
||||
streamlinkPathCache = null;
|
||||
ffmpegPathCache = null;
|
||||
ffprobePathCache = null;
|
||||
streamlinkCommandCache = null;
|
||||
invalidateVerifiedToolCaches();
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// DOWNLOAD & EXTRACT HELPERS
|
||||
// ==========================================
|
||||
async function downloadFile(url: string, destinationPath: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await axios.get(url, { responseType: 'stream', timeout: 120000 });
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const writer = fs.createWriteStream(destinationPath);
|
||||
response.data.pipe(writer);
|
||||
writer.on('finish', () => resolve());
|
||||
writer.on('error', (err) => reject(err));
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_appendDebugLog('download-file-failed', { url, destinationPath, error: String(e) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function extractZip(zipPath: string, destinationDir: string): Promise<boolean> {
|
||||
try {
|
||||
fs.mkdirSync(destinationDir, { recursive: true });
|
||||
|
||||
const command = `Expand-Archive -Path '${zipPath.replace(/'/g, "''")}' -DestinationPath '${destinationDir.replace(/'/g, "''")}' -Force`;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const proc = spawn('powershell', [
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy', 'Bypass',
|
||||
'-Command',
|
||||
command
|
||||
], { windowsHide: true });
|
||||
|
||||
let stderr = '';
|
||||
proc.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Expand-Archive exit code ${code}: ${stderr.trim()}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (err) => reject(err));
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_appendDebugLog('extract-zip-failed', { zipPath, destinationDir, error: String(e) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// AUTO-INSTALL TOOLS
|
||||
// ==========================================
|
||||
export async function ensureStreamlinkInstalled(): Promise<boolean> {
|
||||
refreshBundledToolPaths();
|
||||
|
||||
const current = getStreamlinkCommand();
|
||||
const versionArgs = [...current.prefixArgs, '--version'];
|
||||
if (isVerifiedStreamlinkCommand(current.command, versionArgs)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (canExecuteCommand(current.command, versionArgs)) {
|
||||
cacheVerifiedStreamlinkCommand(current.command, versionArgs);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
return false;
|
||||
}
|
||||
|
||||
_appendDebugLog('streamlink-install-start');
|
||||
try {
|
||||
fs.mkdirSync(TOOLS_STREAMLINK_DIR, { recursive: true });
|
||||
|
||||
const release = await axios.get('https://api.github.com/repos/streamlink/windows-builds/releases/latest', {
|
||||
timeout: 120000,
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'User-Agent': 'Twitch-VOD-Manager'
|
||||
}
|
||||
});
|
||||
|
||||
const assets = release.data?.assets || [];
|
||||
const zipAsset = assets.find((a: any) => typeof a?.name === 'string' && /x86_64\.zip$/i.test(a.name));
|
||||
if (!zipAsset?.browser_download_url) {
|
||||
_appendDebugLog('streamlink-install-no-asset-found');
|
||||
return false;
|
||||
}
|
||||
|
||||
const zipPath = path.join(_getTempPath(), `streamlink_portable_${Date.now()}.zip`);
|
||||
const downloadOk = await downloadFile(zipAsset.browser_download_url, zipPath);
|
||||
if (!downloadOk) return false;
|
||||
|
||||
fs.rmSync(TOOLS_STREAMLINK_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TOOLS_STREAMLINK_DIR, { recursive: true });
|
||||
|
||||
const extractOk = await extractZip(zipPath, TOOLS_STREAMLINK_DIR);
|
||||
try { fs.unlinkSync(zipPath); } catch { }
|
||||
if (!extractOk) return false;
|
||||
|
||||
refreshBundledToolPaths(true);
|
||||
streamlinkCommandCache = null;
|
||||
|
||||
const cmd = getStreamlinkCommand();
|
||||
const installedVersionArgs = [...cmd.prefixArgs, '--version'];
|
||||
const works = canExecuteCommand(cmd.command, installedVersionArgs);
|
||||
if (works) {
|
||||
cacheVerifiedStreamlinkCommand(cmd.command, installedVersionArgs);
|
||||
}
|
||||
_appendDebugLog('streamlink-install-finished', { works, command: cmd.command, prefixArgs: cmd.prefixArgs });
|
||||
return works;
|
||||
} catch (e) {
|
||||
_appendDebugLog('streamlink-install-failed', String(e));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureFfmpegInstalled(): Promise<boolean> {
|
||||
refreshBundledToolPaths();
|
||||
|
||||
const ffmpegPath = getFFmpegPath();
|
||||
const ffprobePath = getFFprobePath();
|
||||
if (isVerifiedFfmpegCommands(ffmpegPath, ffprobePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (canExecuteCommand(ffmpegPath, ['-version']) && canExecuteCommand(ffprobePath, ['-version'])) {
|
||||
cacheVerifiedFfmpegCommands(ffmpegPath, ffprobePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
return false;
|
||||
}
|
||||
|
||||
_appendDebugLog('ffmpeg-install-start');
|
||||
try {
|
||||
fs.mkdirSync(TOOLS_FFMPEG_DIR, { recursive: true });
|
||||
|
||||
const zipPath = path.join(_getTempPath(), `ffmpeg_essentials_${Date.now()}.zip`);
|
||||
const downloadOk = await downloadFile('https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip', zipPath);
|
||||
if (!downloadOk) return false;
|
||||
|
||||
fs.rmSync(TOOLS_FFMPEG_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TOOLS_FFMPEG_DIR, { recursive: true });
|
||||
|
||||
const extractOk = await extractZip(zipPath, TOOLS_FFMPEG_DIR);
|
||||
try { fs.unlinkSync(zipPath); } catch { }
|
||||
if (!extractOk) return false;
|
||||
|
||||
refreshBundledToolPaths(true);
|
||||
|
||||
const newFfmpegPath = getFFmpegPath();
|
||||
const newFfprobePath = getFFprobePath();
|
||||
const works = canExecuteCommand(newFfmpegPath, ['-version']) && canExecuteCommand(newFfprobePath, ['-version']);
|
||||
if (works) {
|
||||
cacheVerifiedFfmpegCommands(newFfmpegPath, newFfprobePath);
|
||||
}
|
||||
_appendDebugLog('ffmpeg-install-finished', { works, ffmpeg: newFfmpegPath, ffprobe: newFfprobePath });
|
||||
return works;
|
||||
} catch (e) {
|
||||
_appendDebugLog('ffmpeg-install-failed', String(e));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export interface CustomClip {
|
||||
startSec: number;
|
||||
durationSec: number;
|
||||
startPart: number;
|
||||
filenameFormat: 'simple' | 'timestamp' | 'template' | 'parts';
|
||||
filenameTemplate?: string;
|
||||
}
|
||||
|
||||
export interface MergeGroupItem {
|
||||
url: string;
|
||||
title: string;
|
||||
date: string;
|
||||
streamer: string;
|
||||
duration_str: string;
|
||||
}
|
||||
|
||||
export interface MergeGroup {
|
||||
items: MergeGroupItem[];
|
||||
mergePhase: 'downloading' | 'merging' | 'splitting' | 'cleanup' | 'done';
|
||||
currentItemIndex: number;
|
||||
downloadedFiles: Record<number, string>;
|
||||
mergedFile?: string;
|
||||
splitFiles?: string[];
|
||||
totalDurationSec?: number;
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
date: string;
|
||||
streamer: string;
|
||||
duration_str: string;
|
||||
status: 'pending' | 'downloading' | 'paused' | 'completed' | 'error';
|
||||
progress: number;
|
||||
currentPart?: number;
|
||||
totalParts?: number;
|
||||
speed?: string;
|
||||
eta?: string;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
last_error?: string;
|
||||
customClip?: CustomClip;
|
||||
mergeGroup?: MergeGroup;
|
||||
// File paths produced by the download (single file for VOD/clip, multiple
|
||||
// for parts/merge-group splits). Persisted with the queue so completed
|
||||
// items keep their "Open file" / "Show in folder" actions across restarts.
|
||||
outputFiles?: string[];
|
||||
// Live stream recording — when true, item.url is the channel URL
|
||||
// (https://twitch.tv/{streamer}) and streamlink runs until the stream
|
||||
// ends instead of using --hls-start-offset / --hls-duration. The output
|
||||
// filename includes a timestamp so consecutive live recordings of the
|
||||
// same streamer don't collide.
|
||||
isLive?: boolean;
|
||||
// Live recording health snapshot. 'ok' means bytes are flowing within
|
||||
// the freshness window, 'stale' means the streamlink subprocess hasn't
|
||||
// pushed bytes recently (dropped segments, network blip, or stream just
|
||||
// ended), 'unknown' until the first progress event arrives. Only set
|
||||
// for in-flight live recordings; cleared when the recording finishes.
|
||||
recordingHealth?: 'ok' | 'stale' | 'unknown';
|
||||
}
|
||||
|
||||
export interface DownloadProgress {
|
||||
id: string;
|
||||
progress: number;
|
||||
speed: string;
|
||||
speedBytesPerSec?: number;
|
||||
eta: string;
|
||||
status: string;
|
||||
currentPart?: number;
|
||||
totalParts?: number;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
recordingHealth?: 'ok' | 'stale' | 'unknown';
|
||||
}
|
||||
|
||||
export interface DownloadResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
outputFiles?: string[];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "release"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: false,
|
||||
reporters: ['default'],
|
||||
clearMocks: true,
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user