Baha Joe commited on
Commit
c6b8dc3
·
1 Parent(s): 87d591a

Final code synchronization and cleanup

Browse files
Files changed (3) hide show
  1. bot.py +29 -26
  2. requirements.txt +6 -4
  3. video_utils.py +104 -80
bot.py CHANGED
@@ -3,9 +3,10 @@ import sys
3
  import logging
4
  from telegram import Update
5
  from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
6
- from video_utils import process_video_clip
 
7
 
8
- # Configure logging
9
  logging.basicConfig(
10
  format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
11
  level=logging.INFO
@@ -13,20 +14,15 @@ logging.basicConfig(
13
  logger = logging.getLogger(__name__)
14
 
15
  # --- Configuration ---
16
- # ⚠️ WARNING: Hardcoding the token is strongly discouraged for security reasons.
17
- # Use Hugging Face Secrets instead.
18
- # If you must hardcode it, replace the line below with the token you provided:
19
- # TOKEN = "8575159633:AAHt8KYNKLrWKID8FOyZipEcPAxZ_zdjgg4"
20
- #
21
- # If you are using Hugging Face Secrets (RECOMMENDED):
22
  TOKEN = os.getenv("BOT_TOKEN")
23
 
24
- # Ensure the token is available
25
  if not TOKEN:
26
  logger.error("FATAL: BOT_TOKEN is missing. Please set it as a Hugging Face Secret.")
27
  sys.exit(1)
28
 
29
- # Ensure necessary directories exist
30
  DOWNLOAD_DIR = "downloads"
31
  CLIP_DIR = "clips"
32
  os.makedirs(DOWNLOAD_DIR, exist_ok=True)
@@ -49,9 +45,8 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
49
  user_message = update.message.text
50
  user_id = update.effective_user.id
51
 
52
- # 1. Simple check for a link and time format
53
- if "youtu" not in user_message and "0:" not in user_message and "1:" not in user_message:
54
- logger.info(f"User {user_id} sent non-link message: {user_message[:20]}...")
55
  return
56
 
57
  try:
@@ -60,46 +55,54 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
60
  url = parts[0]
61
  time_range = parts[1] if len(parts) > 1 else None
62
 
63
- if not time_range:
64
  await update.message.reply_text(
65
  "Please specify the clip range (e.g., `1:30-2:00`) after the link."
66
  )
67
  return
68
 
 
69
  logger.info(f"User {user_id} requested clip: {url} from {time_range}")
70
- await update.message.reply_text(f"Processing your request for: {time_range}...")
71
 
72
- # 2. Call the core processing function
73
- final_clip_path = await process_video_clip(
74
  url,
75
  time_range,
76
  DOWNLOAD_DIR,
77
  CLIP_DIR,
78
  user_id,
79
- logger # Pass logger for better tracking
80
  )
81
 
82
  if final_clip_path:
83
- # 3. Send the final video back
84
  logger.info(f"Sending final clip to user {user_id}: {final_clip_path}")
 
 
 
 
 
 
85
  await update.message.reply_video(
86
  video=open(final_clip_path, 'rb'),
87
- caption=f"✅ Your clip from {time_range} is ready!",
88
- supports_streaming=True
 
 
 
89
  )
90
 
91
  else:
92
  await update.message.reply_text(
93
- "❌ Error processing your clip. Please check the URL and time format (e.g., 0:30-1:00) and ensure the video is available."
94
  )
95
 
96
  except Exception as e:
97
- logger.error(f"An unexpected error occurred during processing for user {user_id}: {e}", exc_info=True)
98
- await update.message.reply_text("An unexpected error occurred. Please check the format and try again.")
99
 
100
  finally:
101
- # 4. Clean up files after processing
102
- # Ensure your video_utils handles cleanup of temporary files (Crucial for free tier)
103
  pass
104
 
105
 
 
3
  import logging
4
  from telegram import Update
5
  from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
6
+ # CORRECT IMPORT: Ensure this function name exactly matches the definition in video_utils.py
7
+ from video_utils import process_youtube_clip
8
 
9
+ # --- Logging Configuration ---
10
  logging.basicConfig(
11
  format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
12
  level=logging.INFO
 
14
  logger = logging.getLogger(__name__)
15
 
16
  # --- Configuration ---
17
+ # 1. Fetch token securely from environment variable (Hugging Face Secret)
 
 
 
 
 
18
  TOKEN = os.getenv("BOT_TOKEN")
19
 
20
+ # 2. Safety check for the token
21
  if not TOKEN:
22
  logger.error("FATAL: BOT_TOKEN is missing. Please set it as a Hugging Face Secret.")
23
  sys.exit(1)
24
 
25
+ # 3. Define local directories (Crucial for Docker environment)
26
  DOWNLOAD_DIR = "downloads"
27
  CLIP_DIR = "clips"
28
  os.makedirs(DOWNLOAD_DIR, exist_ok=True)
 
45
  user_message = update.message.text
46
  user_id = update.effective_user.id
47
 
48
+ # Simple check to filter out non-link/non-command messages
49
+ if "youtu" not in user_message or "-" not in user_message:
 
50
  return
51
 
52
  try:
 
55
  url = parts[0]
56
  time_range = parts[1] if len(parts) > 1 else None
57
 
58
+ if not time_range or len(parts) < 2:
59
  await update.message.reply_text(
60
  "Please specify the clip range (e.g., `1:30-2:00`) after the link."
61
  )
62
  return
63
 
64
+ await update.message.reply_text(f"Processing your request for {time_range}. This might take a few minutes...")
65
  logger.info(f"User {user_id} requested clip: {url} from {time_range}")
 
66
 
67
+ # 4. Call the core processing function (MUST MATCH THE IMPORT NAME)
68
+ final_clip_path, caption = await process_youtube_clip(
69
  url,
70
  time_range,
71
  DOWNLOAD_DIR,
72
  CLIP_DIR,
73
  user_id,
74
+ logger
75
  )
76
 
77
  if final_clip_path:
78
+ # 5. Send the final video back
79
  logger.info(f"Sending final clip to user {user_id}: {final_clip_path}")
80
+
81
+ # Create a caption that includes the transcription
82
+ full_caption = f"✅ Clip ({time_range}) from video.\n\n"
83
+ if caption:
84
+ full_caption += f"Transcription:\n{caption}"
85
+
86
  await update.message.reply_video(
87
  video=open(final_clip_path, 'rb'),
88
+ caption=full_caption,
89
+ supports_streaming=True,
90
+ read_timeout=600, # Allow longer timeout for large video uploads
91
+ write_timeout=600,
92
+ pool_timeout=600
93
  )
94
 
95
  else:
96
  await update.message.reply_text(
97
+ "❌ Error processing your clip. Check the URL/time format (e.g., 0:30-1:00) or ensure the video is public."
98
  )
99
 
100
  except Exception as e:
101
+ logger.error(f"An unexpected error occurred: {e}", exc_info=True)
102
+ await update.message.reply_text("An unexpected error occurred. Check the format and try again.")
103
 
104
  finally:
105
+ # NOTE: Cleanup should primarily happen inside process_youtube_clip
 
106
  pass
107
 
108
 
requirements.txt CHANGED
@@ -1,7 +1,9 @@
1
  python-telegram-bot
2
  yt-dlp
3
- moviepy==2.0.0.dev2
4
  openai-whisper
5
- torch
6
- python-dotenv
7
- nltk
 
 
 
1
  python-telegram-bot
2
  yt-dlp
3
+ moviepy
4
  openai-whisper
5
+ httpx
6
+ Pillow
7
+ tqdm
8
+ numpy
9
+ torch
video_utils.py CHANGED
@@ -1,95 +1,119 @@
1
  import os
2
- import yt_dlp
3
- import whisper
4
- from moviepy.editor import VideoFileClip, TextClip, CompositeVideoClip
5
- from moviepy.video.tools.subtitles import SubtitlesClip
6
- import random
7
- import nltk
8
- from nltk.corpus import stopwords
9
 
10
- # Ensure NLTK data is downloaded for tags
11
- nltk.download('stopwords')
12
- nltk.download('punkt')
13
 
14
- def download_video(url, output_path="downloads"):
15
- """Downloads YouTube video using yt-dlp."""
16
- if not os.path.exists(output_path):
17
- os.makedirs(output_path)
18
-
19
- ydl_opts = {
20
- 'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4',
21
- 'outtmpl': f'{output_path}/%(id)s.%(ext)s',
22
- 'quiet': True,
23
- }
24
-
25
- with yt_dlp.YoutubeDL(ydl_opts) as ydl:
26
- info = ydl.extract_info(url, download=True)
27
- filename = ydl.prepare_filename(info)
28
- return filename, info.get('title', 'Unknown'), info.get('duration', 0)
29
 
30
- def generate_tags(title):
31
- """Generates hashtags based on the video title."""
32
- stop_words = set(stopwords.words('english'))
33
- words = nltk.word_tokenize(title.lower())
34
- keywords = [word for word in words if word.isalnum() and word not in stop_words]
 
 
 
 
 
 
35
 
36
- base_tags = ["#shorts", "#fyp", "#trending", "#reels"]
37
- content_tags = [f"#{word}" for word in keywords[:5]]
38
- return " ".join(base_tags + content_tags)
 
 
 
 
 
 
 
 
 
 
39
 
40
- def create_clips(video_path, num_clips, duration, output_folder="clips"):
41
- """Splits video into random segments of specific duration."""
42
- if not os.path.exists(output_folder):
43
- os.makedirs(output_folder)
 
 
 
 
44
 
45
- video = VideoFileClip(video_path)
46
- video_duration = video.duration
47
- generated_clips = []
 
48
 
49
- # Load Whisper model once (small is faster for CPU)
50
- model = whisper.load_model("small")
51
 
52
- for i in range(num_clips):
53
- # Ensure we don't pick a start time that exceeds video length
54
- max_start = max(0, video_duration - duration)
55
- start_time = random.uniform(0, max_start)
56
- end_time = min(start_time + duration, video_duration)
57
 
58
- # Cut the clip
59
- clip = video.subclip(start_time, end_time)
60
- clip_filename = f"{output_folder}/clip_{i}_{os.path.basename(video_path)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
- # 1. Transcribe Audio
63
- # We need to temporarily save audio to transcribe it
64
- temp_audio = "temp_audio.wav"
65
- clip.audio.write_audiofile(temp_audio, logger=None)
66
- result = model.transcribe(temp_audio)
67
 
68
- # 2. Create Captions (Subtitle Generator)
69
- # This function creates a text clip for every segment found by Whisper
70
- def generator(txt):
71
- return TextClip(txt, font='Arial-Bold', fontsize=24, color='white',
72
- stroke_color='black', stroke_width=2, method='caption',
73
- size=(clip.w, None)).set_pos(('center', 'bottom'))
74
-
75
- # Convert Whisper segments to MoviePy subtitles
76
- subs = []
77
- for segment in result['segments']:
78
- start = segment['start']
79
- end = segment['end']
80
- text = segment['text']
81
- subs.append(((start, end), text))
82
-
83
- subtitles = SubtitlesClip(subs, generator)
84
- final_clip = CompositeVideoClip([clip, subtitles])
85
 
86
- # Write final file
87
- final_clip.write_videofile(clip_filename, codec='libx264', audio_codec='aac', logger=None)
88
- generated_clips.append(clip_filename)
89
 
90
- # Cleanup temp audio
91
- if os.path.exists(temp_audio):
92
- os.remove(temp_audio)
93
 
94
- video.close()
95
- return generated_clips
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
+ import re
3
+ import gc
4
+ from yt_dlp import YoutubeDL
5
+ from moviepy.editor import VideoFileClip
6
+ from whisper import load_model
 
 
7
 
8
+ # --- Helper Functions ---
 
 
9
 
10
+ def clean_filename(title: str) -> str:
11
+ """Sanitizes a string to be a safe filename."""
12
+ s = re.sub(r'[\\/:*?"<>|]+', '', title)
13
+ return s[:100].strip()
14
+
15
+ def time_to_seconds(time_str: str) -> float:
16
+ """Converts a time string (m:s or h:m:s) to seconds."""
17
+ parts = list(map(float, time_str.split(':')))
18
+ if len(parts) == 1:
19
+ return parts[0]
20
+ elif len(parts) == 2:
21
+ return parts[0] * 60 + parts[1]
22
+ elif len(parts) == 3:
23
+ return parts[0] * 3600 + parts[1] * 60 + parts[2]
24
+ return 0.0
25
 
26
+ # --- Core Processing Function ---
27
+
28
+ async def process_youtube_clip(
29
+ url: str,
30
+ time_range: str,
31
+ download_dir: str,
32
+ clip_dir: str,
33
+ user_id: int,
34
+ logger
35
+ ) -> tuple[str, str]:
36
+ """Downloads, clips, transcribes, and cleans up a YouTube video."""
37
 
38
+ downloaded_video_path = None
39
+ final_clip_path = None
40
+ caption_text = None
41
+
42
+ try:
43
+ # 1. Parse time range (e.g., "1:30-2:00")
44
+ start_str, end_str = time_range.split('-')
45
+ start_time = time_to_seconds(start_str)
46
+ end_time = time_to_seconds(end_str)
47
+
48
+ if end_time <= start_time:
49
+ logger.error("End time must be greater than start time.")
50
+ return None, None
51
 
52
+ # 2. Download the video using yt-dlp
53
+ ydl_opts = {
54
+ 'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
55
+ 'outtmpl': os.path.join(download_dir, f'{user_id}_%(title)s.%(ext)s'),
56
+ 'quiet': True,
57
+ 'max_filesize': 500 * 1024 * 1024, # Max 500MB download
58
+ 'download_ranges': lambda _, __: [{'start_time': start_time, 'end_time': end_time + 5}], # Download slightly more
59
+ }
60
 
61
+ with YoutubeDL(ydl_opts) as ydl:
62
+ info = ydl.extract_info(url, download=True)
63
+ original_title = info.get('title', 'clip')
64
+ downloaded_video_path = ydl.prepare_filename(info)
65
 
66
+ logger.info(f"Video downloaded to {downloaded_video_path}")
 
67
 
68
+ # 3. Perform clipping with moviepy
69
+ output_filename = clean_filename(f"clip_{original_title}_{time_range}.mp4")
70
+ final_clip_path = os.path.join(clip_dir, output_filename)
 
 
71
 
72
+ with VideoFileClip(downloaded_video_path) as video:
73
+ clipped_video = video.subclip(start_time, end_time)
74
+
75
+ # Ensure output is Telegram-compatible (h264 codec)
76
+ clipped_video.write_videofile(
77
+ final_clip_path,
78
+ codec='libx264',
79
+ audio_codec='aac',
80
+ temp_audiofile='temp-audio.m4a',
81
+ remove_temp=True,
82
+ logger=None, # Suppress moviepy logging
83
+ fps=24 # Ensure low FPS for smaller size
84
+ )
85
+
86
+ # 4. Transcribe audio with Whisper (using the clipped file)
87
+ logger.info("Starting Whisper transcription...")
88
 
89
+ # Load small model for speed on free CPU tier
90
+ model = load_model("small")
91
+ result = model.transcribe(final_clip_path, fp16=False)
92
+ caption_text = result["text"]
 
93
 
94
+ logger.info("Transcription complete.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
+ # 5. Cleanup the Whisper model (crucial for memory management)
97
+ del model
98
+ gc.collect()
99
 
100
+ return final_clip_path, caption_text
 
 
101
 
102
+ except Exception as e:
103
+ logger.error(f"Error during video processing: {e}", exc_info=True)
104
+ return None, None
105
+
106
+ finally:
107
+ # 6. CRUCIAL CLEANUP: Remove large temporary files to prevent OOM errors
108
+ if downloaded_video_path and os.path.exists(downloaded_video_path):
109
+ os.remove(downloaded_video_path)
110
+ logger.info(f"Cleaned up source file: {downloaded_video_path}")
111
+
112
+ # NOTE: We keep the final_clip_path until it's sent via Telegram, then it can be deleted
113
+ # Telegram handles deleting the local file after successful upload.
114
+ # However, for total safety, you might want to manually delete after sending:
115
+ # if final_clip_path and os.path.exists(final_clip_path):
116
+ # os.remove(final_clip_path)
117
+ # logger.info(f"Cleaned up final clip: {final_clip_path}")
118
+
119
+ gc.collect()