403 lines
10 KiB
JavaScript
403 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 { URL } = require("url");
|
|
const fs = require("fs");
|
|
const FFMPEG = require("./ffmpeg");
|
|
const config = require("./config");
|
|
const { downloadFile, verifyThumbUrl } = require("./utils");
|
|
|
|
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", 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", async code => {
|
|
if (code !== 0) return reject(new Error("yt-dlp failed to get info"));
|
|
|
|
try {
|
|
const info = JSON.parse(data);
|
|
|
|
const thumbnails =
|
|
(await Promise.all(info.thumbnails.map(async thumbnail => {
|
|
const verified = await verifyThumbUrl(thumbnail.url);
|
|
if (!verified) return null;
|
|
return thumbnail;
|
|
}))).filter(x => x !== null);
|
|
|
|
resolve({
|
|
id: info.id,
|
|
title: info.title,
|
|
description: info.description,
|
|
uploader: info.uploader,
|
|
duration: info.duration,
|
|
thumbnail:
|
|
(thumbnails?.[thumbnails.length - 1]?.url ||
|
|
info.thumbnail) || config.emptyImage,
|
|
url: info.webpage_url,
|
|
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,
|
|
];
|
|
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 };
|