Compare commits

...

10 Commits

Author SHA1 Message Date
a3c27eeda6 Supports end 2025-11-09 16:00:42 +03:00
d995b7e1ab Add information about new settings 2025-11-09 15:57:30 +03:00
2b4da355f8 Dual License add 2025-11-09 15:54:42 +03:00
2c01fe389d fix youtube link regexp 2025-11-09 15:33:52 +03:00
3e400cea39 fix preview 2025-11-09 15:25:27 +03:00
852c0e2803 remove deprecated method 2025-11-09 14:53:43 +03:00
a47c8bb83a Add thumb fix + configure empty preview image 2025-11-09 14:51:51 +03:00
13d3607a54 remove // 2025-11-09 14:28:04 +03:00
14f4753b27 remove // 2025-11-09 14:25:49 +03:00
84fbc475fd Fix best video 2025-11-09 14:22:32 +03:00
9 changed files with 128 additions and 158 deletions

18
LICENSE
View File

@ -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 Make sure to provide a way for users interacting with your program
remotely (through a network) to obtain the source code, for example remotely (through a network) to obtain the source code, for example
by providing a link to a repository or downloadable archive. 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

View File

@ -1,6 +1,22 @@
# telegram_ytdlp_node.js # 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 ## Description
This bot can download music and videos from youtube. 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 ## Installation
### Step 1 ### 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_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)> 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!!!)> 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 .` 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` 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`

View File

