telegram_ytdlp_node.js/src/download.js
2025-11-09 14:28:04 +03:00

440 lines
11 KiB
JavaScript

const { spawn } = require("child_process");
const { Readable } = require("stream");
const EventEmitter = require("events");
const mime = require("mime");
const path = require("path");
const http = require("http");
const https = require("https");
const { URL } = require("url");
const fs = require("fs");
const FFMPEG = require("./ffmpeg");
const formatsCache = new Map();
const infoCache = new Map();
const d = r => console.debug("DEBUG:", r) || r;
async function downloadFile(fileUrl) {
return new Promise((resolve, reject) => {
try {
const url = new URL(fileUrl);
const protocol = url.protocol === "https:" ? https : http;
const extMatch = path.extname(url.pathname).split("?")[0];
const extension = extMatch ? extMatch.replace(".", "") : "tmp";
const filename = `${Date.now()}.${extension}`;
const savePath = path.join(__dirname, "tmp", filename);
fs.mkdirSync(path.dirname(savePath), { recursive: true });
const file = fs.createWriteStream(savePath);
protocol.get(fileUrl, (response) => {
if (response.statusCode !== 200) {
return reject(
new Error(`Failed to get '${fileUrl}' (${response.statusCode})`)
);
}
response.pipe(file);
file.on("finish", () => {
file.close();
/*setTimeout(() => {
fs.rm(savePath, () => {});
}, 60000);*/
resolve(savePath);
});
}).on("error", (err) => {
fs.unlinkSync(savePath);
reject(err);
});
} catch (err) {
reject(err);
}
});
}
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", type: "video" },
mp4_best: { exc: "mp4", flag: [
"best[ext=mp4]",
"--merge-output-format", "mp4"
], mime: "video/mp4", type: "video", strictName: "📹 Best video" },
audio_default: { exc: "mp4", flag: "worst", mime: "audio/mpeg", type: "audio", strictName: "🎧 Audio" },
};
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",
"--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
"-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,
thumbnails: info.thumbnails
});
} 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",
"--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
"-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;
const resolution = f.height ? `${f.height}p` : 'audio-only';
let flag = `bestaudio[ext=${f.ext}]`;
if (f.height) {
flag = [
`best[height<=${
f.height
}]`,
"--merge-output-format", f.ext
];
}
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,
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",
"-f", ...flags,
"-o", !isPath ? "-" : isStream, this.url,
];
d("yt-dlp " + params.join(" "));
const child = spawn("yt-dlp", params, {
env: {
...process.env,
LANG: "en_US.UTF-8",
},
stdio: ["pipe","pipe","pipe"],
});
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();
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", async (code) => {
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 !== "audio")
return resolve(isStream);
const ffmpeg = new FFMPEG(isStream);
const fileConverted = isStream +
".mp3";
const videoInfo = await this.getVideoInfo();
return ffmpeg.convertMusic(FFMPEG.fromFile(
isStream, fileConverted, videoInfo.thumbnail
)).then(readyFile => {
return resolve(readyFile);
}).catch(err => {
reject(err);
});
});
return;
}
else if (isStream) {
console.warn("May be unstable with stream");
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);
const resultedStream = this.quality.type === "video" ?
(await ffmpeg.convert()) : 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);
}
console.warn("May be unstable with Buffer");
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, downloadFile };