From c9c28380c67ece3406315c525e876e469c0d7c43 Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Wed, 4 Feb 2026 13:35:31 +0100 Subject: [PATCH] Add Video Cutter, Merge, and Part-based Download features (v3.6.0) New features: - Video Cutter with timeline UI, preview, and frame extraction - Video Merge functionality to combine multiple videos - Part-based VOD downloads (split long VODs into segments) - Download progress with speed and ETA display - New navigation tabs for Cutter and Merge Technical changes: - Added FFmpeg/FFprobe integration for video processing - New IPC handlers for video operations - Extended preload API with cutter/merge methods - Progress event listeners for cut/merge operations Co-Authored-By: Claude Opus 4.5 --- typescript-version/package.json | 2 +- typescript-version/src/index.html | 650 ++++++++++++++++++++++++++++-- typescript-version/src/main.ts | 508 ++++++++++++++++++++--- typescript-version/src/preload.ts | 34 ++ 4 files changed, 1110 insertions(+), 84 deletions(-) diff --git a/typescript-version/package.json b/typescript-version/package.json index 0c6ae71..30fe7ae 100644 --- a/typescript-version/package.json +++ b/typescript-version/package.json @@ -1,6 +1,6 @@ { "name": "twitch-vod-manager", - "version": "3.5.3", + "version": "3.6.0", "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 299a0e7..2fbbdc9 100644 --- a/typescript-version/src/index.html +++ b/typescript-version/src/index.html @@ -496,6 +496,11 @@ background: var(--accent-hover); } + .btn-primary:disabled { + background: var(--text-secondary); + cursor: not-allowed; + } + .btn-secondary { background: var(--bg-card); color: var(--text); @@ -626,6 +631,250 @@ font-weight: 600; } + /* Video Cutter Styles */ + .cutter-container { + max-width: 900px; + margin: 0 auto; + } + + .video-preview { + background: #000; + border-radius: 8px; + overflow: hidden; + margin-bottom: 20px; + aspect-ratio: 16/9; + display: flex; + align-items: center; + justify-content: center; + position: relative; + } + + .video-preview img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + + .video-preview .placeholder { + color: var(--text-secondary); + text-align: center; + } + + .timeline-container { + background: var(--bg-card); + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + } + + .timeline { + position: relative; + height: 60px; + background: var(--bg-main); + border-radius: 4px; + margin: 15px 0; + cursor: pointer; + } + + .timeline-selection { + position: absolute; + top: 0; + height: 100%; + background: rgba(145, 71, 255, 0.3); + border-left: 3px solid var(--accent); + border-right: 3px solid var(--accent); + } + + .timeline-handle { + position: absolute; + top: -5px; + width: 12px; + height: 70px; + background: var(--accent); + border-radius: 3px; + cursor: ew-resize; + } + + .timeline-handle.start { left: 0; transform: translateX(-50%); } + .timeline-handle.end { right: 0; transform: translateX(50%); } + + .timeline-current { + position: absolute; + top: 0; + width: 2px; + height: 100%; + background: var(--success); + pointer-events: none; + } + + .time-inputs { + display: flex; + gap: 20px; + align-items: center; + justify-content: center; + margin-top: 15px; + } + + .time-input-group { + display: flex; + align-items: center; + gap: 8px; + } + + .time-input-group label { + color: var(--text-secondary); + font-size: 13px; + } + + .time-input-group input { + width: 100px; + background: var(--bg-main); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 4px; + padding: 8px 10px; + color: var(--text); + text-align: center; + font-family: monospace; + } + + .cutter-actions { + display: flex; + gap: 10px; + justify-content: center; + margin-top: 20px; + } + + .cutter-info { + background: var(--bg-card); + border-radius: 8px; + padding: 15px 20px; + margin-bottom: 20px; + display: flex; + justify-content: space-around; + text-align: center; + } + + .cutter-info-item { + display: flex; + flex-direction: column; + gap: 5px; + } + + .cutter-info-label { + font-size: 12px; + color: var(--text-secondary); + } + + .cutter-info-value { + font-size: 16px; + font-weight: 600; + font-family: monospace; + } + + /* Merge Styles */ + .merge-container { + max-width: 800px; + margin: 0 auto; + } + + .file-list { + background: var(--bg-card); + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + min-height: 200px; + } + + .file-item { + display: flex; + align-items: center; + gap: 15px; + padding: 12px 15px; + background: var(--bg-main); + border-radius: 6px; + margin-bottom: 10px; + } + + .file-item .file-order { + width: 30px; + height: 30px; + background: var(--accent); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 14px; + } + + .file-item .file-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .file-item .file-actions { + display: flex; + gap: 8px; + } + + .file-item .file-btn { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 5px; + font-size: 16px; + } + + .file-item .file-btn:hover { + color: var(--text); + } + + .file-item .file-btn.remove:hover { + color: var(--error); + } + + .merge-actions { + display: flex; + gap: 10px; + justify-content: center; + } + + /* Progress Bar */ + .progress-container { + background: var(--bg-card); + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + display: none; + } + + .progress-container.show { + display: block; + } + + .progress-bar { + height: 8px; + background: var(--bg-main); + border-radius: 4px; + overflow: hidden; + margin-bottom: 10px; + } + + .progress-bar-fill { + height: 100%; + background: var(--accent); + transition: width 0.3s; + } + + .progress-text { + text-align: center; + color: var(--text-secondary); + font-size: 14px; + } + /* Theme variations */ body.theme-discord { --bg-main: #36393f; @@ -654,7 +903,7 @@
- Neue Version verfügbar! + Neue Version verfugbar!
@@ -674,6 +923,14 @@ Twitch Clips + +
- - + +
@@ -718,7 +975,7 @@

Keine VODs

-

Wähle einen Streamer aus der Liste oder füge einen neuen hinzu.

+

Wahle einen Streamer aus der Liste oder fuge einen neuen hinzu.

@@ -728,25 +985,125 @@

Twitch Clip Downloader

- +
-

ℹ️ Info

+

Info

- Unterstützte Formate:
- • https://clips.twitch.tv/ClipName
- • https://www.twitch.tv/streamer/clip/ClipName

+ Unterstutzte Formate:
+ - https://clips.twitch.tv/ClipName
+ - https://www.twitch.tv/streamer/clip/ClipName

Clips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.

+ +
+
+
+

Video auswahlen

+
+ + +
+
+ +
+
+ +

Video auswahlen um Vorschau zu sehen

+
+
+ + + + + +
+
+
+
+
0%
+
+ +
+ +
+
+
+ + +
+
+
+

Videos zusammenfugen

+

+ Wahle mehrere Videos aus um sie zu einem Video zusammenzufugen. + Die Reihenfolge kann per Drag & Drop geandert werden. +

+ +
+ +
+
+ +

Keine Videos ausgewahlt

+
+
+ +
+
+
+
+
0%
+
+ +
+ +
+
+
+
-

🎨 Design

+

Design

@@ -772,13 +1129,13 @@
-

📁 Download-Einstellungen

+

Download-Einstellungen

- - + +
@@ -789,14 +1146,14 @@
- +
-

🔄 Updates

-

Version: v3.5.3

+

Updates

+

Version: v3.6.0

@@ -807,7 +1164,7 @@
Nicht verbunden
- v3.5.3 + v3.6.0 @@ -820,6 +1177,17 @@ let downloading = false; let queue = []; + // Cutter State + let cutterFile = null; + let cutterVideoInfo = null; + let cutterStartTime = 0; + let cutterEndTime = 0; + let isCutting = false; + + // Merge State + let mergeFiles = []; + let isMerging = false; + // Init async function init() { config = await window.api.getConfig(); @@ -850,7 +1218,6 @@ }); window.api.onDownloadProgress((progress) => { - // Update progress in queue const item = queue.find(i => i.id === progress.id); if (item) { item.progress = progress.progress; @@ -860,17 +1227,26 @@ window.api.onDownloadStarted(() => { downloading = true; - document.getElementById('btnStart').textContent = '⏹ Stoppen'; + document.getElementById('btnStart').textContent = 'Stoppen'; document.getElementById('btnStart').classList.add('downloading'); }); window.api.onDownloadFinished(() => { downloading = false; - document.getElementById('btnStart').textContent = '▶ Start'; + document.getElementById('btnStart').textContent = 'Start'; document.getElementById('btnStart').classList.remove('downloading'); }); - // Check for updates + window.api.onCutProgress((percent) => { + document.getElementById('cutProgressBar').style.width = percent + '%'; + document.getElementById('cutProgressText').textContent = Math.round(percent) + '%'; + }); + + window.api.onMergeProgress((percent) => { + document.getElementById('mergeProgressBar').style.width = percent + '%'; + document.getElementById('mergeProgressText').textContent = Math.round(percent) + '%'; + }); + setTimeout(checkUpdateSilent, 3000); } @@ -896,7 +1272,13 @@ document.querySelector(`.nav-item[data-tab="${tab}"]`).classList.add('active'); document.getElementById(tab + 'Tab').classList.add('active'); - const titles = { vods: 'VODs', clips: 'Clips', settings: 'Einstellungen' }; + const titles = { + vods: 'VODs', + clips: 'Clips', + cutter: 'Video Cutter', + merge: 'Videos Zusammenfugen', + settings: 'Einstellungen' + }; document.getElementById('pageTitle').textContent = currentStreamer || titles[tab]; } @@ -910,7 +1292,7 @@ item.className = 'streamer-item' + (currentStreamer === streamer ? ' active' : ''); item.innerHTML = ` ${streamer} - + x `; item.onclick = () => selectStreamer(streamer); list.appendChild(item); @@ -939,7 +1321,7 @@

Keine VODs

-

Wähle einen Streamer aus der Liste.

+

Wahle einen Streamer aus der Liste.

`; } @@ -1032,7 +1414,7 @@
${item.title}
- + x
`).join(''); } @@ -1095,13 +1477,13 @@ btn.disabled = true; btn.textContent = 'Lade...'; - status.textContent = 'Download läuft...'; + status.textContent = 'Download lauft...'; status.className = 'clip-status loading'; const result = await window.api.downloadClip(url); btn.disabled = false; - btn.textContent = '⬇ Clip herunterladen'; + btn.textContent = 'Clip herunterladen'; if (result.success) { status.textContent = 'Download erfolgreich!'; @@ -1112,19 +1494,225 @@ } } + // Video Cutter + async function selectCutterVideo() { + const filePath = await window.api.selectVideoFile(); + if (!filePath) return; + + cutterFile = filePath; + document.getElementById('cutterFilePath').value = filePath; + + const info = await window.api.getVideoInfo(filePath); + if (!info) { + alert('Konnte Video-Informationen nicht lesen. FFprobe installiert?'); + return; + } + + cutterVideoInfo = info; + cutterStartTime = 0; + cutterEndTime = info.duration; + + document.getElementById('cutterInfo').style.display = 'flex'; + document.getElementById('timelineContainer').style.display = 'block'; + document.getElementById('btnCut').disabled = false; + + document.getElementById('infoDuration').textContent = formatTime(info.duration); + document.getElementById('infoResolution').textContent = `${info.width}x${info.height}`; + document.getElementById('infoFps').textContent = Math.round(info.fps); + document.getElementById('infoSelection').textContent = formatTime(info.duration); + + document.getElementById('startTime').value = '00:00:00'; + document.getElementById('endTime').value = formatTime(info.duration); + + updateTimeline(); + await updatePreview(0); + } + + function formatTime(seconds) { + 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 parseTime(timeStr) { + const parts = timeStr.split(':').map(p => parseInt(p) || 0); + if (parts.length === 3) { + return parts[0] * 3600 + parts[1] * 60 + parts[2]; + } + return 0; + } + + function updateTimeline() { + if (!cutterVideoInfo) return; + + const selection = document.getElementById('timelineSelection'); + const startPercent = (cutterStartTime / cutterVideoInfo.duration) * 100; + const endPercent = (cutterEndTime / cutterVideoInfo.duration) * 100; + + selection.style.left = startPercent + '%'; + selection.style.width = (endPercent - startPercent) + '%'; + + const duration = cutterEndTime - cutterStartTime; + document.getElementById('infoSelection').textContent = formatTime(duration); + } + + function updateTimeFromInput() { + const startStr = document.getElementById('startTime').value; + const endStr = document.getElementById('endTime').value; + + cutterStartTime = Math.max(0, parseTime(startStr)); + cutterEndTime = Math.min(cutterVideoInfo?.duration || 0, parseTime(endStr)); + + if (cutterEndTime <= cutterStartTime) { + cutterEndTime = cutterStartTime + 1; + } + + updateTimeline(); + } + + async function seekTimeline(event) { + if (!cutterVideoInfo) return; + + const timeline = document.getElementById('timeline'); + const rect = timeline.getBoundingClientRect(); + const percent = (event.clientX - rect.left) / rect.width; + const time = percent * cutterVideoInfo.duration; + + document.getElementById('timelineCurrent').style.left = (percent * 100) + '%'; + await updatePreview(time); + } + + async function updatePreview(time) { + if (!cutterFile) return; + + const preview = document.getElementById('cutterPreview'); + preview.innerHTML = '

