UNPKG

xdl-node

Version:

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

336 lines (335 loc) 15.3 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.TwspaceDL = void 0; const fs_1 = __importDefault(require("fs")); const fsp = __importStar(require("fs/promises")); const path_1 = __importDefault(require("path")); const child_process_1 = require("child_process"); const axios_1 = __importDefault(require("axios")); const url_1 = require("url"); const DEFAULT_FNAME_FORMAT = "%(id)s"; // Только ID в имени файла class TwspaceDL { /** * @param space Экземпляр Twspace. * @param formatStr Шаблон имени файла. */ constructor(space, formatStr) { this._tempdir = ""; this._recordingProcess = null; this.space = space; this.formatStr = formatStr || DEFAULT_FNAME_FORMAT; } get filename() { return this.space.format(this.formatStr); } /** * Получает динамический URL (тот, что используется браузером). * @returns Промис с динамическим URL. */ async getDynUrl() { if (this.space.state === "Ended" && !this.space.available_for_replay) { console.error("Can't Download. Space has ended, can't retrieve master url. You can provide it with -f URL if you have it."); throw new Error("Space Ended"); } const media_key = this.space.media_key; let metadata; try { metadata = await global.API.live_video_stream_api.status(media_key); } catch (err) { throw new Error("Space isn't available: " + err.message); } return metadata.source.location; } /** * Формирует master URL путём замены части динамического URL. * @returns Промис с master URL. */ async getMasterUrl() { const dynUrl = await this.getDynUrl(); const idx = dynUrl.indexOf("/audio-space/"); if (idx === -1) { throw new Error("Invalid dynamic URL format"); } const prefix = dynUrl.substring(0, idx + "/audio-space/".length); return prefix + "master_playlist.m3u8"; } /** * Получает URL плейлиста с сегментами. * @returns Промис с URL плейлиста. */ async getPlaylistUrl() { const masterUrl = await this.getMasterUrl(); const response = await axios_1.default.get(masterUrl); const lines = response.data.split('\n'); const playlistSuffix = lines[3]; const parsedUrl = new url_1.URL(masterUrl); const domain = parsedUrl.host; return `https://${domain}${playlistSuffix}`; } /** * Получает текст плейлиста с заменёнными URL сегментов. * @returns Промис с текстом плейлиста. */ async getPlaylistText() { const playlistUrl = await this.getPlaylistUrl(); const response = await axios_1.default.get(playlistUrl); let playlistText = response.data; const masterUrl = await this.getMasterUrl(); const masterUrlWithoutFile = masterUrl.replace(/master_playlist\.m3u8.*/, ""); playlistText = playlistText.replace(/(?=chunk)/g, masterUrlWithoutFile); return playlistText; } /** * **Новый метод:** Возвращает m3u8 плейлист как строку без сохранения на диск. * Это позволяет обработать плейлист напрямую в коде (например, для передачи в другой сервис). * @returns Строка с содержимым m3u8 плейлиста. */ async getM3u8Stream() { return this.getPlaylistText(); } /** * **Новый метод:** Получает полный m3u8 плейлист, сохраняет его в файл и возвращает путь и содержимое файла. */ async getFullPlaylist() { const playlistText = await this.getPlaylistText(); // Формируем виртуальный путь (он может использоваться для ссылок или для информации) const virtualPath = `${this.filename}.m3u8`; // Возвращаем данные без сохранения в файл return { filePath: virtualPath, content: playlistText }; } /** * **Новый метод:** Получает m3u8 плейлист в реальном времени. * Периодически запрашивает плейлист, объединяет новые сегменты с уже полученными, * дописывает результирующий плейлист в файл и возвращает путь и содержимое файла. */ async getFullPlaylistRealtime() { const logsDir = path_1.default.join(process.cwd(), 'logs'); await fsp.mkdir(logsDir, { recursive: true }); const filePath = path_1.default.join(logsDir, `${this.filename}_realtime.m3u8`); const segments = new Map(); let globalHeaders = []; let consecutiveNoNewCount = 0; let prevTotalSegments = 0; const CHECK_INTERVAL = 10000; // 10 секунд function getSegmentKey(segment) { const match = segment.url.match(/chunk_[\d]+_([\d]+)_/); if (match && match[1]) { return match[1]; } return segment.url; } function parsePlaylist(playlistContent) { const lines = playlistContent.split('\n').map(line => line.trim()).filter(Boolean); const segmentsArr = []; const headers = []; let currentHeaders = []; let segmentIndex = 0; // Сначала собираем глобальные заголовки (до первой ссылки) let i = 0; for (; i < lines.length; i++) { const line = lines[i]; if (line.startsWith('#')) { headers.push(line); } else { break; } } // Далее обрабатываем сегменты for (; i < lines.length; i++) { const line = lines[i]; if (line.startsWith('#')) { currentHeaders.push(line); } else { let index = segmentIndex; const indexMatch = line.match(/chunk_[\d]+_([\d]+)_/); if (indexMatch && indexMatch[1]) { index = parseInt(indexMatch[1], 10); } segmentsArr.push({ url: line, index, content: line, headers: [...currentHeaders], }); currentHeaders = []; segmentIndex++; } } return { segments: segmentsArr, headers }; } async function saveSegmentsToFile(segmentsMap, headers) { const sortedSegments = Array.from(segmentsMap.values()).sort((a, b) => a.index - b.index); let content = headers.join('\n') + '\n'; sortedSegments.forEach(segment => { if (segment.headers && segment.headers.length > 0) { content += segment.headers.join('\n') + '\n'; } content += segment.content + '\n'; }); await fsp.writeFile(filePath, content, 'utf8'); return content; } // Функция мониторинга, которая периодически обновляет плейлист return new Promise((resolve, reject) => { const self = this; async function checkPlaylist() { try { const playlistText = await self.getPlaylistText(); const { segments: newSegments, headers } = parsePlaylist(playlistText); if (globalHeaders.length === 0 && headers.length > 0) { globalHeaders = headers; } newSegments.forEach(seg => { const key = getSegmentKey(seg); if (!segments.has(key)) { segments.set(key, seg); } }); const currentTotal = segments.size; if (currentTotal > prevTotalSegments) { consecutiveNoNewCount = 0; prevTotalSegments = currentTotal; } else { consecutiveNoNewCount++; console.log(`Нет новых сегментов. ${consecutiveNoNewCount} запрос(ов) подряд.`); } const fileContent = await saveSegmentsToFile(segments, globalHeaders); // Если за 3 последовательных запроса не появилось новых сегментов – считаем, что стрим завершён. if (consecutiveNoNewCount >= 3) { clearInterval(intervalId); resolve({ filePath, content: fileContent }); } } catch (error) { console.error(`Ошибка при мониторинге плейлиста: ${error.message}`); } } // Первый запрос немедленно checkPlaylist(); const intervalId = setInterval(checkPlaylist, CHECK_INTERVAL); // Обработка завершения по SIGINT (Ctrl+C) process.on('SIGINT', () => { clearInterval(intervalId); console.log(`Реальное время завершено. Плейлист сохранён в ${filePath}`); resolve({ filePath, content: '' }); }); }); } /** * **Помечаем метод как нерабочий:** * Скачивание больше не поддерживается */ async download() { throw new Error("Download functionality is not working."); } /** * Очищает временную директорию. */ async cleanup() { if (this._tempdir && fs_1.default.existsSync(this._tempdir)) { fs_1.default.rmSync(this._tempdir, { recursive: true, force: true }); } } /** * Запускает live‑запись Twitter Space с возможностью остановки. * Добавлены параметры для уменьшения качества записи */ async startLiveRecording() { var _a, _b; if (this._recordingProcess) { throw new Error("Запись уже запущена"); } // Проверяем ffmpeg const ffmpegCheck = (0, child_process_1.spawnSync)('ffmpeg', ['-version']); if (ffmpegCheck.error) { throw new Error("ffmpeg не установлен"); } const dynUrl = await this.getDynUrl(); const outputFilePath = path_1.default.join(process.cwd(), 'tmp', this.filename + ".m4a"); await fsp.mkdir(path_1.default.dirname(outputFilePath), { recursive: true }); const space = this.space; const args = [ "-y", "-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "15", "-live_start_index", "-1", "-i", dynUrl, "-http_persistent", "1", "-c:a", "aac", "-b:a", "32k", "-ar", "22050", "-metadata", `title=${space.title}`, "-metadata", `artist=${space.creator_name}`, "-metadata", `episode_id=${space.id}`, outputFilePath ]; console.debug("Запуск ffmpeg для live‑записи с аргументами: " + args.join(" ")); this._recordingProcess = (0, child_process_1.spawn)('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] }); (_a = this._recordingProcess.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (data) => { console.log(`[ffmpeg stdout] ${data.toString().trim()}`); }); (_b = this._recordingProcess.stderr) === null || _b === void 0 ? void 0 : _b.on('data', (data) => { const msg = data.toString().trim(); if (!msg.includes('frame=') && !msg.includes('size=')) { console.log(`[ffmpeg stderr] ${msg}`); } }); this._recordingProcess.on('exit', (code, signal) => { console.log(`Процесс live‑записи завершился (code: ${code}, signal: ${signal})`); this._recordingProcess = null; if (code !== 0 && code !== null && signal !== 'SIGINT' && signal !== 'SIGTERM') { console.log('Попытка автоматического перезапуска записи...'); setTimeout(() => this.startLiveRecording(), 5000); } }); return outputFilePath; } /** * Останавливает live‑запись Twitter Space. */ stopLiveRecording() { if (this._recordingProcess) { console.log("Остановка live‑записи..."); this._recordingProcess.kill('SIGINT'); } else { console.log("Нет активного процесса live‑записи"); } } } exports.TwspaceDL = TwspaceDL; exports.default = TwspaceDL;