Add smart optimization suite with telemetry and guardrails (v4.1.0)

This commit is contained in:
xRangerDE 2026-02-16 14:24:09 +01:00
parent 886500ad8e
commit 9609c6e767
17 changed files with 1126 additions and 127 deletions

View File

@ -54,6 +54,9 @@ npm run test:e2e:full
# Release validation suite (build + smoke + guide + full)
npm run test:e2e:release
# Extra stress pass (runs release suite 3x)
npm run test:e2e:stress
# Build Windows installer
npm run dist:win
```

View File

@ -24,6 +24,14 @@ cd "typescript-version"
npm run dist:win
```
`dist:win` already runs the full release validation gate (`test:e2e:release`) before packaging.
For extra confidence before major releases, run:
```bash
npm run test:e2e:stress
```
Expected outputs in `typescript-version/release/`:
- `latest.yml`

View File

@ -1,12 +1,12 @@
{
"name": "twitch-vod-manager",
"version": "4.0.8",
"version": "4.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "twitch-vod-manager",
"version": "4.0.8",
"version": "4.1.0",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",

View File

@ -1,6 +1,6 @@
{
"name": "twitch-vod-manager",
"version": "4.0.8",
"version": "4.1.0",
"description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js",
"author": "xRangerDE",
@ -12,6 +12,7 @@
"test:e2e:guide": "npm exec --yes --package=playwright -- node scripts/smoke-test-template-guide.js",
"test:e2e:full": "npm exec --yes --package=playwright -- node scripts/smoke-test-full.js",
"test:e2e:release": "npm run build && 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"

View File

@ -248,6 +248,36 @@ async function run() {
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 = '';
@ -266,7 +296,7 @@ async function run() {
window.updateFromInput('start');
window.updateFromInput('end');
await window.confirmClipDialog();
let q = await window.api.getQueue();
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');

View File

@ -92,6 +92,7 @@
style="width: 100%; background: #333; border: 1px solid #444; border-radius: 4px; padding: 8px 12px; color: white; font-family: monospace;"
oninput="updateFilenameExamples()">
<div id="clipTemplateHelp" style="color: #888; font-size: 12px; margin-top: 6px;">Platzhalter: {title} {id} {channel} {date} {part} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
<div id="clipTemplateLint" style="color: #8bc34a; font-size: 12px; margin-top: 4px;">Template-Check: OK</div>
<button class="btn-secondary" id="clipTemplateGuideBtn" style="margin-top: 8px;" onclick="openTemplateGuide('clip')">Template Guide</button>
</div>
</div>
@ -407,28 +408,56 @@
<label id="partMinutesLabel">Teil-Lange (Minuten)</label>
<input type="number" id="partMinutes" value="120" min="10" max="480">
</div>
<div class="form-group">
<label id="performanceModeLabel">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 style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" id="smartSchedulerToggle" checked>
<span id="smartSchedulerLabel">Smart Queue Scheduler aktivieren</span>
</label>
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="duplicatePreventionToggle" checked>
<span id="duplicatePreventionLabel">Duplikate in Queue verhindern</span>
</label>
</div>
<div class="form-group">
<label id="metadataCacheMinutesLabel">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" style="margin: 0;">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 style="display: grid; gap: 8px; margin-top: 8px;">
<label id="vodTemplateLabel" style="font-size: 13px; color: var(--text-secondary);">VOD Template</label>
<input type="text" id="vodFilenameTemplate" placeholder="{title}.mp4" style="font-family: monospace;">
<input type="text" id="vodFilenameTemplate" placeholder="{title}.mp4" style="font-family: monospace;" oninput="validateFilenameTemplates()">
<label id="partsTemplateLabel" style="font-size: 13px; color: var(--text-secondary); margin-top: 4px;">VOD Part Template</label>
<input type="text" id="partsFilenameTemplate" placeholder="{date}_Part{part_padded}.mp4" style="font-family: monospace;">
<input type="text" id="partsFilenameTemplate" placeholder="{date}_Part{part_padded}.mp4" style="font-family: monospace;" oninput="validateFilenameTemplates()">
<label id="defaultClipTemplateLabel" style="font-size: 13px; color: var(--text-secondary); margin-top: 4px;">Clip Template</label>
<input type="text" id="defaultClipFilenameTemplate" placeholder="{date}_{part}.mp4" style="font-family: monospace;">
<input type="text" id="defaultClipFilenameTemplate" placeholder="{date}_{part}.mp4" style="font-family: monospace;" oninput="validateFilenameTemplates()">
</div>
<div id="filenameTemplateHint" style="color: #888; font-size: 12px; 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" style="font-size: 12px; margin-top: 6px; color: #8bc34a;">Template-Check: OK</div>
</div>
</div>
<div class="settings-card">
<h3 id="updateTitle">Updates</h3>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.0.8</p>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.1.0</p>
<button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div>
@ -452,6 +481,18 @@
</div>
<pre id="debugLogOutput" class="log-panel">Lade...</pre>
</div>
<div class="settings-card">
<h3 id="runtimeMetricsTitle">Runtime Metrics</h3>
<div class="form-row" style="margin-bottom: 10px; align-items: center;">
<button class="btn-secondary" id="btnRefreshMetrics" onclick="refreshRuntimeMetrics()">Aktualisieren</button>
<label style="display:flex; align-items:center; gap:6px; font-size:13px; color: var(--text-secondary);">
<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>
@ -460,7 +501,7 @@
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Nicht verbunden</span>
</div>
<span id="versionText">v4.0.8</span>
<span id="versionText">v4.1.0</span>
</div>
</main>
</div>

View File

@ -8,7 +8,7 @@ import { autoUpdater } from 'electron-updater';
// ==========================================
// CONFIG & CONSTANTS
// ==========================================
const APP_VERSION = '4.0.8';
const APP_VERSION = '4.1.0';
const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
// Paths
@ -23,13 +23,18 @@ const DEFAULT_DOWNLOAD_PATH = path.join(app.getPath('desktop'), 'Twitch_VODs');
const DEFAULT_FILENAME_TEMPLATE_VOD = '{title}.mp4';
const DEFAULT_FILENAME_TEMPLATE_PARTS = '{date}_Part{part_padded}.mp4';
const DEFAULT_FILENAME_TEMPLATE_CLIP = '{date}_{part}.mp4';
const DEFAULT_METADATA_CACHE_MINUTES = 10;
const DEFAULT_PERFORMANCE_MODE: PerformanceMode = 'balanced';
// Timeouts
const API_TIMEOUT = 10000;
const MAX_RETRY_ATTEMPTS = 3;
const RETRY_DELAY_SECONDS = 5;
const DEFAULT_RETRY_DELAY_SECONDS = 5;
const MIN_FILE_BYTES = 256 * 1024;
const TWITCH_WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
type PerformanceMode = 'stability' | 'balanced' | 'speed';
type RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrity' | 'io' | 'unknown';
// Ensure directories exist
if (!fs.existsSync(APPDATA_DIR)) {
fs.mkdirSync(APPDATA_DIR, { recursive: true });
@ -50,6 +55,57 @@ interface Config {
filename_template_vod: string;
filename_template_parts: string;
filename_template_clip: string;
smart_queue_scheduler: boolean;
performance_mode: PerformanceMode;
prevent_duplicate_downloads: boolean;
metadata_cache_minutes: number;
}
interface RuntimeMetrics {
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: RetryErrorClass | null;
lastRetryDelaySeconds: number;
}
interface RuntimeMetricsSnapshot extends RuntimeMetrics {
timestamp: string;
queue: {
pending: number;
downloading: number;
paused: number;
completed: number;
error: number;
total: number;
};
caches: {
loginToUserId: number;
vodList: number;
clipInfo: number;
};
config: {
performanceMode: PerformanceMode;
smartScheduler: boolean;
metadataCacheMinutes: number;
duplicatePrevention: boolean;
};
}
interface CacheEntry<T> {
value: T;
expiresAt: number;
}
interface VOD {
@ -115,6 +171,7 @@ interface DownloadProgress {
id: string;
progress: number;
speed: string;
speedBytesPerSec?: number;
eta: string;
status: string;
currentPart?: number;
@ -144,7 +201,11 @@ const defaultConfig: Config = {
language: 'en',
filename_template_vod: DEFAULT_FILENAME_TEMPLATE_VOD,
filename_template_parts: DEFAULT_FILENAME_TEMPLATE_PARTS,
filename_template_clip: DEFAULT_FILENAME_TEMPLATE_CLIP
filename_template_clip: DEFAULT_FILENAME_TEMPLATE_CLIP,
smart_queue_scheduler: true,
performance_mode: DEFAULT_PERFORMANCE_MODE,
prevent_duplicate_downloads: true,
metadata_cache_minutes: DEFAULT_METADATA_CACHE_MINUTES
};
function normalizeFilenameTemplate(template: string | undefined, fallback: string): string {
@ -152,12 +213,33 @@ function normalizeFilenameTemplate(template: string | undefined, fallback: strin
return value || fallback;
}
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)));
}
function normalizePerformanceMode(mode: unknown): PerformanceMode {
if (mode === 'stability' || mode === 'balanced' || mode === 'speed') {
return mode;
}
return DEFAULT_PERFORMANCE_MODE;
}
function normalizeConfigTemplates(input: Config): Config {
return {
...input,
filename_template_vod: normalizeFilenameTemplate(input.filename_template_vod, DEFAULT_FILENAME_TEMPLATE_VOD),
filename_template_parts: normalizeFilenameTemplate(input.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS),
filename_template_clip: normalizeFilenameTemplate(input.filename_template_clip, DEFAULT_FILENAME_TEMPLATE_CLIP)
filename_template_clip: normalizeFilenameTemplate(input.filename_template_clip, DEFAULT_FILENAME_TEMPLATE_CLIP),
smart_queue_scheduler: input.smart_queue_scheduler !== false,
performance_mode: normalizePerformanceMode(input.performance_mode),
prevent_duplicate_downloads: input.prevent_duplicate_downloads !== false,
metadata_cache_minutes: normalizeMetadataCacheMinutes(input.metadata_cache_minutes)
};
}
@ -218,6 +300,27 @@ let pauseRequested = false;
let downloadStartTime = 0;
let downloadedBytes = 0;
const userIdLoginCache = new Map<string, string>();
const loginToUserIdCache = new Map<string, CacheEntry<string>>();
const vodListCache = new Map<string, CacheEntry<VOD[]>>();
const clipInfoCache = new Map<string, CacheEntry<any>>();
const runtimeMetrics: RuntimeMetrics = {
cacheHits: 0,
cacheMisses: 0,
duplicateSkips: 0,
retriesScheduled: 0,
retriesExhausted: 0,
integrityFailures: 0,
downloadsStarted: 0,
downloadsCompleted: 0,
downloadsFailed: 0,
downloadedBytesTotal: 0,
lastSpeedBytesPerSec: 0,
avgSpeedBytesPerSec: 0,
activeItemId: null,
activeItemTitle: null,
lastErrorClass: null,
lastRetryDelaySeconds: 0
};
let streamlinkCommandCache: { command: string; prefixArgs: string[] } | null = null;
let bundledStreamlinkPath: string | null = null;
let bundledFFmpegPath: string | null = null;
@ -798,6 +901,251 @@ function formatETA(seconds: number): string {
return `${h}h ${m}m`;
}
function getMetadataCacheTtlMs(): number {
return normalizeMetadataCacheMinutes(config.metadata_cache_minutes) * 60 * 1000;
}
function getRetryAttemptLimit(): number {
switch (normalizePerformanceMode(config.performance_mode)) {
case 'stability':
return 5;
case 'speed':
return 2;
case 'balanced':
default:
return 3;
}
}
function classifyDownloadError(errorMessage: string): RetryErrorClass {
const text = (errorMessage || '').toLowerCase();
if (!text) return 'unknown';
if (text.includes('429') || text.includes('rate limit') || text.includes('too many requests')) return 'rate_limit';
if (text.includes('401') || text.includes('403') || text.includes('unauthorized') || text.includes('forbidden')) return 'auth';
if (text.includes('timed out') || text.includes('timeout') || text.includes('network') || text.includes('connection') || text.includes('dns')) return 'network';
if (text.includes('streamlink nicht gefunden') || text.includes('ffmpeg') || text.includes('ffprobe') || text.includes('enoent')) return 'tooling';
if (text.includes('integritaet') || text.includes('integrity') || text.includes('kein videostream')) return 'integrity';
if (text.includes('access denied') || text.includes('permission') || text.includes('disk') || text.includes('file') || text.includes('ordner')) return 'io';
return 'unknown';
}
function getRetryDelaySeconds(errorClass: RetryErrorClass, attempt: number): number {
const jitter = Math.floor(Math.random() * 3);
switch (errorClass) {
case 'rate_limit':
return Math.min(45, 10 + attempt * 6 + jitter);
case 'network':
return Math.min(30, 4 * attempt + jitter);
case 'auth':
return Math.min(40, 8 + attempt * 5 + jitter);
case 'integrity':
return Math.min(20, 3 + attempt * 2 + jitter);
case 'io':
return Math.min(25, 5 + attempt * 3 + jitter);
case 'tooling':
return DEFAULT_RETRY_DELAY_SECONDS;
case 'unknown':
default:
return Math.min(25, DEFAULT_RETRY_DELAY_SECONDS + attempt * 2 + jitter);
}
}
function getQueueCounts(queueData: QueueItem[] = downloadQueue): RuntimeMetricsSnapshot['queue'] {
const counts = {
pending: 0,
downloading: 0,
paused: 0,
completed: 0,
error: 0,
total: queueData.length
};
for (const item of queueData) {
if (item.status === 'pending') counts.pending += 1;
else if (item.status === 'downloading') counts.downloading += 1;
else if (item.status === 'paused') counts.paused += 1;
else if (item.status === 'completed') counts.completed += 1;
else if (item.status === 'error') counts.error += 1;
}
return counts;
}
function getRuntimeMetricsSnapshot(): RuntimeMetricsSnapshot {
return {
...runtimeMetrics,
timestamp: new Date().toISOString(),
queue: getQueueCounts(downloadQueue),
caches: {
loginToUserId: loginToUserIdCache.size,
vodList: vodListCache.size,
clipInfo: clipInfoCache.size
},
config: {
performanceMode: normalizePerformanceMode(config.performance_mode),
smartScheduler: config.smart_queue_scheduler !== false,
metadataCacheMinutes: normalizeMetadataCacheMinutes(config.metadata_cache_minutes),
duplicatePrevention: config.prevent_duplicate_downloads !== false
}
};
}
function normalizeQueueUrlForFingerprint(url: string): string {
return (url || '').trim().toLowerCase().replace(/^https?:\/\/(www\.)?/, '');
}
function getQueueItemFingerprint(item: Pick<QueueItem, 'url' | 'streamer' | 'date' | 'customClip'>): string {
const clip = item.customClip;
const clipFingerprint = clip
? [
'clip',
clip.startSec,
clip.durationSec,
clip.startPart,
clip.filenameFormat,
(clip.filenameTemplate || '').trim().toLowerCase()
].join(':')
: 'vod';
return [
normalizeQueueUrlForFingerprint(item.url),
(item.streamer || '').trim().toLowerCase(),
(item.date || '').trim(),
clipFingerprint
].join('|');
}
function isQueueItemActive(item: QueueItem): boolean {
return item.status === 'pending' || item.status === 'downloading' || item.status === 'paused';
}
function hasActiveDuplicate(candidate: Pick<QueueItem, 'url' | 'streamer' | 'date' | 'customClip'>): boolean {
const candidateFingerprint = getQueueItemFingerprint(candidate);
return downloadQueue.some((existing) => {
if (!isQueueItemActive(existing)) return false;
return getQueueItemFingerprint(existing) === candidateFingerprint;
});
}
function getQueuePriorityScore(item: QueueItem): number {
const now = Date.now();
const createdMs = Number(item.id) || now;
const waitSeconds = Math.max(0, Math.floor((now - createdMs) / 1000));
const durationSeconds = Math.max(0, parseDuration(item.duration_str || '0s'));
const clipBoost = item.customClip ? 1500 : 0;
const shortJobBoost = Math.max(0, 7200 - Math.min(7200, durationSeconds)) / 5;
const ageBoost = Math.min(waitSeconds, 1800) / 2;
return clipBoost + shortJobBoost + ageBoost;
}
function pickNextPendingQueueItem(): QueueItem | null {
const pendingItems = downloadQueue.filter((item) => item.status === 'pending');
if (!pendingItems.length) return null;
if (!config.smart_queue_scheduler) {
return pendingItems[0];
}
let best = pendingItems[0];
let bestScore = getQueuePriorityScore(best);
for (let i = 1; i < pendingItems.length; i += 1) {
const candidate = pendingItems[i];
const score = getQueuePriorityScore(candidate);
if (score > bestScore) {
best = candidate;
bestScore = score;
}
}
return best;
}
function parseClockDurationSeconds(duration: string | null): number | null {
if (!duration) return null;
const parts = duration.split(':').map((part) => Number(part));
if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) {
return null;
}
return Math.max(0, Math.floor(parts[0] * 3600 + parts[1] * 60 + parts[2]));
}
function probeMediaFile(filePath: string): { durationSeconds: number; hasVideo: boolean } | null {
try {
const ffprobePath = getFFprobePath();
if (!canExecuteCommand(ffprobePath, ['-version'])) {
return null;
}
const res = spawnSync(ffprobePath, [
'-v', 'error',
'-print_format', 'json',
'-show_format',
'-show_streams',
filePath
], {
windowsHide: true,
encoding: 'utf-8'
});
if (res.status !== 0 || !res.stdout) {
return null;
}
const parsed = JSON.parse(res.stdout) as {
format?: { duration?: string };
streams?: Array<{ codec_type?: string }>;
};
const durationSeconds = Number(parsed?.format?.duration || 0);
const hasVideo = Boolean(parsed?.streams?.some((stream) => stream.codec_type === 'video'));
return {
durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : 0,
hasVideo
};
} catch {
return null;
}
}
function validateDownloadedFileIntegrity(filePath: string, expectedDurationSeconds: number | null): DownloadResult {
const probed = probeMediaFile(filePath);
if (!probed) {
appendDebugLog('integrity-probe-skipped', { filePath });
return { success: true };
}
if (!probed.hasVideo) {
runtimeMetrics.integrityFailures += 1;
return { success: false, error: 'Integritaetspruefung fehlgeschlagen: Kein Videostream gefunden.' };
}
if (probed.durationSeconds <= 1) {
runtimeMetrics.integrityFailures += 1;
return { success: false, error: `Integritaetspruefung fehlgeschlagen: Dauer zu kurz (${probed.durationSeconds.toFixed(2)}s).` };
}
if (expectedDurationSeconds && expectedDurationSeconds > 4) {
const minExpected = Math.max(2, expectedDurationSeconds * 0.45);
if (probed.durationSeconds < minExpected) {
runtimeMetrics.integrityFailures += 1;
return {
success: false,
error: `Integritaetspruefung fehlgeschlagen: ${probed.durationSeconds.toFixed(1)}s statt erwarteter ~${expectedDurationSeconds}s.`
};
}
}
return { success: true };
}
// ==========================================
// TWITCH API
// ==========================================
@ -881,6 +1229,14 @@ async function getPublicUserId(username: string): Promise<string | null> {
const login = normalizeLogin(username);
if (!login) return null;
const cached = loginToUserIdCache.get(login);
if (cached && cached.expiresAt > Date.now()) {
runtimeMetrics.cacheHits += 1;
return cached.value;
}
runtimeMetrics.cacheMisses += 1;
type UserQueryResult = { user: { id: string; login: string } | null };
const data = await fetchPublicTwitchGql<UserQueryResult>(
'query($login:String!){ user(login:$login){ id login } }',
@ -890,6 +1246,7 @@ async function getPublicUserId(username: string): Promise<string | null> {
const user = data?.user;
if (!user?.id) return null;
loginToUserIdCache.set(login, { value: user.id, expiresAt: Date.now() + getMetadataCacheTtlMs() });
userIdLoginCache.set(user.id, user.login || login);
return user.id;
}
@ -945,6 +1302,14 @@ async function getUserId(username: string): Promise<string | null> {
const login = normalizeLogin(username);
if (!login) return null;
const cached = loginToUserIdCache.get(login);
if (cached && cached.expiresAt > Date.now()) {
runtimeMetrics.cacheHits += 1;
return cached.value;
}
runtimeMetrics.cacheMisses += 1;
const getUserViaPublicApi = async () => {
return await getPublicUserId(login);
};
@ -967,6 +1332,7 @@ async function getUserId(username: string): Promise<string | null> {
const user = response.data.data[0];
if (!user?.id) return await getUserViaPublicApi();
loginToUserIdCache.set(login, { value: user.id, expiresAt: Date.now() + getMetadataCacheTtlMs() });
userIdLoginCache.set(user.id, user.login || login);
return user.id;
} catch (e) {
@ -976,6 +1342,7 @@ async function getUserId(username: string): Promise<string | null> {
const user = retryResponse.data.data[0];
if (!user?.id) return await getUserViaPublicApi();
loginToUserIdCache.set(login, { value: user.id, expiresAt: Date.now() + getMetadataCacheTtlMs() });
userIdLoginCache.set(user.id, user.login || login);
return user.id;
} catch (retryError) {
@ -989,12 +1356,28 @@ async function getUserId(username: string): Promise<string | null> {
}
}
async function getVODs(userId: string): Promise<VOD[]> {
async function getVODs(userId: string, forceRefresh = false): Promise<VOD[]> {
const cacheKey = `user:${userId}`;
if (!forceRefresh) {
const cached = vodListCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
runtimeMetrics.cacheHits += 1;
return cached.value;
}
}
runtimeMetrics.cacheMisses += 1;
const getVodsViaPublicApi = async () => {
const login = userIdLoginCache.get(userId);
if (!login) return [];
return await getPublicVODsByLogin(login);
const vods = await getPublicVODsByLogin(login);
vodListCache.set(cacheKey, {
value: vods,
expiresAt: Date.now() + getMetadataCacheTtlMs()
});
return vods;
};
if (!(await ensureTwitchAuth())) return await getVodsViaPublicApi();
@ -1022,6 +1405,11 @@ async function getVODs(userId: string): Promise<VOD[]> {
userIdLoginCache.set(userId, normalizeLogin(login));
}
vodListCache.set(cacheKey, {
value: vods,
expiresAt: Date.now() + getMetadataCacheTtlMs()
});
return vods;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
@ -1033,6 +1421,11 @@ async function getVODs(userId: string): Promise<VOD[]> {
userIdLoginCache.set(userId, normalizeLogin(login));
}
vodListCache.set(cacheKey, {
value: vods,
expiresAt: Date.now() + getMetadataCacheTtlMs()
});
return vods;
} catch (retryError) {
console.error('Error getting VODs after relogin:', retryError);
@ -1046,6 +1439,14 @@ async function getVODs(userId: string): Promise<VOD[]> {
}
async function getClipInfo(clipId: string): Promise<any | null> {
const cached = clipInfoCache.get(clipId);
if (cached && cached.expiresAt > Date.now()) {
runtimeMetrics.cacheHits += 1;
return cached.value;
}
runtimeMetrics.cacheMisses += 1;
if (!(await ensureTwitchAuth())) return null;
const fetchClip = async () => {
@ -1061,12 +1462,20 @@ async function getClipInfo(clipId: string): Promise<any | null> {
try {
const response = await fetchClip();
return response.data.data[0] || null;
const clip = response.data.data[0] || null;
if (clip) {
clipInfoCache.set(clipId, { value: clip, expiresAt: Date.now() + getMetadataCacheTtlMs() });
}
return clip;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
try {
const retryResponse = await fetchClip();
return retryResponse.data.data[0] || null;
const clip = retryResponse.data.data[0] || null;
if (clip) {
clipInfoCache.set(clipId, { value: clip, expiresAt: Date.now() + getMetadataCacheTtlMs() });
}
return clip;
} catch (retryError) {
console.error('Error getting clip after relogin:', retryError);
return null;
@ -1183,20 +1592,34 @@ async function cutVideo(
return false;
}
return new Promise((resolve) => {
const ffmpeg = getFFmpegPath();
const duration = endTime - startTime;
const duration = Math.max(0.1, endTime - startTime);
const runCutAttempt = async (copyMode: boolean): Promise<boolean> => {
const args = [
'-ss', formatDuration(startTime),
'-i', inputFile,
'-t', formatDuration(duration),
'-c', 'copy',
'-progress', 'pipe:1',
'-y',
outputFile
'-t', formatDuration(duration)
];
if (copyMode) {
args.push('-c', 'copy');
} else {
args.push(
'-c:v', 'libx264',
'-preset', 'veryfast',
'-crf', '20',
'-c:a', 'aac',
'-b:a', '160k',
'-movflags', '+faststart'
);
}
args.push('-progress', 'pipe:1', '-y', outputFile);
appendDebugLog('cut-video-attempt', { copyMode, args });
return await new Promise((resolve) => {
const proc = spawn(ffmpeg, args, { windowsHide: true });
currentProcess = proc;
@ -1204,7 +1627,7 @@ async function cutVideo(
const line = data.toString();
const match = line.match(/out_time_us=(\d+)/);
if (match) {
const currentUs = parseInt(match[1]);
const currentUs = parseInt(match[1], 10);
const percent = Math.min(100, (currentUs / 1000000) / duration * 100);
onProgress(percent);
}
@ -1220,6 +1643,19 @@ async function cutVideo(
resolve(false);
});
});
};
const copySuccess = await runCutAttempt(true);
if (copySuccess) {
return true;
}
appendDebugLog('cut-video-copy-failed-fallback-reencode', { inputFile, outputFile });
try {
if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile);
} catch { }
return await runCutAttempt(false);
}
// ==========================================
@ -1236,58 +1672,54 @@ async function mergeVideos(
return false;
}
return new Promise((resolve) => {
const ffmpeg = getFFmpegPath();
// Create concat file
const concatFile = path.join(app.getPath('temp'), `concat_${Date.now()}.txt`);
const concatContent = inputFiles.map(f => `file '${f.replace(/'/g, "'\\''")}'`).join('\n');
const concatContent = inputFiles.map((filePath) => `file '${filePath.replace(/'/g, "'\\''")}'`).join('\n');
fs.writeFileSync(concatFile, concatContent);
const runMergeAttempt = async (copyMode: boolean): Promise<boolean> => {
const args = [
'-f', 'concat',
'-safe', '0',
'-i', concatFile,
'-c', 'copy',
'-progress', 'pipe:1',
'-y',
outputFile
'-i', concatFile
];
if (copyMode) {
args.push('-c', 'copy');
} else {
args.push(
'-c:v', 'libx264',
'-preset', 'veryfast',
'-crf', '20',
'-c:a', 'aac',
'-b:a', '160k',
'-movflags', '+faststart'
);
}
args.push('-progress', 'pipe:1', '-y', outputFile);
appendDebugLog('merge-video-attempt', { copyMode, argsCount: args.length });
return await new Promise((resolve) => {
const proc = spawn(ffmpeg, args, { windowsHide: true });
currentProcess = proc;
// Get total duration for progress
let totalDuration = 0;
for (const file of inputFiles) {
try {
const stats = fs.statSync(file);
totalDuration += stats.size; // Approximate by file size
} catch { }
}
proc.stdout?.on('data', (data) => {
const line = data.toString();
const match = line.match(/out_time_us=(\d+)/);
if (match) {
const currentUs = parseInt(match[1]);
// Approximate progress
const currentUs = parseInt(match[1], 10);
onProgress(Math.min(99, currentUs / 10000000));
}
});
proc.on('close', (code) => {
currentProcess = null;
try {
fs.unlinkSync(concatFile);
} catch { }
if (code === 0 && fs.existsSync(outputFile)) {
const success = code === 0 && fs.existsSync(outputFile);
if (success) {
onProgress(100);
resolve(true);
} else {
resolve(false);
}
resolve(success);
});
proc.on('error', () => {
@ -1295,6 +1727,25 @@ async function mergeVideos(
resolve(false);
});
});
};
try {
const copySuccess = await runMergeAttempt(true);
if (copySuccess) {
return true;
}
appendDebugLog('merge-video-copy-failed-fallback-reencode', { outputFile, files: inputFiles.length });
try {
if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile);
} catch { }
return await runMergeAttempt(false);
} finally {
try {
fs.unlinkSync(concatFile);
} catch { }
}
}
// ==========================================
@ -1314,6 +1765,7 @@ function downloadVODPart(
const streamlinkCmd = getStreamlinkCommand();
const args = [...streamlinkCmd.prefixArgs, url, 'best', '-o', filename, '--force'];
let lastErrorLine = '';
const expectedDurationSeconds = parseClockDurationSeconds(endTime);
if (startTime) {
args.push('--hls-start-offset', startTime);
@ -1345,6 +1797,13 @@ function downloadVODPart(
const bytesDiff = downloadedBytes - lastBytes;
const speed = timeDiff > 0 ? bytesDiff / timeDiff : 0;
runtimeMetrics.lastSpeedBytesPerSec = speed;
if (speed > 0) {
runtimeMetrics.avgSpeedBytesPerSec = runtimeMetrics.avgSpeedBytesPerSec <= 0
? speed
: (runtimeMetrics.avgSpeedBytesPerSec * 0.8) + (speed * 0.2);
}
lastBytes = downloadedBytes;
lastTime = now;
@ -1356,7 +1815,8 @@ function downloadVODPart(
status: `${formatBytes(downloadedBytes)} heruntergeladen`,
currentPart: partNum,
totalParts: totalParts,
downloadedBytes: downloadedBytes
downloadedBytes: downloadedBytes,
speedBytesPerSec: speed
});
} catch { }
}
@ -1391,7 +1851,7 @@ function downloadVODPart(
}
});
proc.on('close', (code) => {
proc.on('close', async (code) => {
clearInterval(progressInterval);
currentProcess = null;
@ -1403,18 +1863,31 @@ function downloadVODPart(
if (code === 0 && fs.existsSync(filename)) {
const stats = fs.statSync(filename);
if (stats.size > 1024 * 1024) {
appendDebugLog('download-part-success', { itemId, filename, bytes: stats.size });
resolve({ success: true });
return;
}
if (stats.size <= MIN_FILE_BYTES) {
const tooSmall = `Datei zu klein (${stats.size} Bytes)`;
appendDebugLog('download-part-failed-small-file', { itemId, filename, bytes: stats.size });
resolve({ success: false, error: tooSmall });
return;
}
const integrityResult = validateDownloadedFileIntegrity(filename, expectedDurationSeconds);
if (!integrityResult.success) {
appendDebugLog('download-part-failed-integrity', {
itemId,
filename,
bytes: stats.size,
error: integrityResult.error
});
resolve(integrityResult);
return;
}
runtimeMetrics.downloadedBytesTotal += stats.size;
appendDebugLog('download-part-success', { itemId, filename, bytes: stats.size });
resolve({ success: true });
return;
}
const genericError = lastErrorLine || `Streamlink Fehlercode ${code ?? -1}`;
appendDebugLog('download-part-failed', { itemId, filename, code, error: genericError });
resolve({ success: false, error: genericError });
@ -1636,19 +2109,35 @@ async function downloadVOD(
}
async function processQueue(): Promise<void> {
if (isDownloading || downloadQueue.length === 0) return;
if (isDownloading || !downloadQueue.some((item) => item.status === 'pending')) return;
appendDebugLog('queue-start', {
items: downloadQueue.length,
smartScheduler: config.smart_queue_scheduler,
performanceMode: config.performance_mode
});
appendDebugLog('queue-start', { items: downloadQueue.length });
isDownloading = true;
pauseRequested = false;
mainWindow?.webContents.send('download-started');
mainWindow?.webContents.send('queue-updated', downloadQueue);
for (const item of downloadQueue) {
if (!isDownloading || pauseRequested) break;
if (item.status === 'completed' || item.status === 'error' || item.status === 'paused') continue;
while (isDownloading && !pauseRequested) {
const item = pickNextPendingQueueItem();
if (!item) {
break;
}
appendDebugLog('queue-item-start', { itemId: item.id, title: item.title, url: item.url });
appendDebugLog('queue-item-start', {
itemId: item.id,
title: item.title,
url: item.url,
smartScore: config.smart_queue_scheduler ? getQueuePriorityScore(item) : 0
});
runtimeMetrics.downloadsStarted += 1;
runtimeMetrics.activeItemId = item.id;
runtimeMetrics.activeItemTitle = item.title;
currentDownloadCancelled = false;
item.status = 'downloading';
@ -1658,9 +2147,10 @@ async function processQueue(): Promise<void> {
item.last_error = '';
let finalResult: DownloadResult = { success: false, error: 'Unbekannter Fehler beim Download' };
const maxAttempts = getRetryAttemptLimit();
for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) {
appendDebugLog('queue-item-attempt', { itemId: item.id, attempt, max: MAX_RETRY_ATTEMPTS });
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
appendDebugLog('queue-item-attempt', { itemId: item.id, attempt, max: maxAttempts });
const result = await downloadVOD(item, (progress) => {
mainWindow?.webContents.send('download-progress', progress);
@ -1678,20 +2168,34 @@ async function processQueue(): Promise<void> {
break;
}
if (attempt < MAX_RETRY_ATTEMPTS) {
item.last_error = `Versuch ${attempt}/${MAX_RETRY_ATTEMPTS} fehlgeschlagen: ${result.error || 'Unbekannter Fehler'}`;
const errorClass = classifyDownloadError(result.error || '');
runtimeMetrics.lastErrorClass = errorClass;
if (errorClass === 'tooling') {
appendDebugLog('queue-item-no-retry-tooling', { itemId: item.id, error: result.error || 'unknown' });
break;
}
if (attempt < maxAttempts) {
const retryDelaySeconds = getRetryDelaySeconds(errorClass, attempt);
runtimeMetrics.retriesScheduled += 1;
runtimeMetrics.lastRetryDelaySeconds = retryDelaySeconds;
item.last_error = `Versuch ${attempt}/${maxAttempts} fehlgeschlagen (${errorClass}): ${result.error || 'Unbekannter Fehler'}`;
mainWindow?.webContents.send('download-progress', {
id: item.id,
progress: -1,
speed: '',
eta: '',
status: `Neuer Versuch in ${RETRY_DELAY_SECONDS}s...`,
status: `Neuer Versuch in ${retryDelaySeconds}s (${errorClass})...`,
currentPart: item.currentPart,
totalParts: item.totalParts
} as DownloadProgress);
saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue);
await sleep(RETRY_DELAY_SECONDS * 1000);
await sleep(retryDelaySeconds * 1000);
} else {
runtimeMetrics.retriesExhausted += 1;
}
}
@ -1699,17 +2203,31 @@ async function processQueue(): Promise<void> {
item.status = finalResult.success ? 'completed' : (wasPaused ? 'paused' : 'error');
item.progress = finalResult.success ? 100 : item.progress;
item.last_error = finalResult.success || wasPaused ? '' : (finalResult.error || 'Unbekannter Fehler beim Download');
if (finalResult.success) {
runtimeMetrics.downloadsCompleted += 1;
} else if (!wasPaused) {
runtimeMetrics.downloadsFailed += 1;
}
runtimeMetrics.activeItemId = null;
runtimeMetrics.activeItemTitle = null;
appendDebugLog('queue-item-finished', {
itemId: item.id,
status: item.status,
error: item.last_error
});
saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue);
}
isDownloading = false;
pauseRequested = false;
runtimeMetrics.activeItemId = null;
runtimeMetrics.activeItemTitle = null;
saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue);
mainWindow?.webContents.send('download-finished');
@ -1817,6 +2335,7 @@ ipcMain.handle('get-config', () => config);
ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
const previousClientId = config.client_id;
const previousClientSecret = config.client_secret;
const previousCacheMinutes = config.metadata_cache_minutes;
config = normalizeConfigTemplates({ ...config, ...newConfig });
@ -1824,6 +2343,12 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
accessToken = null;
}
if (config.metadata_cache_minutes !== previousCacheMinutes) {
loginToUserIdCache.clear();
vodListCache.clear();
clipInfoCache.clear();
}
saveConfig(config);
return config;
});
@ -1836,13 +2361,23 @@ ipcMain.handle('get-user-id', async (_, username: string) => {
return await getUserId(username);
});
ipcMain.handle('get-vods', async (_, userId: string) => {
return await getVODs(userId);
ipcMain.handle('get-vods', async (_, userId: string, forceRefresh: boolean = false) => {
return await getVODs(userId, forceRefresh);
});
ipcMain.handle('get-queue', () => downloadQueue);
ipcMain.handle('add-to-queue', (_, item: Omit<QueueItem, 'id' | 'status' | 'progress'>) => {
if (config.prevent_duplicate_downloads && hasActiveDuplicate(item)) {
runtimeMetrics.duplicateSkips += 1;
appendDebugLog('queue-item-duplicate-skipped', {
title: item.title,
url: item.url,
streamer: item.streamer
});
return downloadQueue;
}
const queueItem: QueueItem = {
...item,
id: Date.now().toString(),
@ -2047,6 +2582,8 @@ ipcMain.handle('get-debug-log', async (_, lines: number = 200) => {
ipcMain.handle('is-downloading', () => isDownloading);
ipcMain.handle('get-runtime-metrics', () => getRuntimeMetricsSnapshot());
// Video Cutter IPC
ipcMain.handle('get-video-info', async (_, filePath: string) => {
return await getVideoInfo(filePath);

View File

@ -29,6 +29,7 @@ interface DownloadProgress {
id: string;
progress: number;
speed: string;
speedBytesPerSec?: number;
eta: string;
status: string;
currentPart?: number;
@ -37,6 +38,45 @@ interface DownloadProgress {
totalBytes?: number;
}
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;
@ -55,7 +95,7 @@ contextBridge.exposeInMainWorld('api', {
// Twitch API
getUserId: (username: string) => ipcRenderer.invoke('get-user-id', username),
getVODs: (userId: string) => ipcRenderer.invoke('get-vods', userId),
getVODs: (userId: string, forceRefresh: boolean = false) => ipcRenderer.invoke('get-vods', userId, forceRefresh),
// Queue
getQueue: () => ipcRenderer.invoke('get-queue'),
@ -97,6 +137,7 @@ contextBridge.exposeInMainWorld('api', {
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'),
// Events
onDownloadProgress: (callback: (progress: DownloadProgress) => void) => {

View File

@ -10,6 +10,10 @@ interface AppConfig {
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;
metadata_cache_minutes?: number;
[key: string]: unknown;
}
@ -56,6 +60,7 @@ interface DownloadProgress {
id: string;
progress: number;
speed: string;
speedBytesPerSec?: number;
eta: string;
status: string;
currentPart?: number;
@ -64,6 +69,45 @@ interface DownloadProgress {
totalBytes?: number;
}
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;
@ -112,7 +156,7 @@ interface ApiBridge {
saveConfig(config: Partial<AppConfig>): Promise<AppConfig>;
login(): Promise<boolean>;
getUserId(username: string): Promise<string | null>;
getVODs(userId: string): Promise<VOD[]>;
getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>;
getQueue(): Promise<QueueItem[]>;
addToQueue(item: Omit<QueueItem, 'id' | 'status' | 'progress'>): Promise<QueueItem[]>;
removeFromQueue(id: string): Promise<QueueItem[]>;
@ -140,6 +184,7 @@ interface ApiBridge {
openExternal(url: string): Promise<void>;
runPreflight(autoFix: boolean): Promise<PreflightResult>;
getDebugLog(lines: number): Promise<string>;
getRuntimeMetrics(): Promise<RuntimeMetricsSnapshot>;
onDownloadProgress(callback: (progress: DownloadProgress) => void): void;
onQueueUpdated(callback: (queue: QueueItem[]) => void): void;
onDownloadStarted(callback: () => void): void;

View File

@ -40,6 +40,13 @@ const UI_TEXT_DE = {
modeFull: 'Ganzes VOD',
modeParts: 'In Teile splitten',
partMinutesLabel: 'Teil-Lange (Minuten)',
performanceModeLabel: 'Performance-Profil',
performanceModeStability: 'Max Stabilitat',
performanceModeBalanced: 'Ausgewogen',
performanceModeSpeed: 'Max Geschwindigkeit',
smartSchedulerLabel: 'Smart Queue Scheduler aktivieren',
duplicatePreventionLabel: 'Duplikate in Queue verhindern',
metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)',
filenameTemplatesTitle: 'Dateinamen-Templates',
vodTemplateLabel: 'VOD-Template',
partsTemplateLabel: 'VOD-Teile-Template',
@ -48,6 +55,8 @@ const UI_TEXT_DE = {
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.',
@ -65,6 +74,21 @@ const UI_TEXT_DE = {
templateGuideContextParts: 'Kontext: Beispiel fur VOD-Teil',
templateGuideContextClip: 'Kontext: Beispiel fur Clip-Zuschnitt',
templateGuideContextClipLive: 'Kontext: Aktuelle Auswahl im Clip-Dialog',
runtimeMetricsTitle: 'Runtime Metrics',
runtimeMetricsRefresh: 'Aktualisieren',
runtimeMetricsAutoRefresh: 'Auto-Refresh',
runtimeMetricsLoading: 'Metriken werden geladen...',
runtimeMetricsError: 'Runtime-Metriken konnten nicht geladen werden.',
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',
@ -117,7 +141,8 @@ const UI_TEXT_DE = {
speed: 'Geschwindigkeit',
eta: 'Restzeit',
part: 'Teil',
emptyAlert: 'Die Warteschlange ist leer. Fuge zuerst ein VOD oder einen Clip hinzu.'
emptyAlert: 'Die Warteschlange ist leer. Fuge zuerst ein VOD oder einen Clip hinzu.',
duplicateSkipped: 'Dieser Eintrag ist bereits aktiv in der Warteschlange.'
},
vods: {
noneTitle: 'Keine VODs',

View File

@ -40,6 +40,13 @@ const UI_TEXT_EN = {
modeFull: 'Full VOD',
modeParts: 'Split into parts',
partMinutesLabel: 'Part Length (Minutes)',
performanceModeLabel: 'Performance Profile',
performanceModeStability: 'Max Stability',
performanceModeBalanced: 'Balanced',
performanceModeSpeed: 'Max Speed',
smartSchedulerLabel: 'Enable smart queue scheduler',
duplicatePreventionLabel: 'Prevent duplicate queue entries',
metadataCacheMinutesLabel: 'Metadata Cache (Minutes)',
filenameTemplatesTitle: 'Filename Templates',
vodTemplateLabel: 'VOD Template',
partsTemplateLabel: 'VOD Part Template',
@ -48,6 +55,8 @@ const UI_TEXT_EN = {
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.',
@ -65,6 +74,21 @@ const UI_TEXT_EN = {
templateGuideContextParts: 'Context: Sample split VOD part',
templateGuideContextClip: 'Context: Sample clip trim',
templateGuideContextClipLive: 'Context: Current clip dialog selection',
runtimeMetricsTitle: 'Runtime Metrics',
runtimeMetricsRefresh: 'Refresh',
runtimeMetricsAutoRefresh: 'Auto refresh',
runtimeMetricsLoading: 'Loading metrics...',
runtimeMetricsError: 'Could not load runtime metrics.',
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',
@ -117,7 +141,8 @@ const UI_TEXT_EN = {
speed: 'Speed',
eta: 'ETA',
part: 'Part',
emptyAlert: 'Queue is empty. Add a VOD or clip first.'
emptyAlert: 'Queue is empty. Add a VOD or clip first.',
duplicateSkipped: 'This item is already active in the queue.'
},
vods: {
noneTitle: 'No VODs',

View File

@ -1,4 +1,40 @@
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('|');
}
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,

View File

@ -12,6 +12,105 @@ async function connect(): Promise<void> {
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.style.color = '#8bc34a';
lintNode.textContent = UI_TEXT.static.templateLintOk;
return true;
}
lintNode.style.color = '#ff8a80';
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();
}
async function refreshRuntimeMetrics(): Promise<void> {
const output = byId('runtimeMetricsOutput');
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')}`
];
output.textContent = lines.join('\n');
} catch {
output.textContent = UI_TEXT.static.runtimeMetricsError;
}
}
function toggleRuntimeMetricsAutoRefresh(enabled: boolean): void {
if (runtimeMetricsAutoRefreshTimer) {
clearInterval(runtimeMetricsAutoRefreshTimer);
runtimeMetricsAutoRefreshTimer = null;
}
if (enabled) {
runtimeMetricsAutoRefreshTimer = window.setInterval(() => {
void refreshRuntimeMetrics();
}, 2000);
}
}
function updateStatus(text: string, connected: boolean): void {
byId('statusText').textContent = text;
const dot = byId('statusDot');
@ -39,6 +138,9 @@ function changeLanguage(lang: string): void {
} else {
byId('pageTitle').textContent = (UI_TEXT.tabs as Record<string, string>)[activeTab] || UI_TEXT.appName;
}
void refreshRuntimeMetrics();
validateFilenameTemplates();
}
function updateLanguagePicker(lang: string): void {
@ -130,26 +232,44 @@ async function saveSettings(): Promise<void> {
const downloadPath = byId<HTMLInputElement>('downloadPath').value;
const downloadMode = byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full';
const partMinutes = parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120;
const performanceMode = byId<HTMLSelectElement>('performanceMode').value as 'stability' | 'balanced' | 'speed';
const smartQueueScheduler = byId<HTMLInputElement>('smartSchedulerToggle').checked;
const duplicatePrevention = byId<HTMLInputElement>('duplicatePreventionToggle').checked;
const metadataCacheMinutes = parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10;
const vodFilenameTemplate = byId<HTMLInputElement>('vodFilenameTemplate').value.trim() || '{title}.mp4';
const partsFilenameTemplate = byId<HTMLInputElement>('partsFilenameTemplate').value.trim() || '{date}_Part{part_padded}.mp4';
const defaultClipFilenameTemplate = byId<HTMLInputElement>('defaultClipFilenameTemplate').value.trim() || '{date}_{part}.mp4';
if (!validateFilenameTemplates(true)) {
return;
}
config = await window.api.saveConfig({
client_id: clientId,
client_secret: clientSecret,
download_path: downloadPath,
download_mode: downloadMode,
part_minutes: partMinutes,
performance_mode: performanceMode,
smart_queue_scheduler: smartQueueScheduler,
prevent_duplicate_downloads: duplicatePrevention,
metadata_cache_minutes: metadataCacheMinutes,
filename_template_vod: vodFilenameTemplate,
filename_template_parts: partsFilenameTemplate,
filename_template_clip: defaultClipFilenameTemplate
});
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>('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';
validateFilenameTemplates();
await connect();
await refreshRuntimeMetrics();
}
async function selectFolder(): Promise<void> {

View File

@ -39,4 +39,43 @@ 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));
}

View File

@ -49,7 +49,7 @@ async function removeStreamer(name: string): Promise<void> {
`;
}
async function selectStreamer(name: string): Promise<void> {
async function selectStreamer(name: string, forceRefresh = false): Promise<void> {
currentStreamer = name;
renderStreamers();
byId('pageTitle').textContent = name;
@ -70,7 +70,7 @@ async function selectStreamer(name: string): Promise<void> {
return;
}
const vods = await window.api.getVODs(userId);
const vods = await window.api.getVODs(userId, forceRefresh);
renderVODs(vods, name);
}
@ -113,5 +113,5 @@ async function refreshVODs(): Promise<void> {
return;
}
await selectStreamer(currentStreamer);
await selectStreamer(currentStreamer, true);
}

View File

@ -82,13 +82,22 @@ function applyLanguageToStaticUI(): void {
setText('modeFullText', UI_TEXT.static.modeFull);
setText('modePartsText', UI_TEXT.static.modeParts);
setText('partMinutesLabel', UI_TEXT.static.partMinutesLabel);
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);
setText('duplicatePreventionLabel', UI_TEXT.static.duplicatePreventionLabel);
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);
@ -114,6 +123,10 @@ function applyLanguageToStaticUI(): void {
setText('debugLogTitle', UI_TEXT.static.debugLogTitle);
setText('btnRefreshLog', UI_TEXT.static.refreshLog);
setText('autoRefreshText', UI_TEXT.static.autoRefresh);
setText('runtimeMetricsTitle', UI_TEXT.static.runtimeMetricsTitle);
setText('btnRefreshMetrics', UI_TEXT.static.runtimeMetricsRefresh);
setText('runtimeMetricsAutoRefreshText', UI_TEXT.static.runtimeMetricsAutoRefresh);
setText('runtimeMetricsOutput', UI_TEXT.static.runtimeMetricsLoading);
setText('updateText', UI_TEXT.updates.bannerDefault);
setText('updateButton', UI_TEXT.updates.downloadNow);
setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder);

View File

@ -18,6 +18,10 @@ async function init(): Promise<void> {
updateLanguagePicker(config.language ?? 'en');
byId<HTMLSelectElement>('downloadMode').value = config.download_mode ?? 'full';
byId<HTMLInputElement>('partMinutes').value = String(config.part_minutes ?? 120);
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>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10);
byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || DEFAULT_VOD_TEMPLATE;
byId<HTMLInputElement>('partsFilenameTemplate').value = (config.filename_template_parts as string) || DEFAULT_PARTS_TEMPLATE;
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE;
@ -86,6 +90,8 @@ async function init(): Promise<void> {
void runPreflight(false);
void refreshDebugLog();
validateFilenameTemplates();
void refreshRuntimeMetrics();
setInterval(() => {
void syncQueueAndDownloadState();
@ -575,9 +581,19 @@ function updateFilenameExamples(): void {
const durationSec = Math.max(1, endSec - startSec);
const timeStr = formatSecondsToTimeDashed(startSec);
const template = byId<HTMLInputElement>('clipFilenameTemplate').value.trim() || (config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE;
const unknownTokens = collectUnknownTemplatePlaceholders(template);
const clipLint = byId('clipTemplateLint');
updateFilenameTemplateVisibility();
if (!unknownTokens.length) {
clipLint.style.color = '#8bc34a';
clipLint.textContent = UI_TEXT.static.templateLintOk;
} else {
clipLint.style.color = '#ff8a80';
clipLint.textContent = `${UI_TEXT.static.templateLintWarn}: ${unknownTokens.join(' ')}`;
}
byId('formatSimple').textContent = `${dateStr}_${partNum}.mp4 ${UI_TEXT.clips.formatSimple}`;
byId('formatTimestamp').textContent = `${dateStr}_CLIP_${timeStr}_${partNum}.mp4 ${UI_TEXT.clips.formatTimestamp}`;
byId('formatTemplate').textContent = `${buildTemplatePreview(template, {
@ -623,7 +639,32 @@ async function confirmClipDialog(): Promise<void> {
return;
}
if (filenameFormat === 'template') {
const unknownTokens = collectUnknownTemplatePlaceholders(filenameTemplate);
if (unknownTokens.length > 0) {
alert(`${UI_TEXT.static.templateLintWarn}: ${unknownTokens.join(' ')}`);
return;
}
}
const durationSec = endSec - startSec;
const customClip: CustomClip = {
startSec,
durationSec,
startPart,
filenameFormat,
filenameTemplate: filenameFormat === 'template' ? filenameTemplate : undefined
};
if ((config.prevent_duplicate_downloads as boolean) !== false && hasActiveQueueDuplicate(
clipDialogData.url,
clipDialogData.streamer,
clipDialogData.date,
customClip
)) {
alert(UI_TEXT.queue.duplicateSkipped);
return;
}
queue = await window.api.addToQueue({
url: clipDialogData.url,
@ -631,13 +672,7 @@ async function confirmClipDialog(): Promise<void> {
date: clipDialogData.date,
streamer: clipDialogData.streamer,
duration_str: clipDialogData.duration,
customClip: {
startSec,
durationSec,
startPart,
filenameFormat,
filenameTemplate: filenameFormat === 'template' ? filenameTemplate : undefined
}
customClip
});
renderQueue();