Lade Vorschau...

'; + + const frame = await window.api.extractFrame(cutterFile, time); + if (frame) { + preview.innerHTML = `Preview`; + } else { + preview.innerHTML = '

Vorschau nicht verfugbar

'; + } + } + + async function startCutting() { + if (!cutterFile || isCutting) return; + + isCutting = true; + document.getElementById('btnCut').disabled = true; + document.getElementById('btnCut').textContent = 'Schneidet...'; + document.getElementById('cutProgress').classList.add('show'); + + const result = await window.api.cutVideo(cutterFile, cutterStartTime, cutterEndTime); + + isCutting = false; + document.getElementById('btnCut').disabled = false; + document.getElementById('btnCut').textContent = 'Schneiden'; + document.getElementById('cutProgress').classList.remove('show'); + + if (result.success) { + alert('Video erfolgreich geschnitten!\n\n' + result.outputFile); + } else { + alert('Fehler beim Schneiden des Videos.'); + } + } + + // Merge Videos + async function addMergeFiles() { + const files = await window.api.selectMultipleVideos(); + if (files && files.length > 0) { + mergeFiles = [...mergeFiles, ...files]; + renderMergeFiles(); + } + } + + function renderMergeFiles() { + const list = document.getElementById('mergeFileList'); + document.getElementById('btnMerge').disabled = mergeFiles.length < 2; + + if (mergeFiles.length === 0) { + list.innerHTML = ` +
+ +

