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
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

View File

@ -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`

View File

@ -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,
},
{

View File

@ -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) ?? [],

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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
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 };