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