Enutrof commited on
Commit
bd5243c
Β·
1 Parent(s): 7742d70

Added bot.py to implement video download.

Browse files
Files changed (2) hide show
  1. .gitignore +3 -0
  2. bot.py +343 -0
.gitignore CHANGED
@@ -172,3 +172,6 @@ cython_debug/
172
 
173
  # PyPI configuration file
174
  .pypirc
 
 
 
 
172
 
173
  # PyPI configuration file
174
  .pypirc
175
+
176
+ # Media
177
+ *.mp4
bot.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # pylint: disable=unused-argument, wrong-import-position
3
+ # This program is dedicated to the public domain under the MIT license.
4
+
5
+ import logging
6
+ import os
7
+ import asyncio
8
+ import yt_dlp
9
+ from telegram import Update, InputFile
10
+ from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
11
+ from telegram.constants import ParseMode
12
+ from dotenv import load_dotenv
13
+
14
+ load_dotenv() # take environment variables
15
+
16
+ # Enable logging
17
+ logging.basicConfig(
18
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO
19
+ )
20
+ # set higher logging level for httpx to avoid all GET and POST requests being logged
21
+ logging.getLogger("httpx").setLevel(logging.WARNING)
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # --- Configuration ---
26
+ TELEGRAM_BOT_TOKEN = os.getenv("YOUR_TELEGRAM_BOT_TOKEN") # Replace with your bot token
27
+ DOWNLOAD_PATH = "video_downloads/" # Folder to store downloaded videos temporarily
28
+ MAX_FILE_SIZE_MB = 49 # Telegram's typical bot upload limit is 50MB, stay slightly under
29
+
30
+ # --- Helper Functions ---
31
+ def ensure_download_path_exists():
32
+ """Creates the download directory if it doesn't exist."""
33
+ if not os.path.exists(DOWNLOAD_PATH):
34
+ try:
35
+ os.makedirs(DOWNLOAD_PATH)
36
+ logger.info(f"Created download directory: {DOWNLOAD_PATH}")
37
+ except OSError as e:
38
+ logger.error(f"Error creating download directory {DOWNLOAD_PATH}: {e}")
39
+ # Depending on the desired behavior, you might want to exit or raise the exception
40
+ # For this example, we'll log and continue, but downloads will likely fail.
41
+
42
+
43
+ async def send_typing_action(update: Update, context: ContextTypes.DEFAULT_TYPE):
44
+ """Sends a typing action to indicate the bot is working."""
45
+ await context.bot.send_chat_action(chat_id=update.effective_chat.id, action='upload_video')
46
+
47
+
48
+ # --- Bot Command Handlers ---
49
+ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
50
+ """Sends a welcome message when the /start command is issued."""
51
+ user = update.effective_user
52
+ welcome_message = (
53
+ f"πŸ‘‹ Hello {user.mention_html()}!\n\n"
54
+ "I'm your friendly video downloading bot. Send me a link to a video from "
55
+ "sites like YouTube, Vimeo, Twitter, etc., and I'll try my best to download "
56
+ "and send it to you.\n\n"
57
+ "Keep in mind Telegram has a file size limit of about 50MB for bots, "
58
+ "so I'll try to get a version under that size.\n\n"
59
+ "Type /help for more information."
60
+ )
61
+ await update.message.reply_html(welcome_message)
62
+
63
+ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
64
+ """Sends a help message when the /help command is issued."""
65
+ help_text = (
66
+ "ℹ️ **How to use me:**\n"
67
+ "1. Simply send me a direct URL to a video you want to download.\n"
68
+ "2. I will process the link, download the video, and send it back to you.\n\n"
69
+ "**Supported Sites:**\n"
70
+ "I use `yt-dlp`, which supports hundreds of websites. Common ones include YouTube, "
71
+ "Vimeo, Twitter, Facebook, Instagram, and many more.\n\n"
72
+ "**File Size Limit:**\n"
73
+ "Please remember that Telegram bots can only send files up to 50MB. "
74
+ "I'll try to select a video quality that fits this limit. If a video is too large, "
75
+ "I might not be able to send it.\n\n"
76
+ "If you encounter any issues, try a different URL or check if the video is publicly accessible."
77
+ )
78
+ await update.message.reply_text(help_text, parse_mode=ParseMode.MARKDOWN)
79
+
80
+
81
+ # --- Main Video Processing Logic ---
82
+ async def handle_video_url(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
83
+ """Handles messages containing video URLs."""
84
+ url = update.message.text
85
+ chat_id = update.effective_chat.id
86
+
87
+ if not url.startswith(('http://', 'https://')):
88
+ await update.message.reply_text("⚠️ That doesn't look like a valid URL. Please send a direct link to a video.")
89
+ return
90
+
91
+ processing_message = await update.message.reply_text("πŸ” Got your link! Processing and trying to download the video...")
92
+ await send_typing_action(update, context)
93
+
94
+ downloaded_file_path = None # To store the path of the downloaded file
95
+
96
+ try:
97
+ # yt-dlp options
98
+ # We aim for a good quality mp4 file under 50MB.
99
+ # 'bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/bv*+ba/b' is a common format string.
100
+ # We add filesize limits.
101
+ # Note: yt-dlp's filesize_approx can sometimes be inaccurate.
102
+ ydl_opts = {
103
+ 'format': f'bestvideo[ext=mp4][filesize<{MAX_FILE_SIZE_MB}M]+bestaudio[ext=m4a]/best[ext=mp4][filesize<{MAX_FILE_SIZE_MB}M]/best[filesize<{MAX_FILE_SIZE_MB}M]',
104
+ 'outtmpl': os.path.join(DOWNLOAD_PATH, '%(title).200B.%(ext)s'), # Limit title length to avoid overly long filenames
105
+ # 'noplaylist': True, # Download only single video if playlist URL is given
106
+ # 'quiet': True, # Suppress yt-dlp console output
107
+ 'merge_output_format': 'mp4', # Ensure output is mp4 if merging is needed
108
+ # # 'max_filesize': MAX_FILE_SIZE_MB * 1024 * 1024, # Alternative way to specify max filesize
109
+ # 'postprocessors': [{
110
+ # 'key': 'FFmpegVideoConvertor',
111
+ # 'preferedformat': 'mp4',
112
+ # }],
113
+ # 'logger': logger, # Send yt-dlp logs to our logger
114
+ # 'progress_hooks': [lambda d: download_progress_hook(d, update, context, processing_message.message_id)],
115
+ }
116
+
117
+ # Use asyncio.to_thread to run blocking yt-dlp code in a separate thread
118
+ # This prevents the bot from freezing during download.
119
+ loop = asyncio.get_event_loop()
120
+
121
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
122
+ # We need to run extract_info and download in a thread-safe way
123
+ # First, get info to check file size if possible (though format selection handles much of this)
124
+ try:
125
+ # Using download=False first to inspect, then download=True
126
+ # This is a bit more complex; for simplicity, we'll try direct download
127
+ # with format selectors doing the heavy lifting for size.
128
+ logger.info(f"Attempting to download: {url}")
129
+ # The actual download happens here
130
+ # result = await loop.run_in_executor(None, ydl.download, [url])
131
+
132
+ # More robust way to get the filename:
133
+ info_dict = await loop.run_in_executor(None, lambda: ydl.extract_info(url, download=False))
134
+
135
+ # Try to find a suitable format based on filesize if extract_info provides it
136
+ # This is an advanced step; ydl_opts['format'] usually handles it.
137
+ # For now, we rely on the format selector.
138
+
139
+ # Perform the download
140
+ await loop.run_in_executor(None, lambda: ydl.download([url]))
141
+
142
+ # Determine the filename
143
+ # ydl.prepare_filename(info_dict) is usually reliable IF info_dict is from a non-download extract_info call
144
+ # If download=True was used, we need to find the file.
145
+ # A common way is to list files in DOWNLOAD_PATH if we expect only one.
146
+ # For this example, we rely on the outtmpl and the hook to get the filename.
147
+ # The `status: finished` hook will give us the final filename.
148
+ # We'll retrieve it from context.chat_data if the hook sets it.
149
+
150
+ downloaded_file_path = context.chat_data.pop(f'download_filename_{chat_id}', None)
151
+
152
+ if not downloaded_file_path or not os.path.exists(downloaded_file_path):
153
+ # Fallback: try to find the latest mp4 file in the directory (less robust)
154
+ list_of_files = [os.path.join(DOWNLOAD_PATH, f) for f in os.listdir(DOWNLOAD_PATH) if f.endswith(".mp4")]
155
+ if list_of_files:
156
+ downloaded_file_path = max(list_of_files, key=os.path.getctime)
157
+ logger.info(f"Found downloaded file by fallback: {downloaded_file_path}")
158
+ else:
159
+ logger.error("Could not determine downloaded file path after download.")
160
+ await processing_message.edit_text("❌ Download seemed to complete, but I couldn't find the file. Please try again.")
161
+ return
162
+
163
+ file_size = os.path.getsize(downloaded_file_path)
164
+ logger.info(f"File downloaded: {downloaded_file_path}, Size: {file_size / (1024*1024):.2f} MB")
165
+
166
+
167
+ except yt_dlp.utils.DownloadError as e:
168
+ logger.error(f"yt-dlp DownloadError for URL {url}: {e}")
169
+ error_message = f"❌ Failed to download video.\nError: `{str(e)}`"
170
+ if "Unsupported URL" in str(e):
171
+ error_message = "❌ Sorry, this website or video URL is not supported."
172
+ elif "Video unavailable" in str(e):
173
+ error_message = "❌ This video is unavailable or private."
174
+ elif "Unable to extract" in str(e):
175
+ error_message = "❌ Could not extract video information. The link might be broken or unsupported."
176
+ await processing_message.edit_text(error_message, parse_mode=ParseMode.MARKDOWN)
177
+ return
178
+ except Exception as e: # Catch other yt-dlp related errors
179
+ logger.error(f"yt-dlp generic error for URL {url}: {e}")
180
+ await processing_message.edit_text(f"❌ An error occurred during video processing: {str(e)}")
181
+ return
182
+
183
+ if downloaded_file_path and os.path.exists(downloaded_file_path):
184
+ file_size = os.path.getsize(downloaded_file_path)
185
+
186
+ if file_size > MAX_FILE_SIZE_MB * 1024 * 1024:
187
+ logger.warning(f"Video {downloaded_file_path} is too large: {file_size / (1024*1024):.2f} MB")
188
+ await processing_message.edit_text(
189
+ f"⚠️ The downloaded video is too large ({file_size / (1024*1024):.2f} MB) "
190
+ "for me to send via Telegram (max ~50MB). I tried to get a smaller version."
191
+ )
192
+ return # No cleanup here, user might want to access it if bot is self-hosted
193
+
194
+ logger.info(f"PRE-SEND CHECK: Attempting to send video from path: '{downloaded_file_path}'")
195
+ logger.info(f"PRE-SEND CHECK: Does file exist at this exact path? {os.path.exists(downloaded_file_path)}")
196
+ logger.info(f"PRE-SEND CHECK: File size is {file_size / (1024*1024):.2f} MB")
197
+
198
+ await processing_message.edit_text("βœ… Download complete! Now uploading to Telegram...")
199
+ await send_typing_action(update, context)
200
+
201
+ try:
202
+ with open(downloaded_file_path, 'rb') as video_file:
203
+ # For InputFile, you can pass a file path directly or a file-like object.
204
+ # Using a file path directly is often simpler.
205
+ # await context.bot.send_video(chat_id=chat_id, video=video_file, supports_streaming=True, caption=os.path.basename(downloaded_file_path))
206
+ logger.info("Attempting to send video using the file object directly...") # New log line
207
+ sent_message = await context.bot.send_video(
208
+ chat_id=chat_id,
209
+ video=video_file, # USE THIS INSTEAD
210
+ filename=os.path.basename(downloaded_file_path), # Good to add filename when sending file object
211
+ caption=f"🎬 Here's your video!\nOriginal URL: {url}",
212
+ supports_streaming=True,
213
+ read_timeout=180, # Increased timeout slightly for testing
214
+ write_timeout=180, # Increased timeout slightly for testing
215
+ connect_timeout=60
216
+ )
217
+ await processing_message.delete() # Delete "Processing..." message
218
+ logger.info(f"Video sent to chat_id {chat_id}: {downloaded_file_path}")
219
+
220
+ except Exception as e: # Catch errors during Telegram upload
221
+ logger.error(f"Error sending video to Telegram: {e}")
222
+ await processing_message.edit_text(f"❌ Failed to upload video to Telegram: {str(e)}")
223
+ else:
224
+ if not context.chat_data.get(f'download_error_{chat_id}'): # If no specific download error was already sent by hook
225
+ await processing_message.edit_text("❌ Download failed or no file was produced. Please check the URL or try again.")
226
+ # Clear any error flag
227
+ context.chat_data.pop(f'download_error_{chat_id}', None)
228
+
229
+
230
+ except Exception as e:
231
+ logger.error(f"An unexpected error occurred for URL {url}: {e}", exc_info=True)
232
+ await processing_message.edit_text("❌ An unexpected error occurred. Please try again later.")
233
+ finally:
234
+ # # Clean up the downloaded file
235
+ # if downloaded_file_path and os.path.exists(downloaded_file_path):
236
+ # try:
237
+ # os.remove(downloaded_file_path)
238
+ # logger.info(f"Cleaned up downloaded file: {downloaded_file_path}")
239
+ # except OSError as e:
240
+ # logger.error(f"Error deleting file {downloaded_file_path}: {e}")
241
+ # Clear any stored filename from chat_data
242
+ context.chat_data.pop(f'download_filename_{chat_id}', None)
243
+ context.chat_data.pop(f'download_error_{chat_id}', None)
244
+
245
+
246
+ # --- yt-dlp Progress Hook ---
247
+ # Keep track of messages to edit for progress (to avoid spamming)
248
+ progress_message_ids = {} # chat_id: message_id
249
+
250
+ async def download_progress_hook(d, update: Update, context: ContextTypes.DEFAULT_TYPE, initial_message_id: int):
251
+ """yt-dlp progress hook to update Telegram message."""
252
+ chat_id = update.effective_chat.id
253
+
254
+ if d['status'] == 'downloading':
255
+ percent_str = d.get('_percent_str', 'N/A')
256
+ # Remove ANSI codes if present (yt-dlp might use them)
257
+ percent_str = percent_str.replace('\x1b[0;94m', '').replace('\x1b[0m', '')
258
+
259
+ total_bytes_str = d.get('_total_bytes_str', 'N/A')
260
+ speed_str = d.get('_speed_str', 'N/A')
261
+ eta_str = d.get('_eta_str', 'N/A')
262
+
263
+ # To avoid hitting Telegram rate limits, only update message periodically
264
+ # This simple version updates on each hook call, which might be too frequent.
265
+ # A better approach would involve a timer or updating every N percent.
266
+ try:
267
+ # Use the initial "Processing..." message to show progress
268
+ current_progress_message = f"Downloading...\nProgress: {percent_str}\nSize: {total_bytes_str}\nSpeed: {speed_str}\nETA: {eta_str}"
269
+
270
+ # Only edit if the message content has changed significantly
271
+ last_message = context.chat_data.get(f'last_progress_msg_{chat_id}', "")
272
+ if last_message != current_progress_message: # Basic check to avoid identical edits
273
+ await context.bot.edit_message_text(
274
+ text=current_progress_message,
275
+ chat_id=chat_id,
276
+ message_id=initial_message_id
277
+ )
278
+ context.chat_data[f'last_progress_msg_{chat_id}'] = current_progress_message
279
+ except Exception as e:
280
+ # logger.warning(f"Could not edit progress message: {e}") # Can be noisy
281
+ pass # Ignore if editing fails (e.g., message not found or too old)
282
+
283
+ elif d['status'] == 'finished':
284
+ logger.info(f"yt-dlp finished processing for chat {chat_id}. Filename: {d.get('filename') or d.get('info_dict', {}).get('_filename')}")
285
+ # Store the final filename in chat_data to be picked up by the main handler
286
+ # yt-dlp provides filename in different places depending on version/context
287
+ final_filename = d.get('filename') # For when download=True
288
+ if not final_filename and d.get('info_dict'): # For when download=False then True, or from info_dict
289
+ final_filename = d['info_dict'].get('_filename')
290
+
291
+ if final_filename:
292
+ context.chat_data[f'download_filename_{chat_id}'] = final_filename
293
+
294
+ try:
295
+ await context.bot.edit_message_text(
296
+ text="βœ… Download finished by yt-dlp. Preparing to send...",
297
+ chat_id=chat_id,
298
+ message_id=initial_message_id
299
+ )
300
+ except Exception:
301
+ pass # Ignore if editing fails
302
+ # Clean up last progress message cache
303
+ context.chat_data.pop(f'last_progress_msg_{chat_id}', None)
304
+
305
+ elif d['status'] == 'error':
306
+ logger.error(f"yt-dlp reported an error for chat {chat_id}.")
307
+ context.chat_data[f'download_error_{chat_id}'] = True # Flag an error
308
+ try:
309
+ await context.bot.edit_message_text(
310
+ text="❌ yt-dlp encountered an error during download.",
311
+ chat_id=chat_id,
312
+ message_id=initial_message_id
313
+ )
314
+ except Exception:
315
+ pass # Ignore if editing fails
316
+
317
+
318
+ # --- Main Bot Execution ---
319
+ def main() -> None:
320
+ """Starts the bot."""
321
+ if TELEGRAM_BOT_TOKEN == "YOUR_TELEGRAM_BOT_TOKEN":
322
+ logger.error("CRITICAL: Bot token is not set! Please replace 'YOUR_TELEGRAM_BOT_TOKEN' with your actual bot token.")
323
+ return
324
+
325
+ ensure_download_path_exists()
326
+
327
+ # Create the Application and pass it your bot's token.
328
+ application = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
329
+
330
+ # Add command handlers
331
+ application.add_handler(CommandHandler("start", start_command))
332
+ application.add_handler(CommandHandler("help", help_command))
333
+
334
+ # Add message handler for video URLs (non-command text messages)
335
+ application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_video_url))
336
+
337
+ # Run the bot until the user presses Ctrl-C
338
+ logger.info("Bot starting...")
339
+ application.run_polling(allowed_updates=Update.ALL_TYPES)
340
+
341
+
342
+ if __name__ == "__main__":
343
+ main()