diff --git a/docs/src/pages/development.mdx b/docs/src/pages/development.mdx index a5aad48..3e6116e 100644 --- a/docs/src/pages/development.mdx +++ b/docs/src/pages/development.mdx @@ -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 ``` diff --git a/docs/src/pages/release-process.mdx b/docs/src/pages/release-process.mdx index e74b40a..b06b5df 100644 --- a/docs/src/pages/release-process.mdx +++ b/docs/src/pages/release-process.mdx @@ -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` diff --git a/typescript-version/package-lock.json b/typescript-version/package-lock.json index 548ee06..a1b69cd 100644 --- a/typescript-version/package-lock.json +++ b/typescript-version/package-lock.json @@ -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", diff --git a/typescript-version/package.json b/typescript-version/package.json index 00d651f..362272a 100644 --- a/typescript-version/package.json +++ b/typescript-version/package.json @@ -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" diff --git a/typescript-version/scripts/smoke-test-full.js b/typescript-version/scripts/smoke-test-full.js index 6da9f35..0a37389 100644 --- a/typescript-version/scripts/smoke-test-full.js +++ b/typescript-version/scripts/smoke-test-full.js @@ -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'); diff --git a/typescript-version/src/index.html b/typescript-version/src/index.html index 61cd287..feaaa40 100644 --- a/typescript-version/src/index.html +++ b/typescript-version/src/index.html @@ -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()">
Platzhalter: {title} {id} {channel} {date} {part} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}
+
Template-Check: OK
@@ -407,28 +408,56 @@ +
+ + +
+
+ + +
+
+ + +
+
+ + + +
- + - + - +
Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}
+
Template-Check: OK

Updates

-

Version: v4.0.8

+

Version: v4.1.0

@@ -452,6 +481,18 @@
Lade...
+ +
+

Runtime Metrics

