diff --git a/typescript-version/package-lock.json b/typescript-version/package-lock.json
index b2f94f6..2856bb4 100644
--- a/typescript-version/package-lock.json
+++ b/typescript-version/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "twitch-vod-manager",
- "version": "4.0.4",
+ "version": "4.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "twitch-vod-manager",
- "version": "4.0.4",
+ "version": "4.0.5",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",
diff --git a/typescript-version/package.json b/typescript-version/package.json
index 569a233..04cb9b8 100644
--- a/typescript-version/package.json
+++ b/typescript-version/package.json
@@ -1,6 +1,6 @@
{
"name": "twitch-vod-manager",
- "version": "4.0.4",
+ "version": "4.0.5",
"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 7d8a50c..2682026 100644
--- a/typescript-version/src/index.html
+++ b/typescript-version/src/index.html
@@ -364,11 +364,25 @@
+
Updates
-
Version: v4.0.4
+
Version: v4.0.5
@@ -400,7 +414,7 @@
Nicht verbunden
- v4.0.4
+ v4.0.5
diff --git a/typescript-version/src/main.ts b/typescript-version/src/main.ts
index 4db74a0..6a9b02c 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.4';
+const APP_VERSION = '4.0.5';
const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
// Paths
@@ -20,6 +20,9 @@ const TOOLS_DIR = path.join(APPDATA_DIR, 'tools');
const TOOLS_STREAMLINK_DIR = path.join(TOOLS_DIR, 'streamlink');
const TOOLS_FFMPEG_DIR = path.join(TOOLS_DIR, 'ffmpeg');
const DEFAULT_DOWNLOAD_PATH = path.join(app.getPath('desktop'), 'Twitch_VODs');
+const DEFAULT_FILENAME_TEMPLATE_VOD = '{title}.mp4';
+const DEFAULT_FILENAME_TEMPLATE_PARTS = '{date}_Part{part_padded}.mp4';
+const DEFAULT_FILENAME_TEMPLATE_CLIP = '{date}_{part}.mp4';
// Timeouts
const API_TIMEOUT = 10000;
@@ -44,6 +47,9 @@ interface Config {
download_mode: 'parts' | 'full';
part_minutes: number;
language: 'de' | 'en';
+ filename_template_vod: string;
+ filename_template_parts: string;
+ filename_template_clip: string;
}
interface VOD {
@@ -135,19 +141,36 @@ const defaultConfig: Config = {
theme: 'twitch',
download_mode: 'full',
part_minutes: 120,
- language: 'en'
+ language: 'en',
+ filename_template_vod: DEFAULT_FILENAME_TEMPLATE_VOD,
+ filename_template_parts: DEFAULT_FILENAME_TEMPLATE_PARTS,
+ filename_template_clip: DEFAULT_FILENAME_TEMPLATE_CLIP
};
+function normalizeFilenameTemplate(template: string | undefined, fallback: string): string {
+ const value = (template || '').trim();
+ return value || fallback;
+}
+
+function normalizeConfigTemplates(input: Config): Config {
+ return {
+ ...input,
+ filename_template_vod: normalizeFilenameTemplate(input.filename_template_vod, DEFAULT_FILENAME_TEMPLATE_VOD),
+ filename_template_parts: normalizeFilenameTemplate(input.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS),
+ filename_template_clip: normalizeFilenameTemplate(input.filename_template_clip, DEFAULT_FILENAME_TEMPLATE_CLIP)
+ };
+}
+
function loadConfig(): Config {
try {
if (fs.existsSync(CONFIG_FILE)) {
const data = fs.readFileSync(CONFIG_FILE, 'utf-8');
- return { ...defaultConfig, ...JSON.parse(data) };
+ return normalizeConfigTemplates({ ...defaultConfig, ...JSON.parse(data) });
}
} catch (e) {
console.error('Error loading config:', e);
}
- return defaultConfig;
+ return normalizeConfigTemplates(defaultConfig);
}
function saveConfig(config: Config): void {
@@ -697,6 +720,7 @@ interface ClipTemplateContext {
channel: string;
date: Date;
part: number;
+ partPadded: string;
trimStartSec: number;
trimEndSec: number;
trimLengthSec: number;
@@ -712,6 +736,7 @@ function renderClipFilenameTemplate(context: ClipTemplateContext): string {
.replace(/\{channel_id\}/g, '')
.replace(/\{date\}/g, baseDate)
.replace(/\{part\}/g, String(context.part))
+ .replace(/\{part_padded\}/g, context.partPadded)
.replace(/\{trim_start\}/g, formatDurationDashed(context.trimStartSec))
.replace(/\{trim_end\}/g, formatDurationDashed(context.trimEndSec))
.replace(/\{trim_length\}/g, formatDurationDashed(context.trimLengthSec))
@@ -1448,8 +1473,32 @@ async function downloadVOD(
const folder = path.join(config.download_path, streamer, dateStr);
fs.mkdirSync(folder, { recursive: true });
- const safeTitle = item.title.replace(/[^a-zA-Z0-9_\- ]/g, '').substring(0, 50);
const totalDuration = parseDuration(item.duration_str);
+ const vodId = parseVodId(item.url);
+
+ const makeTemplateFilename = (
+ template: string,
+ templateFallback: string,
+ partNum: number,
+ trimStartSec: number,
+ trimLengthSec: number
+ ): string => {
+ const relativeName = renderClipFilenameTemplate({
+ template: normalizeFilenameTemplate(template, templateFallback),
+ title: item.title,
+ vodId,
+ channel: item.streamer,
+ date,
+ part: partNum,
+ partPadded: partNum.toString().padStart(2, '0'),
+ trimStartSec,
+ trimEndSec: trimStartSec + trimLengthSec,
+ trimLengthSec,
+ fullLengthSec: totalDuration
+ });
+
+ return path.join(folder, relativeName);
+ };
// Custom Clip - download specific time range
if (item.customClip) {
@@ -1458,20 +1507,14 @@ async function downloadVOD(
// Helper to generate filename based on format
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 === 'template') {
+ return makeTemplateFilename(
+ clip.filenameTemplate || config.filename_template_clip,
+ DEFAULT_FILENAME_TEMPLATE_CLIP,
+ partNum,
+ startOffset,
+ clipLengthSec
+ );
}
if (clip.filenameFormat === 'timestamp') {
@@ -1538,7 +1581,13 @@ async function downloadVOD(
// Check download mode
if (config.download_mode === 'full' || totalDuration <= config.part_minutes * 60) {
// Full download
- const filename = path.join(folder, `${safeTitle}.mp4`);
+ const filename = makeTemplateFilename(
+ config.filename_template_vod,
+ DEFAULT_FILENAME_TEMPLATE_VOD,
+ 1,
+ 0,
+ totalDuration
+ );
return await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1);
} else {
// Part-based download
@@ -1553,7 +1602,13 @@ async function downloadVOD(
const endSec = Math.min((i + 1) * partDuration, totalDuration);
const duration = endSec - startSec;
- const partFilename = path.join(folder, `${dateStr}_Part${(i + 1).toString().padStart(2, '0')}.mp4`);
+ const partFilename = makeTemplateFilename(
+ config.filename_template_parts,
+ DEFAULT_FILENAME_TEMPLATE_PARTS,
+ i + 1,
+ startSec,
+ duration
+ );
const result = await downloadVODPart(
item.url,
@@ -1756,7 +1811,7 @@ ipcMain.handle('save-config', (_, newConfig: Partial) => {
const previousClientId = config.client_id;
const previousClientSecret = config.client_secret;
- config = { ...config, ...newConfig };
+ config = normalizeConfigTemplates({ ...config, ...newConfig });
if (config.client_id !== previousClientId || config.client_secret !== previousClientSecret) {
accessToken = null;
diff --git a/typescript-version/src/renderer-globals.d.ts b/typescript-version/src/renderer-globals.d.ts
index 44549c8..423a20e 100644
--- a/typescript-version/src/renderer-globals.d.ts
+++ b/typescript-version/src/renderer-globals.d.ts
@@ -7,6 +7,9 @@ interface AppConfig {
download_mode?: 'parts' | 'full';
part_minutes?: number;
language?: 'de' | 'en';
+ filename_template_vod?: string;
+ filename_template_parts?: string;
+ filename_template_clip?: string;
[key: string]: unknown;
}
diff --git a/typescript-version/src/renderer-locale-de.ts b/typescript-version/src/renderer-locale-de.ts
index 97701b1..69e3739 100644
--- a/typescript-version/src/renderer-locale-de.ts
+++ b/typescript-version/src/renderer-locale-de.ts
@@ -40,6 +40,14 @@ const UI_TEXT_DE = {
modeFull: 'Ganzes VOD',
modeParts: 'In Teile splitten',
partMinutesLabel: 'Teil-Lange (Minuten)',
+ filenameTemplatesTitle: 'Dateinamen-Templates',
+ vodTemplateLabel: 'VOD-Template',
+ partsTemplateLabel: 'VOD-Teile-Template',
+ defaultClipTemplateLabel: 'Clip-Template',
+ filenameTemplateHint: 'Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}',
+ vodTemplatePlaceholder: '{title}.mp4',
+ partsTemplatePlaceholder: '{date}_Part{part_padded}.mp4',
+ defaultClipTemplatePlaceholder: '{date}_{part}.mp4',
updateTitle: 'Updates',
checkUpdates: 'Nach Updates suchen',
preflightTitle: 'System-Check',
@@ -122,7 +130,7 @@ const UI_TEXT_DE = {
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"}'
+ templateHelp: 'Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {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 98759ef..1b9e2be 100644
--- a/typescript-version/src/renderer-locale-en.ts
+++ b/typescript-version/src/renderer-locale-en.ts
@@ -40,6 +40,14 @@ const UI_TEXT_EN = {
modeFull: 'Full VOD',
modeParts: 'Split into parts',
partMinutesLabel: 'Part Length (Minutes)',
+ filenameTemplatesTitle: 'Filename Templates',
+ vodTemplateLabel: 'VOD Template',
+ partsTemplateLabel: 'VOD Part Template',
+ defaultClipTemplateLabel: 'Clip Template',
+ filenameTemplateHint: 'Placeholders: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}',
+ vodTemplatePlaceholder: '{title}.mp4',
+ partsTemplatePlaceholder: '{date}_Part{part_padded}.mp4',
+ defaultClipTemplatePlaceholder: '{date}_{part}.mp4',
updateTitle: 'Updates',
checkUpdates: 'Check for updates',
preflightTitle: 'System Check',
@@ -122,7 +130,7 @@ const UI_TEXT_EN = {
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"}'
+ templateHelp: 'Placeholders: {title} {id} {channel} {date} {part} {part_padded} {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-settings.ts b/typescript-version/src/renderer-settings.ts
index 90d5c6a..eec45b2 100644
--- a/typescript-version/src/renderer-settings.ts
+++ b/typescript-version/src/renderer-settings.ts
@@ -130,15 +130,25 @@ async function saveSettings(): Promise {
const downloadPath = byId('downloadPath').value;
const downloadMode = byId('downloadMode').value as 'parts' | 'full';
const partMinutes = parseInt(byId('partMinutes').value, 10) || 120;
+ const vodFilenameTemplate = byId('vodFilenameTemplate').value.trim() || '{title}.mp4';
+ const partsFilenameTemplate = byId('partsFilenameTemplate').value.trim() || '{date}_Part{part_padded}.mp4';
+ const defaultClipFilenameTemplate = byId('defaultClipFilenameTemplate').value.trim() || '{date}_{part}.mp4';
config = await window.api.saveConfig({
client_id: clientId,
client_secret: clientSecret,
download_path: downloadPath,
download_mode: downloadMode,
- part_minutes: partMinutes
+ part_minutes: partMinutes,
+ filename_template_vod: vodFilenameTemplate,
+ filename_template_parts: partsFilenameTemplate,
+ filename_template_clip: defaultClipFilenameTemplate
});
+ byId('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4';
+ byId('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4';
+ byId('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || '{date}_{part}.mp4';
+
await connect();
}
diff --git a/typescript-version/src/renderer-texts.ts b/typescript-version/src/renderer-texts.ts
index d792318..69c3562 100644
--- a/typescript-version/src/renderer-texts.ts
+++ b/typescript-version/src/renderer-texts.ts
@@ -82,6 +82,14 @@ function applyLanguageToStaticUI(): void {
setText('modeFullText', UI_TEXT.static.modeFull);
setText('modePartsText', UI_TEXT.static.modeParts);
setText('partMinutesLabel', UI_TEXT.static.partMinutesLabel);
+ setText('filenameTemplatesTitle', UI_TEXT.static.filenameTemplatesTitle);
+ setText('vodTemplateLabel', UI_TEXT.static.vodTemplateLabel);
+ setText('partsTemplateLabel', UI_TEXT.static.partsTemplateLabel);
+ setText('defaultClipTemplateLabel', UI_TEXT.static.defaultClipTemplateLabel);
+ setText('filenameTemplateHint', UI_TEXT.static.filenameTemplateHint);
+ setPlaceholder('vodFilenameTemplate', UI_TEXT.static.vodTemplatePlaceholder);
+ setPlaceholder('partsFilenameTemplate', UI_TEXT.static.partsTemplatePlaceholder);
+ setPlaceholder('defaultClipFilenameTemplate', UI_TEXT.static.defaultClipTemplatePlaceholder);
setText('updateTitle', UI_TEXT.static.updateTitle);
setText('checkUpdateBtn', UI_TEXT.static.checkUpdates);
setText('preflightTitle', UI_TEXT.static.preflightTitle);
diff --git a/typescript-version/src/renderer.ts b/typescript-version/src/renderer.ts
index 2e4c99c..54631d8 100644
--- a/typescript-version/src/renderer.ts
+++ b/typescript-version/src/renderer.ts
@@ -18,6 +18,9 @@ async function init(): Promise {
updateLanguagePicker(config.language ?? 'en');
byId('downloadMode').value = config.download_mode ?? 'full';
byId('partMinutes').value = String(config.part_minutes ?? 120);
+ byId('vodFilenameTemplate').value = (config.filename_template_vod as string) || DEFAULT_VOD_TEMPLATE;
+ byId('partsFilenameTemplate').value = (config.filename_template_parts as string) || DEFAULT_PARTS_TEMPLATE;
+ byId('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE;
changeTheme(config.theme ?? 'twitch');
renderStreamers();
@@ -174,6 +177,10 @@ function formatSecondsToTimeDashed(seconds: number): string {
return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
}
+const DEFAULT_VOD_TEMPLATE = '{title}.mp4';
+const DEFAULT_PARTS_TEMPLATE = '{date}_Part{part_padded}.mp4';
+const DEFAULT_CLIP_TEMPLATE = '{date}_{part}.mp4';
+
function formatDateWithPattern(date: Date, pattern: string): string {
const tokenMap: Record = {
yyyy: date.getFullYear().toString(),
@@ -248,6 +255,7 @@ function buildTemplatePreview(template: string, context: {
.replace(/\{channel_id\}/g, '0')
.replace(/\{date\}/g, dateStr)
.replace(/\{part\}/g, normalizedPart)
+ .replace(/\{part_padded\}/g, normalizedPart.padStart(2, '0'))
.replace(/\{trim_start\}/g, formatSecondsToTimeDashed(context.startSec))
.replace(/\{trim_end\}/g, formatSecondsToTimeDashed(context.startSec + context.durationSec))
.replace(/\{trim_length\}/g, formatSecondsToTimeDashed(context.durationSec))
@@ -286,7 +294,7 @@ 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';
+ byId('clipFilenameTemplate').value = (config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE;
query('input[name="filenameFormat"][value="simple"]').checked = true;
updateFilenameTemplateVisibility();
@@ -355,7 +363,7 @@ function updateFilenameExamples(): void {
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';
+ const template = byId('clipFilenameTemplate').value.trim() || (config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE;
updateFilenameTemplateVisibility();