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

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

View File

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

View File

@ -24,6 +24,14 @@ cd "typescript-version"
npm run dist:win 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/`: Expected outputs in `typescript-version/release/`:
- `latest.yml` - `latest.yml`

View File

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

View File

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

View File

@ -248,6 +248,36 @@ async function run() {
await clearQueue(); 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'); window.showTab('clips');
const clipUrl = document.getElementById('clipUrl'); const clipUrl = document.getElementById('clipUrl');
clipUrl.value = ''; clipUrl.value = '';
@ -266,7 +296,7 @@ async function run() {
window.updateFromInput('start'); window.updateFromInput('start');
window.updateFromInput('end'); window.updateFromInput('end');
await window.confirmClipDialog(); await window.confirmClipDialog();
let q = await window.api.getQueue(); q = await window.api.getQueue();
const clipItem = q.find((item) => item.title === '__E2E_FULL__clip'); const clipItem = q.find((item) => item.title === '__E2E_FULL__clip');
checks.clipQueue = { queued: !!clipItem, duration: clipItem?.customClip?.durationSec || 0 }; checks.clipQueue = { queued: !!clipItem, duration: clipItem?.customClip?.durationSec || 0 };
assert(Boolean(clipItem && clipItem.customClip && clipItem.customClip.durationSec === 12), 'Clip dialog queue entry invalid'); assert(Boolean(clipItem && clipItem.customClip && clipItem.customClip.durationSec === 12), 'Clip dialog queue entry invalid');

View File

@ -92,6 +92,7 @@
style="width: 100%; background: #333; border: 1px solid #444; border-radius: 4px; padding: 8px 12px; color: white; font-family: monospace;" style="width: 100%; background: #333; border: 1px solid #444; border-radius: 4px; padding: 8px 12px; color: white; font-family: monospace;"
oninput="updateFilenameExamples()"> oninput="updateFilenameExamples()">
<div id="clipTemplateHelp" style="color: #888; font-size: 12px; margin-top: 6px;">Platzhalter: {title} {id} {channel} {date} {part} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div> <div id="clipTemplateHelp" style="color: #888; font-size: 12px; margin-top: 6px;">Platzhalter: {title} {id} {channel} {date} {part} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
<div id="clipTemplateLint" style="color: #8bc34a; font-size: 12px; margin-top: 4px;">Template-Check: OK</div>
<button class="btn-secondary" id="clipTemplateGuideBtn" style="margin-top: 8px;" onclick="openTemplateGuide('clip')">Template Guide</button> <button class="btn-secondary" id="clipTemplateGuideBtn" style="margin-top: 8px;" onclick="openTemplateGuide('clip')">Template Guide</button>
</div> </div>
</div> </div>
@ -407,28 +408,56 @@
<label id="partMinutesLabel">Teil-Lange (Minuten)</label> <label id="partMinutesLabel">Teil-Lange (Minuten)</label>
<input type="number" id="partMinutes" value="120" min="10" max="480"> <input type="number" id="partMinutes" value="120" min="10" max="480">
</div> </div>
<div class="form-group">
<label id="performanceModeLabel">Performance-Profil</label>
<select id="performanceMode">
<option value="stability" id="performanceModeStability">Max Stabilitat</option>
<option value="balanced" id="performanceModeBalanced">Ausgewogen</option>
<option value="speed" id="performanceModeSpeed">Max Geschwindigkeit</option>
</select>
</div>
<div class="form-group">
<label style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" id="smartSchedulerToggle" checked>
<span id="smartSchedulerLabel">Smart Queue Scheduler aktivieren</span>
</label>
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="duplicatePreventionToggle" checked>
<span id="duplicatePreventionLabel">Duplikate in Queue verhindern</span>
</label>
</div>
<div class="form-group">
<label id="metadataCacheMinutesLabel">Metadata-Cache (Minuten)</label>
<input type="number" id="metadataCacheMinutes" value="10" min="1" max="120">
</div>
<div class="form-group"> <div class="form-group">
<div class="form-row" style="align-items:center; margin-bottom: 4px;"> <div class="form-row" style="align-items:center; margin-bottom: 4px;">
<label id="filenameTemplatesTitle" style="margin: 0;">Dateinamen-Templates</label> <label id="filenameTemplatesTitle" style="margin: 0;">Dateinamen-Templates</label>
<button class="btn-secondary" id="settingsTemplateGuideBtn" type="button" onclick="openTemplateGuide('vod')">Template Guide</button> <button class="btn-secondary" id="settingsTemplateGuideBtn" type="button" onclick="openTemplateGuide('vod')">Template Guide</button>
</div> </div>
<div class="form-row" style="gap: 8px; margin: 8px 0 6px;">
<button class="btn-secondary" id="templatePresetDefault" type="button" onclick="applyTemplatePreset('default')">Preset: Default</button>
<button class="btn-secondary" id="templatePresetArchive" type="button" onclick="applyTemplatePreset('archive')">Preset: Archive</button>
<button class="btn-secondary" id="templatePresetClipper" type="button" onclick="applyTemplatePreset('clipper')">Preset: Clipper</button>
</div>
<div style="display: grid; gap: 8px; margin-top: 8px;"> <div style="display: grid; gap: 8px; margin-top: 8px;">
<label id="vodTemplateLabel" style="font-size: 13px; color: var(--text-secondary);">VOD Template</label> <label id="vodTemplateLabel" style="font-size: 13px; color: var(--text-secondary);">VOD Template</label>
<input type="text" id="vodFilenameTemplate" placeholder="{title}.mp4" style="font-family: monospace;"> <input type="text" id="vodFilenameTemplate" placeholder="{title}.mp4" style="font-family: monospace;" oninput="validateFilenameTemplates()">
<label id="partsTemplateLabel" style="font-size: 13px; color: var(--text-secondary); margin-top: 4px;">VOD Part Template</label> <label id="partsTemplateLabel" style="font-size: 13px; color: var(--text-secondary); margin-top: 4px;">VOD Part Template</label>
<input type="text" id="partsFilenameTemplate" placeholder="{date}_Part{part_padded}.mp4" style="font-family: monospace;"> <input type="text" id="partsFilenameTemplate" placeholder="{date}_Part{part_padded}.mp4" style="font-family: monospace;" oninput="validateFilenameTemplates()">
<label id="defaultClipTemplateLabel" style="font-size: 13px; color: var(--text-secondary); margin-top: 4px;">Clip Template</label> <label id="defaultClipTemplateLabel" style="font-size: 13px; color: var(--text-secondary); margin-top: 4px;">Clip Template</label>
<input type="text" id="defaultClipFilenameTemplate" placeholder="{date}_{part}.mp4" style="font-family: monospace;"> <input type="text" id="defaultClipFilenameTemplate" placeholder="{date}_{part}.mp4" style="font-family: monospace;" oninput="validateFilenameTemplates()">
</div> </div>
<div id="filenameTemplateHint" style="color: #888; font-size: 12px; margin-top: 8px;">Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div> <div id="filenameTemplateHint" style="color: #888; font-size: 12px; margin-top: 8px;">Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
<div id="filenameTemplateLint" style="font-size: 12px; margin-top: 6px; color: #8bc34a;">Template-Check: OK</div>
</div> </div>
</div> </div>
<div class="settings-card"> <div class="settings-card">
<h3 id="updateTitle">Updates</h3> <h3 id="updateTitle">Updates</h3>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.0.8</p> <p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.1.0</p>
<button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button> <button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div> </div>
@ -452,6 +481,18 @@
</div> </div>
<pre id="debugLogOutput" class="log-panel">Lade...</pre> <pre id="debugLogOutput" class="log-panel">Lade...</pre>
</div> </div>
<div class="settings-card">
<h3 id="runtimeMetricsTitle">Runtime Metrics</h3>
<div class="form-row" style="margin-bottom: 10px; align-items: center;">
<button class="btn-secondary" id="btnRefreshMetrics" onclick="refreshRuntimeMetrics()">Aktualisieren</button>
<label style="display:flex; align-items:center; gap:6px; font-size:13px; color: var(--text-secondary);">
<input type="checkbox" id="runtimeMetricsAutoRefresh" onchange="toggleRuntimeMetricsAutoRefresh(this.checked)">
<span id="runtimeMetricsAutoRefreshText">Auto-Refresh</span>
</label>
</div>
<pre id="runtimeMetricsOutput" class="log-panel">Lade...</pre>
</div>
</div> </div>
</div> </div>
@ -460,7 +501,7 @@
<div class="status-dot" id="statusDot"></div> <div class="status-dot" id="statusDot"></div>
<span id="statusText">Nicht verbunden</span> <span id="statusText">Nicht verbunden</span>
</div> </div>
<span id="versionText">v4.0.8</span> <span id="versionText">v4.1.0</span>
</div> </div>
</main> </main>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,7 @@ interface DownloadProgress {
id: string; id: string;
progress: number; progress: number;
speed: string; speed: string;
speedBytesPerSec?: number;
eta: string; eta: string;
status: string; status: string;
currentPart?: number; currentPart?: number;
@ -37,6 +38,45 @@ interface DownloadProgress {
totalBytes?: number; 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 { interface VideoInfo {
duration: number; duration: number;
width: number; width: number;
@ -55,7 +95,7 @@ contextBridge.exposeInMainWorld('api', {
// Twitch API // Twitch API
getUserId: (username: string) => ipcRenderer.invoke('get-user-id', username), 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 // Queue
getQueue: () => ipcRenderer.invoke('get-queue'), getQueue: () => ipcRenderer.invoke('get-queue'),
@ -97,6 +137,7 @@ contextBridge.exposeInMainWorld('api', {
openExternal: (url: string) => ipcRenderer.invoke('open-external', url), openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
runPreflight: (autoFix: boolean) => ipcRenderer.invoke('run-preflight', autoFix), runPreflight: (autoFix: boolean) => ipcRenderer.invoke('run-preflight', autoFix),
getDebugLog: (lines: number) => ipcRenderer.invoke('get-debug-log', lines), getDebugLog: (lines: number) => ipcRenderer.invoke('get-debug-log', lines),
getRuntimeMetrics: (): Promise<RuntimeMetricsSnapshot> => ipcRenderer.invoke('get-runtime-metrics'),
// Events // Events
onDownloadProgress: (callback: (progress: DownloadProgress) => void) => { onDownloadProgress: (callback: (progress: DownloadProgress) => void) => {

View File

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

View File

@ -40,6 +40,13 @@ const UI_TEXT_DE = {
modeFull: 'Ganzes VOD', modeFull: 'Ganzes VOD',
modeParts: 'In Teile splitten', modeParts: 'In Teile splitten',
partMinutesLabel: 'Teil-Lange (Minuten)', 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', filenameTemplatesTitle: 'Dateinamen-Templates',
vodTemplateLabel: 'VOD-Template', vodTemplateLabel: 'VOD-Template',
partsTemplateLabel: 'VOD-Teile-Template', partsTemplateLabel: 'VOD-Teile-Template',
@ -48,6 +55,8 @@ const UI_TEXT_DE = {
vodTemplatePlaceholder: '{title}.mp4', vodTemplatePlaceholder: '{title}.mp4',
partsTemplatePlaceholder: '{date}_Part{part_padded}.mp4', partsTemplatePlaceholder: '{date}_Part{part_padded}.mp4',
defaultClipTemplatePlaceholder: '{date}_{part}.mp4', defaultClipTemplatePlaceholder: '{date}_{part}.mp4',
templateLintOk: 'Template-Check: OK',
templateLintWarn: 'Unbekannte Platzhalter',
templateGuideButton: 'Template Guide', templateGuideButton: 'Template Guide',
templateGuideTitle: 'Dateinamen-Template Guide', templateGuideTitle: 'Dateinamen-Template Guide',
templateGuideIntro: 'Nutze Platzhalter fur Dateinamen und teste dein Muster mit einer Live-Vorschau.', 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', templateGuideContextParts: 'Kontext: Beispiel fur VOD-Teil',
templateGuideContextClip: 'Kontext: Beispiel fur Clip-Zuschnitt', templateGuideContextClip: 'Kontext: Beispiel fur Clip-Zuschnitt',
templateGuideContextClipLive: 'Kontext: Aktuelle Auswahl im Clip-Dialog', 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', updateTitle: 'Updates',
checkUpdates: 'Nach Updates suchen', checkUpdates: 'Nach Updates suchen',
preflightTitle: 'System-Check', preflightTitle: 'System-Check',
@ -117,7 +141,8 @@ const UI_TEXT_DE = {
speed: 'Geschwindigkeit', speed: 'Geschwindigkeit',
eta: 'Restzeit', eta: 'Restzeit',
part: 'Teil', 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: { vods: {
noneTitle: 'Keine VODs', noneTitle: 'Keine VODs',

View File

@ -40,6 +40,13 @@ const UI_TEXT_EN = {
modeFull: 'Full VOD', modeFull: 'Full VOD',
modeParts: 'Split into parts', modeParts: 'Split into parts',
partMinutesLabel: 'Part Length (Minutes)', 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', filenameTemplatesTitle: 'Filename Templates',
vodTemplateLabel: 'VOD Template', vodTemplateLabel: 'VOD Template',
partsTemplateLabel: 'VOD Part Template', partsTemplateLabel: 'VOD Part Template',
@ -48,6 +55,8 @@ const UI_TEXT_EN = {
vodTemplatePlaceholder: '{title}.mp4', vodTemplatePlaceholder: '{title}.mp4',
partsTemplatePlaceholder: '{date}_Part{part_padded}.mp4', partsTemplatePlaceholder: '{date}_Part{part_padded}.mp4',
defaultClipTemplatePlaceholder: '{date}_{part}.mp4', defaultClipTemplatePlaceholder: '{date}_{part}.mp4',
templateLintOk: 'Template check: OK',
templateLintWarn: 'Unknown placeholder(s)',
templateGuideButton: 'Template Guide', templateGuideButton: 'Template Guide',
templateGuideTitle: 'Filename Template Guide', templateGuideTitle: 'Filename Template Guide',
templateGuideIntro: 'Use placeholders for filenames and test your pattern with a live preview.', 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', templateGuideContextParts: 'Context: Sample split VOD part',
templateGuideContextClip: 'Context: Sample clip trim', templateGuideContextClip: 'Context: Sample clip trim',
templateGuideContextClipLive: 'Context: Current clip dialog selection', 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', updateTitle: 'Updates',
checkUpdates: 'Check for updates', checkUpdates: 'Check for updates',
preflightTitle: 'System Check', preflightTitle: 'System Check',
@ -117,7 +141,8 @@ const UI_TEXT_EN = {
speed: 'Speed', speed: 'Speed',
eta: 'ETA', eta: 'ETA',
part: 'Part', 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: { vods: {
noneTitle: 'No VODs', noneTitle: 'No VODs',

View File

@ -1,4 +1,40 @@
function buildQueueFingerprint(url: string, streamer: string, date: string, customClip?: CustomClip): string {
const clipFingerprint = customClip
? [
'clip',
customClip.startSec,
customClip.durationSec,
customClip.startPart,
customClip.filenameFormat,
(customClip.filenameTemplate || '').trim().toLowerCase()
].join(':')
: 'vod';
return [
(url || '').trim().toLowerCase().replace(/^https?:\/\/(www\.)?/, ''),
(streamer || '').trim().toLowerCase(),
(date || '').trim(),
clipFingerprint
].join('|');
}
function hasActiveQueueDuplicate(url: string, streamer: string, date: string, customClip?: CustomClip): boolean {
const target = buildQueueFingerprint(url, streamer, date, customClip);
return queue.some((item) => {
if (item.status !== 'pending' && item.status !== 'downloading' && item.status !== 'paused') {
return false;
}
return buildQueueFingerprint(item.url, item.streamer, item.date, item.customClip) === target;
});
}
async function addToQueue(url: string, title: string, date: string, streamer: string, duration: string): Promise<void> { async function addToQueue(url: string, title: string, date: string, streamer: string, duration: string): Promise<void> {
if ((config.prevent_duplicate_downloads as boolean) !== false && hasActiveQueueDuplicate(url, streamer, date)) {
alert(UI_TEXT.queue.duplicateSkipped);
return;
}
queue = await window.api.addToQueue({ queue = await window.api.addToQueue({
url, url,
title, title,

View File

@ -12,6 +12,105 @@ async function connect(): Promise<void> {
updateStatus(success ? UI_TEXT.status.connected : UI_TEXT.status.connectFailedPublic, success); updateStatus(success ? UI_TEXT.status.connected : UI_TEXT.status.connectFailedPublic, success);
} }
function formatBytesForMetrics(bytes: number): string {
const value = Math.max(0, Number(bytes) || 0);
if (value < 1024) return `${value.toFixed(0)} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`;
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function validateFilenameTemplates(showAlert = false): boolean {
const templates = [
byId<HTMLInputElement>('vodFilenameTemplate').value.trim(),
byId<HTMLInputElement>('partsFilenameTemplate').value.trim(),
byId<HTMLInputElement>('defaultClipFilenameTemplate').value.trim()
];
const unknown = templates.flatMap((template) => collectUnknownTemplatePlaceholders(template));
const uniqueUnknown = Array.from(new Set(unknown));
const lintNode = byId('filenameTemplateLint');
if (!uniqueUnknown.length) {
lintNode.style.color = '#8bc34a';
lintNode.textContent = UI_TEXT.static.templateLintOk;
return true;
}
lintNode.style.color = '#ff8a80';
lintNode.textContent = `${UI_TEXT.static.templateLintWarn}: ${uniqueUnknown.join(' ')}`;
if (showAlert) {
alert(`${UI_TEXT.static.templateLintWarn}: ${uniqueUnknown.join(' ')}`);
}
return false;
}
function applyTemplatePreset(preset: string): void {
const presets: Record<string, { vod: string; parts: string; clip: string }> = {
default: {
vod: '{title}.mp4',
parts: '{date}_Part{part_padded}.mp4',
clip: '{date}_{part}.mp4'
},
archive: {
vod: '{channel}_{date_custom="yyyy-MM-dd"}_{title}.mp4',
parts: '{channel}_{date_custom="yyyy-MM-dd"}_Part{part_padded}.mp4',
clip: '{channel}_{date_custom="yyyy-MM-dd"}_{trim_start}_{part}.mp4'
},
clipper: {
vod: '{date_custom="yyyy-MM-dd"}_{title}.mp4',
parts: '{date_custom="yyyy-MM-dd"}_{part_padded}_{trim_start}.mp4',
clip: '{title}_{trim_start_custom="HH-mm-ss"}_{part}.mp4'
}
};
const selected = presets[preset] || presets.default;
byId<HTMLInputElement>('vodFilenameTemplate').value = selected.vod;
byId<HTMLInputElement>('partsFilenameTemplate').value = selected.parts;
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = selected.clip;
validateFilenameTemplates();
}
async function refreshRuntimeMetrics(): Promise<void> {
const output = byId('runtimeMetricsOutput');
output.textContent = UI_TEXT.static.runtimeMetricsLoading;
try {
const metrics = await window.api.getRuntimeMetrics();
const lines = [
`${UI_TEXT.static.runtimeMetricQueue}: ${metrics.queue.total} total (${metrics.queue.pending} pending, ${metrics.queue.downloading} downloading, ${metrics.queue.error} failed)`,
`${UI_TEXT.static.runtimeMetricMode}: ${metrics.config.performanceMode} | smartScheduler=${metrics.config.smartScheduler} | dedupe=${metrics.config.duplicatePrevention}`,
`${UI_TEXT.static.runtimeMetricRetries}: ${metrics.retriesScheduled} scheduled, ${metrics.retriesExhausted} exhausted`,
`${UI_TEXT.static.runtimeMetricIntegrity}: ${metrics.integrityFailures}`,
`${UI_TEXT.static.runtimeMetricCache}: hits=${metrics.cacheHits}, misses=${metrics.cacheMisses}, vod=${metrics.caches.vodList}, users=${metrics.caches.loginToUserId}, clips=${metrics.caches.clipInfo}`,
`${UI_TEXT.static.runtimeMetricBandwidth}: current=${formatBytesForMetrics(metrics.lastSpeedBytesPerSec)}/s, avg=${formatBytesForMetrics(metrics.avgSpeedBytesPerSec)}/s`,
`${UI_TEXT.static.runtimeMetricDownloads}: started=${metrics.downloadsStarted}, done=${metrics.downloadsCompleted}, failed=${metrics.downloadsFailed}, bytes=${formatBytesForMetrics(metrics.downloadedBytesTotal)}`,
`${UI_TEXT.static.runtimeMetricActive}: ${metrics.activeItemTitle || '-'} (${metrics.activeItemId || '-'})`,
`${UI_TEXT.static.runtimeMetricLastError}: ${metrics.lastErrorClass || '-'}, retryDelay=${metrics.lastRetryDelaySeconds}s`,
`${UI_TEXT.static.runtimeMetricUpdated}: ${new Date(metrics.timestamp).toLocaleString(currentLanguage === 'en' ? 'en-US' : 'de-DE')}`
];
output.textContent = lines.join('\n');
} catch {
output.textContent = UI_TEXT.static.runtimeMetricsError;
}
}
function toggleRuntimeMetricsAutoRefresh(enabled: boolean): void {
if (runtimeMetricsAutoRefreshTimer) {
clearInterval(runtimeMetricsAutoRefreshTimer);
runtimeMetricsAutoRefreshTimer = null;
}
if (enabled) {
runtimeMetricsAutoRefreshTimer = window.setInterval(() => {
void refreshRuntimeMetrics();
}, 2000);
}
}
function updateStatus(text: string, connected: boolean): void { function updateStatus(text: string, connected: boolean): void {
byId('statusText').textContent = text; byId('statusText').textContent = text;
const dot = byId('statusDot'); const dot = byId('statusDot');
@ -39,6 +138,9 @@ function changeLanguage(lang: string): void {
} else { } else {
byId('pageTitle').textContent = (UI_TEXT.tabs as Record<string, string>)[activeTab] || UI_TEXT.appName; byId('pageTitle').textContent = (UI_TEXT.tabs as Record<string, string>)[activeTab] || UI_TEXT.appName;
} }
void refreshRuntimeMetrics();
validateFilenameTemplates();
} }
function updateLanguagePicker(lang: string): void { function updateLanguagePicker(lang: string): void {
@ -130,26 +232,44 @@ async function saveSettings(): Promise<void> {
const downloadPath = byId<HTMLInputElement>('downloadPath').value; const downloadPath = byId<HTMLInputElement>('downloadPath').value;
const downloadMode = byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full'; const downloadMode = byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full';
const partMinutes = parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120; const partMinutes = parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120;
const performanceMode = byId<HTMLSelectElement>('performanceMode').value as 'stability' | 'balanced' | 'speed';
const smartQueueScheduler = byId<HTMLInputElement>('smartSchedulerToggle').checked;
const duplicatePrevention = byId<HTMLInputElement>('duplicatePreventionToggle').checked;
const metadataCacheMinutes = parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10;
const vodFilenameTemplate = byId<HTMLInputElement>('vodFilenameTemplate').value.trim() || '{title}.mp4'; const vodFilenameTemplate = byId<HTMLInputElement>('vodFilenameTemplate').value.trim() || '{title}.mp4';
const partsFilenameTemplate = byId<HTMLInputElement>('partsFilenameTemplate').value.trim() || '{date}_Part{part_padded}.mp4'; const partsFilenameTemplate = byId<HTMLInputElement>('partsFilenameTemplate').value.trim() || '{date}_Part{part_padded}.mp4';
const defaultClipFilenameTemplate = byId<HTMLInputElement>('defaultClipFilenameTemplate').value.trim() || '{date}_{part}.mp4'; const defaultClipFilenameTemplate = byId<HTMLInputElement>('defaultClipFilenameTemplate').value.trim() || '{date}_{part}.mp4';
if (!validateFilenameTemplates(true)) {
return;
}
config = await window.api.saveConfig({ config = await window.api.saveConfig({
client_id: clientId, client_id: clientId,
client_secret: clientSecret, client_secret: clientSecret,
download_path: downloadPath, download_path: downloadPath,
download_mode: downloadMode, download_mode: downloadMode,
part_minutes: partMinutes, part_minutes: partMinutes,
performance_mode: performanceMode,
smart_queue_scheduler: smartQueueScheduler,
prevent_duplicate_downloads: duplicatePrevention,
metadata_cache_minutes: metadataCacheMinutes,
filename_template_vod: vodFilenameTemplate, filename_template_vod: vodFilenameTemplate,
filename_template_parts: partsFilenameTemplate, filename_template_parts: partsFilenameTemplate,
filename_template_clip: defaultClipFilenameTemplate filename_template_clip: defaultClipFilenameTemplate
}); });
byId<HTMLSelectElement>('performanceMode').value = (config.performance_mode as string) || 'balanced';
byId<HTMLInputElement>('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false;
byId<HTMLInputElement>('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false;
byId<HTMLInputElement>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10);
byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4'; byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4';
byId<HTMLInputElement>('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4'; byId<HTMLInputElement>('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4';
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || '{date}_{part}.mp4'; byId<HTMLInputElement>('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || '{date}_{part}.mp4';
validateFilenameTemplates();
await connect(); await connect();
await refreshRuntimeMetrics();
} }
async function selectFolder(): Promise<void> { async function selectFolder(): Promise<void> {

View File

@ -39,4 +39,43 @@ let clipTotalSeconds = 0;
let updateReady = false; let updateReady = false;
let debugLogAutoRefreshTimer: number | null = null; let debugLogAutoRefreshTimer: number | null = null;
let runtimeMetricsAutoRefreshTimer: number | null = null;
let draggedQueueItemId: string | null = null; let draggedQueueItemId: string | null = null;
const TEMPLATE_EXACT_TOKENS = new Set([
'{title}',
'{id}',
'{channel}',
'{channel_id}',
'{date}',
'{part}',
'{part_padded}',
'{trim_start}',
'{trim_end}',
'{trim_length}',
'{length}',
'{ext}',
'{random_string}'
]);
const TEMPLATE_CUSTOM_TOKEN_PATTERNS = [
/^\{date_custom=".*"\}$/,
/^\{trim_start_custom=".*"\}$/,
/^\{trim_end_custom=".*"\}$/,
/^\{trim_length_custom=".*"\}$/,
/^\{length_custom=".*"\}$/
];
function isKnownTemplateToken(token: string): boolean {
if (TEMPLATE_EXACT_TOKENS.has(token)) {
return true;
}
return TEMPLATE_CUSTOM_TOKEN_PATTERNS.some((pattern) => pattern.test(token));
}
function collectUnknownTemplatePlaceholders(template: string): string[] {
const tokens = (template.match(/\{[^{}]+\}/g) || []).map((token) => token.trim());
const unknown = tokens.filter((token) => !isKnownTemplateToken(token));
return Array.from(new Set(unknown));
}

View File

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

View File

@ -82,13 +82,22 @@ function applyLanguageToStaticUI(): void {
setText('modeFullText', UI_TEXT.static.modeFull); setText('modeFullText', UI_TEXT.static.modeFull);
setText('modePartsText', UI_TEXT.static.modeParts); setText('modePartsText', UI_TEXT.static.modeParts);
setText('partMinutesLabel', UI_TEXT.static.partMinutesLabel); 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('filenameTemplatesTitle', UI_TEXT.static.filenameTemplatesTitle);
setText('vodTemplateLabel', UI_TEXT.static.vodTemplateLabel); setText('vodTemplateLabel', UI_TEXT.static.vodTemplateLabel);
setText('partsTemplateLabel', UI_TEXT.static.partsTemplateLabel); setText('partsTemplateLabel', UI_TEXT.static.partsTemplateLabel);
setText('defaultClipTemplateLabel', UI_TEXT.static.defaultClipTemplateLabel); setText('defaultClipTemplateLabel', UI_TEXT.static.defaultClipTemplateLabel);
setText('filenameTemplateHint', UI_TEXT.static.filenameTemplateHint); setText('filenameTemplateHint', UI_TEXT.static.filenameTemplateHint);
setText('filenameTemplateLint', UI_TEXT.static.templateLintOk);
setText('settingsTemplateGuideBtn', UI_TEXT.static.templateGuideButton); setText('settingsTemplateGuideBtn', UI_TEXT.static.templateGuideButton);
setText('clipTemplateGuideBtn', UI_TEXT.static.templateGuideButton); setText('clipTemplateGuideBtn', UI_TEXT.static.templateGuideButton);
setText('clipTemplateLint', UI_TEXT.static.templateLintOk);
setText('templateGuideTitle', UI_TEXT.static.templateGuideTitle); setText('templateGuideTitle', UI_TEXT.static.templateGuideTitle);
setText('templateGuideIntro', UI_TEXT.static.templateGuideIntro); setText('templateGuideIntro', UI_TEXT.static.templateGuideIntro);
setText('templateGuideTemplateLabel', UI_TEXT.static.templateGuideTemplateLabel); setText('templateGuideTemplateLabel', UI_TEXT.static.templateGuideTemplateLabel);
@ -114,6 +123,10 @@ function applyLanguageToStaticUI(): void {
setText('debugLogTitle', UI_TEXT.static.debugLogTitle); setText('debugLogTitle', UI_TEXT.static.debugLogTitle);
setText('btnRefreshLog', UI_TEXT.static.refreshLog); setText('btnRefreshLog', UI_TEXT.static.refreshLog);
setText('autoRefreshText', UI_TEXT.static.autoRefresh); 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('updateText', UI_TEXT.updates.bannerDefault);
setText('updateButton', UI_TEXT.updates.downloadNow); setText('updateButton', UI_TEXT.updates.downloadNow);
setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder); setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder);

View File

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