diff --git a/src/bot.js b/src/bot.js index e06122c..57e7a83 100644 --- a/src/bot.js +++ b/src/bot.js @@ -1,7 +1,8 @@ const TelegramBot = require("node-telegram-bot-api"); const fs = require("fs"); +const mime = require("mime"); const path = require("path"); -const { DownloadVideo, YtdlUpdater } = require("./download"); +const { DownloadVideo, YtdlUpdater, downloadFile } = require("./download"); const https = require("https"); async function getImage(url) { @@ -24,7 +25,7 @@ async function getImage(url) { const config = { token: process.env.BOT_TOKEN, admins: process.env.ADMIN_LIST?.split(/\s*,\s*/g).map((x) => +x) ?? [], - allFormats: process.env.SHOW_ALL_FORMATS === "1" + allFormats: false }; config.whitelist = [].concat( process.env.WHITELIST?.split(/\s*,\s*/g).map((x) => +x) ?? [], @@ -160,12 +161,23 @@ class TgBotSender { } MB)`); } } else if (downloaded.format.type === "audio") { + const videoInfo = await video.getVideoInfo(); await this.bot.sendAudio( msg.chat.id, downloaded.data, - { caption: info.title }, { - filename: `downloaded.${downloaded.format.exc}`, + caption: info.title, + thumb: await downloadFile(videoInfo.thumbnail), + /*thumbnail: { + type: mime.getType(videoInfo.thumbnail), + media: videoInfo.thumbnail, + }*/ + }, + { + //filename: `downloaded.${downloaded.format.exc}`, + filename: `${ + videoInfo.title + }.mp3`, contentType: downloaded.format.mime, }, ); @@ -251,7 +263,11 @@ class TgBot { const sent = await this.send(msg, "Wait.."); const errHandler = (err) => { console.error(err.stack); - sent.text("Error. Invalid/Hidden video or Forbidden for download"); + sent.text( + "Error. Invalid/Hidden video or Forbidden for download\n" + + "But you can create your bot: " + + "https://git.fullgream.tech/fullgream/telegram_ytdlp_node.js/" + ); }; try { @@ -271,15 +287,15 @@ class TgBot { for (let quality in qualities) { const qualityItem = qualities[quality]; if (qualityItem.type !== null) { - if (config.allFormats || ({ mp4: true, m4a: true })[qualityItem.exc]) { + if (config.allFormats || ({ mp4: true })[qualityItem.exc]) { if (qualityItem.type === "audio") kbChunkM.push({ - text: `🎧 ${qualityItem.exc}/${qualityItem.resolution}`, + text: qualityItem.strictName ?? `🎧 ${qualityItem.exc}/${qualityItem.resolution}`, callback_data: quality + "_" + msg.message_id, }); else if (qualityItem.type === "video") kbChunk.push({ - text: `📹 ${qualityItem.exc}/${qualityItem.resolution}`, + text: qualityItem.strictName ?? `📹 ${qualityItem.exc}/${qualityItem.resolution}`, callback_data: quality + "_" + msg.message_id, }); if (kbChunk.length >= 3) { diff --git a/src/download.js b/src/download.js index b2e7c9e..ec2a49f 100644 --- a/src/download.js +++ b/src/download.js @@ -3,6 +3,9 @@ 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"); @@ -11,6 +14,48 @@ 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(); @@ -62,12 +107,13 @@ class YtdlUpdater extends EventEmitter { class DownloadVideo { static get qualities () { const q = { - webm_best: { exc: "webm", flag: "best", mime: "video/webm" }, + webm_best: { exc: "webm", flag: "best", mime: "video/webm", type: "video" }, //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" }, + ], 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; @@ -139,6 +185,7 @@ class DownloadVideo { info.thumbnails?.[info.thumbnails.length - 1]?.url || info.thumbnail, // лучшее превью url: info.webpage_url, + thumbnails: info.thumbnails }); } catch (e) { reject(e); @@ -309,7 +356,7 @@ class DownloadVideo { if (isPath) { child.stdout.on("data", stderrHandler); - child.on("close", (code) => { + child.on("close", async (code) => { //console.debug(">>>>>>>>>>>>>FINISHED"); signal.emit("finish"); fine = true; @@ -319,14 +366,16 @@ class DownloadVideo { } cb({ percent: 100, downloaded, fine: true }); - if (this.quality.type !== "video") + if (this.quality.type !== "audio") return resolve(isStream); const ffmpeg = new FFMPEG(isStream); const fileConverted = isStream + - ".converted." + - this.quality.exc; - return ffmpeg.convert(FFMPEG.fromFile( - isStream, fileConverted + //".converted." + + ".mp3"; + const videoInfo = await this.getVideoInfo(); + //console.debug("videoInfo::thumbnails", videoInfo.thumbnails); + return ffmpeg.convertMusic(FFMPEG.fromFile( + isStream, fileConverted, videoInfo.thumbnail )).then(readyFile => { return resolve(readyFile); }).catch(err => { @@ -337,6 +386,7 @@ class DownloadVideo { return; } else if (isStream) { + console.warn("May be unstable with stream"); const stream = new Readable({ read() {} }); const ffmpeg = new FFMPEG(stream); @@ -374,6 +424,7 @@ class DownloadVideo { }); return resolve(resultedStream); } + console.warn("May be unstable with Buffer"); const chunks = []; child.stdout.on("data", (chunk) => { @@ -405,4 +456,4 @@ class DownloadVideo { } } -module.exports = { DownloadVideo, YtdlUpdater }; +module.exports = { DownloadVideo, YtdlUpdater, downloadFile }; diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 173c35f..51c3fa7 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -2,9 +2,10 @@ const { Readable, PassThrough } = require("stream"); const { spawn } = require("child_process"); class FromFileFFMPEG { - constructor (source, video) { + constructor (source, video, preview = null) { this.source = source; this.video = video; + this.preview = preview; } get args () { @@ -13,6 +14,20 @@ class FromFileFFMPEG { '-q:v', '2', this.video ]; } + + get argsMusic () { + if (!this.preview) + return [ + "-i", this.source, + '-q:a', '0', "-map", "a", this.video + ]; + return [ + "-i", this.source, "-i", this.preview, + "-filter:v", "scale='if(gt(iw,ih),720,-1)':'if(gt(ih,iw),720,-1)',pad=720:720:(ow-iw)/2:(oh-ih)/2:black", + "-c:v", "mjpeg", "-q:a", "0", + "-map", "0:a", "-map", "1:v", this.video + ]; + } } class FFMPEG { @@ -21,13 +36,48 @@ class FFMPEG { this.isBuffer = data instanceof Buffer; } - static fromFile (source, video) { - return new FromFileFFMPEG(source, video); + static fromFile (source, video, preview = null) { + return new FromFileFFMPEG(source, video, preview); + } + + async convertMusic (fromFile) { + return await new Promise((resolve, reject) => { + const isFromFile = fromFile && (fromFile instanceof FromFileFFMPEG); + if (!isFromFile) + return reject(new Error("Supports only fromFile")); + const ffmpegArgs = !isFromFile ? [] : fromFile.argsMusic; + + const ff = spawn("ffmpeg", ffmpegArgs); + console.debug("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; + } + // TODO: Supports only fromFile + }); } - convert(fromFile) { - return new Promise((resolve, reject) => { + async convert(fromFile) { + return await new Promise((resolve, reject) => { const isFromFile = fromFile instanceof FromFileFFMPEG; + // TODO: Off convert method + console.trace("Deprecated. Off [ffmpeg].convert() on:"); + if (isFromFile) { + return resolve(fromFile.source); + } + const returnStream = !this.isBuffer ? + this.data : Readable.from(this.data); + return resolve(returnStream); + const ffmpegArgs = !isFromFile ? [ "-i", "pipe:0", @@ -49,7 +99,7 @@ class FFMPEG { "mp4", "pipe:1", ] : fromFile.args; - console.debug("ffmpeg", ...ffmpegArgs); + //console.debug("ffmpeg", ...ffmpegArgs); const ff = spawn("ffmpeg", ffmpegArgs); let errData = "";