diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..392b19d Binary files /dev/null and b/.DS_Store differ diff --git a/locales/en.yaml b/locales/en.yaml index bdccd6e..c99a9f7 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -7,16 +7,22 @@ help: | /help — this message /language — change language /audio — convert all files to audio + /max_quality - automatically download videos in maximum quality Questions, feedback 👉 @borodutch_support. language: 😶 Please, select the language. language_selected: 😎 I speak English now. audio_on: 📣 I will convert all files to audio-only automatically. audio_off: 🎥 I will not convert all files to audio-only automatically. +max_quality_on: 🎬 I will download all videos in maximum quality. +max_quality_off: 🎬 I'll let you choose the video quality. # User-facing progress messages download_started: 😍 Downloading... uploading_started: 🤘 Uploading... download_complete: 🎉 Download complete! +check_resolutions: ⏳ Search for available resolutions... +# Messages +resolutions_question: 'Select resolution:' # Caption for completed video video_caption: | ${title} @@ -28,3 +34,4 @@ error_cache_or_download_job: 😱 I couldn't get the video from cache or create error_video_download: 😱 I couldn't download the video. error_video_upload: 😱 I couldn't upload the video. error_reboot: 😱 The server rebooted, while I was working on the file. Please, try again! +error_outdated_menu: 😱 Sorry, this menu is outdated diff --git a/locales/ru.yaml b/locales/ru.yaml index 4eb5b2c..cd167a3 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -7,16 +7,22 @@ help_ru: | /help — это сообщение /language — выбрать язык /audio — преобразовывать все файлы в аудио + /max_quality - автоматически скачивать видео в максимальном качестве Вопросы, обратная связь 👉 @borodutch_support. language: 😶 Пожалуйста, выберите язык. language_selected: 😎 Теперь я говорю по-русски. audio_on: 📣 Я буду преобразовывать все файлы в аудио автоматически. audio_off: 🎥 Я не буду преобразовывать все файлы в аудио автоматически. +max_quality_on: 🎬 Я буду скачивать все видео в максимальном качетсве. +max_quality_off: 🎬 Я дам тебе выбрать качество при скачивании. # User-facing progress messages download_started: 😍 Качаю... uploading_started: 🤘 Загружаю... download_complete: 🎉 Загрузка завершена! +check_resolutions: ⏳ Определение доступных разрешений... +# Messages +resolutions_question: 'Выберите разрешение:' # Caption for completed video video_caption: | ${title} @@ -28,3 +34,4 @@ error_cache_or_download_job: 😱 Я не смог найти видео в ке error_video_download: 😱 Я не смог скачать видео. error_video_upload: 😱 Я не смог загрузить видео. error_reboot: 😱 Сервер перезагрузился, пока я работал над файлом. Пожалуйста, попробуйте еще раз! +error_outdated_menu: 😱 Извините, это меню устарело diff --git a/package.json b/package.json index 4d88031..977a353 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "@borodutch-labs/yt-dlp-exec": "2.0.0", "@grammyjs/i18n": "^0.3.0", + "@grammyjs/menu": "^1.0.4", "@grammyjs/runner": "^1.0.2", "@typegoose/typegoose": "^9.2.0", "envalid": "^7.2.2", diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000..5c7230a Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/app.ts b/src/app.ts index dad40eb..0bd62db 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,12 +10,14 @@ import bot from '@/helpers/bot' import cleanupDownloadJobs from '@/helpers/cleanupDownloadJobs' import configureI18n from '@/middlewares/configureI18n' import handleAudio from '@/handlers/handleAudio' +import handleMaxQuality from '@/handlers/handleMaxQuality' import handleUrl from '@/handlers/handleUrl' import i18n from '@/helpers/i18n' import ignoreOldMessageUpdates from '@/middlewares/ignoreOldMessageUpdates' import report from '@/helpers/report' import sendHelp from '@/handlers/sendHelp' import startMongo from '@/helpers/startMongo' +import { resolutionMenu } from './menus/resolutionMenu' async function runApp() { console.log('Starting app...') @@ -33,6 +35,9 @@ async function runApp() { bot.command(['help', 'start'], sendHelp) bot.command('language', sendLanguage) bot.command('audio', handleAudio) + bot.command('max_quality', handleMaxQuality) + // Menus + bot.use(resolutionMenu) // Handlers bot.hears( /[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)?/i, diff --git a/src/handlers/handleMaxQuality.ts b/src/handlers/handleMaxQuality.ts new file mode 100644 index 0000000..3eee75b --- /dev/null +++ b/src/handlers/handleMaxQuality.ts @@ -0,0 +1,14 @@ +import Context from '@/models/Context' + +export default async function handleMaxQuality(ctx: Context) { + ctx.dbchat.autoMaxQuality = !ctx.dbchat.autoMaxQuality + await ctx.dbchat.save() + return ctx.reply( + ctx.i18n.t( + ctx.dbchat.autoMaxQuality ? 'max_quality_on' : 'max_quality_off' + ), + { + reply_to_message_id: ctx.message?.message_id, + } + ) +} diff --git a/src/handlers/handleUrl.ts b/src/handlers/handleUrl.ts index 1129e3d..f85f1c0 100644 --- a/src/handlers/handleUrl.ts +++ b/src/handlers/handleUrl.ts @@ -1,8 +1,11 @@ import Context from '@/models/Context' import createDownloadJobAndRequest from '@/helpers/createDownloadJobAndRequest' import report from '@/helpers/report' +import { resolutionMenu } from '@/menus/resolutionMenu' +import bot from '@/helpers/bot' +import { findOrCreateShortUrl } from '@/models/ShortUrl' -export default function handleUrl(ctx: Context) { +export default async function handleUrl(ctx: Context) { try { const match = ctx.message?.text?.match( /[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)?/i @@ -12,8 +15,22 @@ export default function handleUrl(ctx: Context) { reply_to_message_id: ctx.message?.message_id, }) } + const url = match[0] - return createDownloadJobAndRequest(ctx, url) + + if (ctx.dbchat.autoMaxQuality) { + return createDownloadJobAndRequest(ctx, url) + } else { + const waitMessage = await ctx.reply(ctx.i18n.t('check_resolutions')) + const shortUrl = await findOrCreateShortUrl(url) + bot.api.deleteMessage(waitMessage.chat.id, waitMessage.message_id) + + ctx.shortUrlId = shortUrl.shortId + return ctx.reply(ctx.i18n.t('resolutions_question'), { + reply_markup: resolutionMenu, + reply_to_message_id: ctx.message?.message_id, + }) + } } catch (error) { report(error, { ctx, location: 'handleUrl' }) return ctx.reply(ctx.i18n.t('error_cannot_start_download'), { diff --git a/src/helpers/checkAvailableResolutions.ts b/src/helpers/checkAvailableResolutions.ts new file mode 100644 index 0000000..957ee34 --- /dev/null +++ b/src/helpers/checkAvailableResolutions.ts @@ -0,0 +1,19 @@ +import report from './report' +// eslint-disable-next-line @typescript-eslint/no-var-requires +const youtubedl = require('@borodutch-labs/yt-dlp-exec') + +export default async function checkAvailableResolutions(url: string) { + try { + const json = await youtubedl(url, { dumpJson: true }) + + return Array.from( + new Set( + json.formats + .map((f: any) => f.height) + .filter((height: any) => height >= 144) + ) + ) + } catch (error) { + report(error, { location: 'checkResolutions', meta: url }) + } +} diff --git a/src/helpers/checkForCachedUrlAndSendFile.ts b/src/helpers/checkForCachedUrlAndSendFile.ts index 90970ca..31de979 100644 --- a/src/helpers/checkForCachedUrlAndSendFile.ts +++ b/src/helpers/checkForCachedUrlAndSendFile.ts @@ -6,18 +6,25 @@ import sendCompletedFile from '@/helpers/sendCompletedFile' export default async function checkForCachedUrlAndSendFile( url: string, ctx: Context, - editor: MessageEditor + editor: MessageEditor, + resolution?: number ) { - const cachedUrl = await findUrl(url, ctx.dbchat.audio) + const cachedUrl = await findUrl(url, ctx.dbchat.audio, resolution) + if (cachedUrl) { console.log(`Sending cached file for ${url}`) + + const replyTo = resolution + ? ctx.callbackQuery?.message?.reply_to_message?.message_id + : ctx.msg?.message_id + if (editor.messageId) { await editor.editMessage(ctx.i18n.t('download_complete')) } if (ctx.msg) { return sendCompletedFile( ctx.dbchat.telegramId, - ctx.msg?.message_id, + replyTo!, ctx.dbchat.language, ctx.dbchat.audio, cachedUrl.title, diff --git a/src/helpers/createDownloadJobAndRequest.ts b/src/helpers/createDownloadJobAndRequest.ts index e91175b..fbc7a17 100644 --- a/src/helpers/createDownloadJobAndRequest.ts +++ b/src/helpers/createDownloadJobAndRequest.ts @@ -9,7 +9,8 @@ import report from '@/helpers/report' export default async function createDownloadJobAndRequest( ctx: Context, - url: string + url: string, + resolution?: number ) { // Create message editor const downloadMessageEditor = new MessageEditor(undefined, ctx) @@ -20,7 +21,8 @@ export default async function createDownloadJobAndRequest( const cached = await checkForCachedUrlAndSendFile( url, ctx, - downloadMessageEditor + downloadMessageEditor, + resolution ) if (cached) { return @@ -41,7 +43,8 @@ export default async function createDownloadJobAndRequest( url, ctx.dbchat.audio, ctx.dbchat.telegramId, - message_id + message_id, + resolution ) // Create download request await findOrCreateDownloadRequest( diff --git a/src/helpers/downloadUrl.ts b/src/helpers/downloadUrl.ts index 0c8bf75..eb3f34d 100644 --- a/src/helpers/downloadUrl.ts +++ b/src/helpers/downloadUrl.ts @@ -26,6 +26,11 @@ export default async function downloadUrl( try { console.log(`Downloading url ${downloadJob.url}`) // Download + + const videoFormatString = downloadJob.resolution + ? `[height=${downloadJob.resolution}][filesize<=?2G][ext=mp4]/[height=${downloadJob.resolution}][filesize<=?2G]` + : '[filesize<=?2G]' + const config = { dumpSingleJson: true, noWarnings: true, @@ -34,7 +39,7 @@ export default async function downloadUrl( noPlaylist: true, format: downloadJob.audio ? 'bestaudio[filesize<=?2G]' - : '[filesize<=?2G]', + : videoFormatString, maxFilesize: '2048m', noCallHome: true, noProgress: true, @@ -82,7 +87,8 @@ export default async function downloadUrl( downloadJob.url, fileId, downloadJob.audio, - escapedTitle || 'No title' + escapedTitle || 'No title', + downloadJob.resolution ) downloadJob.status = DownloadJobStatus.finished await downloadJob.save() diff --git a/src/menus/resolutionMenu.ts b/src/menus/resolutionMenu.ts new file mode 100644 index 0000000..439337a --- /dev/null +++ b/src/menus/resolutionMenu.ts @@ -0,0 +1,29 @@ +import createDownloadJobAndRequest from '@/helpers/createDownloadJobAndRequest' +import Context from '@/models/Context' +import { findShortUrl } from '@/models/ShortUrl' +import { Menu } from '@grammyjs/menu' + +export const resolutionMenu = new Menu('resolution-menu').dynamic( + async (ctx, range) => { + const shortUrl = await findShortUrl( + ctx.shortUrlId || ctx.match?.toString()! + ) + + if (!shortUrl) { + ctx.reply(ctx.i18n.t('error_outdated_menu')) + return + } + + for (const resolution of shortUrl.availableResolutions) { + range + .text({ text: `${resolution}p`, payload: shortUrl.shortId }, (ctx) => { + createDownloadJobAndRequest(ctx, shortUrl.url, resolution) + return ctx.deleteMessage() + }) + .row() + } + range.text({ text: `Cancel`, payload: shortUrl.shortId }, (ctx) => + ctx.deleteMessage() + ) + } +) diff --git a/src/models/Chat.ts b/src/models/Chat.ts index b077c7f..e05974d 100644 --- a/src/models/Chat.ts +++ b/src/models/Chat.ts @@ -10,6 +10,8 @@ export class Chat extends FindOrCreate { language!: string @prop({ required: true, default: false }) audio!: boolean + @prop({ required: true, default: false }) + autoMaxQuality!: boolean } const ChatModel = getModelForClass(Chat, { diff --git a/src/models/Context.ts b/src/models/Context.ts index 77cf8b4..ebc2033 100644 --- a/src/models/Context.ts +++ b/src/models/Context.ts @@ -6,6 +6,7 @@ import { I18nContext } from '@grammyjs/i18n/dist/source' interface Context extends BaseContext { readonly i18n: I18nContext dbchat: DocumentType + shortUrlId: string } export default Context diff --git a/src/models/DownloadJob.ts b/src/models/DownloadJob.ts index 30e0033..d86eff9 100644 --- a/src/models/DownloadJob.ts +++ b/src/models/DownloadJob.ts @@ -32,4 +32,6 @@ export default class DownloadJob extends FindOrCreate { originalChatId!: number @prop({ required: true, index: true }) originalMessageId!: number + @prop({ index: true }) + resolution?: number } diff --git a/src/models/ShortUrl.ts b/src/models/ShortUrl.ts new file mode 100644 index 0000000..e47e247 --- /dev/null +++ b/src/models/ShortUrl.ts @@ -0,0 +1,37 @@ +import { FindOrCreate } from '@typegoose/typegoose/lib/defaultClasses' +import { getModelForClass, prop } from '@typegoose/typegoose' +import randomToken = require('random-token') +import checkAvailableResolutions from '@/helpers/checkAvailableResolutions' + +export class ShortUrl extends FindOrCreate { + @prop({ + required: true, + index: true, + unique: true, + default: () => randomToken(16), + }) + shortId!: string + @prop({ required: true }) + url!: string + @prop({ required: true, type: () => [Number] }) + availableResolutions!: number[] +} + +const ShortUrlModel = getModelForClass(ShortUrl, { + schemaOptions: { timestamps: true }, +}) + +export async function findOrCreateShortUrl(url: string) { + const shortUrl = await ShortUrlModel.findOne({ url }) + if (shortUrl) { + return shortUrl + } + + const availableResolutions = await checkAvailableResolutions(url) + + return new ShortUrlModel({ url, availableResolutions }).save() +} + +export async function findShortUrl(shortId: string) { + return ShortUrlModel.findOne({ shortId }) +} diff --git a/src/models/Url.ts b/src/models/Url.ts index e212f83..d380f58 100644 --- a/src/models/Url.ts +++ b/src/models/Url.ts @@ -9,23 +9,26 @@ export class Url { audio!: boolean @prop({ required: true }) title!: string + @prop({ index: true }) + resolution?: number } const UrlModel = getModelForClass(Url, { schemaOptions: { timestamps: true }, }) -export function findUrl(url: string, audio: boolean) { - return UrlModel.findOne({ url, audio }) +export function findUrl(url: string, audio: boolean, resolution?: number) { + return UrlModel.findOne({ url, audio, resolution }) } export async function findOrCreateUrl( url: string, fileId: string, audio: boolean, - title: string + title: string, + resolution?: number ) { - const dburl = await UrlModel.findOne({ url, audio }) + const dburl = await UrlModel.findOne({ url, audio, resolution }) if (dburl) { return dburl } @@ -34,5 +37,6 @@ export async function findOrCreateUrl( fileId, audio, title, + resolution, }) } diff --git a/src/models/downloadJobFunctions.ts b/src/models/downloadJobFunctions.ts index d4bcb7d..3367456 100644 --- a/src/models/downloadJobFunctions.ts +++ b/src/models/downloadJobFunctions.ts @@ -4,10 +4,11 @@ export function findOrCreateDownloadJob( url: string, audio: boolean, originalChatId: number, - originalMessageId: number + originalMessageId: number, + resolution?: number ) { return DownloadJobModel.findOrCreate( - { url, audio }, + { url, audio, resolution }, { originalChatId, originalMessageId } ) } diff --git a/src/types/random-token.d.ts b/src/types/random-token.d.ts new file mode 100644 index 0000000..87d9406 --- /dev/null +++ b/src/types/random-token.d.ts @@ -0,0 +1 @@ +declare module 'random-token' diff --git a/yarn.lock b/yarn.lock index 56b5220..f697643 100644 --- a/yarn.lock +++ b/yarn.lock @@ -46,6 +46,15 @@ __metadata: languageName: node linkType: hard +"@grammyjs/menu@npm:^1.0.4": + version: 1.0.4 + resolution: "@grammyjs/menu@npm:1.0.4" + peerDependencies: + grammy: ^1.0.0 + checksum: bb53965399780d25566f85dfbf717c18324a1fbbd82e54722e063e151dbbe2f07e0a60723d52f45bd83b79e2b2cf678e9a114ac7bd7ebc1e33721bf5be7abbfc + languageName: node + linkType: hard + "@grammyjs/runner@npm:^1.0.2": version: 1.0.2 resolution: "@grammyjs/runner@npm:1.0.2" @@ -2470,6 +2479,7 @@ __metadata: dependencies: "@borodutch-labs/yt-dlp-exec": 2.0.0 "@grammyjs/i18n": ^0.3.0 + "@grammyjs/menu": ^1.0.4 "@grammyjs/runner": ^1.0.2 "@typegoose/typegoose": ^9.2.0 "@types/js-yaml": ^4.0.5