diff --git a/src/bot.js b/src/bot.js index cb83d8f..126799d 100644 --- a/src/bot.js +++ b/src/bot.js @@ -1,5 +1,6 @@ const TelegramBot = require("node-telegram-bot-api"); const fs = require("fs"); +const path = require("path"); const { DownloadVideo, YtdlUpdater } = require("./download"); const https = require("https"); @@ -43,6 +44,10 @@ if (!process.env.BOT_TOKEN) { config.admins = Object.fromEntries(config.admins.map((x) => [x, true])); config.whitelist = Object.fromEntries(config.whitelist.map((x) => [x, true])); +try { + fs.rmdirSync(path.join(__dirname, "tmp"), { recursive: true, force: true }, () => {}); +} catch (_) {} + const ytLinkRegexp = /^https:\/\/www\.youtube\.com/m; class TgBotSender { @@ -81,30 +86,50 @@ class TgBotSender { const info = await video.getVideoInfo(); await video.setupQuality(quality, false); let cooldown = new Date(); + //const isStream = true; + const filename = `${(new Date()).getTime()}.${quality.exc}`; + const isStream = path.join(__dirname, `tmp/${filename}`); + + const finallyHandler = (result) => { + if (isStream) { + fs.rm(isStream, { recursive: true, force: true }, () => {}); + if (result) + fs.rm(result, { recursive: true, force: true }, () => {}); + } + }; + video .download(({ percent, downloaded, fine, msg }) => { - //console.debug({ percent, fine, downloaded }); + //console.debug({ percent, fine, downloaded, msg }); const now = new Date(); if (!fine && now - cooldown <= 3500) return; cooldown = new Date(); if (!fine) sent.text(`Downloaded (${percent}%), ${downloaded} bytes`); else - sent.text(!msg ? "Download complete" : msg); - }, true) - .then((downloaded) => { + sent.text(!msg ? "Download complete. Converting.." : msg); + }, isStream) + .then(async (downloaded) => { + console.debug("downloaded:", downloaded); if (downloaded.format.type === "video") { - this.bot.sendVideo( + await this.bot.sendChatAction(msg.chat.id, 'upload_document'); + const uploadFileInterval = setInterval(() => { + this.bot.sendChatAction(msg.chat.id, 'upload_document'); + }, 4250); + await new Promise(r => setTimeout(r, 3000)); + await this.bot.sendVideo( msg.chat.id, downloaded.data, { caption: info.title }, { - filename: `downloaded.${downloaded.format.exc}`, - contentType: downloaded.format.mime, + filename, + contentType: isStream === true ? "application/octet-stream" : downloaded.format.mime, + //contentType: downloaded.format.mime, }, ); + clearInterval(uploadFileInterval); } else if (downloaded.format.type === "audio") { - this.bot.sendAudio( + await this.bot.sendAudio( msg.chat.id, downloaded.data, { caption: info.title }, @@ -114,10 +139,12 @@ class TgBotSender { }, ); } + finallyHandler(downloaded.data); }) .catch((err) => { - //console.error(err.stack); + console.error(err.stack); this.bot.sendMessage(msg.chat.id, "Download failed"); + finallyHandler(); }); } } @@ -150,7 +177,8 @@ class VideosGarbageCollector { } tasks.forEach(task => task()); - console.info("GC Clean up", tasks.length, "items(s)"); + if (tasks.length > 0) + console.info("GC Clean up", tasks.length, "items(s)"); }, 60000); } register (key, act, pinnedVideo) { diff --git a/src/download.js b/src/download.js index 6949738..51ab7d6 100644 --- a/src/download.js +++ b/src/download.js @@ -3,6 +3,7 @@ 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(); @@ -195,12 +196,15 @@ class DownloadVideo { 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<=${ + /*`bestvideo[height<=${ f.height - }][vcodec^=avc1]+bestaudio[ext=m4a]/best[ext=${f.ext}]`, + }][vcodec^=avc1]+bestaudio[ext=m4a]/best[ext=${f.ext}]`,*/ /*`bestvideo[height<=${ f.height }][vcodec^=mov]+bestaudio[ext=m4a]/best`,*/ @@ -244,11 +248,12 @@ class DownloadVideo { this.quality = quality; return true; } - return false; + 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", () => { @@ -263,13 +268,13 @@ class DownloadVideo { [this.quality.flag] : this.quality.flag; const params = [ "--cookies", "cookies.txt", - "-f", ...flags, "%%", - "-o", "-", this.url, + "-f", ...flags, + "-o", !isPath ? "-" : isStream, this.url, ]; - d(params); + d("yt-dlp " + params.join(" ")); const child = spawn("yt-dlp", params); let downloaded = 0; - let lastPercent = 0; + let percent = 0; let sizeTg = 0; let fine = false; const getDwnld = () => `Real size: ${downloaded}, total: ${sizeTg}`; @@ -279,41 +284,71 @@ class DownloadVideo { //console.debug(str); const match = str.match(/(\d+\.\d+)%/); if (match && cb) { - const percent = parseFloat(match[1]); - if (percent > lastPercent) - lastPercent = percent; - cb({ percent: lastPercent, downloaded: getDwnld(), fine }); + percent = parseFloat(match[1]); + cb({ percent, downloaded: !isPath ? getDwnld() : "Processing", fine }); } }); - if (isStream) { + if (isPath) { + child.on("close", (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 !== "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: lastPercent, downloaded: getDwnld(), fine }); + cb({ percent, downloaded: getDwnld(), fine }); }); child.on("close", (code) => { stream.push(null); signal.emit("finish"); - cb({ percent: 100, downloaded, fine: true }); fine = true; - if (code !== 0) reject(new Error(`yt-dlp exited with code ${code}`)); + 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); - const resultedStream = await ffmpeg.convert(); + //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: lastPercent, downloaded, fine, msg: "Download failed: 50MB Telegram limit reached" }); + cb({ percent, downloaded, fine, msg: "Download failed: 50MB Telegram limit reached" }); resultedStream.push(null); } }); @@ -324,7 +359,7 @@ class DownloadVideo { child.stdout.on("data", (chunk) => { chunks.push(chunk); downloaded += chunk.length; - cb({ percent: lastPercent, downloaded, fine: false }); + cb({ percent, downloaded, fine: false }); }); child.on("close", (code) => { @@ -332,6 +367,9 @@ class DownloadVideo { 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}`)); diff --git a/src/ffmpeg.js b/src/ffmpeg.js index f2f1a4e..173c35f 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -1,15 +1,34 @@ const { Readable, PassThrough } = require("stream"); const { spawn } = require("child_process"); +class FromFileFFMPEG { + constructor (source, video) { + this.source = source; + this.video = video; + } + + get args () { + return [ + "-y", "-i", this.source, + '-q:v', '2', this.video + ]; + } +} + class FFMPEG { constructor(data) { this.data = data; // Can be Readable or Buffer this.isBuffer = data instanceof Buffer; } + + static fromFile (source, video) { + return new FromFileFFMPEG(source, video); + } - convert() { + convert(fromFile) { return new Promise((resolve, reject) => { - const ffmpegArgs = [ + const isFromFile = fromFile instanceof FromFileFFMPEG; + const ffmpegArgs = !isFromFile ? [ "-i", "pipe:0", "-c:v", @@ -29,9 +48,22 @@ class FFMPEG { "-f", "mp4", "pipe:1", - ]; - + ] : fromFile.args; + console.debug("ffmpeg", ...ffmpegArgs); const ff = spawn("ffmpeg", ffmpegArgs); + + let errData = ""; + ff.stderr.on("data", (chunk) => (errData += chunk.toString())); + if (isFromFile) { + ff.on("close", (code) => { + if (code !== 0) { + return reject(new Error(`FFMPEG exited with code ${code}\n${errData}`)); + } + resolve(fromFile.video); + }); + + return; + } const inputStream = this.isBuffer ? Readable.from(this.data) : this.data; @@ -40,9 +72,6 @@ class FFMPEG { const output = new PassThrough(); ff.stdout.pipe(output); - let errData = ""; - ff.stderr.on("data", (chunk) => (errData += chunk.toString())); - ff.on("close", (code) => { if (code !== 0) { reject(new Error(`FFMPEG exited with code ${code}\n${errData}`));