+
+ + +
+
Lade...
+
@@ -460,7 +501,7 @@
Nicht verbunden - v4.0.8 + v4.1.0 diff --git a/typescript-version/src/main.ts b/typescript-version/src/main.ts index 26710da..198a53e 100644 --- a/typescript-version/src/main.ts +++ b/typescript-version/src/main.ts @@ -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 { + 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(); +const loginToUserIdCache = new Map>(); +const vodListCache = new Map>(); +const clipInfoCache = new Map>(); +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): 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): 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 { 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( 'query($login:String!){ user(login:$login){ id login } }', @@ -890,6 +1246,7 @@ async function getPublicUserId(username: string): Promise { 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 { 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 { 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 { 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 { } } -async function getVODs(userId: string): Promise { +async function getVODs(userId: string, forceRefresh = false): Promise { + 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 { 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 { 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 { } async function getClipInfo(clipId: string): Promise { + 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 { 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,43 +1592,70 @@ async function cutVideo( return false; } - return new Promise((resolve) => { - const ffmpeg = getFFmpegPath(); - const duration = endTime - startTime; + const ffmpeg = getFFmpegPath(); + const duration = Math.max(0.1, endTime - startTime); + const runCutAttempt = async (copyMode: boolean): Promise => { const args = [ '-ss', formatDuration(startTime), '-i', inputFile, - '-t', formatDuration(duration), - '-c', 'copy', - '-progress', 'pipe:1', - '-y', - outputFile + '-t', formatDuration(duration) ]; - const proc = spawn(ffmpeg, args, { windowsHide: true }); - currentProcess = proc; + if (copyMode) { + args.push('-c', 'copy'); + } else { + args.push( + '-c:v', 'libx264', + '-preset', 'veryfast', + '-crf', '20', + '-c:a', 'aac', + '-b:a', '160k', + '-movflags', '+faststart' + ); + } - proc.stdout?.on('data', (data) => { - const line = data.toString(); - const match = line.match(/out_time_us=(\d+)/); - if (match) { - const currentUs = parseInt(match[1]); - const percent = Math.min(100, (currentUs / 1000000) / duration * 100); - onProgress(percent); - } - }); + args.push('-progress', 'pipe:1', '-y', outputFile); - proc.on('close', (code) => { - currentProcess = null; - resolve(code === 0 && fs.existsSync(outputFile)); - }); + appendDebugLog('cut-video-attempt', { copyMode, args }); - proc.on('error', () => { - currentProcess = null; - resolve(false); + return await new Promise((resolve) => { + const proc = spawn(ffmpeg, args, { windowsHide: true }); + currentProcess = proc; + + proc.stdout?.on('data', (data) => { + const line = data.toString(); + const match = line.match(/out_time_us=(\d+)/); + if (match) { + const currentUs = parseInt(match[1], 10); + const percent = Math.min(100, (currentUs / 1000000) / duration * 100); + onProgress(percent); + } + }); + + proc.on('close', (code) => { + currentProcess = null; + resolve(code === 0 && fs.existsSync(outputFile)); + }); + + proc.on('error', () => { + currentProcess = null; + 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,65 +1672,80 @@ 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'); - fs.writeFileSync(concatFile, concatContent); + const ffmpeg = getFFmpegPath(); + const concatFile = path.join(app.getPath('temp'), `concat_${Date.now()}.txt`); + const concatContent = inputFiles.map((filePath) => `file '${filePath.replace(/'/g, "'\\''")}'`).join('\n'); + fs.writeFileSync(concatFile, concatContent); + const runMergeAttempt = async (copyMode: boolean): Promise => { const args = [ '-f', 'concat', '-safe', '0', - '-i', concatFile, - '-c', 'copy', - '-progress', 'pipe:1', - '-y', - outputFile + '-i', concatFile ]; - 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 { } + if (copyMode) { + args.push('-c', 'copy'); + } else { + args.push( + '-c:v', 'libx264', + '-preset', 'veryfast', + '-crf', '20', + '-c:a', 'aac', + '-b:a', '160k', + '-movflags', '+faststart' + ); } - 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 - onProgress(Math.min(99, currentUs / 10000000)); - } - }); + args.push('-progress', 'pipe:1', '-y', outputFile); + appendDebugLog('merge-video-attempt', { copyMode, argsCount: args.length }); - proc.on('close', (code) => { - currentProcess = null; - try { - fs.unlinkSync(concatFile); - } catch { } + return await new Promise((resolve) => { + const proc = spawn(ffmpeg, args, { windowsHide: true }); + currentProcess = proc; - if (code === 0 && fs.existsSync(outputFile)) { - onProgress(100); - resolve(true); - } else { + proc.stdout?.on('data', (data) => { + const line = data.toString(); + const match = line.match(/out_time_us=(\d+)/); + if (match) { + const currentUs = parseInt(match[1], 10); + onProgress(Math.min(99, currentUs / 10000000)); + } + }); + + proc.on('close', (code) => { + currentProcess = null; + const success = code === 0 && fs.existsSync(outputFile); + if (success) { + onProgress(100); + } + resolve(success); + }); + + proc.on('error', () => { + currentProcess = null; resolve(false); - } + }); }); + }; - proc.on('error', () => { - currentProcess = null; - 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,15 +1863,28 @@ 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 }); + 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 tooSmall = `Datei zu klein (${stats.size} Bytes)`; - appendDebugLog('download-part-failed-small-file', { itemId, filename, bytes: stats.size }); - resolve({ success: false, error: tooSmall }); + 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; } @@ -1636,19 +2109,35 @@ async function downloadVOD( } async function processQueue(): Promise { - 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 { 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 { 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 { 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) => { 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) => { 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) => { + 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); diff --git a/typescript-version/src/preload.ts b/typescript-version/src/preload.ts index bbbaf39..87ca0bc 100644 --- a/typescript-version/src/preload.ts +++ b/typescript-version/src/preload.ts @@ -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 => ipcRenderer.invoke('get-runtime-metrics'), // Events onDownloadProgress: (callback: (progress: DownloadProgress) => void) => { diff --git a/typescript-version/src/renderer-globals.d.ts b/typescript-version/src/renderer-globals.d.ts index 423a20e..a30488d 100644 --- a/typescript-version/src/renderer-globals.d.ts +++ b/typescript-version/src/renderer-globals.d.ts @@ -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): Promise; login(): Promise; getUserId(username: string): Promise; - getVODs(userId: string): Promise; + getVODs(userId: string, forceRefresh?: boolean): Promise; getQueue(): Promise; addToQueue(item: Omit): Promise; removeFromQueue(id: string): Promise; @@ -140,6 +184,7 @@ interface ApiBridge { openExternal(url: string): Promise; runPreflight(autoFix: boolean): Promise; getDebugLog(lines: number): Promise; + getRuntimeMetrics(): Promise; onDownloadProgress(callback: (progress: DownloadProgress) => void): void; onQueueUpdated(callback: (queue: QueueItem[]) => void): void; onDownloadStarted(callback: () => void): void; diff --git a/typescript-version/src/renderer-locale-de.ts b/typescript-version/src/renderer-locale-de.ts index 377f85c..2720645 100644 --- a/typescript-version/src/renderer-locale-de.ts +++ b/typescript-version/src/renderer-locale-de.ts @@ -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', diff --git a/typescript-version/src/renderer-locale-en.ts b/typescript-version/src/renderer-locale-en.ts index 535d6e7..b8ea1f4 100644 --- a/typescript-version/src/renderer-locale-en.ts +++ b/typescript-version/src/renderer-locale-en.ts @@ -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', diff --git a/typescript-version/src/renderer-queue.ts b/typescript-version/src/renderer-queue.ts index 3e4a57c..7c37797 100644 --- a/typescript-version/src/renderer-queue.ts +++ b/typescript-version/src/renderer-queue.ts @@ -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 { + 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, diff --git a/typescript-version/src/renderer-settings.ts b/typescript-version/src/renderer-settings.ts index eec45b2..d2ab9dc 100644 --- a/typescript-version/src/renderer-settings.ts +++ b/typescript-version/src/renderer-settings.ts @@ -12,6 +12,105 @@ async function connect(): Promise { 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('vodFilenameTemplate').value.trim(), + byId('partsFilenameTemplate').value.trim(), + byId('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 = { + 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('vodFilenameTemplate').value = selected.vod; + byId('partsFilenameTemplate').value = selected.parts; + byId('defaultClipFilenameTemplate').value = selected.clip; + validateFilenameTemplates(); +} + +async function refreshRuntimeMetrics(): Promise { + 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)[activeTab] || UI_TEXT.appName; } + + void refreshRuntimeMetrics(); + validateFilenameTemplates(); } function updateLanguagePicker(lang: string): void { @@ -130,26 +232,44 @@ async function saveSettings(): Promise { const downloadPath = byId('downloadPath').value; const downloadMode = byId('downloadMode').value as 'parts' | 'full'; const partMinutes = parseInt(byId('partMinutes').value, 10) || 120; + const performanceMode = byId('performanceMode').value as 'stability' | 'balanced' | 'speed'; + const smartQueueScheduler = byId('smartSchedulerToggle').checked; + const duplicatePrevention = byId('duplicatePreventionToggle').checked; + const metadataCacheMinutes = parseInt(byId('metadataCacheMinutes').value, 10) || 10; const vodFilenameTemplate = byId('vodFilenameTemplate').value.trim() || '{title}.mp4'; const partsFilenameTemplate = byId('partsFilenameTemplate').value.trim() || '{date}_Part{part_padded}.mp4'; const defaultClipFilenameTemplate = byId('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('performanceMode').value = (config.performance_mode as string) || 'balanced'; + byId('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false; + byId('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false; + byId('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10); byId('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4'; byId('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4'; byId('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || '{date}_{part}.mp4'; + validateFilenameTemplates(); await connect(); + await refreshRuntimeMetrics(); } async function selectFolder(): Promise { diff --git a/typescript-version/src/renderer-shared.ts b/typescript-version/src/renderer-shared.ts index 05535c0..963bfd3 100644 --- a/typescript-version/src/renderer-shared.ts +++ b/typescript-version/src/renderer-shared.ts @@ -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)); +} diff --git a/typescript-version/src/renderer-streamers.ts b/typescript-version/src/renderer-streamers.ts index 0d9c0d0..9c1b5ea 100644 --- a/typescript-version/src/renderer-streamers.ts +++ b/typescript-version/src/renderer-streamers.ts @@ -49,7 +49,7 @@ async function removeStreamer(name: string): Promise { `; } -async function selectStreamer(name: string): Promise { +async function selectStreamer(name: string, forceRefresh = false): Promise { currentStreamer = name; renderStreamers(); byId('pageTitle').textContent = name; @@ -70,7 +70,7 @@ async function selectStreamer(name: string): Promise { 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 { return; } - await selectStreamer(currentStreamer); + await selectStreamer(currentStreamer, true); } diff --git a/typescript-version/src/renderer-texts.ts b/typescript-version/src/renderer-texts.ts index 2e31770..fb05fdc 100644 --- a/typescript-version/src/renderer-texts.ts +++ b/typescript-version/src/renderer-texts.ts @@ -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); diff --git a/typescript-version/src/renderer.ts b/typescript-version/src/renderer.ts index 8d3f689..5b6d4bc 100644 --- a/typescript-version/src/renderer.ts +++ b/typescript-version/src/renderer.ts @@ -18,6 +18,10 @@ async function init(): Promise { updateLanguagePicker(config.language ?? 'en'); byId('downloadMode').value = config.download_mode ?? 'full'; byId('partMinutes').value = String(config.part_minutes ?? 120); + byId('performanceMode').value = (config.performance_mode as string) || 'balanced'; + byId('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false; + byId('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false; + byId('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10); byId('vodFilenameTemplate').value = (config.filename_template_vod as string) || DEFAULT_VOD_TEMPLATE; byId('partsFilenameTemplate').value = (config.filename_template_parts as string) || DEFAULT_PARTS_TEMPLATE; byId('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE; @@ -86,6 +90,8 @@ async function init(): Promise { 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('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 { 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 { date: clipDialogData.date, streamer: clipDialogData.streamer, duration_str: clipDialogData.duration, - customClip: { - startSec, - durationSec, - startPart, - filenameFormat, - filenameTemplate: filenameFormat === 'template' ? filenameTemplate : undefined - } + customClip }); renderQueue();