feat(youtube): refactor download logic and integrate new API
- Added optional YTDLP_PATH environment variable to configure yt-dlp path. - Removed old youtube download utility and replaced it with a new API-based approach. - Implemented new functions to fetch YouTube video download URLs using an external service. - Introduced yt-dlp integration for fallback download method. - Created a structured response for video information retrieval. - Enhanced error handling for video duration and response validation.
This commit is contained in:
parent
88aa8eb34a
commit
e9146a0656
@ -64,7 +64,8 @@ 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
|
||||||
|
|||||||
@ -18,6 +18,7 @@ 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,69 +0,0 @@
|
|||||||
import { getClient } from './client';
|
|
||||||
import { MAX_VIDEO_DURATION_SECONDS } from '@/constants/limits';
|
|
||||||
|
|
||||||
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) {
|
|
||||||
const client = await getClient();
|
|
||||||
// fetch 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
40
apps/bot/src/utils/youtube/api.ts
Normal file
40
apps/bot/src/utils/youtube/api.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
20
apps/bot/src/utils/youtube/index.ts
Normal file
20
apps/bot/src/utils/youtube/index.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
22
apps/bot/src/utils/yt-dlp/get-download-url.ts
Normal file
22
apps/bot/src/utils/yt-dlp/get-download-url.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
1710
apps/bot/src/utils/yt-dlp/get-info.ts
Normal file
1710
apps/bot/src/utils/yt-dlp/get-info.ts
Normal file
File diff suppressed because it is too large
Load Diff
2
apps/bot/src/utils/yt-dlp/index.ts
Normal file
2
apps/bot/src/utils/yt-dlp/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { ytDlpGetUrl } from './get-download-url';
|
||||||
|
export { getInfo } from './get-info';
|
||||||
32
apps/bot/src/utils/yt-dlp/yt-dlp.ts
Normal file
32
apps/bot/src/utils/yt-dlp/yt-dlp.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
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