diff --git a/src/main.ts b/src/main.ts index ec27704..f485b81 100644 --- a/src/main.ts +++ b/src/main.ts @@ -60,6 +60,17 @@ type RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrit type UpdateCheckSource = 'startup' | 'interval' | 'manual'; type UpdateDownloadSource = 'auto' | 'manual'; +function getMergeGroupPhaseText(phase: string): string { + const isEnglish = config.language === 'en'; + switch (phase) { + case 'downloading': return isEnglish ? 'Downloading VOD' : 'VOD wird heruntergeladen'; + case 'merging': return isEnglish ? 'Merging...' : 'Zusammenfugen...'; + case 'splitting': return isEnglish ? 'Splitting Part' : 'Part wird erstellt'; + case 'cleanup': return isEnglish ? 'Cleaning up...' : 'Aufraumen...'; + default: return phase; + } +} + // Ensure directories exist if (!fs.existsSync(APPDATA_DIR)) { fs.mkdirSync(APPDATA_DIR, { recursive: true }); @@ -2973,6 +2984,235 @@ async function downloadVOD( } } +// ========================================== +// MERGE GROUP DOWNLOAD PIPELINE +// ========================================== +async function processDownloadMergeGroup( + item: QueueItem, + onProgress: (progress: DownloadProgress) => void +): Promise { + const mg = item.mergeGroup!; + const totalDurationSec = mg.totalDurationSec || mg.items.reduce((sum, i) => sum + parseDuration(i.duration_str), 0); + mg.totalDurationSec = totalDurationSec; + + // ---- PHASE 1: DOWNLOADING ---- + if (mg.mergePhase === 'downloading') { + const streamlinkReady = await ensureStreamlinkInstalled(); + if (!streamlinkReady) { + return { success: false, error: 'Streamlink fehlt.' }; + } + + const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, ''); + const date = new Date(mg.items[0].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 }); + + // Disk space pre-check: 3x total estimated size + const estimatedBytes = mg.items.reduce((sum, i) => { + const dur = parseDuration(i.duration_str); + return sum + Math.ceil(dur * 500_000); // ~500KB/s estimate + }, 0); + const requiredBytes = Math.max(256 * 1024 * 1024, estimatedBytes * 3); + const diskCheck = ensureDiskSpace(folder, requiredBytes, 'Merge-Group-Download'); + if (!diskCheck.success) { + return diskCheck; + } + + for (let i = 0; i < mg.items.length; i++) { + if (currentDownloadCancelled) { + return { success: false, error: 'Download wurde abgebrochen.' }; + } + + // Skip already downloaded files (retry recovery) + if (mg.downloadedFiles[i] && fs.existsSync(mg.downloadedFiles[i])) { + appendDebugLog('merge-group-skip-existing', { index: i, file: mg.downloadedFiles[i] }); + continue; + } + + currentDownloadCancelled = false; // Reset stale cancel state + mg.currentItemIndex = i; + mg.mergePhase = 'downloading'; + saveQueue(downloadQueue); + + const vodItem = mg.items[i]; + const tmpFilename = path.join(folder, `merge_tmp_${i}_${Date.now()}.mp4`); + + // Calculate progress weighting per VOD + const vodDuration = parseDuration(vodItem.duration_str); + const vodWeight = vodDuration / totalDurationSec; + const priorWeight = mg.items.slice(0, i).reduce((s, v) => s + parseDuration(v.duration_str), 0) / totalDurationSec; + + const result = await downloadVODPart( + vodItem.url, + tmpFilename, + null, // startTime: null = full VOD + null, // endTime: null = full VOD + (progress) => { + // Weighted progress: download phase = 0-70% + const vodProgress = progress.progress > 0 ? progress.progress : 0; + const overallProgress = (priorWeight + vodWeight * (vodProgress / 100)) * 70; + onProgress({ + ...progress, + id: item.id, + progress: overallProgress, + status: `${getMergeGroupPhaseText('downloading')} ${i + 1}/${mg.items.length} — ${progress.status}`, + currentPart: i + 1, + totalParts: mg.items.length + }); + }, + item.id, + i + 1, + mg.items.length + ); + + if (!result.success) { + return result; + } + + mg.downloadedFiles[i] = tmpFilename; + saveQueue(downloadQueue); + } + } + + // ---- PHASE 2: MERGING ---- + mg.mergePhase = 'merging'; + saveQueue(downloadQueue); + emitQueueUpdated(); + + // Check all downloaded files exist (retry recovery) + for (let i = 0; i < mg.items.length; i++) { + if (!mg.downloadedFiles[i] || !fs.existsSync(mg.downloadedFiles[i])) { + mg.mergePhase = 'downloading'; + return { success: false, error: `Heruntergeladene Datei ${i + 1} fehlt.` }; + } + } + + if (!mg.mergedFile || !fs.existsSync(mg.mergedFile)) { + const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, ''); + const date = new Date(mg.items[0].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); + const mergedFilePath = path.join(folder, `merged_${Date.now()}.mp4`); + + // Get files in correct order (explicit sort by index — do NOT rely on Object.values ordering) + const sortedFiles = Object.keys(mg.downloadedFiles) + .sort((a, b) => Number(a) - Number(b)) + .map(k => mg.downloadedFiles[Number(k)]); + + const mergeSuccess = await mergeVideos( + sortedFiles, + mergedFilePath, + (percent) => { + const overallProgress = 70 + (percent / 100) * 20; // merge = 70-90% + onProgress({ + id: item.id, + progress: overallProgress, + speed: '', + eta: '', + status: getMergeGroupPhaseText('merging'), + currentPart: 0, + totalParts: 0 + }); + }, + totalDurationSec + ); + + if (!mergeSuccess) { + return { success: false, error: 'FFmpeg Merge fehlgeschlagen.' }; + } + + mg.mergedFile = mergedFilePath; + saveQueue(downloadQueue); + } + + // ---- PHASE 3: SPLITTING ---- + mg.mergePhase = 'splitting'; + saveQueue(downloadQueue); + emitQueueUpdated(); + + if (currentDownloadCancelled) { + return { success: false, error: 'Download wurde abgebrochen.' }; + } + + const partDuration = config.part_minutes * 60; + const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, ''); + const date = new Date(mg.items[0].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); + const vodId = parseVodId(mg.items[0].url) || 'merged'; + + const splitResult = await splitMergedFile( + mg.mergedFile!, + folder, + partDuration, + totalDurationSec, + (partNum: number) => { + const startSec = (partNum - 1) * partDuration; + const thisDuration = Math.min(partDuration, totalDurationSec - startSec); + return renderClipFilenameTemplate({ + template: normalizeFilenameTemplate(config.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS), + title: mg.items[0].title, + vodId, + channel: mg.items[0].streamer, + date, + part: partNum, + partPadded: partNum.toString().padStart(2, '0'), + trimStartSec: startSec, + trimEndSec: startSec + thisDuration, + trimLengthSec: thisDuration, + fullLengthSec: totalDurationSec + }); + }, + (currentPart, totalParts) => { + const overallProgress = 90 + (currentPart / totalParts) * 10; // split = 90-100% + onProgress({ + id: item.id, + progress: overallProgress, + speed: '', + eta: '', + status: `${getMergeGroupPhaseText('splitting')} ${currentPart}/${totalParts}...`, + currentPart, + totalParts + }); + } + ); + + if (!splitResult.success) { + return { success: false, error: 'FFmpeg Split fehlgeschlagen.' }; + } + + mg.splitFiles = splitResult.files; + + // ---- PHASE 4: CLEANUP ---- + mg.mergePhase = 'cleanup'; + saveQueue(downloadQueue); + + // Delete individual downloads + for (const key of Object.keys(mg.downloadedFiles)) { + const filePath = mg.downloadedFiles[Number(key)]; + try { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + } catch { } + } + + // Delete merged file + if (mg.mergedFile) { + try { + if (fs.existsSync(mg.mergedFile)) fs.unlinkSync(mg.mergedFile); + } catch { } + } + + mg.mergePhase = 'done'; + appendDebugLog('merge-group-complete', { + itemId: item.id, + parts: splitResult.files.length, + totalDurationSec + }); + + return { success: true }; +} + async function processQueue(): Promise { if (isDownloading || !downloadQueue.some((item) => item.status === 'pending')) return;