Compare commits
10 Commits
abe724029c
...
a3c27eeda6
| Author | SHA1 | Date | |
|---|---|---|---|
| a3c27eeda6 | |||
| d995b7e1ab | |||
| 2b4da355f8 | |||
| 2c01fe389d | |||
| 3e400cea39 | |||
| 852c0e2803 | |||
| a47c8bb83a | |||
| 13d3607a54 | |||
| 14f4753b27 | |||
| 84fbc475fd |
18
LICENSE
18
LICENSE
@ -686,3 +686,21 @@ Attach the following notice to the start of each source file:
|
||||
Make sure to provide a way for users interacting with your program
|
||||
remotely (through a network) to obtain the source code, for example
|
||||
by providing a link to a repository or downloadable archive.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Dual License Notice:
|
||||
|
||||
This software is available under the following two licenses:
|
||||
|
||||
1. GNU Affero General Public License version 3 (AGPLv3 or later)
|
||||
- This license allows you to use, modify, and distribute the software freely
|
||||
under the terms of AGPLv3. You must provide source code to users interacting
|
||||
with your software remotely, as required by AGPLv3.
|
||||
|
||||
2. Commercial License
|
||||
- For commercial use (for example, integrating this software into
|
||||
paid services or monetizing it in any way), you must obtain a separate
|
||||
commercial license from the copyright holder.
|
||||
|
||||
For inquiries regarding commercial licensing, contact: fullgream@i2pmail.org
|
||||
|
||||
17
README.md
17
README.md
@ -1,6 +1,22 @@
|
||||
# telegram_ytdlp_node.js
|
||||
|
||||
## ⚠️ WARNING!!!
|
||||
|
||||
The **telegram_ytdlp_node.js** bot project will no longer be actively maintained in its open-source version. This means that new commits and bug fixes in the public repository are **unlikely**, although not completely impossible.
|
||||
|
||||
In the future, a link to the running bot and the project roadmap will be shared. **The source code will no longer be publicly available**.
|
||||
|
||||
Thank you to everyone who used and supported the project in its open-source form. Your feedback and contributions have been greatly appreciated.
|
||||
|
||||
## Description
|
||||
|
||||
This bot can download music and videos from youtube.
|
||||
|
||||
## License
|
||||
|
||||
This software is available under AGPLv3 for open-source use.
|
||||
For commercial use, please contact fullgream@i2pmail.org for a commercial license.
|
||||
|
||||
## Installation
|
||||
|
||||
### Step 1
|
||||
@ -24,6 +40,7 @@ API_ID=<If you use MTProto API. Type here API_ID from Telegram API Development T
|
||||
API_HASH=<If you use MTProto API. Type here API_HASH from Telegram API Development Tools>
|
||||
API_SESSION=<If you use MTProto API. Type here API_SESSION from Session get script (Step 2)>
|
||||
CHAT_ID=<If you use MTProto API. ID of chat. You can send new message and chat and use command: curl -s "https://api.telegram.org/bot$BOT_TOKEN/getUpdates" (REPLACE '$BOT_TOKEN' to Your Bot token!!!)>
|
||||
EMPTY_IMAGE_URL=<URL to empty preview image (for default using: https://placekitten.com/500/350)>
|
||||
```
|
||||
4) Build docker container: `docker build -t ytdlp_node:release .`
|
||||
5) Up docker container: `docker run -d --name ytdlp_node --restart unless-stopped --env-file .env -v "$(pwd)/cookies.txt:/app/cookies.txt" ytdlp_node:release`
|
||||
|
||||
15
src/bot.js
15
src/bot.js
@ -31,7 +31,7 @@ function removeTemp () {
|
||||
}
|
||||
removeTemp();
|
||||
|
||||
const ytLinkRegexp = /^https:\/\/www\.youtube\.com/m;
|
||||
const ytLinkRegexp = /^https:\/\/(www\.|m\.|)youtu(be\.com|\.be)/m;
|
||||
|
||||
async function getFileSize(pathname) {
|
||||
const stream = fs.createReadStream(pathname);
|
||||
@ -78,7 +78,9 @@ class TgBotSender {
|
||||
async sendVideo (chatId, data, settings = {}, apiOptions = {}) {
|
||||
if (this.parent.mtproto !== null) {
|
||||
const mtprotoMID = await this.parent.mtproto.saveMedia(
|
||||
data, settings.caption ?? ""
|
||||
data, settings.caption ?? "", {
|
||||
thumb: settings.thumb,
|
||||
}
|
||||
);
|
||||
|
||||
return await this.bot.copyMessage(
|
||||
@ -157,6 +159,7 @@ class TgBotSender {
|
||||
.then(async (downloaded) => {
|
||||
//console.debug("downloaded:", downloaded);
|
||||
try {
|
||||
const videoInfo = await video.getVideoInfo();
|
||||
if (downloaded.format.type === "video") {
|
||||
const filesize = (typeof isStream !== "string" || this.parent.mtproto !== null) ?
|
||||
0 : await getFileSize(downloaded.data);
|
||||
@ -170,7 +173,10 @@ class TgBotSender {
|
||||
await this.sendVideo(
|
||||
msg.chat.id,
|
||||
downloaded.data,
|
||||
{ caption: info.title },
|
||||
{
|
||||
caption: info.title,
|
||||
thumb: await downloadFile(videoInfo.thumbnail),
|
||||
},
|
||||
{
|
||||
filename,
|
||||
contentType: isStream === true ? "application/octet-stream" : downloaded.format.mime,
|
||||
@ -193,7 +199,6 @@ class TgBotSender {
|
||||
} MB)`);
|
||||
}
|
||||
} else if (downloaded.format.type === "audio") {
|
||||
const videoInfo = await video.getVideoInfo();
|
||||
await this.sendAudio(
|
||||
msg.chat.id,
|
||||
downloaded.data,
|
||||
@ -420,7 +425,7 @@ class TgBot {
|
||||
{
|
||||
type: "photo",
|
||||
//media: info.thumbnail,
|
||||
media: "https://placekitten.com/500/350",
|
||||
media: config.emptyImage,
|
||||
caption: info.title,
|
||||
},
|
||||
{
|
||||
|
||||
@ -8,6 +8,7 @@ const config = {
|
||||
session: process.env.API_SESSION,
|
||||
chatId: +process.env.CHAT_ID,
|
||||
},
|
||||
emptyImage: process.env.EMPTY_IMAGE_URL ?? "https://placekitten.com/500/350",
|
||||
};
|
||||
config.whitelist = [].concat(
|
||||
process.env.WHITELIST?.split(/\s*,\s*/g).map((x) => +x) ?? [],
|
||||
|
||||
101
src/download.js
101
src/download.js
@ -3,59 +3,16 @@ 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 config = require("./config");
|
||||
const { downloadFile, verifyThumbUrl } = require("./utils");
|
||||
|
||||
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();
|
||||
@ -108,9 +65,8 @@ 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]", mime: "video/mp4" },
|
||||
mp4_best: { exc: "mp4", flag: [
|
||||
"bestvideo[height<=1080]+bestaudio/best",
|
||||
"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" },
|
||||
@ -173,23 +129,30 @@ class DownloadVideo {
|
||||
console.error("yt-dlp error:", err.toString());
|
||||
});
|
||||
|
||||
child.on("close", code => {
|
||||
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, // секунды
|
||||
duration: info.duration,
|
||||
thumbnail:
|
||||
info.thumbnails?.[info.thumbnails.length - 1]?.url ||
|
||||
info.thumbnail, // лучшее превью
|
||||
(thumbnails?.[thumbnails.length - 1]?.url ||
|
||||
info.thumbnail) || config.emptyImage,
|
||||
url: info.webpage_url,
|
||||
thumbnails: info.thumbnails
|
||||
thumbnails
|
||||
});
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
@ -240,33 +203,19 @@ class DownloadVideo {
|
||||
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" : (
|
||||
@ -274,8 +223,8 @@ class DownloadVideo {
|
||||
);
|
||||
q[f.ext + "_" + resolution] = {
|
||||
format_id: f.format_id,
|
||||
flag, // <- ключевой момент для yt-dlp
|
||||
exc: f.ext, // <- расширение
|
||||
flag,
|
||||
exc: f.ext,
|
||||
resolution,
|
||||
filesize: f.filesize || null,
|
||||
mime: mimeType, type: !mimeType ? null : type,
|
||||
@ -325,22 +274,16 @@ class DownloadVideo {
|
||||
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;
|
||||
@ -350,8 +293,6 @@ class DownloadVideo {
|
||||
|
||||
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]);
|
||||
@ -365,7 +306,6 @@ class DownloadVideo {
|
||||
child.stdout.on("data", stderrHandler);
|
||||
|
||||
child.on("close", async (code) => {
|
||||
//console.debug(">>>>>>>>>>>>>FINISHED");
|
||||
signal.emit("finish");
|
||||
fine = true;
|
||||
if (code !== 0) {
|
||||
@ -378,10 +318,8 @@ class DownloadVideo {
|
||||
return resolve(isStream);
|
||||
const ffmpeg = new FFMPEG(isStream);
|
||||
const fileConverted = isStream +
|
||||
//".converted." +
|
||||
".mp3";
|
||||
const videoInfo = await this.getVideoInfo();
|
||||
//console.debug("videoInfo::thumbnails", videoInfo.thumbnails);
|
||||
return ffmpeg.convertMusic(FFMPEG.fromFile(
|
||||
isStream, fileConverted, videoInfo.thumbnail
|
||||
)).then(readyFile => {
|
||||
@ -416,11 +354,8 @@ class DownloadVideo {
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@ -65,74 +65,6 @@ class FFMPEG {
|
||||
// TODO: Supports only fromFile
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-profile:v",
|
||||
"high",
|
||||
"-level",
|
||||
"4.1",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-crf",
|
||||
"26",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-movflags",
|
||||
"+frag_keyframe+empty_moov+default_base_moof",
|
||||
"-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;
|
||||
|
||||
inputStream.pipe(ff.stdin);
|
||||
|
||||
const output = new PassThrough();
|
||||
ff.stdout.pipe(output);
|
||||
|
||||
ff.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`FFMPEG exited with code ${code}\n${errData}`));
|
||||
}
|
||||
});
|
||||
|
||||
ff.on("error", reject);
|
||||
|
||||
resolve(output);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FFMPEG;
|
||||
|
||||
@ -32,7 +32,6 @@ class MTProtoUser {
|
||||
writeStream.on("error", reject);
|
||||
writeStream.on("finish", resolve);
|
||||
});
|
||||
//fs.writeFileSync();
|
||||
}
|
||||
|
||||
const msg = await this.client.sendFile(config.mtproto.chatId, {
|
||||
@ -41,6 +40,14 @@ class MTProtoUser {
|
||||
thumb: options.thumb,
|
||||
//filename: options.filename,
|
||||
supportsStreaming: true,
|
||||
attributes: [
|
||||
new Api.DocumentAttributeVideo({
|
||||
duration: options.duration || 0,
|
||||
w: options.width || 1280,
|
||||
h: options.height || 720,
|
||||
supportsStreaming: true
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
return msg.id;
|
||||
|
||||
@ -24,5 +24,5 @@
|
||||
"bot"
|
||||
],
|
||||
"author": "FullGreaM",
|
||||
"license": "AGPL-3.0-or-later"
|
||||
"license": "AGPL-3.0-or-later OR Commercial"
|
||||
}
|
||||
|
||||
55
src/utils.js
Normal file
55
src/utils.js
Normal file
@ -0,0 +1,55 @@
|
||||
const http = require("http");
|
||||
const https = require("https");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
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();
|
||||
resolve(savePath);
|
||||
});
|
||||
}).on("error", (err) => {
|
||||
fs.unlinkSync(savePath);
|
||||
reject(err);
|
||||
});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyThumbUrl (fileUrl) {
|
||||
try {
|
||||
if (/\.webp$/m.test(fileUrl)) return false;
|
||||
await downloadFile(fileUrl);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { downloadFile, verifyThumbUrl };
|
||||
Loading…
Reference in New Issue
Block a user