diff --git a/typescript-version/package-lock.json b/typescript-version/package-lock.json
index 2856bb4..cec5f88 100644
--- a/typescript-version/package-lock.json
+++ b/typescript-version/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "twitch-vod-manager",
- "version": "4.0.5",
+ "version": "4.0.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "twitch-vod-manager",
- "version": "4.0.5",
+ "version": "4.0.6",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",
diff --git a/typescript-version/package.json b/typescript-version/package.json
index 04cb9b8..be59de4 100644
--- a/typescript-version/package.json
+++ b/typescript-version/package.json
@@ -1,6 +1,6 @@
{
"name": "twitch-vod-manager",
- "version": "4.0.5",
+ "version": "4.0.6",
"description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js",
"author": "xRangerDE",
diff --git a/typescript-version/src/index.html b/typescript-version/src/index.html
index 2682026..e4b4e61 100644
--- a/typescript-version/src/index.html
+++ b/typescript-version/src/index.html
@@ -92,6 +92,7 @@
style="width: 100%; background: #333; border: 1px solid #444; border-radius: 4px; padding: 8px 12px; color: white; font-family: monospace;"
oninput="updateFilenameExamples()">
Platzhalter: {title} {id} {channel} {date} {part} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}
+
@@ -102,6 +103,48 @@
+
+
+
+
+
Template Guide
+
Nutze Variablen fur Dateinamen und prufe das Ergebnis als Live-Vorschau.
+
+
+
+
+
+
+
+
+
+
+
+
+
Verfugbare Variablen
+
+
+
+
+ | Variable |
+ Beschreibung |
+ Beispiel |
+
+
+
+
+
+
+
+
+
+
diff --git a/typescript-version/src/main.ts b/typescript-version/src/main.ts
index 6a9b02c..e94af7b 100644
--- a/typescript-version/src/main.ts
+++ b/typescript-version/src/main.ts
@@ -8,7 +8,7 @@ import { autoUpdater } from 'electron-updater';
// ==========================================
// CONFIG & CONSTANTS
// ==========================================
-const APP_VERSION = '4.0.5';
+const APP_VERSION = '4.0.6';
const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
// Paths
diff --git a/typescript-version/src/renderer-locale-de.ts b/typescript-version/src/renderer-locale-de.ts
index 69e3739..377f85c 100644
--- a/typescript-version/src/renderer-locale-de.ts
+++ b/typescript-version/src/renderer-locale-de.ts
@@ -48,6 +48,23 @@ const UI_TEXT_DE = {
vodTemplatePlaceholder: '{title}.mp4',
partsTemplatePlaceholder: '{date}_Part{part_padded}.mp4',
defaultClipTemplatePlaceholder: '{date}_{part}.mp4',
+ templateGuideButton: 'Template Guide',
+ templateGuideTitle: 'Dateinamen-Template Guide',
+ templateGuideIntro: 'Nutze Platzhalter fur Dateinamen und teste dein Muster mit einer Live-Vorschau.',
+ templateGuideTemplateLabel: 'Template',
+ templateGuideOutputLabel: 'Live-Vorschau',
+ templateGuideVarsTitle: 'Verfugbare Platzhalter',
+ templateGuideVarCol: 'Platzhalter',
+ templateGuideDescCol: 'Beschreibung',
+ templateGuideExampleCol: 'Beispiel',
+ templateGuideUseVod: 'VOD-Template nutzen',
+ templateGuideUseParts: 'Teile-Template nutzen',
+ templateGuideUseClip: 'Clip-Template nutzen',
+ templateGuideClose: 'Schliessen',
+ templateGuideContextVod: 'Kontext: Beispiel fur kompletten VOD-Download',
+ templateGuideContextParts: 'Kontext: Beispiel fur VOD-Teil',
+ templateGuideContextClip: 'Kontext: Beispiel fur Clip-Zuschnitt',
+ templateGuideContextClipLive: 'Kontext: Aktuelle Auswahl im Clip-Dialog',
updateTitle: 'Updates',
checkUpdates: 'Nach Updates suchen',
preflightTitle: 'System-Check',
diff --git a/typescript-version/src/renderer-locale-en.ts b/typescript-version/src/renderer-locale-en.ts
index 1b9e2be..535d6e7 100644
--- a/typescript-version/src/renderer-locale-en.ts
+++ b/typescript-version/src/renderer-locale-en.ts
@@ -48,6 +48,23 @@ const UI_TEXT_EN = {
vodTemplatePlaceholder: '{title}.mp4',
partsTemplatePlaceholder: '{date}_Part{part_padded}.mp4',
defaultClipTemplatePlaceholder: '{date}_{part}.mp4',
+ templateGuideButton: 'Template Guide',
+ templateGuideTitle: 'Filename Template Guide',
+ templateGuideIntro: 'Use placeholders for filenames and test your pattern with a live preview.',
+ templateGuideTemplateLabel: 'Template',
+ templateGuideOutputLabel: 'Live preview',
+ templateGuideVarsTitle: 'Available placeholders',
+ templateGuideVarCol: 'Placeholder',
+ templateGuideDescCol: 'Description',
+ templateGuideExampleCol: 'Example',
+ templateGuideUseVod: 'Use VOD template',
+ templateGuideUseParts: 'Use part template',
+ templateGuideUseClip: 'Use clip template',
+ templateGuideClose: 'Close',
+ templateGuideContextVod: 'Context: Sample full VOD download',
+ templateGuideContextParts: 'Context: Sample split VOD part',
+ templateGuideContextClip: 'Context: Sample clip trim',
+ templateGuideContextClipLive: 'Context: Current clip dialog selection',
updateTitle: 'Updates',
checkUpdates: 'Check for updates',
preflightTitle: 'System Check',
diff --git a/typescript-version/src/renderer-texts.ts b/typescript-version/src/renderer-texts.ts
index 69c3562..2e31770 100644
--- a/typescript-version/src/renderer-texts.ts
+++ b/typescript-version/src/renderer-texts.ts
@@ -87,6 +87,21 @@ function applyLanguageToStaticUI(): void {
setText('partsTemplateLabel', UI_TEXT.static.partsTemplateLabel);
setText('defaultClipTemplateLabel', UI_TEXT.static.defaultClipTemplateLabel);
setText('filenameTemplateHint', UI_TEXT.static.filenameTemplateHint);
+ setText('settingsTemplateGuideBtn', UI_TEXT.static.templateGuideButton);
+ setText('clipTemplateGuideBtn', UI_TEXT.static.templateGuideButton);
+ setText('templateGuideTitle', UI_TEXT.static.templateGuideTitle);
+ setText('templateGuideIntro', UI_TEXT.static.templateGuideIntro);
+ setText('templateGuideTemplateLabel', UI_TEXT.static.templateGuideTemplateLabel);
+ setText('templateGuideOutputLabel', UI_TEXT.static.templateGuideOutputLabel);
+ setText('templateGuideVarsTitle', UI_TEXT.static.templateGuideVarsTitle);
+ setText('templateGuideVarCol', UI_TEXT.static.templateGuideVarCol);
+ setText('templateGuideDescCol', UI_TEXT.static.templateGuideDescCol);
+ setText('templateGuideExampleCol', UI_TEXT.static.templateGuideExampleCol);
+ setText('templateGuideUseVod', UI_TEXT.static.templateGuideUseVod);
+ setText('templateGuideUseParts', UI_TEXT.static.templateGuideUseParts);
+ setText('templateGuideUseClip', UI_TEXT.static.templateGuideUseClip);
+ setText('templateGuideCloseBtn', UI_TEXT.static.templateGuideClose);
+ setPlaceholder('templateGuideInput', UI_TEXT.static.vodTemplatePlaceholder);
setPlaceholder('vodFilenameTemplate', UI_TEXT.static.vodTemplatePlaceholder);
setPlaceholder('partsFilenameTemplate', UI_TEXT.static.partsTemplatePlaceholder);
setPlaceholder('defaultClipFilenameTemplate', UI_TEXT.static.defaultClipTemplatePlaceholder);
@@ -107,6 +122,11 @@ function applyLanguageToStaticUI(): void {
if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) {
setText('statusText', UI_TEXT.static.notConnected);
}
+
+ const guideRefresh = (window as unknown as { refreshTemplateGuideTexts?: () => void }).refreshTemplateGuideTexts;
+ if (typeof guideRefresh === 'function') {
+ guideRefresh();
+ }
}
function localizeCurrentStatusText(current: string): string {
diff --git a/typescript-version/src/renderer.ts b/typescript-version/src/renderer.ts
index 54631d8..8d3f689 100644
--- a/typescript-version/src/renderer.ts
+++ b/typescript-version/src/renderer.ts
@@ -181,6 +181,10 @@ const DEFAULT_VOD_TEMPLATE = '{title}.mp4';
const DEFAULT_PARTS_TEMPLATE = '{date}_Part{part_padded}.mp4';
const DEFAULT_CLIP_TEMPLATE = '{date}_{part}.mp4';
+type TemplateGuideSource = 'vod' | 'parts' | 'clip';
+
+let templateGuideSource: TemplateGuideSource = 'vod';
+
function formatDateWithPattern(date: Date, pattern: string): string {
const tokenMap: Record = {
yyyy: date.getFullYear().toString(),
@@ -237,7 +241,7 @@ function updateFilenameTemplateVisibility(): void {
wrap.style.display = selected === 'template' ? 'block' : 'none';
}
-function buildTemplatePreview(template: string, context: {
+interface TemplatePreviewContext {
title: string;
date: Date;
streamer: string;
@@ -245,7 +249,9 @@ function buildTemplatePreview(template: string, context: {
startSec: number;
durationSec: number;
totalSec: number;
-}): string {
+}
+
+function buildTemplatePreview(template: string, context: TemplatePreviewContext): string {
const dateStr = `${context.date.getDate().toString().padStart(2, '0')}.${(context.date.getMonth() + 1).toString().padStart(2, '0')}.${context.date.getFullYear()}`;
const normalizedPart = context.partNum || '1';
let output = template
@@ -272,6 +278,211 @@ function buildTemplatePreview(template: string, context: {
return output;
}
+function getTemplateForSource(source: TemplateGuideSource): string {
+ if (source === 'vod') {
+ return ((config.filename_template_vod as string) || DEFAULT_VOD_TEMPLATE).trim() || DEFAULT_VOD_TEMPLATE;
+ }
+
+ if (source === 'parts') {
+ return ((config.filename_template_parts as string) || DEFAULT_PARTS_TEMPLATE).trim() || DEFAULT_PARTS_TEMPLATE;
+ }
+
+ const clipField = document.getElementById('clipFilenameTemplate') as HTMLInputElement | null;
+ const clipFromDialog = clipField?.value.trim() || '';
+ if (clipFromDialog) {
+ return clipFromDialog;
+ }
+
+ return ((config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE).trim() || DEFAULT_CLIP_TEMPLATE;
+}
+
+function getTemplateGuidePreviewContext(source: TemplateGuideSource): { context: TemplatePreviewContext; contextText: string } {
+ const now = new Date();
+ const sampleDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 20, 15, 8);
+ const sampleStreamer = currentStreamer || 'sample_streamer';
+
+ if (source === 'clip' && clipDialogData) {
+ const startSec = parseTimeToSeconds(byId('clipStartTime').value);
+ const endSec = parseTimeToSeconds(byId('clipEndTime').value);
+ const clipDuration = Math.max(1, endSec - startSec);
+ const totalSec = Math.max(1, clipTotalSeconds || parseDurationToSeconds(clipDialogData.duration));
+
+ return {
+ context: {
+ title: clipDialogData.title || 'Clip Title',
+ date: new Date(clipDialogData.date),
+ streamer: clipDialogData.streamer || sampleStreamer,
+ partNum: byId('clipStartPart').value.trim() || '1',
+ startSec,
+ durationSec: clipDuration,
+ totalSec
+ },
+ contextText: UI_TEXT.static.templateGuideContextClipLive
+ };
+ }
+
+ if (source === 'parts') {
+ const partLen = Math.max(60, Number(config.part_minutes ?? 120) * 60);
+ return {
+ context: {
+ title: 'Epic Ranked Session',
+ date: sampleDate,
+ streamer: sampleStreamer,
+ partNum: '3',
+ startSec: partLen * 2,
+ durationSec: partLen,
+ totalSec: partLen * 5
+ },
+ contextText: UI_TEXT.static.templateGuideContextParts
+ };
+ }
+
+ if (source === 'clip') {
+ return {
+ context: {
+ title: 'Funny Clip Moment',
+ date: sampleDate,
+ streamer: sampleStreamer,
+ partNum: '1',
+ startSec: 95,
+ durationSec: 45,
+ totalSec: 5400
+ },
+ contextText: UI_TEXT.static.templateGuideContextClip
+ };
+ }
+
+ return {
+ context: {
+ title: 'Epic Ranked Session',
+ date: sampleDate,
+ streamer: sampleStreamer,
+ partNum: '1',
+ startSec: 0,
+ durationSec: 3 * 3600 + 12 * 60 + 5,
+ totalSec: 3 * 3600 + 12 * 60 + 5
+ },
+ contextText: UI_TEXT.static.templateGuideContextVod
+ };
+}
+
+interface TemplateVariableDoc {
+ placeholder: string;
+ description: string;
+ exampleTemplate: string;
+}
+
+function getTemplateVariableDocs(): TemplateVariableDoc[] {
+ const de = currentLanguage !== 'en';
+ const text = (deText: string, enText: string) => de ? deText : enText;
+
+ return [
+ { placeholder: '{title}', description: text('Titel des VODs/Clips', 'Title of the VOD/clip'), exampleTemplate: '{title}' },
+ { placeholder: '{id}', description: text('VOD-ID', 'VOD id'), exampleTemplate: '{id}' },
+ { placeholder: '{channel}', description: text('Kanalname', 'Channel name'), exampleTemplate: '{channel}' },
+ { placeholder: '{date}', description: text('Datum (DD.MM.YYYY)', 'Date (DD.MM.YYYY)'), exampleTemplate: '{date}' },
+ { placeholder: '{part}', description: text('Teilnummer', 'Part number'), exampleTemplate: '{part}' },
+ { placeholder: '{part_padded}', description: text('Teilnummer mit 2 Stellen', 'Part number padded to 2 digits'), exampleTemplate: '{part_padded}' },
+ { placeholder: '{trim_start}', description: text('Startzeit des Ausschnitts', 'Trim start time'), exampleTemplate: '{trim_start}' },
+ { placeholder: '{trim_end}', description: text('Endzeit des Ausschnitts', 'Trim end time'), exampleTemplate: '{trim_end}' },
+ { placeholder: '{trim_length}', description: text('Lange des Ausschnitts', 'Trimmed duration'), exampleTemplate: '{trim_length}' },
+ { placeholder: '{length}', description: text('Gesamtdauer', 'Total duration'), exampleTemplate: '{length}' },
+ { placeholder: '{ext}', description: text('Dateiendung', 'File extension'), exampleTemplate: '{ext}' },
+ { placeholder: '{random_string}', description: text('Zufallsstring (8 Zeichen)', 'Random string (8 chars)'), exampleTemplate: '{random_string}' },
+ { placeholder: '{date_custom="yyyy-MM-dd"}', description: text('Datum mit eigenem Format', 'Custom-formatted date'), exampleTemplate: '{date_custom="yyyy-MM-dd"}' },
+ { placeholder: '{trim_start_custom="HH-mm-ss"}', description: text('Startzeit mit eigenem Format', 'Custom-formatted trim start'), exampleTemplate: '{trim_start_custom="HH-mm-ss"}' },
+ { placeholder: '{trim_end_custom="HH-mm-ss"}', description: text('Endzeit mit eigenem Format', 'Custom-formatted trim end'), exampleTemplate: '{trim_end_custom="HH-mm-ss"}' },
+ { placeholder: '{trim_length_custom="HH-mm-ss"}', description: text('Trim-Dauer mit eigenem Format', 'Custom-formatted trim length'), exampleTemplate: '{trim_length_custom="HH-mm-ss"}' },
+ { placeholder: '{length_custom="HH-mm-ss"}', description: text('Gesamtdauer mit eigenem Format', 'Custom-formatted total duration'), exampleTemplate: '{length_custom="HH-mm-ss"}' }
+ ];
+}
+
+function renderTemplateGuideTable(context: TemplatePreviewContext): void {
+ const body = byId('templateGuideBody');
+ body.innerHTML = '';
+
+ for (const item of getTemplateVariableDocs()) {
+ const row = document.createElement('tr');
+ const varCell = document.createElement('td');
+ const descCell = document.createElement('td');
+ const exampleCell = document.createElement('td');
+
+ varCell.textContent = item.placeholder;
+ descCell.textContent = item.description;
+ exampleCell.textContent = buildTemplatePreview(item.exampleTemplate, context);
+
+ row.append(varCell, descCell, exampleCell);
+ body.appendChild(row);
+ }
+}
+
+function updateTemplateGuidePresetButtons(): void {
+ const activeId: Record = {
+ vod: 'templateGuideUseVod',
+ parts: 'templateGuideUseParts',
+ clip: 'templateGuideUseClip'
+ };
+
+ (Object.keys(activeId) as TemplateGuideSource[]).forEach((key) => {
+ const btn = byId(activeId[key]);
+ btn.classList.toggle('active', key === templateGuideSource);
+ });
+}
+
+function refreshTemplateGuideTexts(): void {
+ setText('settingsTemplateGuideBtn', UI_TEXT.static.templateGuideButton);
+ setText('clipTemplateGuideBtn', UI_TEXT.static.templateGuideButton);
+ setText('templateGuideTitle', UI_TEXT.static.templateGuideTitle);
+ setText('templateGuideIntro', UI_TEXT.static.templateGuideIntro);
+ setText('templateGuideTemplateLabel', UI_TEXT.static.templateGuideTemplateLabel);
+ setText('templateGuideOutputLabel', UI_TEXT.static.templateGuideOutputLabel);
+ setText('templateGuideVarsTitle', UI_TEXT.static.templateGuideVarsTitle);
+ setText('templateGuideVarCol', UI_TEXT.static.templateGuideVarCol);
+ setText('templateGuideDescCol', UI_TEXT.static.templateGuideDescCol);
+ setText('templateGuideExampleCol', UI_TEXT.static.templateGuideExampleCol);
+ setText('templateGuideUseVod', UI_TEXT.static.templateGuideUseVod);
+ setText('templateGuideUseParts', UI_TEXT.static.templateGuideUseParts);
+ setText('templateGuideUseClip', UI_TEXT.static.templateGuideUseClip);
+ setText('templateGuideCloseBtn', UI_TEXT.static.templateGuideClose);
+ setPlaceholder('templateGuideInput', getTemplateForSource(templateGuideSource));
+ updateTemplateGuidePresetButtons();
+
+ const modal = document.getElementById('templateGuideModal');
+ if (modal?.classList.contains('show')) {
+ updateTemplateGuidePreview();
+ }
+}
+
+function openTemplateGuide(source: TemplateGuideSource = 'vod'): void {
+ templateGuideSource = source;
+ byId('templateGuideModal').classList.add('show');
+ refreshTemplateGuideTexts();
+ setTemplateGuidePreset(source);
+}
+
+function closeTemplateGuide(): void {
+ byId('templateGuideModal').classList.remove('show');
+}
+
+function setTemplateGuidePreset(source: TemplateGuideSource): void {
+ templateGuideSource = source;
+ const template = getTemplateForSource(source);
+ byId('templateGuideInput').value = template;
+ setPlaceholder('templateGuideInput', template);
+ updateTemplateGuidePresetButtons();
+ updateTemplateGuidePreview();
+}
+
+function updateTemplateGuidePreview(): void {
+ const input = byId('templateGuideInput');
+ const template = input.value.trim() || getTemplateForSource(templateGuideSource);
+ const { context, contextText } = getTemplateGuidePreviewContext(templateGuideSource);
+
+ byId('templateGuideOutput').textContent = buildTemplatePreview(template, context);
+ byId('templateGuideContext').textContent = contextText;
+ renderTemplateGuideTable(context);
+}
+
function parseTimeToSeconds(timeStr: string): number {
const parts = timeStr.split(':').map((p: string) => parseInt(p, 10) || 0);
if (parts.length === 3) {
@@ -378,6 +589,11 @@ function updateFilenameExamples(): void {
durationSec,
totalSec: clipTotalSeconds
})} ${UI_TEXT.clips.formatTemplate}`;
+
+ const guideModal = document.getElementById('templateGuideModal');
+ if (guideModal?.classList.contains('show') && templateGuideSource === 'clip') {
+ updateTemplateGuidePreview();
+ }
}
async function confirmClipDialog(): Promise {
diff --git a/typescript-version/src/styles.css b/typescript-version/src/styles.css
index 373eeba..d759082 100644
--- a/typescript-version/src/styles.css
+++ b/typescript-version/src/styles.css
@@ -1268,3 +1268,114 @@ body.theme-apple {
.modal-actions button {
flex: 1;
}
+
+.template-guide-modal {
+ max-width: 860px;
+}
+
+.template-guide-intro {
+ color: var(--text-secondary);
+ margin-bottom: 14px;
+ line-height: 1.5;
+}
+
+.template-guide-actions {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin-bottom: 12px;
+}
+
+.template-guide-actions .btn-secondary {
+ padding: 8px 12px;
+ min-width: 140px;
+}
+
+.template-guide-actions .btn-secondary.active {
+ background: var(--accent);
+ color: #fff;
+ border-color: transparent;
+}
+
+.template-guide-label {
+ display: block;
+ margin-bottom: 6px;
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+.template-guide-input {
+ width: 100%;
+ font-family: Consolas, "Courier New", monospace;
+ margin-bottom: 10px;
+}
+
+.template-guide-preview-box {
+ background: rgba(0, 0, 0, 0.22);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ padding: 10px;
+ margin-bottom: 14px;
+}
+
+.template-guide-preview-label {
+ font-size: 12px;
+ color: var(--text-secondary);
+ margin-bottom: 6px;
+}
+
+.template-guide-output {
+ font-family: Consolas, "Courier New", monospace;
+ color: var(--text);
+ word-break: break-word;
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 6px;
+ padding: 8px;
+}
+
+.template-guide-context {
+ margin-top: 6px;
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.template-guide-vars-title {
+ margin: 0 0 8px;
+ font-size: 14px;
+}
+
+.template-guide-table-wrap {
+ max-height: 280px;
+ overflow: auto;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ margin-bottom: 12px;
+}
+
+.template-guide-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+}
+
+.template-guide-table th,
+.template-guide-table td {
+ text-align: left;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ padding: 8px;
+ vertical-align: top;
+}
+
+.template-guide-table tbody tr:last-child td {
+ border-bottom: 0;
+}
+
+.template-guide-table td:first-child,
+.template-guide-table td:last-child {
+ font-family: Consolas, "Courier New", monospace;
+}
+
+.template-guide-footer {
+ display: flex;
+ justify-content: flex-end;
+}