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 @@
+
+
+
+
+
+
+
+
+
+
+
+
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();