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
JavaScript
;
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);
}