Add global filename templates for VODs, parts, and clips (v4.0.5)

This commit is contained in:
xRangerDE 2026-02-16 12:49:44 +01:00
parent 9845b25d03
commit 1306309e6e
10 changed files with 146 additions and 32 deletions

View File

@ -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",

View File

@ -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",

View File

@ -364,11 +364,25 @@
<label id="partMinutesLabel">Teil-Lange (Minuten)</label>
<input type="number" id="partMinutes" value="120" min="10" max="480">
</div>
<div class="form-group">
<label id="filenameTemplatesTitle">Dateinamen-Templates</label>
<div style="display: grid; gap: 8px; margin-top: 8px;">
<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;">
<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;">
<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;">
</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>
</div>
<div class="settings-card">
<h3 id="updateTitle">Updates</h3>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.0.4</p>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.0.5</p>
<button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div>
@ -400,7 +414,7 @@
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Nicht verbunden</span>
</div>
<span id="versionText">v4.0.4</span>
<span id="versionText">v4.0.5</span>
</div>
</main>
</div>

View File

@ -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<Config>) => {
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;

View File

@ -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;
}

View File

@ -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?',

View File

@ -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?',

View File

@ -130,15 +130,25 @@ async function saveSettings(): Promise<void> {
const downloadPath = byId<HTMLInputElement>('downloadPath').value;
const downloadMode = byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full';
const partMinutes = parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120;
const vodFilenameTemplate = byId<HTMLInputElement>('vodFilenameTemplate').value.trim() || '{title}.mp4';
const partsFilenameTemplate = byId<HTMLInputElement>('partsFilenameTemplate').value.trim() || '{date}_Part{part_padded}.mp4';
const defaultClipFilenameTemplate = byId<HTMLInputElement>('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<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>('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || '{date}_{part}.mp4';
await connect();
}

View File

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

View File

@ -18,6 +18,9 @@ async function init(): Promise<void> {
updateLanguagePicker(config.language ?? 'en');
byId<HTMLSelectElement>('downloadMode').value = config.download_mode ?? 'full';
byId<HTMLInputElement>('partMinutes').value = String(config.part_minutes ?? 120);
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>('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<string, string> = {
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<HTMLInputElement>('clipStartTime').value = '00:00:00';
byId<HTMLInputElement>('clipEndTime').value = formatSecondsToTime(Math.min(60, clipTotalSeconds));
byId<HTMLInputElement>('clipStartPart').value = '';
byId<HTMLInputElement>('clipFilenameTemplate').value = '{date}_{part}.mp4';
byId<HTMLInputElement>('clipFilenameTemplate').value = (config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE;
query<HTMLInputElement>('input[name="filenameFormat"][value="simple"]').checked = true;
updateFilenameTemplateVisibility();
@ -355,7 +363,7 @@ function updateFilenameExamples(): void {
const endSec = parseTimeToSeconds(byId<HTMLInputElement>('clipEndTime').value);
const durationSec = Math.max(1, endSec - startSec);
const timeStr = formatSecondsToTimeDashed(startSec);
const template = byId<HTMLInputElement>('clipFilenameTemplate').value.trim() || '{date}_{part}.mp4';
const template = byId<HTMLInputElement>('clipFilenameTemplate').value.trim() || (config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE;
updateFilenameTemplateVisibility();