@ -31,7 +31,7 @@ function removeTemp () {
} }
removeTemp(); removeTemp();
const ytLinkRegexp = /^https:\/\/www\.youtube\.com/m; const ytLinkRegexp = /^https:\/\/(www\.|m\.|)youtu(be\.com|\.be)/m;
async function getFileSize(pathname) { async function getFileSize(pathname) {
const stream = fs.createReadStream(pathname); const stream = fs.createReadStream(pathname);
@ -78,7 +78,9 @@ class TgBotSender {
async sendVideo (chatId, data, settings = {}, apiOptions = {}) { async sendVideo (chatId, data, settings = {}, apiOptions = {}) {
if (this.parent.mtproto !== null) { if (this.parent.mtproto !== null) {
const mtprotoMID = await this.parent.mtproto.saveMedia( const mtprotoMID = await this.parent.mtproto.saveMedia(
data, settings.caption ?? "" data, settings.caption ?? "", {
thumb: settings.thumb,
}
); );
return await this.bot.copyMessage( return await this.bot.copyMessage(
@ -157,6 +159,7 @@ class TgBotSender {
.then(async (downloaded) => { .then(async (downloaded) => {
//console.debug("downloaded:", downloaded); //console.debug("downloaded:", downloaded);
try { try {
const videoInfo = await video.getVideoInfo();
if (downloaded.format.type === "video") { if (downloaded.format.type === "video") {
const filesize = (typeof isStream !== "string" || this.parent.mtproto !== null) ? const filesize = (typeof isStream !== "string" || this.parent.mtproto !== null) ?
0 : await getFileSize(downloaded.data); 0 : await getFileSize(downloaded.data);
@ -170,7 +173,10 @@ class TgBotSender {
await this.sendVideo( await this.sendVideo(
msg.chat.id, msg.chat.id,
downloaded.data, downloaded.data,
{ caption: info.title }, {
caption: info.title,
thumb: await downloadFile(videoInfo.thumbnail),
},
{ {
filename, filename,
contentType: isStream === true ? "application/octet-stream" : downloaded.format.mime, contentType: isStream === true ? "application/octet-stream" : downloaded.format.mime,
@ -193,7 +199,6 @@ class TgBotSender {
} MB)`); } MB)`);
} }
} else if (downloaded.format.type === "audio") { } else if (downloaded.format.type === "audio") {
const videoInfo = await video.getVideoInfo();
await this.sendAudio( await this.sendAudio(
msg.chat.id, msg.chat.id,
downloaded.data, downloaded.data,
@ -420,7 +425,7 @@ class TgBot {
{ {
type: "photo", type: "photo",
//media: info.thumbnail, //media: info.thumbnail,
media: "https://placekitten.com/500/350", media: config.emptyImage,
caption: info.title, caption: info.title,
}, },
{ {

View File

@ -8,6 +8,7 @@ const config = {
session: process.env.API_SESSION, session: process.env.API_SESSION,
chatId: +process.env.CHAT_ID, chatId: +process.env.CHAT_ID,
}, },
emptyImage: process.env.EMPTY_IMAGE_URL ?? "https://placekitten.com/500/350",
}; };
config.whitelist = [].concat( config.whitelist = [].concat(
process.env.WHITELIST?.split(/\s*,\s*/g).map((x) => +x) ?? [], process.env.WHITELIST?.split(/\s*,\s*/g).map((x) => +x) ?? [],

View File

@ -3,59 +3,16 @@ const { Readable } = require("stream");
const EventEmitter = require("events"); const EventEmitter = require("events");
const mime = require("mime"); const mime = require("mime");
const path = require("path"); const path = require("path");
const http = require("http");
const https = require("https");
const { URL } = require("url"); const { URL } = require("url");
const fs = require("fs"); const fs = require("fs");
const FFMPEG = require("./ffmpeg"); const FFMPEG = require("./ffmpeg");
const config = require("./config");
const { downloadFile, verifyThumbUrl } = require("./utils");
const formatsCache = new Map(); const formatsCache = new Map();
const infoCache = new Map(); const infoCache = new Map();
const d = r => console.debug("DEBUG:", r) || r; 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 { class YtdlUpdater extends EventEmitter {
constructor () { constructor () {
super(); super();
@ -108,9 +65,8 @@ class DownloadVideo {
static get qualities () { static get qualities () {
const q = { const q = {
webm_best: { exc: "webm", flag: "best", mime: "video/webm", type: "video" }, 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: [ mp4_best: { exc: "mp4", flag: [
"bestvideo[height<=1080]+bestaudio/best", "best[ext=mp4]",
"--merge-output-format", "mp4" "--merge-output-format", "mp4"
], mime: "video/mp4", type: "video", strictName: "📹 Best video" }, ], mime: "video/mp4", type: "video", strictName: "📹 Best video" },
audio_default: { exc: "mp4", flag: "worst", mime: "audio/mpeg", type: "audio", strictName: "🎧 Audio" }, 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()); 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")); if (code !== 0) return reject(new Error("yt-dlp failed to get info"));
try { try {
const info = JSON.parse(data); 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({ resolve({
id: info.id, id: info.id,
title: info.title, title: info.title,
description: info.description, description: info.description,
uploader: info.uploader, uploader: info.uploader,
duration: info.duration, // секунды duration: info.duration,
thumbnail: thumbnail:
info.thumbnails?.[info.thumbnails.length - 1]?.url || (thumbnails?.[thumbnails.length - 1]?.url ||
info.thumbnail, // лучшее превью info.thumbnail) || config.emptyImage,
url: info.webpage_url, url: info.webpage_url,
thumbnails: info.thumbnails thumbnails
}); });
} catch (e) { } catch (e) {
reject(e); reject(e);
@ -240,33 +203,19 @@ class DownloadVideo {
const formats = await this.getFormats(); const formats = await this.getFormats();
const q = {}; const q = {};
// Перебираем форматы и группируем по расширению
formats.forEach(f => { formats.forEach(f => {
if (!f.ext || !f.format_id) return; if (!f.ext || !f.format_id) return;
//if (!q[f.ext]) q[f.ext] = [];
const resolution = f.height ? `${f.height}p` : 'audio-only'; const resolution = f.height ? `${f.height}p` : 'audio-only';
let flag = `bestaudio[ext=${f.ext}]`; let flag = `bestaudio[ext=${f.ext}]`;
if (f.height) { if (f.height) {
//flag = `best[height<=${f.height}][ext=${f.ext}]`;
flag = [ flag = [
`best[height<=${ `best[height<=${
f.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 "--merge-output-format", f.ext
]; ];
} }
//const flag = f.format_id;
const mimeType = mime.getType(f.ext); const mimeType = mime.getType(f.ext);
const type = (resolution === "audio-only" || /^audio\//m.test(mimeType)) ? "audio" : ( const type = (resolution === "audio-only" || /^audio\//m.test(mimeType)) ? "audio" : (
@ -274,8 +223,8 @@ class DownloadVideo {
); );
q[f.ext + "_" + resolution] = { q[f.ext + "_" + resolution] = {
format_id: f.format_id, format_id: f.format_id,
flag, // <- ключевой момент для yt-dlp flag,
exc: f.ext, // <- расширение exc: f.ext,
resolution, resolution,
filesize: f.filesize || null, filesize: f.filesize || null,
mime: mimeType, type: !mimeType ? null : type, mime: mimeType, type: !mimeType ? null : type,
@ -325,22 +274,16 @@ class DownloadVideo {
const params = [ const params = [
"--cookies", "cookies.txt", "--cookies", "cookies.txt",
"--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "--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", "--extractor-args", "youtube:player_client=default,mweb",
"-f", ...flags, "-f", ...flags,
"-o", !isPath ? "-" : isStream, this.url, "-o", !isPath ? "-" : isStream, this.url,
]; ];
d("yt-dlp " + params.join(" "));
//console.debug({ HOME: process.env.HOME });
const child = spawn("yt-dlp", params, { const child = spawn("yt-dlp", params, {
env: { env: {
...process.env, ...process.env,
//HOME: process.env.HOME,
LANG: "en_US.UTF-8", LANG: "en_US.UTF-8",
}, },
stdio: ["pipe","pipe","pipe"], stdio: ["pipe","pipe","pipe"],
//stdio: ["pipe","inherit","pipe"],
//stdio: ["inherit", "inherit", "inherit"],
}); });
let downloaded = 0; let downloaded = 0;
let percent = 0; let percent = 0;
@ -350,8 +293,6 @@ class DownloadVideo {
const stderrHandler = (data) => { const stderrHandler = (data) => {
const str = data.toString(); const str = data.toString();
//console.debug("DEBUG >>>>>>", str);
//console.debug(str);
const match = str.match(/(\d+\.\d+)%/); const match = str.match(/(\d+\.\d+)%/);
if (match && cb) { if (match && cb) {
percent = parseFloat(match[1]); percent = parseFloat(match[1]);
@ -365,7 +306,6 @@ class DownloadVideo {
child.stdout.on("data", stderrHandler); child.stdout.on("data", stderrHandler);
child.on("close", async (code) => { child.on("close", async (code) => {
//console.debug(">>>>>>>>>>>>>FINISHED");
signal.emit("finish"); signal.emit("finish");
fine = true; fine = true;
if (code !== 0) { if (code !== 0) {
@ -378,10 +318,8 @@ class DownloadVideo {
return resolve(isStream); return resolve(isStream);
const ffmpeg = new FFMPEG(isStream); const ffmpeg = new FFMPEG(isStream);
const fileConverted = isStream + const fileConverted = isStream +
//".converted." +
".mp3"; ".mp3";
const videoInfo = await this.getVideoInfo(); const videoInfo = await this.getVideoInfo();
//console.debug("videoInfo::thumbnails", videoInfo.thumbnails);
return ffmpeg.convertMusic(FFMPEG.fromFile( return ffmpeg.convertMusic(FFMPEG.fromFile(
isStream, fileConverted, videoInfo.thumbnail isStream, fileConverted, videoInfo.thumbnail
)).then(readyFile => { )).then(readyFile => {
@ -416,11 +354,8 @@ class DownloadVideo {
}); });
child.on("error", reject); child.on("error", reject);
//return resolve(stream);
//console.debug(this.quality);
const resultedStream = this.quality.type === "video" ? const resultedStream = this.quality.type === "video" ?
(await ffmpeg.convert()) : stream; (await ffmpeg.convert()) : stream;
//const resultedStream = stream;
resultedStream.on("data", chunk => { resultedStream.on("data", chunk => {
sizeTg += chunk.length; sizeTg += chunk.length;

View File

@ -65,74 +65,6 @@ class FFMPEG {
// TODO: Supports only fromFile // 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; module.exports = FFMPEG;

View File

@ -32,7 +32,6 @@ class MTProtoUser {
writeStream.on("error", reject); writeStream.on("error", reject);
writeStream.on("finish", resolve); writeStream.on("finish", resolve);
}); });
//fs.writeFileSync();
} }
const msg = await this.client.sendFile(config.mtproto.chatId, { const msg = await this.client.sendFile(config.mtproto.chatId, {
@ -41,6 +40,14 @@ class MTProtoUser {
thumb: options.thumb, thumb: options.thumb,
//filename: options.filename, //filename: options.filename,
supportsStreaming: true, supportsStreaming: true,
attributes: [
new Api.DocumentAttributeVideo({
duration: options.duration || 0,
w: options.width || 1280,
h: options.height || 720,
supportsStreaming: true
}),
],
}); });
return msg.id; return msg.id;

View File

@ -24,5 +24,5 @@
"bot" "bot"
], ],
"author": "FullGreaM", "author": "FullGreaM",
"license": "AGPL-3.0-or-later" "license": "AGPL-3.0-or-later OR Commercial"
} }

55
src/utils.js Normal file
View 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 };