Add advanced clip filename templates with live preview (v4.0.4)
This commit is contained in:
parent
a29c252606
commit
9845b25d03
4
typescript-version/package-lock.json
generated
4
typescript-version/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"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",
|
||||||
|
|||||||
@ -71,15 +71,28 @@
|
|||||||
<div style="margin-bottom: 20px;">
|
<div style="margin-bottom: 20px;">
|
||||||
<label style="display: block; margin-bottom: 10px;">Dateinamen-Format:</label>
|
<label style="display: block; margin-bottom: 10px;">Dateinamen-Format:</label>
|
||||||
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 8px;">
|
<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;">
|
style="width: 18px; height: 18px; accent-color: #9146FF;">
|
||||||
<span id="formatSimple" style="color: #aaa;">01.02.2026_1.mp4 (Standard)</span>
|
<span id="formatSimple" style="color: #aaa;">01.02.2026_1.mp4 (Standard)</span>
|
||||||
</label>
|
</label>
|
||||||
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
|
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 8px;">
|
||||||
<input type="radio" name="filenameFormat" value="timestamp"
|
<input type="radio" name="filenameFormat" value="timestamp" onchange="updateFilenameExamples()"
|
||||||
style="width: 18px; height: 18px; accent-color: #9146FF;">
|
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>
|
<span id="formatTimestamp" style="color: #aaa;">01.02.2026_CLIP_00-00-00_1.mp4 (mit Zeitstempel)</span>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Button -->
|
<!-- Button -->
|
||||||
@ -355,7 +368,7 @@
|
|||||||
|
|
||||||
<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.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>
|
<button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -387,7 +400,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.3</span>
|
<span id="versionText">v4.0.4</span>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { autoUpdater } from 'electron-updater';
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// CONFIG & CONSTANTS
|
// CONFIG & CONSTANTS
|
||||||
// ==========================================
|
// ==========================================
|
||||||
const APP_VERSION = '4.0.3';
|
const APP_VERSION = '4.0.4';
|
||||||
const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
|
const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
|
||||||
|
|
||||||
// Paths
|
// Paths
|
||||||
@ -61,7 +61,8 @@ interface CustomClip {
|
|||||||
startSec: number;
|
startSec: number;
|
||||||
durationSec: number;
|
durationSec: number;
|
||||||
startPart: number;
|
startPart: number;
|
||||||
filenameFormat: 'simple' | 'timestamp';
|
filenameFormat: 'simple' | 'timestamp' | 'template';
|
||||||
|
filenameTemplate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QueueItem {
|
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')}`;
|
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 {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes < 1024) return bytes + ' B';
|
if (bytes < 1024) return bytes + ' B';
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
@ -1329,7 +1457,23 @@ async function downloadVOD(
|
|||||||
const partDuration = config.part_minutes * 60;
|
const partDuration = config.part_minutes * 60;
|
||||||
|
|
||||||
// Helper to generate filename based on format
|
// 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') {
|
if (clip.filenameFormat === 'timestamp') {
|
||||||
const h = Math.floor(startOffset / 3600);
|
const h = Math.floor(startOffset / 3600);
|
||||||
const m = Math.floor((startOffset % 3600) / 60);
|
const m = Math.floor((startOffset % 3600) / 60);
|
||||||
@ -1354,7 +1498,7 @@ async function downloadVOD(
|
|||||||
const remainingDuration = clip.durationSec - (i * partDuration);
|
const remainingDuration = clip.durationSec - (i * partDuration);
|
||||||
const thisDuration = Math.min(partDuration, remainingDuration);
|
const thisDuration = Math.min(partDuration, remainingDuration);
|
||||||
|
|
||||||
const partFilename = makeClipFilename(partNum, startOffset);
|
const partFilename = makeClipFilename(partNum, startOffset, thisDuration);
|
||||||
|
|
||||||
const result = await downloadVODPart(
|
const result = await downloadVODPart(
|
||||||
item.url,
|
item.url,
|
||||||
@ -1377,7 +1521,7 @@ async function downloadVOD(
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Single clip file
|
// Single clip file
|
||||||
const filename = makeClipFilename(clip.startPart, clip.startSec);
|
const filename = makeClipFilename(clip.startPart, clip.startSec, clip.durationSec);
|
||||||
return await downloadVODPart(
|
return await downloadVODPart(
|
||||||
item.url,
|
item.url,
|
||||||
filename,
|
filename,
|
||||||
|
|||||||
@ -5,7 +5,8 @@ interface CustomClip {
|
|||||||
startSec: number;
|
startSec: number;
|
||||||
durationSec: number;
|
durationSec: number;
|
||||||
startPart: number;
|
startPart: number;
|
||||||
filenameFormat: 'simple' | 'timestamp';
|
filenameFormat: 'simple' | 'timestamp' | 'template';
|
||||||
|
filenameTemplate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QueueItem {
|
interface QueueItem {
|
||||||
|
|||||||
3
typescript-version/src/renderer-globals.d.ts
vendored
3
typescript-version/src/renderer-globals.d.ts
vendored
@ -25,7 +25,8 @@ interface CustomClip {
|
|||||||
startSec: number;
|
startSec: number;
|
||||||
durationSec: number;
|
durationSec: number;
|
||||||
startPart: number;
|
startPart: number;
|
||||||
filenameFormat: 'simple' | 'timestamp';
|
filenameFormat: 'simple' | 'timestamp' | 'template';
|
||||||
|
filenameTemplate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QueueItem {
|
interface QueueItem {
|
||||||
|
|||||||
@ -118,7 +118,11 @@ const UI_TEXT_DE = {
|
|||||||
errorPrefix: 'Fehler: ',
|
errorPrefix: 'Fehler: ',
|
||||||
unknownError: 'Unbekannter Fehler',
|
unknownError: 'Unbekannter Fehler',
|
||||||
formatSimple: '(Standard)',
|
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: {
|
cutter: {
|
||||||
videoInfoFailed: 'Konnte Video-Informationen nicht lesen. FFprobe installiert?',
|
videoInfoFailed: 'Konnte Video-Informationen nicht lesen. FFprobe installiert?',
|
||||||
|
|||||||
@ -118,7 +118,11 @@ const UI_TEXT_EN = {
|
|||||||
errorPrefix: 'Error: ',
|
errorPrefix: 'Error: ',
|
||||||
unknownError: 'Unknown error',
|
unknownError: 'Unknown error',
|
||||||
formatSimple: '(default)',
|
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: {
|
cutter: {
|
||||||
videoInfoFailed: 'Could not read video info. Is FFprobe installed?',
|
videoInfoFailed: 'Could not read video info. Is FFprobe installed?',
|
||||||
|
|||||||
@ -59,6 +59,8 @@ function applyLanguageToStaticUI(): void {
|
|||||||
setText('clipsHeading', UI_TEXT.static.clipsHeading);
|
setText('clipsHeading', UI_TEXT.static.clipsHeading);
|
||||||
setText('clipsInfoTitle', UI_TEXT.static.clipsInfoTitle);
|
setText('clipsInfoTitle', UI_TEXT.static.clipsInfoTitle);
|
||||||
setText('clipsInfoText', UI_TEXT.static.clipsInfoText);
|
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('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle);
|
||||||
setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse);
|
setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse);
|
||||||
setText('mergeTitle', UI_TEXT.static.mergeTitle);
|
setText('mergeTitle', UI_TEXT.static.mergeTitle);
|
||||||
|
|||||||
@ -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')}`;
|
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 {
|
function parseTimeToSeconds(timeStr: string): number {
|
||||||
const parts = timeStr.split(':').map((p: string) => parseInt(p, 10) || 0);
|
const parts = timeStr.split(':').map((p: string) => parseInt(p, 10) || 0);
|
||||||
if (parts.length === 3) {
|
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>('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';
|
||||||
|
query<HTMLInputElement>('input[name="filenameFormat"][value="simple"]').checked = true;
|
||||||
|
updateFilenameTemplateVisibility();
|
||||||
|
|
||||||
updateClipDuration();
|
updateClipDuration();
|
||||||
updateFilenameExamples();
|
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 dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`;
|
||||||
const partNum = byId<HTMLInputElement>('clipStartPart').value || '1';
|
const partNum = byId<HTMLInputElement>('clipStartPart').value || '1';
|
||||||
const startSec = parseTimeToSeconds(byId<HTMLInputElement>('clipStartTime').value);
|
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 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('formatSimple').textContent = `${dateStr}_${partNum}.mp4 ${UI_TEXT.clips.formatSimple}`;
|
||||||
byId('formatTimestamp').textContent = `${dateStr}_CLIP_${timeStr}_${partNum}.mp4 ${UI_TEXT.clips.formatTimestamp}`;
|
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> {
|
async function confirmClipDialog(): Promise<void> {
|
||||||
@ -274,7 +381,8 @@ async function confirmClipDialog(): Promise<void> {
|
|||||||
const endSec = parseTimeToSeconds(byId<HTMLInputElement>('clipEndTime').value);
|
const endSec = parseTimeToSeconds(byId<HTMLInputElement>('clipEndTime').value);
|
||||||
const startPartStr = byId<HTMLInputElement>('clipStartPart').value.trim();
|
const startPartStr = byId<HTMLInputElement>('clipStartPart').value.trim();
|
||||||
const startPart = startPartStr ? parseInt(startPartStr, 10) : 1;
|
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) {
|
if (endSec <= startSec) {
|
||||||
alert(UI_TEXT.clips.endBeforeStart);
|
alert(UI_TEXT.clips.endBeforeStart);
|
||||||
@ -286,6 +394,11 @@ async function confirmClipDialog(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filenameFormat === 'template' && !filenameTemplate) {
|
||||||
|
alert(UI_TEXT.clips.templateEmpty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const durationSec = endSec - startSec;
|
const durationSec = endSec - startSec;
|
||||||
|
|
||||||
queue = await window.api.addToQueue({
|
queue = await window.api.addToQueue({
|
||||||
@ -298,7 +411,8 @@ async function confirmClipDialog(): Promise<void> {
|
|||||||
startSec,
|
startSec,
|
||||||
durationSec,
|
durationSec,
|
||||||
startPart,
|
startPart,
|
||||||
filenameFormat
|
filenameFormat,
|
||||||
|
filenameTemplate: filenameFormat === 'template' ? filenameTemplate : undefined
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user