UNPKG

xdl-node

Version:

A library for retrieving audio streams and other data from X Spaces, built on Node.js and TypeScript.

470 lines (469 loc) 20 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.fastDownloadAndMerge = void 0; const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const child_process_1 = require("child_process"); const axios_1 = __importDefault(require("axios")); const readline_1 = __importDefault(require("readline")); const os_1 = __importDefault(require("os")); /** * Инициализирует оптимизированную конфигурацию */ function initConfig(spaceId) { // Если spaceId не указан, пробуем получить его из плейлиста в папке logs if (!spaceId) { try { const logsDir = './logs'; const files = fs_1.default.readdirSync(logsDir).filter(f => f.endsWith('.m3u8')); if (files.length > 0) { spaceId = files[0].replace('.m3u8', ''); } else { spaceId = 'unknown'; } } catch (error) { spaceId = 'unknown'; } } const availableCpus = os_1.default.cpus().length; const cpuThreads = Math.max(1, availableCpus - 1); // оставляем 1 ядро для OS const maxConcurrentDownloads = Math.max(20, Math.floor(availableCpus * 5)); return { spaceId, inputPlaylist: `./logs/${spaceId}.m3u8`, outputDir: './downloads', outputFile: `${spaceId}.m4a`, tempDir: './temp_segments', retryCount: 3, connectionTimeout: 10, maxConcurrentDownloads, cpuThreads, directDownload: false, cleanupTemp: true }; } /** * Проверяет наличие ffmpeg в системе */ function checkFfmpeg() { try { const result = (0, child_process_1.spawnSync)('ffmpeg', ['-version'], { stdio: 'pipe' }); return !result.error; } catch (error) { return false; } } /** * Создает необходимые директории */ function createDirectories(config) { if (!fs_1.default.existsSync(config.outputDir)) { fs_1.default.mkdirSync(config.outputDir, { recursive: true }); } if (!fs_1.default.existsSync(config.tempDir)) { fs_1.default.mkdirSync(config.tempDir, { recursive: true }); } } /** * Отображает полоску прогресса */ function renderProgressBar(current, total, width = 40) { const percentage = Math.floor((current / total) * 100); const filledWidth = Math.floor((current / total) * width); const emptyWidth = width - filledWidth; const filledBar = '█'.repeat(filledWidth); const emptyBar = '░'.repeat(emptyWidth); return `[${filledBar}${emptyBar}] ${percentage}% (${current}/${total})`; } /** * Прямое объединение с помощью ffmpeg без предварительного скачивания сегментов */ async function directProcessPlaylist(config) { if (!fs_1.default.existsSync(config.inputPlaylist)) { throw new Error(`Плейлист не найден: ${config.inputPlaylist}`); } const outputPath = path_1.default.join(config.outputDir, config.outputFile); console.log(`🚀 Прямая обработка плейлиста Space ID: ${config.spaceId}`); return new Promise((resolve, reject) => { const args = [ '-y', '-v', 'warning', '-stats', '-threads', config.cpuThreads.toString(), '-thread_queue_size', '4096', '-max_muxing_queue_size', '9999', '-fflags', '+genpts+discardcorrupt', '-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_at_eof', '1', '-reconnect_delay_max', '5', '-protocol_whitelist', 'file,http,https,tcp,tls', '-i', config.inputPlaylist, '-c:a', 'aac', '-b:a', '64k', '-ar', '22050', '-movflags', '+faststart', '-metadata', `title=Twitter Space ${config.spaceId}`, outputPath ]; const ffmpeg = (0, child_process_1.spawn)('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'], ...(process.platform !== 'win32' ? { stdio: 'pipe', env: { ...process.env, DBUS_SESSION_BUS_ADDRESS: '/dev/null' } } : {}) }); process.stdout.write('\x1b[?25l'); let progressRegex = /time=(\d+):(\d+):(\d+\.\d+)/; let lastUpdateTime = Date.now(); let startTime = Date.now(); ffmpeg.stderr.on('data', (chunk) => { const data = chunk.toString(); const match = progressRegex.exec(data); if (match) { const hours = parseInt(match[1]); const minutes = parseInt(match[2]); const seconds = parseFloat(match[3]); const totalSeconds = hours * 3600 + minutes * 60 + seconds; const elapsedSeconds = (Date.now() - startTime) / 1000; const speed = totalSeconds / elapsedSeconds; const currentTime = Date.now(); if (currentTime - lastUpdateTime < 500) return; lastUpdateTime = currentTime; readline_1.default.clearLine(process.stdout, 0); readline_1.default.cursorTo(process.stdout, 0); process.stdout.write(`⚡ Обработано: ${formatTime(totalSeconds)} | Скорость: ${speed.toFixed(2)}x`); } }); ffmpeg.on('close', (code) => { process.stdout.write('\n\x1b[?25h'); if (code === 0) { const totalTime = (Date.now() - startTime) / 1000; console.log(`✅ Обработка завершена за ${formatTime(totalTime)}!`); resolve(outputPath); } else { reject(new Error(`Ошибка обработки (код: ${code})`)); } }); ffmpeg.on('error', reject); }); } /** * Получает список сегментов из плейлиста для предварительной обработки */ function extractSegmentsFromPlaylist(playlistPath) { const content = fs_1.default.readFileSync(playlistPath, 'utf8'); const lines = content.split('\n'); return lines.filter(line => !line.startsWith('#') && line.length > 0 && (line.endsWith('.aac') || line.endsWith('.ts') || line.includes('chunk_'))); } /** * Скачивает сегменты параллельно с максимальной скоростью */ async function downloadSegments(config, segmentUrls) { const totalSegments = segmentUrls.length; const segmentsDir = path_1.default.join(config.tempDir, 'segments'); if (!fs_1.default.existsSync(segmentsDir)) { fs_1.default.mkdirSync(segmentsDir, { recursive: true }); } console.log(`⚡ Скачивание ${totalSegments.toLocaleString()} сегментов (${config.maxConcurrentDownloads} потоков)`); async function downloadSegment(url) { const filename = path_1.default.basename(url); const outputPath = path_1.default.join(segmentsDir, filename); if (fs_1.default.existsSync(outputPath) && fs_1.default.statSync(outputPath).size > 0) { return true; } try { const response = await axios_1.default.get(url, { responseType: 'arraybuffer', timeout: config.connectionTimeout * 1000, headers: { 'Connection': 'keep-alive', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' }, maxContentLength: 10 * 1024 * 1024 }); fs_1.default.writeFileSync(outputPath, Buffer.from(response.data)); return true; } catch (error) { for (let attempt = 1; attempt < config.retryCount; attempt++) { try { const response = await axios_1.default.get(url, { responseType: 'arraybuffer', timeout: config.connectionTimeout * 1000 }); fs_1.default.writeFileSync(outputPath, Buffer.from(response.data)); return true; } catch { } } fs_1.default.writeFileSync(outputPath, Buffer.from('')); return false; } } let downloaded = 0; let failed = 0; let lastUpdateTime = Date.now(); const startTime = Date.now(); process.stdout.write('\x1b[?25l'); function updateProgress() { const currentTime = Date.now(); if (currentTime - lastUpdateTime < 200) return; lastUpdateTime = currentTime; const elapsed = (currentTime - startTime) / 1000; const rate = downloaded / elapsed; readline_1.default.clearLine(process.stdout, 0); readline_1.default.cursorTo(process.stdout, 0); const progressBar = renderProgressBar(downloaded + failed, totalSegments); process.stdout.write(`⏳ ${progressBar} | ${rate.toFixed(1)} сегм/сек`); } async function processInBatches() { const sema = new Array(config.maxConcurrentDownloads).fill(null); const batchSize = 500; for (let i = 0; i < segmentUrls.length; i += batchSize) { const batch = segmentUrls.slice(i, Math.min(i + batchSize, segmentUrls.length)); await Promise.all(batch.map(async (url) => { const slot = await Promise.race(sema.map((p, i) => p === null ? Promise.resolve(i) : p.then(() => i))); sema[slot] = downloadSegment(url).then(success => { if (success) downloaded++; else failed++; updateProgress(); sema[slot] = null; }); })); } await Promise.all(sema.filter(Boolean)); } await processInBatches(); process.stdout.write('\n\x1b[?25h'); const totalTime = (Date.now() - startTime) / 1000; const averageSpeed = downloaded / totalTime; console.log(`✅ Загрузка: ${downloaded} успешно, ${failed} ошибок за ${formatTime(totalTime)}`); console.log(`📊 Средняя скорость: ${averageSpeed.toFixed(1)} сегментов/сек`); return downloaded > totalSegments * 0.9; } /** * Быстрое объединение скачанных сегментов */ async function fastMergeSegments(config, segmentUrls) { const outputPath = path_1.default.join(config.outputDir, config.outputFile); console.log(`🔄 Ускоренное объединение в файл: ${outputPath}`); const segmentsDir = path_1.default.join(config.tempDir, 'segments'); const localPlaylistPath = path_1.default.join(config.tempDir, 'local_playlist.m3u8'); let localContent = '#EXTM3U\n'; localContent += '#EXT-X-VERSION:3\n'; localContent += '#EXT-X-TARGETDURATION:4\n'; localContent += '#EXT-X-MEDIA-SEQUENCE:0\n'; segmentUrls.forEach(url => { localContent += `#EXTINF:3.0,\n`; const segmentFilename = path_1.default.basename(url); localContent += `segments/${segmentFilename}\n`; }); localContent += '#EXT-X-ENDLIST\n'; fs_1.default.writeFileSync(localPlaylistPath, localContent); return new Promise((resolve, reject) => { const args = [ '-y', '-v', 'warning', '-stats', '-threads', config.cpuThreads.toString(), '-thread_queue_size', '4096', '-protocol_whitelist', 'file,http,https,tcp,tls', '-i', localPlaylistPath, '-c:a', 'aac', '-b:a', '64k', '-ar', '44100', '-af', 'aresample=async=1000', '-max_muxing_queue_size', '9999', '-movflags', '+faststart', outputPath ]; const startTime = Date.now(); const ffmpeg = (0, child_process_1.spawn)('ffmpeg', args); process.stdout.write('\x1b[?25l'); let lastProgressPercent = -1; ffmpeg.stderr.on('data', (data) => { const line = data.toString(); const match = line.match(/time=(\d+):(\d+):(\d+\.\d+)/); if (match) { const hours = parseInt(match[1]); const minutes = parseInt(match[2]); const seconds = parseFloat(match[3]); const totalProcessedSeconds = hours * 3600 + minutes * 60 + seconds; const estimatedTotalSeconds = segmentUrls.length * 3; const progressPercent = Math.min(99, Math.floor((totalProcessedSeconds / estimatedTotalSeconds) * 100)); if (progressPercent > lastProgressPercent) { lastProgressPercent = progressPercent; const elapsed = (Date.now() - startTime) / 1000; const speed = totalProcessedSeconds / elapsed; readline_1.default.clearLine(process.stdout, 0); readline_1.default.cursorTo(process.stdout, 0); const progressBar = renderProgressBar(progressPercent, 100); process.stdout.write(`🔄 ${progressBar} | ${speed.toFixed(2)}x скорость`); } } }); ffmpeg.on('close', (code) => { process.stdout.write('\n\x1b[?25h'); if (code === 0) { const elapsedTime = (Date.now() - startTime) / 1000; console.log(`✅ Объединение завершено за ${formatTime(elapsedTime)}`); resolve(outputPath); } else { console.error(`❌ Ошибка при объединении (код: ${code})`); reject(new Error(`Ошибка при объединении (код: ${code})`)); } }); ffmpeg.on('error', reject); }); } /** * Форматирует время в формате ЧЧ:ММ:СС */ function formatTime(seconds) { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } /** * Получает информацию о файле (длительность, размер) */ async function getOutputInfo(outputPath) { return new Promise((resolve) => { const ffprobe = (0, child_process_1.spawn)('ffprobe', [ '-v', 'error', '-show_entries', 'format=duration,size', '-of', 'json', outputPath ]); let output = ''; ffprobe.stdout.on('data', (data) => output += data.toString()); ffprobe.on('close', (code) => { if (code === 0) { try { const info = JSON.parse(output); const duration = parseFloat(info.format.duration); const size = (parseInt(info.format.size) / (1024 * 1024)).toFixed(2); resolve({ success: true, duration, size: `${size} MB` }); } catch (e) { resolve({ success: true }); } } else { resolve({ success: false }); } }); ffprobe.on('error', () => resolve({ success: false })); }); } /** * Очищает временные файлы */ function cleanup(config) { if (!config.cleanupTemp) return; try { if (fs_1.default.existsSync(config.tempDir)) { fs_1.default.rmSync(config.tempDir, { recursive: true, force: true }); } } catch (error) { // Игнорируем ошибки очистки } } /** * Разбивает полный аудиофайл на части, каждая из которых не превышает 5 МБ. * Части сохраняются с шаблоном: {spaceId}_part_XXX.m4a. */ async function splitFullFile(config, inputPath) { return new Promise((resolve, reject) => { const partPattern = path_1.default.join(config.outputDir, `${config.spaceId}_part_%03d.m4a`); console.log(`🔪 Разбивка файла на части (не более 5 МБ каждая): ${partPattern}`); const args = [ '-i', inputPath, '-c', 'copy', '-f', 'segment', '-segment_time', '655', '-reset_timestamps', '1', partPattern ]; const ffmpeg = (0, child_process_1.spawn)('ffmpeg', args); ffmpeg.stderr.on('data', (data) => process.stderr.write(data)); ffmpeg.on('close', (code) => { if (code === 0) { console.log(`✅ Файл успешно разбит на части.`); resolve(); } else { reject(new Error(`Ошибка разбивки файла (код: ${code})`)); } }); ffmpeg.on('error', reject); }); } /** * Основная функция для быстрого скачивания и объединения. * После формирования полного аудиофайла запускается разбивка на части по 5 МБ. */ async function fastDownloadAndMerge(spaceId) { const config = initConfig(spaceId); const startTime = Date.now(); console.log(`⚡ БЫСТРАЯ ОБРАБОТКА SPACE: ${config.spaceId}`); console.log(`📁 Выходной файл: ${path_1.default.join(config.outputDir, config.outputFile)}`); try { if (!checkFfmpeg()) { throw new Error('ffmpeg не найден'); } createDirectories(config); let outputPath; if (config.directDownload) { outputPath = await directProcessPlaylist(config); } else { const segmentUrls = extractSegmentsFromPlaylist(config.inputPlaylist); await downloadSegments(config, segmentUrls); outputPath = await fastMergeSegments(config, segmentUrls); } const info = await getOutputInfo(outputPath); // Разбиваем полный файл на части по 5 МБ await splitFullFile(config, outputPath); cleanup(config); const totalTime = (Date.now() - startTime) / 1000; console.log('='.repeat(50)); console.log(`✅ ОБРАБОТКА ЗАВЕРШЕНА ЗА ${formatTime(totalTime)}`); if (info.success && info.duration) { const processingRatio = info.duration > 0 ? totalTime / info.duration : 0; console.log(`📊 Длительность: ${formatTime(info.duration)}`); console.log(`📊 Размер файла: ${info.size}`); console.log(`⚡ Эффективность: ${(processingRatio * 100).toFixed(2)}% от длительности`); } console.log(`🎵 Файл: ${outputPath}`); } catch (error) { console.error(`❌ ОШИБКА: ${error.message}`); process.exit(1); } } exports.fastDownloadAndMerge = fastDownloadAndMerge; if (require.main === module) { const spaceId = process.argv[2]; fastDownloadAndMerge(spaceId).catch(console.error); }