diff --git a/typescript-version/package-lock.json b/typescript-version/package-lock.json index 3c2921d..b2f94f6 100644 --- a/typescript-version/package-lock.json +++ b/typescript-version/package-lock.json @@ -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", diff --git a/typescript-version/package.json b/typescript-version/package.json index d144708..569a233 100644 --- a/typescript-version/package.json +++ b/typescript-version/package.json @@ -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", diff --git a/typescript-version/src/index.html b/typescript-version/src/index.html index 2293545..7d8a50c 100644 --- a/typescript-version/src/index.html +++ b/typescript-version/src/index.html @@ -71,15 +71,28 @@
- + + +
@@ -355,7 +368,7 @@

Updates

-

Version: v4.0.3

+

Version: v4.0.4

@@ -387,7 +400,7 @@
Nicht verbunden - v4.0.3 + v4.0.4 diff --git a/typescript-version/src/main.ts b/typescript-version/src/main.ts index ac4fcd2..4db74a0 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.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 = { + 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 = { + 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, diff --git a/typescript-version/src/preload.ts b/typescript-version/src/preload.ts index a9c0c4b..bbbaf39 100644 --- a/typescript-version/src/preload.ts +++ b/typescript-version/src/preload.ts @@ -5,7 +5,8 @@ interface CustomClip { startSec: number; durationSec: number; startPart: number; - filenameFormat: 'simple' | 'timestamp'; + filenameFormat: 'simple' | 'timestamp' | 'template'; + filenameTemplate?: string; } interface QueueItem { diff --git a/typescript-version/src/renderer-globals.d.ts b/typescript-version/src/renderer-globals.d.ts index 1873655..44549c8 100644 --- a/typescript-version/src/renderer-globals.d.ts +++ b/typescript-version/src/renderer-globals.d.ts @@ -25,7 +25,8 @@ interface CustomClip { startSec: number; durationSec: number; startPart: number; - filenameFormat: 'simple' | 'timestamp'; + filenameFormat: 'simple' | 'timestamp' | 'template'; + filenameTemplate?: string; } interface QueueItem { diff --git a/typescript-version/src/renderer-locale-de.ts b/typescript-version/src/renderer-locale-de.ts index 43f0fb4..97701b1 100644 --- a/typescript-version/src/renderer-locale-de.ts +++ b/typescript-version/src/renderer-locale-de.ts @@ -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?', diff --git a/typescript-version/src/renderer-locale-en.ts b/typescript-version/src/renderer-locale-en.ts index 7b41738..98759ef 100644 --- a/typescript-version/src/renderer-locale-en.ts +++ b/typescript-version/src/renderer-locale-en.ts @@ -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?', diff --git a/typescript-version/src/renderer-texts.ts b/typescript-version/src/renderer-texts.ts index 04348a0..d792318 100644 --- a/typescript-version/src/renderer-texts.ts +++ b/typescript-version/src/renderer-texts.ts @@ -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); diff --git a/typescript-version/src/renderer.ts b/typescript-version/src/renderer.ts index ced3056..2e4c99c 100644 --- a/typescript-version/src/renderer.ts +++ b/typescript-version/src/renderer.ts @@ -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 = { + 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 = { + 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('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('clipStartTime').value = '00:00:00'; byId('clipEndTime').value = formatSecondsToTime(Math.min(60, clipTotalSeconds)); byId('clipStartPart').value = ''; + byId('clipFilenameTemplate').value = '{date}_{part}.mp4'; + query('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('clipStartPart').value || '1'; const startSec = parseTimeToSeconds(byId('clipStartTime').value); + 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'; + + 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 { @@ -274,7 +381,8 @@ async function confirmClipDialog(): Promise { const endSec = parseTimeToSeconds(byId('clipEndTime').value); const startPartStr = byId('clipStartPart').value.trim(); const startPart = startPartStr ? parseInt(startPartStr, 10) : 1; - const filenameFormat = query('input[name="filenameFormat"]:checked').value as 'simple' | 'timestamp'; + const filenameFormat = getSelectedFilenameFormat(); + const filenameTemplate = byId('clipFilenameTemplate').value.trim(); if (endSec <= startSec) { alert(UI_TEXT.clips.endBeforeStart); @@ -286,6 +394,11 @@ async function confirmClipDialog(): Promise { 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 { startSec, durationSec, startPart, - filenameFormat + filenameFormat, + filenameTemplate: filenameFormat === 'template' ? filenameTemplate : undefined } });