Spaces:
Build error
Build error
Baha Joe commited on
Commit ·
c6b8dc3
1
Parent(s): 87d591a
Final code synchronization and cleanup
Browse files- bot.py +29 -26
- requirements.txt +6 -4
- 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 |
-
|
|
|
|
| 7 |
|
| 8 |
-
#
|
| 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 |
-
#
|
| 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 |
-
#
|
| 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 |
-
#
|
| 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 |
-
#
|
| 53 |
-
if "youtu" 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 |
-
#
|
| 73 |
-
final_clip_path = await
|
| 74 |
url,
|
| 75 |
time_range,
|
| 76 |
DOWNLOAD_DIR,
|
| 77 |
CLIP_DIR,
|
| 78 |
user_id,
|
| 79 |
-
logger
|
| 80 |
)
|
| 81 |
|
| 82 |
if final_clip_path:
|
| 83 |
-
#
|
| 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=
|
| 88 |
-
supports_streaming=True
|
|
|
|
|
|
|
|
|
|
| 89 |
)
|
| 90 |
|
| 91 |
else:
|
| 92 |
await update.message.reply_text(
|
| 93 |
-
"❌ Error processing your clip.
|
| 94 |
)
|
| 95 |
|
| 96 |
except Exception as e:
|
| 97 |
-
logger.error(f"An unexpected error occurred
|
| 98 |
-
await update.message.reply_text("An unexpected error occurred.
|
| 99 |
|
| 100 |
finally:
|
| 101 |
-
#
|
| 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
|
| 4 |
openai-whisper
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
| 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
|
| 3 |
-
import
|
| 4 |
-
from
|
| 5 |
-
from moviepy.
|
| 6 |
-
import
|
| 7 |
-
import nltk
|
| 8 |
-
from nltk.corpus import stopwords
|
| 9 |
|
| 10 |
-
#
|
| 11 |
-
nltk.download('stopwords')
|
| 12 |
-
nltk.download('punkt')
|
| 13 |
|
| 14 |
-
def
|
| 15 |
-
"""
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
| 48 |
|
| 49 |
-
|
| 50 |
-
model = whisper.load_model("small")
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
start_time = random.uniform(0, max_start)
|
| 56 |
-
end_time = min(start_time + duration, video_duration)
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
-
#
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
result = model.transcribe(temp_audio)
|
| 67 |
|
| 68 |
-
|
| 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 |
-
#
|
| 87 |
-
|
| 88 |
-
|
| 89 |
|
| 90 |
-
|
| 91 |
-
if os.path.exists(temp_audio):
|
| 92 |
-
os.remove(temp_audio)
|
| 93 |
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|