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
+
+
+
+ Videos Zusammenfugen
+
-
-
+
+
@@ -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 @@
-
ℹ️ 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
+
+
+
+
+
+ Dauer
+ --:--:--
+
+
+ Auflosung
+ ----x----
+
+
+ FPS
+ --
+
+
+ Auswahl
+ --:--:--
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Videos zusammenfugen
+
+ Wahle mehrere Videos aus um sie zu einem Video zusammenzufugen.
+ Die Reihenfolge kann per Drag & Drop geandert werden.
+
+
+
+
+
+
+
+
Keine Videos ausgewahlt
+
+
+
+
+
+
+
+
+
+
+
-
🎨 Design
+
Design
-
🔑 Twitch API
+
Twitch API
@@ -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 @@
`).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 = '';
+
+ const frame = await window.api.extractFrame(cutterFile, time);
+ if (frame) {
+ preview.innerHTML = `
`;
+ } else {
+ preview.innerHTML = '';
+ }
+ }
+
+ 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));
}
});