From 1306309e6e9eb2d4a79d77ead1695d78163b3096 Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Mon, 16 Feb 2026 12:49:44 +0100 Subject: [PATCH] Add global filename templates for VODs, parts, and clips (v4.0.5) --- typescript-version/package-lock.json | 4 +- typescript-version/package.json | 2 +- typescript-version/src/index.html | 18 +++- typescript-version/src/main.ts | 99 +++++++++++++++----- typescript-version/src/renderer-globals.d.ts | 3 + typescript-version/src/renderer-locale-de.ts | 10 +- typescript-version/src/renderer-locale-en.ts | 10 +- typescript-version/src/renderer-settings.ts | 12 ++- typescript-version/src/renderer-texts.ts | 8 ++ typescript-version/src/renderer.ts | 12 ++- 10 files changed, 146 insertions(+), 32 deletions(-) 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 @@ +
+ +
+ + + + + + + + +
+
Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}
+

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();