Add smart optimization suite with telemetry and guardrails (v4.1.0)
This commit is contained in:
parent
886500ad8e
commit
9609c6e767
@ -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
|
||||
```
|
||||
|
||||
@ -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`
|
||||
|
||||
4
typescript-version/package-lock.json
generated
4
typescript-version/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) => {
|
||||
|
||||
47
typescript-version/src/renderer-globals.d.ts
vendored
47
typescript-version/src/renderer-globals.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user