Compare commits
5 Commits
master
...
feature/se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebff5a7ce1 | ||
|
|
f48230cbeb | ||
|
|
816eca4da5 | ||
|
|
58dc5f5ae3 | ||
|
|
6e9d2e9b9e |
8
.github/workflows/deploy.yml
vendored
8
.github/workflows/deploy.yml
vendored
@ -98,8 +98,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
ssh -i ~/.ssh/id_rsa -p ${{ secrets.VPS_PORT }} -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
|
ssh -i ~/.ssh/id_rsa -p ${{ secrets.VPS_PORT }} -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
|
||||||
cd /home/${{ secrets.VPS_USER }}/next-downloader-bot && \
|
cd /home/${{ secrets.VPS_USER }}/next-downloader-bot && \
|
||||||
sudo docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }} && \
|
docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }} && \
|
||||||
sudo docker compose pull && \
|
docker compose pull && \
|
||||||
sudo docker compose down && \
|
docker compose down && \
|
||||||
sudo docker compose up -d
|
docker compose up -d
|
||||||
"
|
"
|
||||||
|
|||||||
@ -64,8 +64,7 @@ RUN apk add --no-cache \
|
|||||||
pixman \
|
pixman \
|
||||||
pangomm \
|
pangomm \
|
||||||
libjpeg-turbo \
|
libjpeg-turbo \
|
||||||
freetype \
|
freetype
|
||||||
yt-dlp
|
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 botuser
|
RUN adduser --system --uid 1001 botuser
|
||||||
|
|||||||
@ -10,9 +10,9 @@ description =
|
|||||||
⚡ Simply send the video link, and the bot will download it for you.
|
⚡ Simply send the video link, and the bot will download it for you.
|
||||||
|
|
||||||
short-description =
|
short-description =
|
||||||
Bot downloads videos and pictures
|
Download TikTok, Instagram and YouTube videos and images.
|
||||||
Support: @v_dev_support
|
|
||||||
Donate: { $donateLink }
|
For any questions: @v_dev_support
|
||||||
|
|
||||||
|
|
||||||
start =
|
start =
|
||||||
|
|||||||
@ -10,9 +10,9 @@ description =
|
|||||||
⚡ Просто отправьте ссылку на видео и изображения, и бот сразу скачает его для вас!
|
⚡ Просто отправьте ссылку на видео и изображения, и бот сразу скачает его для вас!
|
||||||
|
|
||||||
short-description =
|
short-description =
|
||||||
Бот скачивает видео и изображения
|
Скачивай видео и изображения из TikTok, Instagram и YouTube.
|
||||||
Поддержка: @v_dev_support
|
|
||||||
Поблагодарить: { $donateLink }
|
По всем вопросам: @v_dev_support
|
||||||
|
|
||||||
|
|
||||||
start =
|
start =
|
||||||
|
|||||||
@ -16,12 +16,8 @@ const composer = new Composer<Context>();
|
|||||||
const feature = composer.chatType('private');
|
const feature = composer.chatType('private');
|
||||||
const redis = getRedisInstance();
|
const redis = getRedisInstance();
|
||||||
|
|
||||||
type DownloadResult = {
|
// Проверить кэш и, при наличии, ответить видеo/подписью.
|
||||||
caption?: string;
|
// Возвращает { contentMessageId?, captionSent? }
|
||||||
imagesUrls?: string[];
|
|
||||||
videoUrl?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function checkCacheAndReply(context: Context, url: string) {
|
async function checkCacheAndReply(context: Context, url: string) {
|
||||||
let contentMessageId: number | undefined;
|
let contentMessageId: number | undefined;
|
||||||
|
|
||||||
@ -36,13 +32,11 @@ async function checkCacheAndReply(context: Context, url: string) {
|
|||||||
if (cachedCaption) {
|
if (cachedCaption) {
|
||||||
const { entities, text } = formatCaption(cachedCaption);
|
const { entities, text } = formatCaption(cachedCaption);
|
||||||
|
|
||||||
if (text.trim().length) {
|
if (text.trim().length)
|
||||||
await context.reply(text, {
|
await context.reply(text, {
|
||||||
entities,
|
entities,
|
||||||
reply_parameters: { message_id: contentMessageId },
|
reply_parameters: contentMessageId ? { message_id: contentMessageId } : undefined,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return { captionSent: true, contentMessageId };
|
return { captionSent: true, contentMessageId };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -50,49 +44,13 @@ async function checkCacheAndReply(context: Context, url: string) {
|
|||||||
return { contentMessageId };
|
return { contentMessageId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Форматирование подписи как expandable blockquote
|
||||||
function formatCaption(caption: string) {
|
function formatCaption(caption: string) {
|
||||||
const cleanCaption = removeHashtags(caption);
|
const cleanCaption = removeHashtags(caption);
|
||||||
return fmt`${expandableBlockquote} ${code} ${cleanCaption} ${code} ${expandableBlockquote}`;
|
return fmt`${expandableBlockquote} ${code} ${cleanCaption} ${code} ${expandableBlockquote}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getDownloadData(
|
// Отправка подписи и запись в кэш
|
||||||
url: string,
|
|
||||||
opts: {
|
|
||||||
isInstagram: boolean;
|
|
||||||
isTikTok: boolean;
|
|
||||||
isYoutube: boolean;
|
|
||||||
},
|
|
||||||
): Promise<DownloadResult> {
|
|
||||||
const { isInstagram, isTikTok, isYoutube } = opts;
|
|
||||||
|
|
||||||
if (isTikTok) {
|
|
||||||
const result = await getTiktokDownloadUrl(url);
|
|
||||||
return {
|
|
||||||
caption: result.title,
|
|
||||||
imagesUrls: result.images,
|
|
||||||
videoUrl: result.play,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isInstagram) {
|
|
||||||
const result = await getInstagramDownloadUrl(url.replace('/reel/', '/p/'));
|
|
||||||
return {
|
|
||||||
caption: result.caption,
|
|
||||||
imagesUrls: result.images,
|
|
||||||
videoUrl: result.play,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isYoutube) {
|
|
||||||
const result = await getYoutubeDownloadUrl(url);
|
|
||||||
return {
|
|
||||||
videoUrl: result.play,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendCaptionAndCache(
|
async function sendCaptionAndCache(
|
||||||
context: Context,
|
context: Context,
|
||||||
caption: string | undefined,
|
caption: string | undefined,
|
||||||
@ -104,20 +62,20 @@ async function sendCaptionAndCache(
|
|||||||
const { entities, text } = formatCaption(caption);
|
const { entities, text } = formatCaption(caption);
|
||||||
await redis.set(`caption:${url}`, caption, 'EX', TTL_URLS);
|
await redis.set(`caption:${url}`, caption, 'EX', TTL_URLS);
|
||||||
|
|
||||||
if (text.trim().length) {
|
if (text.trim().length)
|
||||||
await context.reply(text, {
|
await context.reply(text, {
|
||||||
entities,
|
entities,
|
||||||
reply_parameters: contentMessageId ? { message_id: contentMessageId } : undefined,
|
reply_parameters: contentMessageId ? { message_id: contentMessageId } : undefined,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Отправка изображений (порциями). Возвращает contentMessageId (если установлен)
|
||||||
async function sendImages(
|
async function sendImages(
|
||||||
context: Context,
|
context: Context,
|
||||||
imagesUrls: string[],
|
imagesUrls: string[],
|
||||||
existingContentMessageId?: number,
|
existingContentMessageId?: number,
|
||||||
) {
|
) {
|
||||||
if (!imagesUrls.length) return existingContentMessageId;
|
if (!imagesUrls?.length) return existingContentMessageId;
|
||||||
|
|
||||||
const chunks = cluster(imagesUrls, 10);
|
const chunks = cluster(imagesUrls, 10);
|
||||||
let contentMessageId = existingContentMessageId;
|
let contentMessageId = existingContentMessageId;
|
||||||
@ -135,6 +93,7 @@ async function sendImages(
|
|||||||
return contentMessageId;
|
return contentMessageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Отправка видео и запись в кэш (только если видео отправлено впервые)
|
||||||
async function sendVideoAndCache(
|
async function sendVideoAndCache(
|
||||||
context: Context,
|
context: Context,
|
||||||
videoUrl: string | undefined,
|
videoUrl: string | undefined,
|
||||||
@ -147,8 +106,9 @@ async function sendVideoAndCache(
|
|||||||
const { video, ...videoMessage } = await context.replyWithVideo(
|
const { video, ...videoMessage } = await context.replyWithVideo(
|
||||||
new InputFile({ url: videoUrl }),
|
new InputFile({ url: videoUrl }),
|
||||||
);
|
);
|
||||||
|
|
||||||
contentMessageId = videoMessage.message_id;
|
contentMessageId = videoMessage.message_id;
|
||||||
|
|
||||||
|
// сохраняем file_id полученного видео
|
||||||
await redis.set(url, video.file_id, 'EX', TTL_URLS);
|
await redis.set(url, video.file_id, 'EX', TTL_URLS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,33 +118,42 @@ async function sendVideoAndCache(
|
|||||||
feature.on('message:text', logHandle('download-message'), async (context) => {
|
feature.on('message:text', logHandle('download-message'), async (context) => {
|
||||||
const url = context.message.text.trim();
|
const url = context.message.text.trim();
|
||||||
|
|
||||||
|
// Проверка поддерживаемых сервисов
|
||||||
const isTikTok = validateTikTokUrl(url);
|
const isTikTok = validateTikTokUrl(url);
|
||||||
const isInstagram = validateInstagramUrl(url);
|
const isInstagram = validateInstagramUrl(url);
|
||||||
const isYoutube = validateYoutubeUrl(url);
|
const isYoutube = validateYoutubeUrl(url);
|
||||||
|
|
||||||
if (!isTikTok && !isInstagram && !isYoutube) {
|
const isSupportedService = isTikTok || isInstagram || isYoutube;
|
||||||
|
|
||||||
|
if (!isSupportedService) {
|
||||||
return context.reply(context.t('err-invalid-url'));
|
return context.reply(context.t('err-invalid-url'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверка кеша и быстрый ответ, если есть подпись в кеше
|
||||||
const cacheResult = await checkCacheAndReply(context, url);
|
const cacheResult = await checkCacheAndReply(context, url);
|
||||||
if (cacheResult.captionSent) return;
|
if (cacheResult.captionSent) return;
|
||||||
|
|
||||||
let contentMessageId = cacheResult.contentMessageId;
|
let contentMessageId = cacheResult.contentMessageId;
|
||||||
|
|
||||||
|
// Загрузка данных с сервисов
|
||||||
let imagesUrls: string[] | undefined;
|
let imagesUrls: string[] | undefined;
|
||||||
let videoUrl: string | undefined;
|
let videoUrl: string | undefined;
|
||||||
let caption: string | undefined;
|
let caption: string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getDownloadData(url, {
|
if (isTikTok) {
|
||||||
isInstagram,
|
const result = await getTiktokDownloadUrl(url);
|
||||||
isTikTok,
|
imagesUrls = result.images;
|
||||||
isYoutube,
|
videoUrl = result.play;
|
||||||
});
|
caption = result.title;
|
||||||
|
} else if (isInstagram) {
|
||||||
imagesUrls = result.imagesUrls;
|
const result = await getInstagramDownloadUrl(url);
|
||||||
videoUrl = result.videoUrl;
|
imagesUrls = result.images;
|
||||||
caption = result.caption;
|
videoUrl = result.play;
|
||||||
|
caption = result.caption;
|
||||||
|
} else if (isYoutube) {
|
||||||
|
const result = await getYoutubeDownloadUrl(url);
|
||||||
|
videoUrl = result.play;
|
||||||
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = (error as Error)?.message ?? String(error);
|
const message = (error as Error)?.message ?? String(error);
|
||||||
if (typeof message === 'string' && message.startsWith('err-')) {
|
if (typeof message === 'string' && message.startsWith('err-')) {
|
||||||
@ -198,8 +167,13 @@ feature.on('message:text', logHandle('download-message'), async (context) => {
|
|||||||
return context.reply(context.t('err-invalid-download-urls'));
|
return context.reply(context.t('err-invalid-download-urls'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Отправка изображений
|
||||||
contentMessageId = await sendImages(context, imagesUrls ?? [], contentMessageId);
|
contentMessageId = await sendImages(context, imagesUrls ?? [], contentMessageId);
|
||||||
|
|
||||||
|
// Отправка видео (если ещё не отправлено) и запись в кэш
|
||||||
contentMessageId = await sendVideoAndCache(context, videoUrl, url, contentMessageId);
|
contentMessageId = await sendVideoAndCache(context, videoUrl, url, contentMessageId);
|
||||||
|
|
||||||
|
// Отправка описания и запись в кэш
|
||||||
await sendCaptionAndCache(context, caption, url, contentMessageId);
|
await sendCaptionAndCache(context, caption, url, contentMessageId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,10 @@
|
|||||||
import { type Context } from '../context';
|
import { type Context } from '../context';
|
||||||
import { i18n } from '../i18n';
|
import { i18n } from '../i18n';
|
||||||
import { env } from '@/config/env';
|
|
||||||
import { type Api, type Bot, type RawApi } from 'grammy';
|
import { type Api, type Bot, type RawApi } from 'grammy';
|
||||||
|
|
||||||
export async function setInfo({ api }: Bot<Context, Api<RawApi>>) {
|
export async function setInfo({ api }: Bot<Context, Api<RawApi>>) {
|
||||||
for (const locale of i18n.locales) {
|
for (const locale of i18n.locales) {
|
||||||
await api.setMyDescription(i18n.t(locale, 'description'));
|
await api.setMyDescription(i18n.t(locale, 'description'));
|
||||||
await api.setMyShortDescription(
|
await api.setMyShortDescription(i18n.t(locale, 'short-description'));
|
||||||
i18n.t(locale, 'short-description', { donateLink: env.DONATE_LINK }),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
export const envSchema = z.object({
|
export const envSchema = z.object({
|
||||||
BOT_TOKEN: z.string(),
|
BOT_TOKEN: z.string(),
|
||||||
DONATE_LINK: z.string(),
|
|
||||||
RATE_LIMIT: z
|
RATE_LIMIT: z
|
||||||
.string()
|
.string()
|
||||||
.transform((value) => Number.parseInt(value, 10))
|
.transform((value) => Number.parseInt(value, 10))
|
||||||
@ -19,7 +18,6 @@ export const envSchema = z.object({
|
|||||||
.transform((value) => Number.parseInt(value, 10))
|
.transform((value) => Number.parseInt(value, 10))
|
||||||
.default('6379'),
|
.default('6379'),
|
||||||
TELEGRAM_API_ROOT: z.string(),
|
TELEGRAM_API_ROOT: z.string(),
|
||||||
YTDLP_PATH: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const env = envSchema.parse(process.env);
|
export const env = envSchema.parse(process.env);
|
||||||
|
|||||||
@ -1,37 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import { wrapper } from 'axios-cookiejar-support';
|
|
||||||
import * as tough from 'tough-cookie';
|
|
||||||
|
|
||||||
const jar = new tough.CookieJar();
|
|
||||||
|
|
||||||
const headers = {
|
|
||||||
accept: '*/*',
|
|
||||||
// 'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7',
|
|
||||||
'content-type': 'application/json',
|
|
||||||
dnt: '1',
|
|
||||||
priority: 'u=1, i',
|
|
||||||
'sec-ch-ua': '"Google Chrome";v="143", "Chromium";v="143", "Not A(Brand";v="24"',
|
|
||||||
'sec-ch-ua-mobile': '?0',
|
|
||||||
'sec-ch-ua-platform': '"Windows"',
|
|
||||||
'sec-fetch-dest': 'empty',
|
|
||||||
'sec-fetch-mode': 'cors',
|
|
||||||
'sec-fetch-site': 'same-origin',
|
|
||||||
'sec-gpc': '1',
|
|
||||||
'user-agent':
|
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getClient = async () => {
|
|
||||||
const client = wrapper(
|
|
||||||
axios.create({
|
|
||||||
headers,
|
|
||||||
jar,
|
|
||||||
withCredentials: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// get session cookie
|
|
||||||
await client.get('https://downr.org/.netlify/functions/analytics');
|
|
||||||
|
|
||||||
return client;
|
|
||||||
};
|
|
||||||
@ -1,76 +1,48 @@
|
|||||||
import { getClient } from './client';
|
import axios from 'axios';
|
||||||
|
|
||||||
export type InfoRoot = {
|
export type AuthorInfo = {
|
||||||
author: string;
|
avatar: string;
|
||||||
error: boolean;
|
id: number;
|
||||||
like_count: number;
|
nickname: string;
|
||||||
medias: Media[];
|
username: string;
|
||||||
music_attribution_info: unknown;
|
|
||||||
owner: Owner;
|
|
||||||
shortcode: string;
|
|
||||||
source: string;
|
|
||||||
thumbnail: string;
|
|
||||||
time_end: number;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
url: string;
|
|
||||||
view_count: unknown;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Media = {
|
export type CarouselItem = {
|
||||||
bandwidth?: number;
|
|
||||||
codec?: string;
|
|
||||||
extension: string;
|
|
||||||
frameRate: unknown;
|
|
||||||
id: string;
|
|
||||||
is_audio: boolean;
|
|
||||||
mimeType?: string;
|
|
||||||
quality: string;
|
|
||||||
resolution?: string;
|
|
||||||
thumbnail?: string;
|
|
||||||
type: string;
|
type: string;
|
||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Owner = {
|
export type Root = {
|
||||||
__typename: string;
|
authorInfo: AuthorInfo;
|
||||||
ai_agent_owner_username: unknown;
|
caption: string;
|
||||||
friendship_status: unknown;
|
carouselItems: CarouselItem[];
|
||||||
id: string;
|
id: number;
|
||||||
is_private: boolean;
|
mediaUrls: string[];
|
||||||
is_unpublished: boolean;
|
type: string;
|
||||||
is_verified: boolean;
|
url: string;
|
||||||
pk: string;
|
|
||||||
profile_pic_url: string;
|
|
||||||
show_account_transparency_details: boolean;
|
|
||||||
transparency_label: unknown;
|
|
||||||
transparency_product: unknown;
|
|
||||||
transparency_product_enabled: boolean;
|
|
||||||
username: string;
|
username: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getInstagramDownloadUrl(url: string) {
|
export async function getInstagramDownloadUrl(url: string) {
|
||||||
const client = await getClient();
|
const { data } = await axios.post<Root>('https://thesocialcat.com/api/instagram-download', {
|
||||||
// fetch video info
|
|
||||||
const { data } = await client.post<InfoRoot>('https://downr.org/.netlify/functions/nyt', {
|
|
||||||
url,
|
url,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data) throw new Error('err-invalid-instagram-response');
|
if (!data) throw new Error('err-invalid-instagram-response');
|
||||||
|
|
||||||
const video = data.medias.find((media) => media.type === 'video');
|
const isVideo = data.type === 'video' || !data.carouselItems.length;
|
||||||
|
|
||||||
if (video) {
|
if (isVideo) {
|
||||||
return {
|
return {
|
||||||
caption: data.title,
|
caption: data.caption,
|
||||||
images: [],
|
images: [],
|
||||||
play: video.url,
|
play: data.mediaUrls.at(0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
caption: data.title,
|
caption: data.caption,
|
||||||
images: data.medias.map((media) => media.url),
|
images: data.mediaUrls,
|
||||||
play: undefined,
|
play: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
100
apps/bot/src/utils/youtube.ts
Normal file
100
apps/bot/src/utils/youtube.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { MAX_VIDEO_DURATION_SECONDS } from '@/constants/limits';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { wrapper } from 'axios-cookiejar-support';
|
||||||
|
import * as tough from 'tough-cookie';
|
||||||
|
|
||||||
|
const jar = new tough.CookieJar();
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
accept: '*/*',
|
||||||
|
'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
dnt: '1',
|
||||||
|
priority: 'u=1, i',
|
||||||
|
'sec-ch-ua': '"Google Chrome";v="143", "Chromium";v="143", "Not A(Brand";v="24"',
|
||||||
|
'sec-ch-ua-mobile': '?0',
|
||||||
|
'sec-ch-ua-platform': '"Windows"',
|
||||||
|
'sec-fetch-dest': 'empty',
|
||||||
|
'sec-fetch-mode': 'cors',
|
||||||
|
'sec-fetch-site': 'same-origin',
|
||||||
|
'sec-gpc': '1',
|
||||||
|
'user-agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36',
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = wrapper(
|
||||||
|
axios.create({
|
||||||
|
headers,
|
||||||
|
jar,
|
||||||
|
withCredentials: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type DownloadRoot = {
|
||||||
|
duration: number;
|
||||||
|
filename: string;
|
||||||
|
status: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InfoRoot = {
|
||||||
|
duration: number;
|
||||||
|
medias: Media[];
|
||||||
|
thumbnail: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Media = {
|
||||||
|
extension: string;
|
||||||
|
fileSize: number;
|
||||||
|
quality: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const qualityOrder = ['144p', '240p', '360p', '480p', '1080p', '720p'].reverse();
|
||||||
|
|
||||||
|
export async function getYoutubeDownloadUrl(url: string) {
|
||||||
|
// get session cookie
|
||||||
|
await client.get('https://downr.org/.netlify/functions/analytics');
|
||||||
|
|
||||||
|
// fetch video info
|
||||||
|
const { data: infoData } = await client.post<InfoRoot>(
|
||||||
|
'https://downr.org/.netlify/functions/video-info',
|
||||||
|
{
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!infoData?.medias.length) throw new Error('err-invalid-youtube-response');
|
||||||
|
if (infoData.duration > MAX_VIDEO_DURATION_SECONDS)
|
||||||
|
throw new Error('err-youtube-duration-exceeded');
|
||||||
|
|
||||||
|
let quality: string | undefined;
|
||||||
|
|
||||||
|
for (const q of qualityOrder) {
|
||||||
|
const hasQuality = infoData.medias.find(
|
||||||
|
(media) => media.type === 'video' && media.quality === q && media.extension === 'mp4',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasQuality) {
|
||||||
|
quality = q;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!quality) throw new Error('err-youtube-no-quality');
|
||||||
|
|
||||||
|
// fetch download link
|
||||||
|
const { data: downloadData } = await client.post<DownloadRoot>(
|
||||||
|
'https://downr.org/.netlify/functions/youtube-download',
|
||||||
|
{
|
||||||
|
downloadMode: 'video',
|
||||||
|
url,
|
||||||
|
videoQuality: quality,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
play: downloadData.url,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,40 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
type DlsrvResponse = {
|
|
||||||
duration: number;
|
|
||||||
filename: string;
|
|
||||||
status: string;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getYoutubeDownload(url: string) {
|
|
||||||
const videoId = getYouTubeVideoId(url);
|
|
||||||
|
|
||||||
const { data } = await axios.post<DlsrvResponse>('https://embed.dlsrv.online/api/download/mp4', {
|
|
||||||
format: 'mp4',
|
|
||||||
quality: '720',
|
|
||||||
videoId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return data.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getYouTubeVideoId(link: string) {
|
|
||||||
const url = new URL(link);
|
|
||||||
|
|
||||||
// 1. shorts
|
|
||||||
if (url.pathname.startsWith('/shorts/')) {
|
|
||||||
return url.pathname.split('/')[2] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. обычное видео: watch?v=
|
|
||||||
const vParam = url.searchParams.get('v');
|
|
||||||
if (vParam) return vParam;
|
|
||||||
|
|
||||||
// 3. короткая ссылка youtu.be
|
|
||||||
if (url.hostname.includes('youtu.be')) {
|
|
||||||
return url.pathname.slice(1) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
import { getInfo, ytDlpGetUrl } from '../yt-dlp';
|
|
||||||
import { getYoutubeDownload } from './api';
|
|
||||||
import { MAX_VIDEO_DURATION_SECONDS } from '@/constants/limits';
|
|
||||||
|
|
||||||
export async function getYoutubeDownloadUrl(url: string) {
|
|
||||||
const infoData = await getInfo(url);
|
|
||||||
|
|
||||||
if (!infoData) throw new Error('err-invalid-youtube-response');
|
|
||||||
|
|
||||||
if (infoData.duration > MAX_VIDEO_DURATION_SECONDS)
|
|
||||||
throw new Error('err-youtube-duration-exceeded');
|
|
||||||
|
|
||||||
let play: string | undefined;
|
|
||||||
try {
|
|
||||||
play = await getYoutubeDownload(url);
|
|
||||||
} catch {
|
|
||||||
play = await ytDlpGetUrl(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { play, title: infoData.title };
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { runYtDlp } from './yt-dlp';
|
|
||||||
|
|
||||||
export async function ytDlpGetUrl(url: string): Promise<string> {
|
|
||||||
const output = await runYtDlp([
|
|
||||||
'-f',
|
|
||||||
// '298+ba/136+ba/22+ba/247+251/best[height<=720]+ba/bv+ba',
|
|
||||||
'best[height<=720]+ba/bv+ba',
|
|
||||||
'-g',
|
|
||||||
'--no-playlist',
|
|
||||||
'--no-warnings',
|
|
||||||
url,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// yt-dlp может вернуть несколько строк — берём первую
|
|
||||||
const directUrl = output.split('\n')[0];
|
|
||||||
|
|
||||||
if (!directUrl?.startsWith('http')) {
|
|
||||||
throw new Error('Failed to get direct video URL');
|
|
||||||
}
|
|
||||||
|
|
||||||
return directUrl;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,2 +0,0 @@
|
|||||||
export { ytDlpGetUrl } from './get-download-url';
|
|
||||||
export { getInfo } from './get-info';
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
import { env } from '@/config/env';
|
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
|
|
||||||
export function runYtDlp(args: string[]): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const ytDlpPath = getYtDlpPath();
|
|
||||||
const process = spawn(ytDlpPath, args);
|
|
||||||
|
|
||||||
let output = '';
|
|
||||||
let error = '';
|
|
||||||
|
|
||||||
process.stdout.on('data', (data) => {
|
|
||||||
output += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
process.stderr.on('data', (data) => {
|
|
||||||
error += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('close', (code) => {
|
|
||||||
if (code === 0) {
|
|
||||||
resolve(output.trim());
|
|
||||||
} else {
|
|
||||||
reject(new Error(`yt-dlp error: ${error}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getYtDlpPath(): string {
|
|
||||||
return env.YTDLP_PATH || 'yt-dlp';
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user