Add advanced clip filename templates with live preview (v4.0.4)

This commit is contained in:
xRangerDE 2026-02-16 12:25:00 +01:00
parent a29c252606
commit 9845b25d03
10 changed files with 302 additions and 19 deletions

View File

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

View File

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

View File

@ -71,15 +71,28 @@
<div style="margin-bottom: 20px;">
<label style="display: block; margin-bottom: 10px;">Dateinamen-Format:</label>
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 8px;">
<input type="radio" name="filenameFormat" value="simple" checked
<input type="radio" name="filenameFormat" value="simple" checked onchange="updateFilenameExamples()"
style="width: 18px; height: 18px; accent-color: #9146FF;">
<span id="formatSimple" style="color: #aaa;">01.02.2026_1.mp4 (Standard)</span>
</label>
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
<input type="radio" name="filenameFormat" value="timestamp"
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 8px;">
<input type="radio" name="filenameFormat" value="timestamp" onchange="updateFilenameExamples()"
style="width: 18px; height: 18px; accent-color: #9146FF;">
<span id="formatTimestamp" style="color: #aaa;">01.02.2026_CLIP_00-00-00_1.mp4 (mit Zeitstempel)</span>
</label>
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 10px;">
<input type="radio" name="filenameFormat" value="template" onchange="updateFilenameExamples()"
style="width: 18px; height: 18px; accent-color: #9146FF;">
<span id="formatTemplate" style="color: #aaa;">{date}_{part}.mp4 (benutzerdefiniert)</span>
</label>
<div id="clipFilenameTemplateWrap" style="display:none; margin-top: 10px;">
<input type="text" id="clipFilenameTemplate" value="{date}_{part}.mp4"
placeholder="{date}_{part}.mp4"
style="width: 100%; background: #333; border: 1px solid #444; border-radius: 4px; padding: 8px 12px; color: white; font-family: monospace;"
oninput="updateFilenameExamples()">
<div id="clipTemplateHelp" style="color: #888; font-size: 12px; margin-top: 6px;">Platzhalter: {title} {id} {channel} {date} {part} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
</div>
</div>
<!-- Button -->
@ -355,7 +368,7 @@
<div class="settings-card">
<h3 id="updateTitle">Updates</h3>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.0.3</p>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.0.4</p>
<button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div>
@ -387,7 +400,7 @@
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Nicht verbunden</span>
</div>
<span id="versionText">v4.0.3</span>
<span id="versionText">v4.0.4</span>
</div>
</main>
</div>

View File

@ -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<string, string> = {
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<string, string> = {
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,

View File

@ -5,7 +5,8 @@ interface CustomClip {
startSec: number;
durationSec: number;
startPart: number;
filenameFormat: 'simple' | 'timestamp';
filenameFormat: 'simple' | 'timestamp' | 'template';
filenameTemplate?: string;
}
interface QueueItem {

View File

@ -25,7 +25,8 @@ interface CustomClip {
startSec: number;
durationSec: number;
startPart: number;
filenameFormat: 'simple' | 'timestamp';
filenameFormat: 'simple' | 'timestamp' | 'template';
filenameTemplate?: string;
}
interface QueueItem {

View File

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

View File

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

View File

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

View File

@ -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<string, string> = {
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<string, string> = {
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<HTMLInputElement>('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<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';
query<HTMLInputElement>('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<HTMLInputElement>('clipStartPart').value || '1';
const startSec = parseTimeToSeconds(byId<HTMLInputElement>('clipStartTime').value);
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';
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<void> {
@ -274,7 +381,8 @@ async function confirmClipDialog(): Promise<void> {
const endSec = parseTimeToSeconds(byId<HTMLInputElement>('clipEndTime').value);
const startPartStr = byId<HTMLInputElement>('clipStartPart').value.trim();
const startPart = startPartStr ? parseInt(startPartStr, 10) : 1;
const filenameFormat = query<HTMLInputElement>('input[name="filenameFormat"]:checked').value as 'simple' | 'timestamp';
const filenameFormat = getSelectedFilenameFormat();
const filenameTemplate = byId<HTMLInputElement>('clipFilenameTemplate').value.trim();
if (endSec <= startSec) {
alert(UI_TEXT.clips.endBeforeStart);
@ -286,6 +394,11 @@ async function confirmClipDialog(): Promise<void> {
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<void> {
startSec,
durationSec,
startPart,
filenameFormat
filenameFormat,
filenameTemplate: filenameFormat === 'template' ? filenameTemplate : undefined
}
});