Add smart optimization suite with telemetry and guardrails (v4.1.0)
This commit is contained in:
parent
886500ad8e
commit
9609c6e767
@ -54,6 +54,9 @@ npm run test:e2e:full
|
|||||||
# Release validation suite (build + smoke + guide + full)
|
# 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
|
||||||
```
|
```
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
4
typescript-version/package-lock.json
generated
4
typescript-version/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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
@ -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) => {
|
||||||
|
|||||||
47
typescript-version/src/renderer-globals.d.ts
vendored
47
typescript-version/src/renderer-globals.d.ts
vendored
@ -10,6 +10,10 @@ interface AppConfig {
|
|||||||
filename_template_vod?: string;
|
filename_template_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;
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
@ -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));
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user