diff --git a/typescript-version/package-lock.json b/typescript-version/package-lock.json
index 3c2921d..b2f94f6 100644
--- a/typescript-version/package-lock.json
+++ b/typescript-version/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "twitch-vod-manager",
- "version": "4.0.3",
+ "version": "4.0.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "twitch-vod-manager",
- "version": "4.0.3",
+ "version": "4.0.4",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",
diff --git a/typescript-version/package.json b/typescript-version/package.json
index d144708..569a233 100644
--- a/typescript-version/package.json
+++ b/typescript-version/package.json
@@ -1,6 +1,6 @@
{
"name": "twitch-vod-manager",
- "version": "4.0.3",
+ "version": "4.0.4",
"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 2293545..7d8a50c 100644
--- a/typescript-version/src/index.html
+++ b/typescript-version/src/index.html
@@ -71,15 +71,28 @@
@@ -355,7 +368,7 @@
Updates
-
Version: v4.0.3
+
Version: v4.0.4
@@ -387,7 +400,7 @@
Nicht verbunden
- v4.0.3
+ v4.0.4
diff --git a/typescript-version/src/main.ts b/typescript-version/src/main.ts
index ac4fcd2..4db74a0 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.3';
+const APP_VERSION = '4.0.4';
const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
// Paths
@@ -61,7 +61,8 @@ interface CustomClip {
startSec: number;
durationSec: number;
startPart: number;
- filenameFormat: 'simple' | 'timestamp';
+ filenameFormat: 'simple' | 'timestamp' | 'template';
+ filenameTemplate?: string;
}
interface QueueItem {
@@ -624,6 +625,133 @@ function formatDuration(seconds: number): string {
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
+function formatDurationDashed(seconds: number): string {
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ const s = Math.floor(seconds % 60);
+ return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
+}
+
+function sanitizeFilenamePart(input: string, fallback = 'unnamed'): string {
+ const cleaned = (input || '')
+ .replace(/[<>:"|?*\x00-\x1f]/g, '_')
+ .replace(/[\\/]/g, '_')
+ .trim();
+ return cleaned || fallback;
+}
+
+function formatDateWithPattern(date: Date, pattern: string): string {
+ const tokenMap: Record = {
+ yyyy: date.getFullYear().toString(),
+ yy: date.getFullYear().toString().slice(-2),
+ MM: (date.getMonth() + 1).toString().padStart(2, '0'),
+ M: (date.getMonth() + 1).toString(),
+ dd: date.getDate().toString().padStart(2, '0'),
+ d: date.getDate().toString(),
+ HH: date.getHours().toString().padStart(2, '0'),
+ H: date.getHours().toString(),
+ hh: date.getHours().toString().padStart(2, '0'),
+ h: date.getHours().toString(),
+ mm: date.getMinutes().toString().padStart(2, '0'),
+ m: date.getMinutes().toString(),
+ ss: date.getSeconds().toString().padStart(2, '0'),
+ s: date.getSeconds().toString()
+ };
+
+ return pattern
+ .replace(/yyyy|yy|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s/g, (token) => tokenMap[token] ?? token)
+ .replace(/\\(.)/g, '$1');
+}
+
+function formatSecondsWithPattern(totalSeconds: number, pattern: string): string {
+ const safe = Math.max(0, Math.floor(totalSeconds));
+ const hours = Math.floor(safe / 3600);
+ const minutes = Math.floor((safe % 3600) / 60);
+ const seconds = safe % 60;
+
+ const tokenMap: Record = {
+ HH: hours.toString().padStart(2, '0'),
+ H: hours.toString(),
+ hh: hours.toString().padStart(2, '0'),
+ h: hours.toString(),
+ mm: minutes.toString().padStart(2, '0'),
+ m: minutes.toString(),
+ ss: seconds.toString().padStart(2, '0'),
+ s: seconds.toString()
+ };
+
+ return pattern
+ .replace(/HH|H|hh|h|mm|m|ss|s/g, (token) => tokenMap[token] ?? token)
+ .replace(/\\(.)/g, '$1');
+}
+
+function parseVodId(url: string): string {
+ const match = url.match(/videos\/(\d+)/i);
+ return match?.[1] || '';
+}
+
+interface ClipTemplateContext {
+ template: string;
+ title: string;
+ vodId: string;
+ channel: string;
+ date: Date;
+ part: number;
+ trimStartSec: number;
+ trimEndSec: number;
+ trimLengthSec: number;
+ fullLengthSec: number;
+}
+
+function renderClipFilenameTemplate(context: ClipTemplateContext): string {
+ const baseDate = `${context.date.getDate().toString().padStart(2, '0')}.${(context.date.getMonth() + 1).toString().padStart(2, '0')}.${context.date.getFullYear()}`;
+ let rendered = context.template
+ .replace(/\{title\}/g, sanitizeFilenamePart(context.title, 'untitled'))
+ .replace(/\{id\}/g, sanitizeFilenamePart(context.vodId, 'unknown'))
+ .replace(/\{channel\}/g, sanitizeFilenamePart(context.channel, 'unknown'))
+ .replace(/\{channel_id\}/g, '')
+ .replace(/\{date\}/g, baseDate)
+ .replace(/\{part\}/g, String(context.part))
+ .replace(/\{trim_start\}/g, formatDurationDashed(context.trimStartSec))
+ .replace(/\{trim_end\}/g, formatDurationDashed(context.trimEndSec))
+ .replace(/\{trim_length\}/g, formatDurationDashed(context.trimLengthSec))
+ .replace(/\{length\}/g, formatDurationDashed(context.fullLengthSec))
+ .replace(/\{ext\}/g, 'mp4')
+ .replace(/\{random_string\}/g, Math.random().toString(36).slice(2, 10));
+
+ rendered = rendered.replace(/\{date_custom="(.*?)"\}/g, (_, pattern: string) => {
+ return sanitizeFilenamePart(formatDateWithPattern(context.date, pattern), 'date');
+ });
+ rendered = rendered.replace(/\{trim_start_custom="(.*?)"\}/g, (_, pattern: string) => {
+ return sanitizeFilenamePart(formatSecondsWithPattern(context.trimStartSec, pattern), '00-00-00');
+ });
+ rendered = rendered.replace(/\{trim_end_custom="(.*?)"\}/g, (_, pattern: string) => {
+ return sanitizeFilenamePart(formatSecondsWithPattern(context.trimEndSec, pattern), '00-00-00');
+ });
+ rendered = rendered.replace(/\{trim_length_custom="(.*?)"\}/g, (_, pattern: string) => {
+ return sanitizeFilenamePart(formatSecondsWithPattern(context.trimLengthSec, pattern), '00-00-00');
+ });
+ rendered = rendered.replace(/\{length_custom="(.*?)"\}/g, (_, pattern: string) => {
+ return sanitizeFilenamePart(formatSecondsWithPattern(context.fullLengthSec, pattern), '00-00-00');
+ });
+
+ const parts = rendered
+ .split(/[\\/]+/)
+ .map((segment) => sanitizeFilenamePart(segment, 'unnamed'))
+ .filter((segment) => segment !== '.' && segment !== '..');
+
+ if (parts.length === 0) {
+ return 'clip.mp4';
+ }
+
+ const lastIdx = parts.length - 1;
+ if (!/\.[A-Za-z0-9]{1,8}$/.test(parts[lastIdx])) {
+ parts[lastIdx] = `${parts[lastIdx]}.mp4`;
+ }
+
+ return path.join(...parts);
+}
+
function formatBytes(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
@@ -1329,7 +1457,23 @@ async function downloadVOD(
const partDuration = config.part_minutes * 60;
// Helper to generate filename based on format
- const makeClipFilename = (partNum: number, startOffset: number): string => {
+ const makeClipFilename = (partNum: number, startOffset: number, clipLengthSec: number): string => {
+ if (clip.filenameFormat === 'template' && (clip.filenameTemplate || '').trim()) {
+ const relativeName = renderClipFilenameTemplate({
+ template: clip.filenameTemplate as string,
+ title: item.title,
+ vodId: parseVodId(item.url),
+ channel: item.streamer,
+ date,
+ part: partNum,
+ trimStartSec: startOffset,
+ trimEndSec: startOffset + clipLengthSec,
+ trimLengthSec: clipLengthSec,
+ fullLengthSec: totalDuration
+ });
+ return path.join(folder, relativeName);
+ }
+
if (clip.filenameFormat === 'timestamp') {
const h = Math.floor(startOffset / 3600);
const m = Math.floor((startOffset % 3600) / 60);
@@ -1354,7 +1498,7 @@ async function downloadVOD(
const remainingDuration = clip.durationSec - (i * partDuration);
const thisDuration = Math.min(partDuration, remainingDuration);
- const partFilename = makeClipFilename(partNum, startOffset);
+ const partFilename = makeClipFilename(partNum, startOffset, thisDuration);
const result = await downloadVODPart(
item.url,
@@ -1377,7 +1521,7 @@ async function downloadVOD(
};
} else {
// Single clip file
- const filename = makeClipFilename(clip.startPart, clip.startSec);
+ const filename = makeClipFilename(clip.startPart, clip.startSec, clip.durationSec);
return await downloadVODPart(
item.url,
filename,
diff --git a/typescript-version/src/preload.ts b/typescript-version/src/preload.ts
index a9c0c4b..bbbaf39 100644
--- a/typescript-version/src/preload.ts
+++ b/typescript-version/src/preload.ts
@@ -5,7 +5,8 @@ interface CustomClip {
startSec: number;
durationSec: number;
startPart: number;
- filenameFormat: 'simple' | 'timestamp';
+ filenameFormat: 'simple' | 'timestamp' | 'template';
+ filenameTemplate?: string;
}
interface QueueItem {
diff --git a/typescript-version/src/renderer-globals.d.ts b/typescript-version/src/renderer-globals.d.ts
index 1873655..44549c8 100644
--- a/typescript-version/src/renderer-globals.d.ts
+++ b/typescript-version/src/renderer-globals.d.ts
@@ -25,7 +25,8 @@ interface CustomClip {
startSec: number;
durationSec: number;
startPart: number;
- filenameFormat: 'simple' | 'timestamp';
+ filenameFormat: 'simple' | 'timestamp' | 'template';
+ filenameTemplate?: string;
}
interface QueueItem {
diff --git a/typescript-version/src/renderer-locale-de.ts b/typescript-version/src/renderer-locale-de.ts
index 43f0fb4..97701b1 100644
--- a/typescript-version/src/renderer-locale-de.ts
+++ b/typescript-version/src/renderer-locale-de.ts
@@ -118,7 +118,11 @@ const UI_TEXT_DE = {
errorPrefix: 'Fehler: ',
unknownError: 'Unbekannter Fehler',
formatSimple: '(Standard)',
- formatTimestamp: '(mit Zeitstempel)'
+ formatTimestamp: '(mit Zeitstempel)',
+ formatTemplate: '(benutzerdefiniert)',
+ templateEmpty: 'Das Template darf im benutzerdefinierten Modus nicht leer sein.',
+ templatePlaceholder: '{date}_{part}.mp4',
+ templateHelp: 'Platzhalter: {title} {id} {channel} {date} {part} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}'
},
cutter: {
videoInfoFailed: 'Konnte Video-Informationen nicht lesen. FFprobe installiert?',
diff --git a/typescript-version/src/renderer-locale-en.ts b/typescript-version/src/renderer-locale-en.ts
index 7b41738..98759ef 100644
--- a/typescript-version/src/renderer-locale-en.ts
+++ b/typescript-version/src/renderer-locale-en.ts
@@ -118,7 +118,11 @@ const UI_TEXT_EN = {
errorPrefix: 'Error: ',
unknownError: 'Unknown error',
formatSimple: '(default)',
- formatTimestamp: '(with timestamp)'
+ formatTimestamp: '(with timestamp)',
+ formatTemplate: '(custom template)',
+ templateEmpty: 'Template cannot be empty in custom template mode.',
+ templatePlaceholder: '{date}_{part}.mp4',
+ templateHelp: 'Placeholders: {title} {id} {channel} {date} {part} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}'
},
cutter: {
videoInfoFailed: 'Could not read video info. Is FFprobe installed?',
diff --git a/typescript-version/src/renderer-texts.ts b/typescript-version/src/renderer-texts.ts
index 04348a0..d792318 100644
--- a/typescript-version/src/renderer-texts.ts
+++ b/typescript-version/src/renderer-texts.ts
@@ -59,6 +59,8 @@ function applyLanguageToStaticUI(): void {
setText('clipsHeading', UI_TEXT.static.clipsHeading);
setText('clipsInfoTitle', UI_TEXT.static.clipsInfoTitle);
setText('clipsInfoText', UI_TEXT.static.clipsInfoText);
+ setText('clipTemplateHelp', UI_TEXT.clips.templateHelp);
+ setPlaceholder('clipFilenameTemplate', UI_TEXT.clips.templatePlaceholder);
setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle);
setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse);
setText('mergeTitle', UI_TEXT.static.mergeTitle);
diff --git a/typescript-version/src/renderer.ts b/typescript-version/src/renderer.ts
index ced3056..2e4c99c 100644
--- a/typescript-version/src/renderer.ts
+++ b/typescript-version/src/renderer.ts
@@ -174,6 +174,96 @@ function formatSecondsToTimeDashed(seconds: number): string {
return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
}
+function formatDateWithPattern(date: Date, pattern: string): string {
+ const tokenMap: Record = {
+ yyyy: date.getFullYear().toString(),
+ yy: date.getFullYear().toString().slice(-2),
+ MM: (date.getMonth() + 1).toString().padStart(2, '0'),
+ M: (date.getMonth() + 1).toString(),
+ dd: date.getDate().toString().padStart(2, '0'),
+ d: date.getDate().toString(),
+ HH: date.getHours().toString().padStart(2, '0'),
+ H: date.getHours().toString(),
+ hh: date.getHours().toString().padStart(2, '0'),
+ h: date.getHours().toString(),
+ mm: date.getMinutes().toString().padStart(2, '0'),
+ m: date.getMinutes().toString(),
+ ss: date.getSeconds().toString().padStart(2, '0'),
+ s: date.getSeconds().toString()
+ };
+
+ return pattern
+ .replace(/yyyy|yy|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s/g, (token) => tokenMap[token] ?? token)
+ .replace(/\\(.)/g, '$1');
+}
+
+function formatSecondsWithPattern(totalSeconds: number, pattern: string): string {
+ const safe = Math.max(0, Math.floor(totalSeconds));
+ const hours = Math.floor(safe / 3600);
+ const minutes = Math.floor((safe % 3600) / 60);
+ const seconds = safe % 60;
+
+ const tokenMap: Record = {
+ HH: hours.toString().padStart(2, '0'),
+ H: hours.toString(),
+ hh: hours.toString().padStart(2, '0'),
+ h: hours.toString(),
+ mm: minutes.toString().padStart(2, '0'),
+ m: minutes.toString(),
+ ss: seconds.toString().padStart(2, '0'),
+ s: seconds.toString()
+ };
+
+ return pattern
+ .replace(/HH|H|hh|h|mm|m|ss|s/g, (token) => tokenMap[token] ?? token)
+ .replace(/\\(.)/g, '$1');
+}
+
+function getSelectedFilenameFormat(): 'simple' | 'timestamp' | 'template' {
+ const selected = query('input[name="filenameFormat"]:checked').value;
+ return selected === 'template' ? 'template' : selected === 'timestamp' ? 'timestamp' : 'simple';
+}
+
+function updateFilenameTemplateVisibility(): void {
+ const selected = getSelectedFilenameFormat();
+ const wrap = byId('clipFilenameTemplateWrap');
+ wrap.style.display = selected === 'template' ? 'block' : 'none';
+}
+
+function buildTemplatePreview(template: string, context: {
+ title: string;
+ date: Date;
+ streamer: string;
+ partNum: string;
+ startSec: number;
+ durationSec: number;
+ totalSec: number;
+}): 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
+ .replace(/\{title\}/g, context.title || 'Untitled')
+ .replace(/\{id\}/g, '123456789')
+ .replace(/\{channel\}/g, context.streamer || 'streamer')
+ .replace(/\{channel_id\}/g, '0')
+ .replace(/\{date\}/g, dateStr)
+ .replace(/\{part\}/g, normalizedPart)
+ .replace(/\{trim_start\}/g, formatSecondsToTimeDashed(context.startSec))
+ .replace(/\{trim_end\}/g, formatSecondsToTimeDashed(context.startSec + context.durationSec))
+ .replace(/\{trim_length\}/g, formatSecondsToTimeDashed(context.durationSec))
+ .replace(/\{length\}/g, formatSecondsToTimeDashed(context.totalSec))
+ .replace(/\{ext\}/g, 'mp4')
+ .replace(/\{random_string\}/g, 'abcd1234');
+
+ output = output.replace(/\{date_custom="(.*?)"\}/g, (_, pattern: string) => formatDateWithPattern(context.date, pattern));
+ output = output.replace(/\{trim_start_custom="(.*?)"\}/g, (_, pattern: string) => formatSecondsWithPattern(context.startSec, pattern));
+ output = output.replace(/\{trim_end_custom="(.*?)"\}/g, (_, pattern: string) => formatSecondsWithPattern(context.startSec + context.durationSec, pattern));
+ output = output.replace(/\{trim_length_custom="(.*?)"\}/g, (_, pattern: string) => formatSecondsWithPattern(context.durationSec, pattern));
+ output = output.replace(/\{length_custom="(.*?)"\}/g, (_, pattern: string) => formatSecondsWithPattern(context.totalSec, pattern));
+
+ return output;
+}
+
function parseTimeToSeconds(timeStr: string): number {
const parts = timeStr.split(':').map((p: string) => parseInt(p, 10) || 0);
if (parts.length === 3) {
@@ -196,6 +286,9 @@ function openClipDialog(url: string, title: string, date: string, streamer: stri
byId('clipStartTime').value = '00:00:00';
byId('clipEndTime').value = formatSecondsToTime(Math.min(60, clipTotalSeconds));
byId('clipStartPart').value = '';
+ byId('clipFilenameTemplate').value = '{date}_{part}.mp4';
+ query('input[name="filenameFormat"][value="simple"]').checked = true;
+ updateFilenameTemplateVisibility();
updateClipDuration();
updateFilenameExamples();
@@ -259,10 +352,24 @@ function updateFilenameExamples(): void {
const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`;
const partNum = byId('clipStartPart').value || '1';
const startSec = parseTimeToSeconds(byId('clipStartTime').value);
+ const endSec = parseTimeToSeconds(byId('clipEndTime').value);
+ const durationSec = Math.max(1, endSec - startSec);
const timeStr = formatSecondsToTimeDashed(startSec);
+ const template = byId('clipFilenameTemplate').value.trim() || '{date}_{part}.mp4';
+
+ updateFilenameTemplateVisibility();
byId('formatSimple').textContent = `${dateStr}_${partNum}.mp4 ${UI_TEXT.clips.formatSimple}`;
byId('formatTimestamp').textContent = `${dateStr}_CLIP_${timeStr}_${partNum}.mp4 ${UI_TEXT.clips.formatTimestamp}`;
+ byId('formatTemplate').textContent = `${buildTemplatePreview(template, {
+ title: clipDialogData.title,
+ date,
+ streamer: clipDialogData.streamer,
+ partNum,
+ startSec,
+ durationSec,
+ totalSec: clipTotalSeconds
+ })} ${UI_TEXT.clips.formatTemplate}`;
}
async function confirmClipDialog(): Promise {
@@ -274,7 +381,8 @@ async function confirmClipDialog(): Promise {
const endSec = parseTimeToSeconds(byId('clipEndTime').value);
const startPartStr = byId('clipStartPart').value.trim();
const startPart = startPartStr ? parseInt(startPartStr, 10) : 1;
- const filenameFormat = query('input[name="filenameFormat"]:checked').value as 'simple' | 'timestamp';
+ const filenameFormat = getSelectedFilenameFormat();
+ const filenameTemplate = byId('clipFilenameTemplate').value.trim();
if (endSec <= startSec) {
alert(UI_TEXT.clips.endBeforeStart);
@@ -286,6 +394,11 @@ async function confirmClipDialog(): Promise {
return;
}
+ if (filenameFormat === 'template' && !filenameTemplate) {
+ alert(UI_TEXT.clips.templateEmpty);
+ return;
+ }
+
const durationSec = endSec - startSec;
queue = await window.api.addToQueue({
@@ -298,7 +411,8 @@ async function confirmClipDialog(): Promise {
startSec,
durationSec,
startPart,
- filenameFormat
+ filenameFormat,
+ filenameTemplate: filenameFormat === 'template' ? filenameTemplate : undefined
}
});