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