Add global filename templates for VODs, parts, and clips (v4.0.5)
This commit is contained in:
parent
9845b25d03
commit
1306309e6e
4
typescript-version/package-lock.json
generated
4
typescript-version/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
3
typescript-version/src/renderer-globals.d.ts
vendored
3
typescript-version/src/renderer-globals.d.ts
vendored
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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?',
|
||||
|
||||
@ -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?',
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user