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", "name": "twitch-vod-manager",
"version": "4.0.4", "version": "4.0.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.0.4", "version": "4.0.5",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.0", "axios": "^1.6.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.0.4", "version": "4.0.5",
"description": "Twitch VOD Manager - Download Twitch VODs easily", "description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js", "main": "dist/main.js",
"author": "xRangerDE", "author": "xRangerDE",

View File

@ -364,11 +364,25 @@
<label id="partMinutesLabel">Teil-Lange (Minuten)</label> <label id="partMinutesLabel">Teil-Lange (Minuten)</label>
<input type="number" id="partMinutes" value="120" min="10" max="480"> <input type="number" id="partMinutes" value="120" min="10" max="480">
</div> </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>
<div class="settings-card"> <div class="settings-card">
<h3 id="updateTitle">Updates</h3> <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> <button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div> </div>
@ -400,7 +414,7 @@
<div class="status-dot" id="statusDot"></div> <div class="status-dot" id="statusDot"></div>
<span id="statusText">Nicht verbunden</span> <span id="statusText">Nicht verbunden</span>
</div> </div>
<span id="versionText">v4.0.4</span> <span id="versionText">v4.0.5</span>
</div> </div>
</main> </main>
</div> </div>

View File

@ -8,7 +8,7 @@ import { autoUpdater } from 'electron-updater';
// ========================================== // ==========================================
// CONFIG & CONSTANTS // CONFIG & CONSTANTS
// ========================================== // ==========================================
const APP_VERSION = '4.0.4'; const APP_VERSION = '4.0.5';
const UPDATE_CHECK_URL = 'http://24-music.de/version.json'; const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
// Paths // Paths
@ -20,6 +20,9 @@ const TOOLS_DIR = path.join(APPDATA_DIR, 'tools');
const TOOLS_STREAMLINK_DIR = path.join(TOOLS_DIR, 'streamlink'); const TOOLS_STREAMLINK_DIR = path.join(TOOLS_DIR, 'streamlink');
const TOOLS_FFMPEG_DIR = path.join(TOOLS_DIR, 'ffmpeg'); const TOOLS_FFMPEG_DIR = path.join(TOOLS_DIR, 'ffmpeg');
const DEFAULT_DOWNLOAD_PATH = path.join(app.getPath('desktop'), 'Twitch_VODs'); 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 // Timeouts
const API_TIMEOUT = 10000; const API_TIMEOUT = 10000;
@ -44,6 +47,9 @@ interface Config {
download_mode: 'parts' | 'full'; download_mode: 'parts' | 'full';
part_minutes: number; part_minutes: number;
language: 'de' | 'en'; language: 'de' | 'en';
filename_template_vod: string;
filename_template_parts: string;
filename_template_clip: string;
} }
interface VOD { interface VOD {
@ -135,19 +141,36 @@ const defaultConfig: Config = {
theme: 'twitch', theme: 'twitch',
download_mode: 'full', download_mode: 'full',
part_minutes: 120, 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 { function loadConfig(): Config {
try { try {
if (fs.existsSync(CONFIG_FILE)) { if (fs.existsSync(CONFIG_FILE)) {
const data = fs.readFileSync(CONFIG_FILE, 'utf-8'); const data = fs.readFileSync(CONFIG_FILE, 'utf-8');
return { ...defaultConfig, ...JSON.parse(data) }; return normalizeConfigTemplates({ ...defaultConfig, ...JSON.parse(data) });
} }
} catch (e) { } catch (e) {
console.error('Error loading config:', e); console.error('Error loading config:', e);
} }
return defaultConfig; return normalizeConfigTemplates(defaultConfig);
} }
function saveConfig(config: Config): void { function saveConfig(config: Config): void {
@ -697,6 +720,7 @@ interface ClipTemplateContext {
channel: string; channel: string;
date: Date; date: Date;
part: number; part: number;
partPadded: string;
trimStartSec: number; trimStartSec: number;
trimEndSec: number; trimEndSec: number;
trimLengthSec: number; trimLengthSec: number;
@ -712,6 +736,7 @@ function renderClipFilenameTemplate(context: ClipTemplateContext): string {
.replace(/\{channel_id\}/g, '') .replace(/\{channel_id\}/g, '')
.replace(/\{date\}/g, baseDate) .replace(/\{date\}/g, baseDate)
.replace(/\{part\}/g, String(context.part)) .replace(/\{part\}/g, String(context.part))
.replace(/\{part_padded\}/g, context.partPadded)
.replace(/\{trim_start\}/g, formatDurationDashed(context.trimStartSec)) .replace(/\{trim_start\}/g, formatDurationDashed(context.trimStartSec))
.replace(/\{trim_end\}/g, formatDurationDashed(context.trimEndSec)) .replace(/\{trim_end\}/g, formatDurationDashed(context.trimEndSec))
.replace(/\{trim_length\}/g, formatDurationDashed(context.trimLengthSec)) .replace(/\{trim_length\}/g, formatDurationDashed(context.trimLengthSec))
@ -1448,8 +1473,32 @@ async function downloadVOD(
const folder = path.join(config.download_path, streamer, dateStr); const folder = path.join(config.download_path, streamer, dateStr);
fs.mkdirSync(folder, { recursive: true }); 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 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 // Custom Clip - download specific time range
if (item.customClip) { if (item.customClip) {
@ -1458,20 +1507,14 @@ async function downloadVOD(
// Helper to generate filename based on format // Helper to generate filename based on format
const makeClipFilename = (partNum: number, startOffset: number, clipLengthSec: number): string => { const makeClipFilename = (partNum: number, startOffset: number, clipLengthSec: number): string => {
if (clip.filenameFormat === 'template' && (clip.filenameTemplate || '').trim()) { if (clip.filenameFormat === 'template') {
const relativeName = renderClipFilenameTemplate({ return makeTemplateFilename(
template: clip.filenameTemplate as string, clip.filenameTemplate || config.filename_template_clip,
title: item.title, DEFAULT_FILENAME_TEMPLATE_CLIP,
vodId: parseVodId(item.url), partNum,
channel: item.streamer, startOffset,
date, clipLengthSec
part: partNum, );
trimStartSec: startOffset,
trimEndSec: startOffset + clipLengthSec,
trimLengthSec: clipLengthSec,
fullLengthSec: totalDuration
});
return path.join(folder, relativeName);
} }
if (clip.filenameFormat === 'timestamp') { if (clip.filenameFormat === 'timestamp') {
@ -1538,7 +1581,13 @@ async function downloadVOD(
// Check download mode // Check download mode
if (config.download_mode === 'full' || totalDuration <= config.part_minutes * 60) { if (config.download_mode === 'full' || totalDuration <= config.part_minutes * 60) {
// Full download // 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); return await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1);
} else { } else {
// Part-based download // Part-based download
@ -1553,7 +1602,13 @@ async function downloadVOD(
const endSec = Math.min((i + 1) * partDuration, totalDuration); const endSec = Math.min((i + 1) * partDuration, totalDuration);
const duration = endSec - startSec; 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( const result = await downloadVODPart(
item.url, item.url,
@ -1756,7 +1811,7 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
const previousClientId = config.client_id; const previousClientId = config.client_id;
const previousClientSecret = config.client_secret; const previousClientSecret = config.client_secret;
config = { ...config, ...newConfig }; config = normalizeConfigTemplates({ ...config, ...newConfig });
if (config.client_id !== previousClientId || config.client_secret !== previousClientSecret) { if (config.client_id !== previousClientId || config.client_secret !== previousClientSecret) {
accessToken = null; accessToken = null;

View File

@ -7,6 +7,9 @@ interface AppConfig {
download_mode?: 'parts' | 'full'; download_mode?: 'parts' | 'full';
part_minutes?: number; part_minutes?: number;
language?: 'de' | 'en'; language?: 'de' | 'en';
filename_template_vod?: string;
filename_template_parts?: string;
filename_template_clip?: string;
[key: string]: unknown; [key: string]: unknown;
} }

View File

@ -40,6 +40,14 @@ const UI_TEXT_DE = {
modeFull: 'Ganzes VOD', modeFull: 'Ganzes VOD',
modeParts: 'In Teile splitten', modeParts: 'In Teile splitten',
partMinutesLabel: 'Teil-Lange (Minuten)', 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', updateTitle: 'Updates',
checkUpdates: 'Nach Updates suchen', checkUpdates: 'Nach Updates suchen',
preflightTitle: 'System-Check', preflightTitle: 'System-Check',
@ -122,7 +130,7 @@ const UI_TEXT_DE = {
formatTemplate: '(benutzerdefiniert)', formatTemplate: '(benutzerdefiniert)',
templateEmpty: 'Das Template darf im benutzerdefinierten Modus nicht leer sein.', templateEmpty: 'Das Template darf im benutzerdefinierten Modus nicht leer sein.',
templatePlaceholder: '{date}_{part}.mp4', 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: { cutter: {
videoInfoFailed: 'Konnte Video-Informationen nicht lesen. FFprobe installiert?', videoInfoFailed: 'Konnte Video-Informationen nicht lesen. FFprobe installiert?',

View File

@ -40,6 +40,14 @@ const UI_TEXT_EN = {
modeFull: 'Full VOD', modeFull: 'Full VOD',
modeParts: 'Split into parts', modeParts: 'Split into parts',
partMinutesLabel: 'Part Length (Minutes)', 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', updateTitle: 'Updates',
checkUpdates: 'Check for updates', checkUpdates: 'Check for updates',
preflightTitle: 'System Check', preflightTitle: 'System Check',
@ -122,7 +130,7 @@ const UI_TEXT_EN = {
formatTemplate: '(custom template)', formatTemplate: '(custom template)',
templateEmpty: 'Template cannot be empty in custom template mode.', templateEmpty: 'Template cannot be empty in custom template mode.',
templatePlaceholder: '{date}_{part}.mp4', 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: { cutter: {
videoInfoFailed: 'Could not read video info. Is FFprobe installed?', 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 downloadPath = byId<HTMLInputElement>('downloadPath').value;
const downloadMode = byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full'; const downloadMode = byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full';
const partMinutes = parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120; 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({ config = await window.api.saveConfig({
client_id: clientId, client_id: clientId,
client_secret: clientSecret, client_secret: clientSecret,
download_path: downloadPath, download_path: downloadPath,
download_mode: downloadMode, 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(); await connect();
} }

View File

@ -82,6 +82,14 @@ function applyLanguageToStaticUI(): void {
setText('modeFullText', UI_TEXT.static.modeFull); setText('modeFullText', UI_TEXT.static.modeFull);
setText('modePartsText', UI_TEXT.static.modeParts); setText('modePartsText', UI_TEXT.static.modeParts);
setText('partMinutesLabel', UI_TEXT.static.partMinutesLabel); 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('updateTitle', UI_TEXT.static.updateTitle);
setText('checkUpdateBtn', UI_TEXT.static.checkUpdates); setText('checkUpdateBtn', UI_TEXT.static.checkUpdates);
setText('preflightTitle', UI_TEXT.static.preflightTitle); setText('preflightTitle', UI_TEXT.static.preflightTitle);

View File

@ -18,6 +18,9 @@ async function init(): Promise<void> {
updateLanguagePicker(config.language ?? 'en'); updateLanguagePicker(config.language ?? 'en');
byId<HTMLSelectElement>('downloadMode').value = config.download_mode ?? 'full'; byId<HTMLSelectElement>('downloadMode').value = config.download_mode ?? 'full';
byId<HTMLInputElement>('partMinutes').value = String(config.part_minutes ?? 120); 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'); changeTheme(config.theme ?? 'twitch');
renderStreamers(); 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')}`; 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 { function formatDateWithPattern(date: Date, pattern: string): string {
const tokenMap: Record<string, string> = { const tokenMap: Record<string, string> = {
yyyy: date.getFullYear().toString(), yyyy: date.getFullYear().toString(),
@ -248,6 +255,7 @@ function buildTemplatePreview(template: string, context: {
.replace(/\{channel_id\}/g, '0') .replace(/\{channel_id\}/g, '0')
.replace(/\{date\}/g, dateStr) .replace(/\{date\}/g, dateStr)
.replace(/\{part\}/g, normalizedPart) .replace(/\{part\}/g, normalizedPart)
.replace(/\{part_padded\}/g, normalizedPart.padStart(2, '0'))
.replace(/\{trim_start\}/g, formatSecondsToTimeDashed(context.startSec)) .replace(/\{trim_start\}/g, formatSecondsToTimeDashed(context.startSec))
.replace(/\{trim_end\}/g, formatSecondsToTimeDashed(context.startSec + context.durationSec)) .replace(/\{trim_end\}/g, formatSecondsToTimeDashed(context.startSec + context.durationSec))
.replace(/\{trim_length\}/g, formatSecondsToTimeDashed(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>('clipStartTime').value = '00:00:00';
byId<HTMLInputElement>('clipEndTime').value = formatSecondsToTime(Math.min(60, clipTotalSeconds)); byId<HTMLInputElement>('clipEndTime').value = formatSecondsToTime(Math.min(60, clipTotalSeconds));
byId<HTMLInputElement>('clipStartPart').value = ''; 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; query<HTMLInputElement>('input[name="filenameFormat"][value="simple"]').checked = true;
updateFilenameTemplateVisibility(); updateFilenameTemplateVisibility();
@ -355,7 +363,7 @@ function updateFilenameExamples(): void {
const endSec = parseTimeToSeconds(byId<HTMLInputElement>('clipEndTime').value); const endSec = parseTimeToSeconds(byId<HTMLInputElement>('clipEndTime').value);
const durationSec = Math.max(1, endSec - startSec); const durationSec = Math.max(1, endSec - startSec);
const timeStr = formatSecondsToTimeDashed(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(); updateFilenameTemplateVisibility();