telegram_ytdlp_node.js/src/download.js

409 lines
10 KiB
JavaScript

const { spawn } = require("child_process");
const { Readable } = require("stream");
const EventEmitter = require("events");
const mime = require("mime");
const path = require("path");
const fs = require("fs");
const FFMPEG = require("./ffmpeg");
const formatsCache = new Map();
const infoCache = new Map();
const d = r => console.debug("DEBUG:", r) || r;
class YtdlUpdater extends EventEmitter {
constructor () {
super();
this.updateRunned = false;
const updInterval = 3600000 * 24; // production
//const updInterval = 30000; // test
this.update();
setInterval(() => this.update(), updInterval);
this.locks = 0;
}
lock (lockedBy) {
if (this.updateRunned) {
return false; // Denied
}
this.locks++;
lockedBy.once("finish", () => {
if (--this.locks === 0) {
this.emit("resume");
}
});
return true;
}
async update () {
if (this.updateRunned) return;
this.updateRunned = true;
this.emit("init");
if (this.locks > 0) await new Promise(rs => {
this.once("resume", rs);
});
this.emit("run");
console.info("RUN UPDATE", new Date());
const code = await new Promise((resolve, reject) => {
const child = spawn("yt-dlp", ["-U"]);
child.on("close", (code) => {
resolve(code);
});
});
if (code !== 0) console.warn("yt-dlp update failed with error: " + code);
else console.info("Update finished");
this.emit("finish");
this.updateRunned = false;
}
}
class DownloadVideo {
static get qualities () {
const q = {
webm_best: { exc: "webm", flag: "best", mime: "video/webm" },
//mp4_best: { exc: "mp4", flag: "best[ext=mp4]", mime: "video/mp4" },
mp4_best: { exc: "mp4", flag: [
"bestvideo[height<=1080]+bestaudio/best",
"--merge-output-format", "mp4"
], mime: "video/mp4" },
};
q.default = q.webm_best;
return q;
}
destructor () {
CLASS
.instances
.pop(CLASS
.instances
.indexOf(this));
}
constructor(url, quality) {
const CLASS = this.__proto__.constructor;
if (!CLASS.instances) CLASS.instances = new Array();
CLASS.instances.push(this);
this.url = url;
this.quality = quality;
if (!this.quality) this.quality = CLASS.qualities.default;
}
setupParent (parent) {
this.parent = parent;
}
async getVideoInfo() {
const fromCache = infoCache.get(this.url);
if (fromCache) return fromCache;
const signal = new EventEmitter();
if (!this.parent.updater.lock(signal)) {
await new Promise((rs => {
this.parent.updater.once("finish", () => {
this.parent.updater.lock(this);
rs();
});
}));
}
const result = await new Promise((resolve, reject) => {
const child = spawn("yt-dlp", ["--cookies", "cookies.txt", "-j", this.url]);
let data = "";
child.stdout.on("data", chunk => {
data += chunk.toString();
});
child.stderr.on("data", err => {
console.error("yt-dlp error:", err.toString());
});
child.on("close", code => {
if (code !== 0) return reject(new Error("yt-dlp failed to get info"));
try {
const info = JSON.parse(data);
resolve({
id: info.id,
title: info.title,
description: info.description,
uploader: info.uploader,
duration: info.duration, // секунды
thumbnail:
info.thumbnails?.[info.thumbnails.length - 1]?.url ||
info.thumbnail, // лучшее превью
url: info.webpage_url,
});
} catch (e) {
reject(e);
}
});
});
infoCache.set(this.url, result);
signal.emit("finish");
return result;
}
async getFormats() {
const fromCache = formatsCache.get(this.url);
if (fromCache) return fromCache;
const signal = new EventEmitter();
if (!this.parent.updater.lock(signal)) {
await new Promise((rs => {
this.parent.updater.once("finish", () => {
this.parent.updater.lock(this);
rs();
});
}));
}
const result = await new Promise((resolve, reject) => {
const child = spawn("yt-dlp", ["--cookies", "cookies.txt", "-j", this.url]);
let data = "";
child.stdout.on("data", (chunk) => (data += chunk.toString()));
child.on("close", (code) => {
if (code !== 0) return reject(new Error("yt-dlp failed"));
const info = JSON.parse(data);
resolve(info.formats);
});
});
formatsCache.set(this.url, result);
signal.emit("finish");
return result;
}
async qualities(withDefaults = false) {
const CLASS = this.__proto__.constructor;
const formats = await this.getFormats();
const q = {};
// Перебираем форматы и группируем по расширению
formats.forEach(f => {
if (!f.ext || !f.format_id) return;
//if (!q[f.ext]) q[f.ext] = [];
const resolution = f.height ? `${f.height}p` : 'audio-only';
let flag = `bestaudio[ext=${f.ext}]`;
if (f.height) {
//flag = `best[height<=${f.height}][ext=${f.ext}]`;
flag = [
`best[height<=${
f.height
}]`,
/*`bestvideo[height<=${
f.height
}]+bestaudio[ext=m4a]/best`,*/
/*`bestvideo[height<=${
f.height
}][vcodec^=avc1]+bestaudio[ext=m4a]/best[ext=${f.ext}]`,*/
/*`bestvideo[height<=${
f.height
}][vcodec^=mov]+bestaudio[ext=m4a]/best`,*/
"--merge-output-format", f.ext
];
}
//const flag = f.format_id;
const mimeType = mime.getType(f.ext);
const type = (resolution === "audio-only" || /^audio\//m.test(mimeType)) ? "audio" : (
/^video\//m.test(mimeType) ? "video" : null
);
q[f.ext + "_" + resolution] = {
format_id: f.format_id,
flag, // <- ключевой момент для yt-dlp
exc: f.ext, // <- расширение
resolution,
filesize: f.filesize || null,
mime: mimeType, type: !mimeType ? null : type,
forceAll: () => f
};
});
if (withDefaults) {
for (let quality in CLASS.qualities) {
if (!q[quality])
q[quality] = CLASS.qualities[quality];
}
}
return q;
}
async setupQuality (quality, check=true) {
if (!check) {
this.quality = quality;
return true;
}
const CLASS = this.__proto__.constructor;
const all = Object.assign({}, (await this.qualities()), CLASS.qualities);
if (all[quality.exc + "_" + quality.resolution]) {
this.quality = quality;
return true;
}
return true;
}
async download(cb, isStream = false) {
const signal = new EventEmitter();
const isPath = typeof isStream === "string";
if (!this.parent.updater.lock(signal)) {
await new Promise((rs => {
this.parent.updater.once("finish", () => {
this.parent.updater.lock(this);
rs();
});
}));
}
const data = await new Promise(async (resolve, reject) => {
const flags = !Array.isArray(this.quality.flag) ?
[this.quality.flag] : this.quality.flag;
const params = [
"--cookies", "cookies.txt",
"--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
//"--extractor-args", "youtube:player-client=default,mweb;po_token=mweb.gvs+XXX",
"--extractor-args", "youtube:player_client=default,mweb",
"-f", ...flags,
"-o", !isPath ? "-" : isStream, this.url,
];
d("yt-dlp " + params.join(" "));
//console.debug({ HOME: process.env.HOME });
const child = spawn("yt-dlp", params, {
env: {
...process.env,
//HOME: process.env.HOME,
LANG: "en_US.UTF-8",
},
stdio: ["pipe","pipe","pipe"],
//stdio: ["pipe","inherit","pipe"],
//stdio: ["inherit", "inherit", "inherit"],
});
let downloaded = 0;
let percent = 0;
let sizeTg = 0;
let fine = false;
const getDwnld = () => `Real size: ${downloaded}, total: ${sizeTg}`;
const stderrHandler = (data) => {
const str = data.toString();
//console.debug("DEBUG >>>>>>", str);
//console.debug(str);
const match = str.match(/(\d+\.\d+)%/);
if (match && cb) {
percent = parseFloat(match[1]);
cb({ percent, downloaded: !isPath ? getDwnld() : "Processing", fine });
}
};
child.stderr.on("data", stderrHandler);
if (isPath) {
child.stdout.on("data", stderrHandler);
child.on("close", (code) => {
//console.debug(">>>>>>>>>>>>>FINISHED");
signal.emit("finish");
fine = true;
if (code !== 0) {
reject(new Error(`yt-dlp exited with code ${code}`));
return cb({ percent, downloaded, fine, msg: "Download stream failed" });
}
cb({ percent: 100, downloaded, fine: true });
if (this.quality.type !== "video")
return resolve(isStream);
const ffmpeg = new FFMPEG(isStream);
const fileConverted = isStream +
".converted." +
this.quality.exc;
return ffmpeg.convert(FFMPEG.fromFile(
isStream, fileConverted
)).then(readyFile => {
return resolve(readyFile);
}).catch(err => {
reject(err);
});
});
return;
}
else if (isStream) {
const stream = new Readable({ read() {} });
const ffmpeg = new FFMPEG(stream);
child.stdout.on("data", (chunk) => {
stream.push(chunk);
downloaded += chunk.length;
cb({ percent, downloaded: getDwnld(), fine });
});
child.on("close", (code) => {
stream.push(null);
signal.emit("finish");
fine = true;
if (code !== 0) {
reject(new Error(`yt-dlp exited with code ${code}`));
return cb({ percent, downloaded, fine, msg: "Download stream failed" });
}
cb({ percent: 100, downloaded, fine: true });
});
child.on("error", reject);
//return resolve(stream);
//console.debug(this.quality);
const resultedStream = this.quality.type === "video" ?
(await ffmpeg.convert()) : stream;
//const resultedStream = stream;
resultedStream.on("data", chunk => {
sizeTg += chunk.length;
if (sizeTg > 50 * 1024 * 1024) {
fine = true;
cb({ percent, downloaded, fine, msg: "Download failed: 50MB Telegram limit reached" });
resultedStream.push(null);
}
});
return resolve(resultedStream);
}
const chunks = [];
child.stdout.on("data", (chunk) => {
chunks.push(chunk);
downloaded += chunk.length;
cb({ percent, downloaded, fine: false });
});
child.on("close", (code) => {
fine = true;
signal.emit("finish");
cb({ percent: 100, downloaded, fine });
if (code === 0) {
if (isPath) {
}
resolve(Buffer.concat(chunks));
} else {
reject(new Error(`yt-dlp exited with code ${code}`));
}
});
});
const result = {
format: this.quality,
data,
};
return result;
}
}
module.exports = { DownloadVideo, YtdlUpdater };