Keine Videos ausgewahlt

+
+ `; + return; + } + + list.innerHTML = mergeFiles.map((file, index) => { + const name = file.split(/[/\\]/).pop(); + return ` +
+
${index + 1}
+
${name}
+
+ + + +
+
+ `; + }).join(''); + } + + function moveMergeFile(index, direction) { + const newIndex = index + direction; + if (newIndex < 0 || newIndex >= mergeFiles.length) return; + + const temp = mergeFiles[index]; + mergeFiles[index] = mergeFiles[newIndex]; + mergeFiles[newIndex] = temp; + renderMergeFiles(); + } + + function removeMergeFile(index) { + mergeFiles.splice(index, 1); + renderMergeFiles(); + } + + async function startMerging() { + if (mergeFiles.length < 2 || isMerging) return; + + const outputFile = await window.api.saveVideoDialog('merged_video.mp4'); + if (!outputFile) return; + + isMerging = true; + document.getElementById('btnMerge').disabled = true; + document.getElementById('btnMerge').textContent = 'Zusammenfugen...'; + document.getElementById('mergeProgress').classList.add('show'); + + const result = await window.api.mergeVideos(mergeFiles, outputFile); + + isMerging = false; + document.getElementById('btnMerge').disabled = false; + document.getElementById('btnMerge').textContent = 'Zusammenfugen'; + document.getElementById('mergeProgress').classList.remove('show'); + + if (result.success) { + alert('Videos erfolgreich zusammengefugt!\n\n' + result.outputFile); + mergeFiles = []; + renderMergeFiles(); + } else { + alert('Fehler beim Zusammenfugen der Videos.'); + } + } + // Updates async function checkUpdateSilent() { const result = await window.api.checkUpdate(); if (result.hasUpdate) { document.getElementById('updateBanner').classList.add('show'); - document.getElementById('updateText').textContent = `Version ${result.version} verfügbar: ${result.changelog}`; + document.getElementById('updateText').textContent = `Version ${result.version} verfugbar: ${result.changelog}`; } } async function checkUpdate() { const result = await window.api.checkUpdate(); if (result.hasUpdate) { - alert(`Neue Version ${result.version} verfügbar!\n\n${result.changelog}`); + alert(`Neue Version ${result.version} verfugbar!\n\n${result.changelog}`); } else { alert('Du hast die neueste Version!'); } diff --git a/typescript-version/src/main.ts b/typescript-version/src/main.ts index e8bd62a..8c1625c 100644 --- a/typescript-version/src/main.ts +++ b/typescript-version/src/main.ts @@ -1,14 +1,14 @@ import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron'; import * as path from 'path'; import * as fs from 'fs'; -import { spawn, ChildProcess, execSync } from 'child_process'; +import { spawn, ChildProcess, execSync, exec } from 'child_process'; import axios from 'axios'; import { autoUpdater } from 'electron-updater'; // ========================================== // CONFIG & CONSTANTS // ========================================== -const APP_VERSION = '3.5.3'; +const APP_VERSION = '3.6.0'; const UPDATE_CHECK_URL = 'http://24-music.de/version.json'; // Paths @@ -60,6 +60,12 @@ interface QueueItem { duration_str: string; status: 'pending' | 'downloading' | 'completed' | 'error'; progress: number; + currentPart?: number; + totalParts?: number; + speed?: string; + eta?: string; + downloadedBytes?: number; + totalBytes?: number; } interface DownloadProgress { @@ -68,6 +74,17 @@ interface DownloadProgress { speed: string; eta: string; status: string; + currentPart?: number; + totalParts?: number; + downloadedBytes?: number; + totalBytes?: number; +} + +interface VideoInfo { + duration: number; + width: number; + height: number; + fps: number; } // ========================================== @@ -78,8 +95,8 @@ const defaultConfig: Config = { client_secret: '', download_path: DEFAULT_DOWNLOAD_PATH, streamers: [], - theme: 'Twitch', - download_mode: 'parts', + theme: 'twitch', + download_mode: 'full', part_minutes: 120 }; @@ -136,12 +153,13 @@ let downloadQueue: QueueItem[] = loadQueue(); let isDownloading = false; let currentProcess: ChildProcess | null = null; let currentDownloadCancelled = false; +let downloadStartTime = 0; +let downloadedBytes = 0; // ========================================== -// STREAMLINK HELPER +// TOOL PATHS // ========================================== function getStreamlinkPath(): string { - // Try to find streamlink in PATH try { if (process.platform === 'win32') { const result = execSync('where streamlink', { encoding: 'utf-8' }); @@ -151,11 +169,8 @@ function getStreamlinkPath(): string { const result = execSync('which streamlink', { encoding: 'utf-8' }); return result.trim(); } - } catch { - // Streamlink not in PATH - } + } catch { } - // Common installation paths const commonPaths = [ 'C:\\Program Files\\Streamlink\\bin\\streamlink.exe', 'C:\\Program Files (x86)\\Streamlink\\bin\\streamlink.exe', @@ -166,14 +181,43 @@ function getStreamlinkPath(): string { if (fs.existsSync(p)) return p; } - return 'streamlink'; // Fallback + return 'streamlink'; +} + +function getFFmpegPath(): string { + try { + if (process.platform === 'win32') { + const result = execSync('where ffmpeg', { encoding: 'utf-8' }); + const paths = result.trim().split('\n'); + if (paths.length > 0) return paths[0].trim(); + } else { + const result = execSync('which ffmpeg', { encoding: 'utf-8' }); + return result.trim(); + } + } catch { } + + const commonPaths = [ + 'C:\\ffmpeg\\bin\\ffmpeg.exe', + 'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe', + path.join(process.env.LOCALAPPDATA || '', 'Programs', 'ffmpeg', 'bin', 'ffmpeg.exe') + ]; + + for (const p of commonPaths) { + if (fs.existsSync(p)) return p; + } + + return 'ffmpeg'; +} + +function getFFprobePath(): string { + const ffmpegPath = getFFmpegPath(); + return ffmpegPath.replace('ffmpeg.exe', 'ffprobe.exe').replace('ffmpeg', 'ffprobe'); } // ========================================== // DURATION HELPERS // ========================================== function parseDuration(duration: string): number { - // Parse Twitch duration format like "3h45m20s" let seconds = 0; const hours = duration.match(/(\d+)h/); const minutes = duration.match(/(\d+)m/); @@ -189,10 +233,31 @@ function parseDuration(duration: string): number { function formatDuration(seconds: number): string { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); - const s = seconds % 60; + const s = Math.floor(seconds % 60); return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; } +function formatBytes(bytes: number): string { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; +} + +function formatSpeed(bytesPerSec: number): string { + if (bytesPerSec < 1024) return bytesPerSec.toFixed(0) + ' B/s'; + if (bytesPerSec < 1024 * 1024) return (bytesPerSec / 1024).toFixed(1) + ' KB/s'; + return (bytesPerSec / (1024 * 1024)).toFixed(1) + ' MB/s'; +} + +function formatETA(seconds: number): string { + if (seconds < 60) return `${Math.floor(seconds)}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return `${h}h ${m}m`; +} + // ========================================== // TWITCH API // ========================================== @@ -279,53 +344,280 @@ async function getClipInfo(clipId: string): Promise { } } +// ========================================== +// VIDEO INFO (for cutter) +// ========================================== +async function getVideoInfo(filePath: string): Promise { + return new Promise((resolve) => { + const ffprobe = getFFprobePath(); + const args = [ + '-v', 'quiet', + '-print_format', 'json', + '-show_format', + '-show_streams', + filePath + ]; + + const proc = spawn(ffprobe, args, { windowsHide: true }); + let output = ''; + + proc.stdout?.on('data', (data) => { + output += data.toString(); + }); + + proc.on('close', (code) => { + if (code !== 0) { + resolve(null); + return; + } + + try { + const info = JSON.parse(output); + const videoStream = info.streams?.find((s: any) => s.codec_type === 'video'); + + resolve({ + duration: parseFloat(info.format?.duration || '0'), + width: videoStream?.width || 0, + height: videoStream?.height || 0, + fps: eval(videoStream?.r_frame_rate || '30') || 30 + }); + } catch { + resolve(null); + } + }); + + proc.on('error', () => resolve(null)); + }); +} + +// ========================================== +// VIDEO CUTTER +// ========================================== +async function extractFrame(filePath: string, timeSeconds: number): Promise { + return new Promise((resolve) => { + const ffmpeg = getFFmpegPath(); + const tempFile = path.join(app.getPath('temp'), `frame_${Date.now()}.jpg`); + + const args = [ + '-ss', timeSeconds.toString(), + '-i', filePath, + '-vframes', '1', + '-q:v', '2', + '-y', + tempFile + ]; + + const proc = spawn(ffmpeg, args, { windowsHide: true }); + + proc.on('close', (code) => { + if (code === 0 && fs.existsSync(tempFile)) { + const imageData = fs.readFileSync(tempFile); + const base64 = `data:image/jpeg;base64,${imageData.toString('base64')}`; + fs.unlinkSync(tempFile); + resolve(base64); + } else { + resolve(null); + } + }); + + proc.on('error', () => resolve(null)); + }); +} + +async function cutVideo( + inputFile: string, + outputFile: string, + startTime: number, + endTime: number, + onProgress: (percent: number) => void +): Promise { + return new Promise((resolve) => { + const ffmpeg = getFFmpegPath(); + const duration = endTime - startTime; + + const args = [ + '-ss', formatDuration(startTime), + '-i', inputFile, + '-t', formatDuration(duration), + '-c', 'copy', + '-progress', 'pipe:1', + '-y', + outputFile + ]; + + const proc = spawn(ffmpeg, args, { windowsHide: true }); + currentProcess = proc; + + proc.stdout?.on('data', (data) => { + const line = data.toString(); + const match = line.match(/out_time_us=(\d+)/); + if (match) { + const currentUs = parseInt(match[1]); + const percent = Math.min(100, (currentUs / 1000000) / duration * 100); + onProgress(percent); + } + }); + + proc.on('close', (code) => { + currentProcess = null; + resolve(code === 0 && fs.existsSync(outputFile)); + }); + + proc.on('error', () => { + currentProcess = null; + resolve(false); + }); + }); +} + +// ========================================== +// MERGE VIDEOS +// ========================================== +async function mergeVideos( + inputFiles: string[], + outputFile: string, + onProgress: (percent: number) => void +): Promise { + return new Promise((resolve) => { + const ffmpeg = getFFmpegPath(); + + // Create concat file + const concatFile = path.join(app.getPath('temp'), `concat_${Date.now()}.txt`); + const concatContent = inputFiles.map(f => `file '${f.replace(/'/g, "'\\''")}'`).join('\n'); + fs.writeFileSync(concatFile, concatContent); + + const args = [ + '-f', 'concat', + '-safe', '0', + '-i', concatFile, + '-c', 'copy', + '-progress', 'pipe:1', + '-y', + outputFile + ]; + + const proc = spawn(ffmpeg, args, { windowsHide: true }); + currentProcess = proc; + + // Get total duration for progress + let totalDuration = 0; + for (const file of inputFiles) { + try { + const stats = fs.statSync(file); + totalDuration += stats.size; // Approximate by file size + } catch { } + } + + proc.stdout?.on('data', (data) => { + const line = data.toString(); + const match = line.match(/out_time_us=(\d+)/); + if (match) { + const currentUs = parseInt(match[1]); + // Approximate progress + onProgress(Math.min(99, currentUs / 10000000)); + } + }); + + proc.on('close', (code) => { + currentProcess = null; + try { + fs.unlinkSync(concatFile); + } catch { } + + if (code === 0 && fs.existsSync(outputFile)) { + onProgress(100); + resolve(true); + } else { + resolve(false); + } + }); + + proc.on('error', () => { + currentProcess = null; + resolve(false); + }); + }); +} + // ========================================== // DOWNLOAD FUNCTIONS // ========================================== -function downloadVOD(item: QueueItem, onProgress: (progress: DownloadProgress) => void): Promise { +function downloadVODPart( + url: string, + filename: string, + startTime: string | null, + endTime: string | null, + onProgress: (progress: DownloadProgress) => void, + itemId: string, + partNum: number, + totalParts: number +): Promise { return new Promise((resolve) => { - const streamer = item.streamer.replace(/[^a-zA-Z0-9_-]/g, ''); - const date = new Date(item.date); - const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`; - - 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 filename = path.join(folder, `${safeTitle}.mp4`); - const streamlinkPath = getStreamlinkPath(); - const args = [ - item.url, - 'best', - '-o', filename, - '--force', - '--progress', 'force' - ]; + const args = [url, 'best', '-o', filename, '--force']; + + if (startTime) { + args.push('--hls-start-offset', startTime); + } + if (endTime) { + args.push('--hls-duration', endTime); + } console.log('Starting download:', streamlinkPath, args); - const proc = spawn(streamlinkPath, args, { - windowsHide: true - }); - + const proc = spawn(streamlinkPath, args, { windowsHide: true }); currentProcess = proc; - let lastProgress = 0; + + downloadStartTime = Date.now(); + downloadedBytes = 0; + let lastBytes = 0; + let lastTime = Date.now(); + + // Monitor file size for progress + const progressInterval = setInterval(() => { + if (fs.existsSync(filename)) { + try { + const stats = fs.statSync(filename); + downloadedBytes = stats.size; + + const now = Date.now(); + const timeDiff = (now - lastTime) / 1000; + const bytesDiff = downloadedBytes - lastBytes; + const speed = timeDiff > 0 ? bytesDiff / timeDiff : 0; + + lastBytes = downloadedBytes; + lastTime = now; + + onProgress({ + id: itemId, + progress: -1, // Unknown total + speed: formatSpeed(speed), + eta: '', + status: `Part ${partNum}/${totalParts}: ${formatBytes(downloadedBytes)}`, + currentPart: partNum, + totalParts: totalParts, + downloadedBytes: downloadedBytes + }); + } catch { } + } + }, 1000); proc.stdout?.on('data', (data: Buffer) => { const line = data.toString(); console.log('Streamlink:', line); - // Parse progress from streamlink output + // Parse progress const match = line.match(/(\d+\.\d+)%/); if (match) { - lastProgress = parseFloat(match[1]); + const percent = parseFloat(match[1]); onProgress({ - id: item.id, - progress: lastProgress, + id: itemId, + progress: percent, speed: '', eta: '', - status: `Downloading: ${lastProgress.toFixed(1)}%` + status: `Part ${partNum}/${totalParts}: ${percent.toFixed(1)}%`, + currentPart: partNum, + totalParts: totalParts }); } }); @@ -335,6 +627,7 @@ function downloadVOD(item: QueueItem, onProgress: (progress: DownloadProgress) = }); proc.on('close', (code) => { + clearInterval(progressInterval); currentProcess = null; if (currentDownloadCancelled) { @@ -344,14 +637,7 @@ function downloadVOD(item: QueueItem, onProgress: (progress: DownloadProgress) = if (code === 0 && fs.existsSync(filename)) { const stats = fs.statSync(filename); - if (stats.size > 1024 * 1024) { // At least 1MB - onProgress({ - id: item.id, - progress: 100, - speed: '', - eta: '', - status: 'Completed' - }); + if (stats.size > 1024 * 1024) { resolve(true); return; } @@ -361,6 +647,7 @@ function downloadVOD(item: QueueItem, onProgress: (progress: DownloadProgress) = }); proc.on('error', (err) => { + clearInterval(progressInterval); console.error('Process error:', err); currentProcess = null; resolve(false); @@ -368,6 +655,62 @@ function downloadVOD(item: QueueItem, onProgress: (progress: DownloadProgress) = }); } +async function downloadVOD( + item: QueueItem, + onProgress: (progress: DownloadProgress) => void +): Promise { + const streamer = item.streamer.replace(/[^a-zA-Z0-9_-]/g, ''); + const date = new Date(item.date); + const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`; + + 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); + + // Check download mode + if (config.download_mode === 'full' || totalDuration <= config.part_minutes * 60) { + // Full download + const filename = path.join(folder, `${safeTitle}.mp4`); + return await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1); + } else { + // Part-based download + const partDuration = config.part_minutes * 60; + const numParts = Math.ceil(totalDuration / partDuration); + const downloadedFiles: string[] = []; + + for (let i = 0; i < numParts; i++) { + if (currentDownloadCancelled) break; + + const startSec = i * partDuration; + const endSec = Math.min((i + 1) * partDuration, totalDuration); + const duration = endSec - startSec; + + const partFilename = path.join(folder, `${safeTitle}_Part${(i + 1).toString().padStart(2, '0')}.mp4`); + + const success = await downloadVODPart( + item.url, + partFilename, + formatDuration(startSec), + formatDuration(duration), + onProgress, + item.id, + i + 1, + numParts + ); + + if (!success) { + return false; + } + + downloadedFiles.push(partFilename); + } + + return downloadedFiles.length === numParts; + } +} + async function processQueue(): Promise { if (isDownloading || downloadQueue.length === 0) return; @@ -420,7 +763,6 @@ function createWindow(): void { mainWindow = null; }); - // Check for updates on startup setTimeout(() => { checkForUpdates(); }, 3000); @@ -431,7 +773,7 @@ async function checkForUpdates(): Promise<{ hasUpdate: boolean; version?: string const response = await axios.get(UPDATE_CHECK_URL, { timeout: 5000 }); const latest = response.data.version; - if (latest !== APP_VERSION.replace('v', '')) { + if (latest !== APP_VERSION) { return { hasUpdate: true, version: latest, @@ -515,6 +857,16 @@ ipcMain.handle('select-folder', async () => { return result.filePaths[0] || null; }); +ipcMain.handle('select-video-file', async () => { + const result = await dialog.showOpenDialog(mainWindow!, { + properties: ['openFile'], + filters: [ + { name: 'Video Files', extensions: ['mp4', 'mkv', 'ts', 'mov', 'avi'] } + ] + }); + return result.filePaths[0] || null; +}); + ipcMain.handle('open-folder', (_, folderPath: string) => { if (fs.existsSync(folderPath)) { shell.openPath(folderPath); @@ -528,7 +880,6 @@ ipcMain.handle('check-update', async () => { }); ipcMain.handle('download-clip', async (_, clipUrl: string) => { - // Extract clip ID from URL let clipId = ''; const match1 = clipUrl.match(/clips\.twitch\.tv\/([A-Za-z0-9_-]+)/); const match2 = clipUrl.match(/twitch\.tv\/[^/]+\/clip\/([A-Za-z0-9_-]+)/); @@ -571,6 +922,59 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => { ipcMain.handle('is-downloading', () => isDownloading); +// Video Cutter IPC +ipcMain.handle('get-video-info', async (_, filePath: string) => { + return await getVideoInfo(filePath); +}); + +ipcMain.handle('extract-frame', async (_, filePath: string, timeSeconds: number) => { + return await extractFrame(filePath, timeSeconds); +}); + +ipcMain.handle('cut-video', async (_, inputFile: string, startTime: number, endTime: number) => { + const dir = path.dirname(inputFile); + const baseName = path.basename(inputFile, path.extname(inputFile)); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(11, 19); + const outputFile = path.join(dir, `${baseName}_cut_${timestamp}.mp4`); + + let lastProgress = 0; + const success = await cutVideo(inputFile, outputFile, startTime, endTime, (percent) => { + lastProgress = percent; + mainWindow?.webContents.send('cut-progress', percent); + }); + + return { success, outputFile: success ? outputFile : null }; +}); + +// Merge IPC +ipcMain.handle('merge-videos', async (_, inputFiles: string[], outputFile: string) => { + const success = await mergeVideos(inputFiles, outputFile, (percent) => { + mainWindow?.webContents.send('merge-progress', percent); + }); + + return { success, outputFile: success ? outputFile : null }; +}); + +ipcMain.handle('select-multiple-videos', async () => { + const result = await dialog.showOpenDialog(mainWindow!, { + properties: ['openFile', 'multiSelections'], + filters: [ + { name: 'Video Files', extensions: ['mp4', 'mkv', 'ts', 'mov', 'avi'] } + ] + }); + return result.filePaths; +}); + +ipcMain.handle('save-video-dialog', async (_, defaultName: string) => { + const result = await dialog.showSaveDialog(mainWindow!, { + defaultPath: defaultName, + filters: [ + { name: 'MP4 Video', extensions: ['mp4'] } + ] + }); + return result.filePath || null; +}); + // ========================================== // APP LIFECYCLE // ========================================== diff --git a/typescript-version/src/preload.ts b/typescript-version/src/preload.ts index e657528..5420e66 100644 --- a/typescript-version/src/preload.ts +++ b/typescript-version/src/preload.ts @@ -10,6 +10,10 @@ interface QueueItem { duration_str: string; status: 'pending' | 'downloading' | 'completed' | 'error'; progress: number; + currentPart?: number; + totalParts?: number; + speed?: string; + eta?: string; } interface DownloadProgress { @@ -18,6 +22,17 @@ interface DownloadProgress { speed: string; eta: string; status: string; + currentPart?: number; + totalParts?: number; + downloadedBytes?: number; + totalBytes?: number; +} + +interface VideoInfo { + duration: number; + width: number; + height: number; + fps: number; } // Expose protected methods to renderer @@ -47,8 +62,21 @@ contextBridge.exposeInMainWorld('api', { // Files selectFolder: () => ipcRenderer.invoke('select-folder'), + selectVideoFile: () => ipcRenderer.invoke('select-video-file'), + selectMultipleVideos: () => ipcRenderer.invoke('select-multiple-videos'), + saveVideoDialog: (defaultName: string) => ipcRenderer.invoke('save-video-dialog', defaultName), openFolder: (path: string) => ipcRenderer.invoke('open-folder', path), + // Video Cutter + getVideoInfo: (filePath: string): Promise => ipcRenderer.invoke('get-video-info', filePath), + extractFrame: (filePath: string, timeSeconds: number): Promise => ipcRenderer.invoke('extract-frame', filePath, timeSeconds), + cutVideo: (inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }> => + ipcRenderer.invoke('cut-video', inputFile, startTime, endTime), + + // Merge Videos + mergeVideos: (inputFiles: string[], outputFile: string): Promise<{ success: boolean; outputFile: string | null }> => + ipcRenderer.invoke('merge-videos', inputFiles, outputFile), + // App getVersion: () => ipcRenderer.invoke('get-version'), checkUpdate: () => ipcRenderer.invoke('check-update'), @@ -65,5 +93,11 @@ contextBridge.exposeInMainWorld('api', { }, onDownloadFinished: (callback: () => void) => { ipcRenderer.on('download-finished', () => callback()); + }, + onCutProgress: (callback: (percent: number) => void) => { + ipcRenderer.on('cut-progress', (_, percent) => callback(percent)); + }, + onMergeProgress: (callback: (percent: number) => void) => { + ipcRenderer.on('merge-progress', (_, percent) => callback(percent)); } });