Download app.py from Vvbbnnn/Abs: direct link, hf CLI and curl.
- Browser
- Download file 53.2 kB
-
https://huggingface.co/spaces/Vvbbnnn/Abs/resolve/main/app.py
- Command line
-
hf download hf://spaces/Vvbbnnn/Abs/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Vvbbnnn/Abs/resolve/main/app.py
53.2 kB
| import os | |
| # βββββββββββββ Standard Library | |
| import os | |
| import time | |
| import uuid | |
| import json | |
| import base64 | |
| import asyncio | |
| import nest_asyncio | |
| import random | |
| import logging | |
| import atexit | |
| import pathlib | |
| from threading import Thread | |
| from datetime import datetime, timedelta, timezone | |
| from typing import Optional, Tuple, List | |
| # βββββββββββββ Flask | |
| from flask import Flask, render_template, request, redirect, session | |
| # βββββββββββββ MoviePy | |
| from moviepy.editor import ( | |
| VideoFileClip, | |
| ImageClip, | |
| ColorClip, | |
| CompositeVideoClip, | |
| concatenate_videoclips | |
| ) | |
| from moviepy.video.fx import resize | |
| # βββββββββββββ Pillow (PIL) | |
| from PIL import Image, ImageDraw, ImageFont | |
| # βββββββββββββ NumPy | |
| import numpy as np | |
| # βββββββββββββ Requests | |
| import requests | |
| # βββββββββββββ Emoji Handling | |
| import emoji | |
| # βββββββββββββ MongoDB | |
| from pymongo import MongoClient | |
| # βββββββββββββ YouTube API (Google) | |
| from google.oauth2.credentials import Credentials | |
| from google.auth.transport.requests import Request | |
| from googleapiclient.discovery import build | |
| from googleapiclient.http import MediaFileUpload | |
| # βββββββββββββ yt-dlp | |
| from yt_dlp import YoutubeDL | |
| # βββββββββββββ Telegram Bot | |
| from telegram import Update | |
| from telegram.ext import ( | |
| Application, | |
| CommandHandler, | |
| MessageHandler, | |
| filters, | |
| ContextTypes, | |
| JobQueue | |
| ) | |
| UPLOAD_TIMES = [] | |
| NEXT_RESET = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) | |
| def patch_pillow_compat(): | |
| """Pillow >= 10 removed ``Image.ANTIALIAS`` and the other module-level | |
| resampling constants, but moviepy 1.0.3 (which we rely on) still calls | |
| ``Image.ANTIALIAS`` inside its resizer. The previous ``patch_moviepy`` never | |
| actually fixed this β it only worked because Pillow was pinned to 9.4.0. | |
| Restoring the constants lets us run a modern Pillow (required by instagrapi) | |
| while keeping the stable, well-tested moviepy 1.0.3 video pipeline intact. | |
| """ | |
| resampling = getattr(Image, "Resampling", None) | |
| if resampling is None: | |
| return | |
| for name, res in ( | |
| ("ANTIALIAS", "LANCZOS"), ("LANCZOS", "LANCZOS"), ("BICUBIC", "BICUBIC"), | |
| ("BILINEAR", "BILINEAR"), ("NEAREST", "NEAREST"), ("HAMMING", "HAMMING"), | |
| ("BOX", "BOX"), | |
| ): | |
| if not hasattr(Image, name): | |
| setattr(Image, name, getattr(resampling, res)) | |
| patch_pillow_compat() | |
| # Allow nested event loops: auto_loop runs asyncio.run() repeatedly from a worker | |
| # thread, and some libraries (telethon) may already hold a running loop. Applying | |
| # nest_asyncio makes those re-entrant instead of raising "loop already running". | |
| try: | |
| nest_asyncio.apply() | |
| except Exception: | |
| pass | |
| def _textsize(draw, text, font): | |
| """``ImageDraw.textsize`` was removed in Pillow 10. Reimplement it with | |
| ``textbbox`` so text measurement works on every Pillow version.""" | |
| if hasattr(draw, "textbbox"): | |
| left, top, right, bottom = draw.textbbox((0, 0), text, font=font) | |
| return right - left, bottom - top | |
| return draw.textsize(text, font=font) # pragma: no cover (very old Pillow) | |
| _FONT_CANDIDATES = ( | |
| "DejaVuSans-Bold.ttf", | |
| "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", | |
| "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", | |
| "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", | |
| ) | |
| def _load_font(size, name=None): | |
| """Load a TrueType font robustly. Try the given name/path, then common system | |
| locations, then a correctly-sized default. NEVER raises (a missing font used | |
| to crash create_text_image / create_text_image_with_shadow).""" | |
| for cand in ([name] if name else []) + list(_FONT_CANDIDATES): | |
| try: | |
| return ImageFont.truetype(cand, size=int(size)) | |
| except OSError: | |
| continue | |
| try: | |
| return ImageFont.load_default(size=int(size)) # Pillow >= 10 | |
| except TypeError: | |
| return ImageFont.load_default() | |
| import emoji | |
| from PIL import Image, ImageDraw, ImageFont | |
| import emoji | |
| import os | |
| import requests | |
| from PIL import Image, ImageDraw, ImageFont | |
| import emoji | |
| import os, requests | |
| import os | |
| import emoji | |
| import requests | |
| from PIL import Image, ImageDraw, ImageFont | |
| def create_text_image_2( | |
| text: str, | |
| width: int, | |
| height: int, | |
| *, | |
| font_size: int = 60, | |
| align: str = "center", # "left" | "center" | "right" | |
| bg_color=(255, 255, 255), | |
| text_color=(0, 0, 0) | |
| ): | |
| img = Image.new("RGBA", (width, height), color=bg_color) | |
| draw = ImageDraw.Draw(img) | |
| # Load font | |
| font = _load_font(font_size) | |
| # Extract emojis and plain text | |
| all_emojis = emoji.emoji_list(text) | |
| plain_text = emoji.replace_emoji(text, replace='') | |
| # Measure text size | |
| text_width, text_height = _textsize(draw, plain_text, font) | |
| total_emoji_width = len(all_emojis) * font_size | |
| full_width = text_width + total_emoji_width + 5 * len(all_emojis) | |
| # Xβoffset based on alignment | |
| if align == "left": | |
| x_start = 20 | |
| elif align == "right": | |
| x_start = max(20, width - full_width - 20) | |
| else: # center | |
| x_start = max(20, (width - full_width) // 2) | |
| y_start = (height - text_height) // 2 | |
| # Draw the plain text | |
| draw.text((x_start, y_start), plain_text, font=font, fill=text_color) | |
| # Get emoji positions (in original string) | |
| x = x_start + text_width + 5 | |
| for em in all_emojis: | |
| char = em["emoji"] | |
| hexcode = "-".join(f"{ord(c):x}" for c in char) | |
| # Emoji image file | |
| emoji_path = f"emoji_pngs/{hexcode}.png" | |
| if not os.path.exists(emoji_path): | |
| url = f"https://github.com/twitter/twemoji/raw/master/assets/72x72/{hexcode}.png" | |
| os.makedirs("emoji_pngs", exist_ok=True) | |
| try: | |
| response = requests.get(url, timeout=5) | |
| if response.ok: | |
| with open(emoji_path, "wb") as f: | |
| f.write(response.content) | |
| except Exception: | |
| continue | |
| # Paste emoji | |
| if os.path.exists(emoji_path): | |
| em_img = Image.open(emoji_path).convert("RGBA").resize((font_size, font_size)) | |
| img.paste(em_img, (x, y_start), em_img) | |
| x += font_size + 5 | |
| return img | |
| from PIL import Image, ImageDraw, ImageFont | |
| def create_text_image_with_shadow( | |
| text: str, | |
| width: int, | |
| height: int, | |
| *, | |
| font_size: int = 60, | |
| align: str = "center", | |
| bg_color=(0, 0, 0, 0), # transparent | |
| text_color=(255, 255, 255), | |
| shadow_color=(0, 0, 0), | |
| font_name="DejaVuSans-Bold.ttf" # Only font name, not full path | |
| ): | |
| # Create image | |
| img = Image.new("RGBA", (width, height), bg_color) | |
| draw = ImageDraw.Draw(img) | |
| # Load font by name with robust fallbacks (never raises) | |
| font = _load_font(font_size, font_name) | |
| # Get size | |
| text_bbox = draw.textbbox((0, 0), text, font=font) | |
| text_width = text_bbox[2] - text_bbox[0] | |
| text_height = text_bbox[3] - text_bbox[1] | |
| # Alignment | |
| if align == "left": | |
| x = 0 | |
| elif align == "right": | |
| x = width - text_width | |
| else: # center | |
| x = (width - text_width) // 2 | |
| y = (height - text_height) // 2 | |
| # Draw shadow (1px right) | |
| draw.text((x + 1, y), text, font=font, fill=shadow_color) | |
| # Draw actual text | |
| draw.text((x, y), text, font=font, fill=text_color) | |
| return img | |
| def create_text_image(text, width, height): | |
| img = Image.new("RGB", (width, height), color=(255, 255, 255)) | |
| draw = ImageDraw.Draw(img) | |
| # Load font | |
| font = _load_font(60) | |
| # Extract emoji and clean text | |
| emojis = emoji.emoji_list(text) | |
| pure_text = emoji.replace_emoji(text, replace='') | |
| # Adjust font size to fit | |
| max_font_size = 70 | |
| while True: | |
| font = _load_font(max_font_size) | |
| text_width, text_height = _textsize(draw, pure_text, font) | |
| total_width = text_width + (len(emojis) * 60) + 20 | |
| if total_width <= width - 40 or max_font_size <= 30: | |
| break | |
| max_font_size -= 2 | |
| # Starting X & Y for centered layout | |
| start_x = (width - total_width) // 2 | |
| y = (height - text_height) // 2 | |
| # Draw text first | |
| draw.text((start_x, y), pure_text, font=font, fill=(0, 0, 0)) | |
| # Then draw emojis to the right of the text | |
| x = start_x + text_width + 10 | |
| for e in emojis: | |
| hexcode = '-'.join(f"{ord(c):x}" for c in e['emoji']) | |
| emoji_path = f"emoji_pngs/{hexcode}.png" | |
| if not os.path.exists(emoji_path): | |
| download_emoji_png(e['emoji']) | |
| if os.path.exists(emoji_path): | |
| emoji_img = Image.open(emoji_path).convert("RGBA") | |
| emoji_img = emoji_img.resize((60, 60)) | |
| img.paste(emoji_img, (x, y), emoji_img) | |
| x += 60 + 4 | |
| return img | |
| from PIL import Image, ImageDraw, ImageFont | |
| import numpy as np | |
| from moviepy.editor import ImageClip | |
| def generate_watermark_img(text, width, height=50): | |
| img = Image.new("RGBA", (width, height), (0, 0, 0, 0)) | |
| draw = ImageDraw.Draw(img) | |
| font = _load_font(35) | |
| text_width, text_height = _textsize(draw, text, font) | |
| draw.text((5, height - text_height - 2), text, fill="white", font=font, stroke_width=1, stroke_fill="black") | |
| return img | |
| def download_emoji_png(emoji_char): | |
| hexcode = '-'.join(f"{ord(c):x}" for c in emoji_char) | |
| url = f"https://github.com/twitter/twemoji/raw/master/assets/72x72/{hexcode}.png" | |
| os.makedirs("emoji_pngs", exist_ok=True) | |
| path = f"emoji_pngs/{hexcode}.png" | |
| try: | |
| r = requests.get(url) | |
| if r.status_code == 200: | |
| with open(path, "wb") as f: | |
| f.write(r.content) | |
| print(f"β Downloaded emoji: {emoji_char} β {path}") | |
| else: | |
| print(f"β Failed to download emoji: {emoji_char}") | |
| except Exception as e: | |
| print(f"β οΈ Error downloading emoji {emoji_char}: {e}") | |
| # βββββββββββββββββββββββββββββ HIGH-QUALITY / CONSISTENT EXPORT | |
| # | |
| # ABOUT THE "2ND/3RD VIDEO LOOKS WORSE, FIXES ITSELF NEXT DAY" PROBLEM | |
| # -------------------------------------------------------------------- | |
| # That quality drop is NOT created by this program. Every file we export below | |
| # is encoded with the *exact same* maximum-quality settings, so the 1st and the | |
| # 8th video uploaded in the same hour are byte-for-byte identical in quality (we | |
| # also log each file's resolution / size / bitrate so you can confirm it). | |
| # | |
| # The drop happens on YouTube / Instagram *after* the upload: when you push many | |
| # videos in a short window their servers first publish a fast, low-resolution | |
| # transcode and only finish the full HD / VP9 transcode later β which is exactly | |
| # why it "improves by the next day". An uploader cannot disable that, but it CAN | |
| # hand the platform the best possible master so the temporary version looks as | |
| # good as possible and the HD transcode is prioritised. We do that by: | |
| # β’ keeping CRF at maximum quality (env CRF, default 15), | |
| # β’ adding +faststart so processing/streaming can begin immediately, | |
| # β’ optionally upscaling the YouTube copy to >=1440p (see prepare_yt_file), | |
| # which makes YouTube encode with the higher-quality VP9 codec. | |
| def _probe_width(path: str): | |
| """Return the pixel width of a video using ffprobe, or None if unavailable.""" | |
| import shutil, subprocess | |
| ff = shutil.which("ffprobe") | |
| if not ff: | |
| return None | |
| try: | |
| out = subprocess.run( | |
| [ff, "-v", "error", "-select_streams", "v:0", | |
| "-show_entries", "stream=width", "-of", "default=nw=1:nk=1", path], | |
| capture_output=True, text=True, timeout=30, | |
| ).stdout.strip() | |
| return int(out.split()[0]) if out else None | |
| except Exception: | |
| return None | |
| def _log_quality(path: str) -> None: | |
| """Log resolution / size / bitrate of an exported file so quality parity | |
| across every upload is visible and verifiable.""" | |
| import shutil, subprocess | |
| try: | |
| size_mb = os.path.getsize(path) / 1_000_000 | |
| except OSError: | |
| size_mb = 0.0 | |
| details = "" | |
| ff = shutil.which("ffprobe") | |
| if ff: | |
| try: | |
| details = " ".join(subprocess.run( | |
| [ff, "-v", "error", "-select_streams", "v:0", | |
| "-show_entries", "stream=width,height,r_frame_rate,bit_rate", | |
| "-of", "default=nw=1:nk=1", path], | |
| capture_output=True, text=True, timeout=30, | |
| ).stdout.split()) | |
| except Exception: | |
| pass | |
| logger.info(f"[ποΈ] export {os.path.basename(path)} | {size_mb:.1f} MB | {details}") | |
| def write_high_quality(final, out_path: str, fps=None): | |
| """The single, shared exporter used by every edit function so all uploads | |
| (1st .. Nth in the same hour) get IDENTICAL maximum-quality encoding.""" | |
| crf = os.getenv("CRF", "15") # lower = higher quality (15 β visually lossless) | |
| preset = os.getenv("FFMPEG_PRESET", "medium") # at a fixed CRF this changes speed/size, NOT visible quality | |
| try: | |
| threads = int(os.getenv("FFMPEG_THREADS", "2")) | |
| except ValueError: | |
| threads = 2 | |
| ffmpeg_params = [ | |
| "-crf", str(crf), | |
| "-pix_fmt", "yuv420p", # universal player/platform compatibility | |
| "-profile:v", "high", # better detail retention | |
| "-bf", "2", # B-frames | |
| "-g", "60", # keyframe interval | |
| "-movflags", "+faststart", # let platforms start processing/streaming immediately | |
| ] | |
| kwargs = dict( | |
| codec="libx264", | |
| audio_codec="aac", | |
| audio_bitrate="192k", | |
| preset=preset, | |
| ffmpeg_params=ffmpeg_params, | |
| threads=threads, | |
| verbose=False, | |
| logger=None, | |
| ) | |
| if fps: | |
| kwargs["fps"] = fps | |
| final.write_videofile(out_path, **kwargs) | |
| _log_quality(out_path) | |
| return out_path | |
| # βββββββββββββββββββββββββββββ OUTPUT FORMAT (true 9:16, NO black bars) | |
| # Both edit paths render to exactly OUT_W x OUT_H so YouTube and Instagram get | |
| # pixel-identical content (Instagram uploads this size; YouTube uploads an | |
| # upscaled copy of the SAME frame). 1080x1920 is the standard 9:16 size, so the | |
| # video fills the whole screen edge-to-edge on both apps with no padding. | |
| OUT_W, OUT_H = 1080, 1920 | |
| def _cover_fit(clip, tw, th): | |
| """Scale `clip` to COVER a tw x th box (fill it completely) then center-crop. | |
| Guarantees the box is filled edge-to-edge with NO black bars on width/height.""" | |
| scale = max(tw / clip.w, th / clip.h) | |
| resized = clip.resize(width=max(int(round(clip.w * scale)), int(tw))) | |
| return resized.crop(x_center=resized.w / 2, y_center=resized.h / 2, width=tw, height=th) | |
| def edit_video(video_path): | |
| clip = VideoFileClip(video_path) | |
| BAR_H = 140 # white caption strip at the very top | |
| MAIN_H = OUT_H - BAR_H # the source video fills all the remaining space | |
| # White background covers the whole 9:16 frame (edges are never black) | |
| final_bg = ColorClip(size=(OUT_W, OUT_H), color=(255, 255, 255), duration=clip.duration) | |
| # === Caption Bar (Top), full width | |
| caption = random.choice(CAPTIONS) | |
| caption_img = create_text_image(caption, OUT_W, BAR_H) | |
| caption_clip = ImageClip(np.array(caption_img)).set_duration(clip.duration).set_position((0, 0)) | |
| # === Main video: COVER-fit into the main area β fills width & height, no bars | |
| main_video = _cover_fit(clip, OUT_W, MAIN_H).set_duration(clip.duration).set_position((0, BAR_H)) | |
| # === Eye-protection overlay (subtle) over the video area only | |
| eye_protection = ColorClip(size=(OUT_W, MAIN_H), color=(255, 255, 255), duration=clip.duration) | |
| eye_protection = eye_protection.set_opacity(0.1).set_position((0, BAR_H)) | |
| final = CompositeVideoClip( | |
| [final_bg, caption_clip, main_video, eye_protection], | |
| size=(OUT_W, OUT_H), | |
| ) | |
| os.makedirs("edited", exist_ok=True) | |
| output_path = f"edited/{uuid.uuid4().hex}.mp4" | |
| # Shared max-quality exporter β every upload is encoded identically. | |
| write_high_quality(final, output_path, fps=clip.fps) | |
| clip.close() | |
| final.close() | |
| return output_path | |
| def edit_video_raw(video_path: str) -> str: | |
| import os, uuid, random | |
| import numpy as np | |
| from moviepy.editor import ( | |
| VideoFileClip, ImageClip, ColorClip, CompositeVideoClip | |
| ) | |
| # Ensure CAPTIONS exist | |
| if not CAPTIONS: | |
| raise ValueError("CAPTIONS list is empty!") | |
| # Load main video | |
| clip = VideoFileClip(video_path) | |
| vw, vh = OUT_W, OUT_H # true 9:16 (1080x1920) β no black bars, identical on both platforms | |
| # Heights (scaled from the original 2100 design to fill 1920, same proportions) | |
| CAPTION_H = 156 | |
| LAUGH_H = 457 | |
| MID_H = 110 | |
| MAIN_H = vh - (CAPTION_H + LAUGH_H + MID_H) # = 1197 | |
| if clip.duration < 6: | |
| raise ValueError("Main video must be at least 6 seconds.") | |
| if clip.duration > 180: | |
| clip = clip.subclip(0, 180) | |
| # === CAPTION === | |
| caption_text = random.choice(CAPTIONS) | |
| caption_img = create_text_image_2( | |
| caption_text, vw, CAPTION_H, | |
| font_size=72, align="center", | |
| bg_color=(255, 255, 255), text_color=(0, 0, 0) | |
| ) | |
| caption_clip = ImageClip(np.array(caption_img)) \ | |
| .set_duration(clip.duration) \ | |
| .set_position((0, 0)) | |
| # === MID CAPTION === | |
| mid_text = random.choice([ | |
| "Pura 1 din ke baad ye mila π€£", | |
| "Ye miss mat kr dena π", | |
| "Kha thi ye reel ab tak π€¨π€", | |
| "Wait, ye dekh kr hi janna π₯π₯", | |
| ]) | |
| mid_img = create_text_image_2( | |
| mid_text, vw, MID_H, | |
| font_size=64, align="center", | |
| bg_color=(0, 0, 0), text_color=(255, 255, 255) | |
| ) | |
| mid_caption_clip = ImageClip(np.array(mid_img)) \ | |
| .set_duration(clip.duration) \ | |
| .set_position((0, CAPTION_H + LAUGH_H)) | |
| # === MAIN VIDEO === | |
| clip = clip.resize(width=vw) | |
| crop_y1 = 200 | |
| crop_y2 = crop_y1 + MAIN_H | |
| main_video = clip.crop(y1=crop_y1, y2=crop_y2) \ | |
| .set_position((0, CAPTION_H + LAUGH_H + MID_H)) | |
| # Overlay (optional soft filter) | |
| overlay = ColorClip(size=(vw, MAIN_H), color=(255, 255, 255), duration=clip.duration) \ | |
| .set_opacity(0.1).set_position((0, CAPTION_H + LAUGH_H + MID_H)) | |
| # === LAUGH MEME === | |
| laugh_files = ["laugh/laugh_one.mp4", "laugh/laugh_two.mp4"] | |
| laugh_path = random.choice(laugh_files) | |
| if not os.path.exists(laugh_path): | |
| raise FileNotFoundError(f"β Laugh meme not found: {laugh_path}") | |
| laugh_clip = VideoFileClip(laugh_path).resize(width=vw).subclip(0, 4) | |
| y1 = (laugh_clip.h - LAUGH_H) // 2 | |
| y2 = y1 + LAUGH_H | |
| laugh_crop = laugh_clip.crop(y1=y1, y2=y2).set_position((0, CAPTION_H)) | |
| # Parts: | |
| laugh_part1 = laugh_crop.subclip(0, 2).set_start(0) | |
| laugh_frozen = ImageClip(laugh_crop.get_frame(2)) \ | |
| .set_duration(clip.duration - 4).set_start(2) \ | |
| .set_position((0, CAPTION_H)) | |
| laugh_part3 = laugh_crop.subclip(2, 4).set_start(clip.duration - 2) | |
| # === FINAL COMPOSITION === | |
| final = CompositeVideoClip([ | |
| caption_clip, # π Caption | |
| laugh_part1, # π Laugh 0β2s (video) | |
| laugh_frozen, # π§ Freeze mid | |
| laugh_part3, # π Laugh end 2s (video) | |
| mid_caption_clip, # π¬ Mid caption | |
| main_video, # π₯ Main video | |
| overlay # β¨ Optional light filter | |
| ], size=(vw, vh)).set_duration(clip.duration) | |
| # Export | |
| os.makedirs("edited", exist_ok=True) | |
| out_path = f"edited/{uuid.uuid4().hex}.mp4" | |
| # Shared max-quality exporter β every upload is encoded identically. | |
| write_high_quality(final, out_path, fps=clip.fps) | |
| clip.close(), laugh_clip.close(), final.close() | |
| return out_path | |
| # βββββββββββββββββββββββββββββ LOGGING | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s - %(levelname)s - %(message)s", | |
| handlers=[ | |
| logging.FileHandler("app.log"), | |
| logging.StreamHandler() | |
| ] | |
| ) | |
| logger = logging.getLogger(__name__) | |
| # βββββββββββββββββββββββββββββ CONSTANTS & GLOBALS | |
| CAPTIONS = [ | |
| "Wait for it π", "Watch till end π", "Try not to laugh π€£", | |
| "Don't skip this π₯", "You won't expect this! π", "Keep watching π", | |
| "Stay till end! π₯", "Funniest one yet" | |
| ] | |
| BLOCKLIST = [ | |
| "nsfw", "18+", "xxx", "sexy", "adult", "porn", "onlyfans", "escort", | |
| "betting", "gambling", "iplwin", "1xbet", "winzo", "my11circle", "dream11", | |
| "rummy", "teenpatti", "fantasy", "casino", "promotion" | |
| ] | |
| UPLOAD_TIMES: List[datetime] = [] | |
| NEXT_RESET: datetime | None = None | |
| first_run = True | |
| # βββββββββββββββββββββββββββββ DATABASE | |
| client = MongoClient(os.getenv("MONGO_URI")) | |
| db1 = client.shortttt # meta for YouTube uploads | |
| meta = db1.meta | |
| sync = db1.sync # cross-app handshake (kept for backward compatibility) | |
| # Shared config used to sync the HF app with the Instagram Telegram bot (koyeb/): | |
| # {_id:"telegram", bot_token, bot_username, authorized_user_ids:[...]} | |
| # {_id:"ig_cookies", cookies_b64} | |
| config = db1.config | |
| botdb = client.teleg4am_reelssss | |
| a_raw = botdb.raw_links # {link:str, used:bool} | |
| a_reacted = botdb.reacted_links | |
| # βββββββββββββββββββββββββββββ FLASK UI | |
| from flask import Flask | |
| app = Flask(__name__) | |
| def home(): | |
| return "β Code is running!" | |
| # βββββ Function 1: pick random link ββββββββββββββββββββββββββββββββββ | |
| def get_random_link() -> Tuple[Optional[str], Optional[str]]: | |
| raw_left = list(a_raw.find({"used": False})) | |
| reacted_left = list(a_reacted.find({"used": False})) | |
| if not raw_left and not reacted_left: | |
| return None, None | |
| choice_pool = "raw" if random.random() < 0.4 else "reacted" | |
| if choice_pool == "raw" and not raw_left: | |
| choice_pool = "reacted" | |
| if choice_pool == "reacted" and not reacted_left: | |
| choice_pool = "raw" | |
| col, pool_list = (a_raw, raw_left) if choice_pool == "raw" else (a_reacted, reacted_left) | |
| doc = random.choice(pool_list) | |
| col.update_one({"_id": doc["_id"]}, {"$set": {"used": True}}) | |
| return doc["link"], choice_pool | |
| import os | |
| import re | |
| import uuid | |
| import asyncio | |
| import pathlib | |
| import logging | |
| from typing import Optional, Tuple | |
| from telethon import TelegramClient | |
| from telethon.sessions import StringSession | |
| from telethon.tl.types import DocumentAttributeVideo | |
| from moviepy.editor import VideoFileClip | |
| API_ID = int(os.getenv("TG_API_ID", "3704772")) | |
| API_HASH = os.getenv("TG_API_HASH", "b8e50a035abb851c0dd424e14cac4c06") | |
| SESSION_STR = os.getenv("SESSION") | |
| TARGET_BOT = "instasavegrambot" | |
| logger = logging.getLogger(__name__) | |
| def tg_duration_seconds(message) -> Optional[int]: | |
| if not message or not message.media or not message.media.document: | |
| return None | |
| for attr in message.media.document.attributes: | |
| if isinstance(attr, DocumentAttributeVideo): | |
| return attr.duration | |
| return None | |
| def message_has_video(message) -> bool: | |
| """True if the message carries a video FILE, regardless of its (often 0 or | |
| missing) duration attribute. Many reels arrive with duration=0 β Telegram | |
| then shows '0:00' even though the clip is 20s+ β so we must NOT rely on the | |
| duration to decide whether a video was sent.""" | |
| if not message or not getattr(message, "media", None): | |
| return False | |
| doc = getattr(message.media, "document", None) | |
| if not doc: | |
| return False | |
| mime = (getattr(doc, "mime_type", "") or "").lower() | |
| if mime.startswith("video/"): | |
| return True | |
| for attr in getattr(doc, "attributes", []): | |
| if isinstance(attr, DocumentAttributeVideo): | |
| return True | |
| return False | |
| import asyncio | |
| import logging | |
| import shutil | |
| async def download_url_mp4(url: str, filename: str, timeout: int = 30) -> bool: | |
| # 1. Ensure wget exists | |
| if not shutil.which("wget"): | |
| logger.error("β wget is not installed or not in PATH.") | |
| return False | |
| # 2. Build wget command | |
| wget_cmd = [ | |
| "wget", | |
| "--quiet", # minimal output # still show a progress bar | |
| f"--timeout={timeout}", # seconds | |
| "--header=User-Agent: Mozilla/5.0 (Linux; Android 10)", | |
| "--header=Referer: https://www.instagram.com/", | |
| "-O", filename, # output path | |
| url | |
| ] | |
| try: | |
| # 3. Launch wget as an async subprocess | |
| proc = await asyncio.create_subprocess_exec( | |
| *wget_cmd, | |
| stdout=asyncio.subprocess.PIPE, | |
| stderr=asyncio.subprocess.PIPE, | |
| ) | |
| # 4. Wait for it to finish | |
| stdout, stderr = await proc.communicate() | |
| if proc.returncode == 0: | |
| logger.info(f"π₯ Downloaded MP4 β {filename}") | |
| return True | |
| else: | |
| logger.error( | |
| f"β wget exited with {proc.returncode}\nSTDERR: {stderr.decode().strip()}" | |
| ) | |
| return False | |
| except Exception as e: | |
| logger.error(f"β wget launch failed: {e}") | |
| return False | |
| async def send_to_bot_and_get_video(link: str) -> Tuple[Optional[str], Optional[int]]: | |
| async with TelegramClient(StringSession(SESSION_STR), API_ID, API_HASH) as client: | |
| bot = await client.get_entity(TARGET_BOT) | |
| async def attempt(send_link: str, depth=0) -> Tuple[Optional[str], Optional[int]]: | |
| if depth > 2: | |
| logger.warning("π Retry limit reached.") | |
| return None, None | |
| async with client.conversation(bot, timeout=30) as conv: | |
| await conv.send_message(send_link) | |
| logger.info(f"π€ Sent to {TARGET_BOT}: {send_link}") | |
| wait_minutes = 4 | |
| deadline = asyncio.get_event_loop().time() + (wait_minutes * 60) | |
| while True: | |
| remaining = deadline - asyncio.get_event_loop().time() | |
| if remaining <= 0: | |
| logger.warning(f"β° No video received within {wait_minutes} minutes. Giving up on this link.") | |
| return None, None | |
| try: | |
| msg = await conv.get_response(timeout=remaining) | |
| except asyncio.TimeoutError: | |
| logger.warning(f"β° No reply from bot within {wait_minutes} minutes.") | |
| return None, None | |
| text = msg.message or "" | |
| logger.info(f"π¬ Bot replied: {text[:80]}") | |
| # β Case 1: Telegram video file. Detect by PRESENCE of a video | |
| # (its duration attribute is frequently 0/missing β '0:00'), then | |
| # read the REAL duration from the downloaded file. | |
| if message_has_video(msg): | |
| tg_dur = tg_duration_seconds(msg) or 0 | |
| pathlib.Path("reels").mkdir(exist_ok=True) | |
| file_path = await msg.download_media(file="reels/") | |
| if not file_path: | |
| logger.warning("ποΈ Video download returned no file β waitingβ¦") | |
| continue | |
| real_dur = tg_dur | |
| try: | |
| clip = VideoFileClip(file_path) | |
| real_dur = int(clip.duration) | |
| clip.close() | |
| except Exception as e: | |
| logger.warning(f"ποΈ Could not probe duration ({e}); " | |
| f"falling back to tg={tg_dur}s") | |
| # Only reject when we actually KNOW it's out of range. | |
| if real_dur and not (20 <= real_dur <= 180): | |
| logger.info(f"β© Skipped due to duration = {real_dur}s") | |
| try: | |
| os.remove(file_path) | |
| except OSError: | |
| pass | |
| return None, None | |
| logger.info(f"β Downloaded video β {file_path} " | |
| f"(duration={real_dur or 'unknown'}s, tg={tg_dur}s)") | |
| return file_path, (real_dur or tg_dur or 30) | |
| # β Case 2: CDN link in full text | |
| urls = re.findall(r"https://[^\s]+", text) | |
| if urls: | |
| cdn_url = urls[0].strip() | |
| pathlib.Path("reels").mkdir(exist_ok=True) | |
| fname = f"reels/{uuid.uuid4().hex}.mp4" | |
| success = await download_url_mp4(cdn_url, fname) | |
| if not success: | |
| return None, None | |
| try: | |
| clip = VideoFileClip(fname) | |
| duration = int(clip.duration) | |
| clip.close() | |
| if 20 <= duration <= 180: | |
| return fname, duration | |
| else: | |
| logger.info(f"β© CDN duration = {duration}s β Skipped") | |
| os.remove(fname) | |
| return None, None | |
| except Exception as e: | |
| logger.warning(f"ποΈ Duration read failed: {e}") | |
| return None, None | |
| # β Case 3: Ad or error β keep waiting for next message instead of bailing | |
| if "Request failed" in text: | |
| logger.warning("π Bot said request failed, retrying onceβ¦") | |
| await asyncio.sleep(5) | |
| return await attempt(send_link, depth + 1) | |
| if "We are experiencing high" in text: | |
| await asyncio.sleep(3600) | |
| return await attempt(send_link, depth + 1) | |
| # Not a video, not a recognized error β log and keep listening | |
| logger.info("β Not a video yet, still waiting within the 4-minute windowβ¦") | |
| continue | |
| return await attempt(link) | |
| # ββββββββββ Loop until we get a valid reel ββββββββ | |
| async def fetch_valid_reel() -> Tuple[Optional[str], Optional[str]]: | |
| for _ in range(10): | |
| link, pool = get_random_link() | |
| if not link: | |
| return None, None | |
| logger.info(f"Trying {pool} link: {link}") | |
| video_path, duration = await send_to_bot_and_get_video(link) | |
| if video_path: | |
| return video_path, pool | |
| await asyncio.sleep(15) | |
| return None, None | |
| db = client["youtube"] | |
| tokens = db["tokens"] | |
| import os | |
| from google.auth.transport.requests import Request | |
| from google.oauth2.credentials import Credentials | |
| from googleapiclient.discovery import build | |
| from googleapiclient.http import MediaFileUpload | |
| # MongoDB token collection assumed to be initialized as `tokens` | |
| def get_stored_token(): | |
| return tokens.find_one({"_id": "youtube_token"}) | |
| def save_token(creds: Credentials): | |
| tokens.update_one( | |
| {"_id": "youtube_token"}, | |
| {"$set": { | |
| "access_token": creds.token, | |
| "expiry": creds.expiry.isoformat() if creds.expiry else None, | |
| }}, | |
| upsert=True | |
| ) | |
| def upload_to_youtube(video_path, title, desc): | |
| # Load token from DB | |
| stored = get_stored_token() | |
| token = stored["access_token"] if stored else None | |
| expiry = stored["expiry"] if stored else None | |
| # Parse expiry if it exists | |
| if expiry: | |
| expiry = datetime.fromisoformat(expiry) | |
| now = datetime.utcnow() | |
| # Create credentials object | |
| creds = Credentials( | |
| token=token if expiry and now < expiry else None, | |
| refresh_token=os.getenv("YT_REFRESH_TOKEN"), | |
| token_uri="https://oauth2.googleapis.com/token", | |
| client_id=os.getenv("YT_CLIENT_ID"), | |
| client_secret=os.getenv("YT_CLIENT_SECRET"), | |
| scopes=["https://www.googleapis.com/auth/youtube.upload"] | |
| ) | |
| # Refresh token if expired or missing | |
| if not creds.valid or creds.expired: | |
| creds.refresh(Request()) | |
| save_token(creds) | |
| logger.info("π Refreshed access token") | |
| # Build YouTube API client | |
| youtube = build("youtube", "v3", credentials=creds) | |
| # Prepare and upload the video | |
| request = youtube.videos().insert( | |
| part="snippet,status", | |
| body={ | |
| "snippet": { | |
| "title": title, | |
| "description": desc, | |
| "tags": ["funny", "memes", "comedy", "shorts"], | |
| "categoryId": "23" | |
| }, | |
| "status": { | |
| "privacyStatus": "public", | |
| "madeForKids": False | |
| } | |
| }, | |
| media_body=MediaFileUpload(video_path) | |
| ) | |
| res = request.execute() | |
| video_url = f"https://youtube.com/watch?v={res['id']}" | |
| logger.info(f"β Uploaded: {video_url}") | |
| return video_url | |
| # βββββββββββββββββββββββββββββ INSTAGRAM COOKIES (netscape cookies in base64) | |
| # | |
| # The Instagram "Netscape cookies" export (cookies.txt) is supplied as ONE base64 | |
| # string. On Hugging Face it lives in the IG_COOKIES_B64 environment variable so | |
| # you can rotate it from the HF UI whenever you need. We ALSO mirror it into | |
| # MongoDB so the Instagram Telegram bot (koyeb/) β which performs the actual | |
| # upload β always reads the latest cookies from one shared place. | |
| def _decode_b64_cookies() -> str: | |
| """Decode IG_COOKIES_B64 into the raw netscape cookies text.""" | |
| b64 = os.getenv("IG_COOKIES_B64", "").strip() | |
| if not b64: | |
| return "" | |
| # tolerate a data-URI prefix, whitespace/newlines and missing padding | |
| if b64.lower().startswith("data:") and "," in b64: | |
| b64 = b64.split(",", 1)[1] | |
| b64 = "".join(b64.split()) | |
| b64 += "=" * (-len(b64) % 4) | |
| try: | |
| return base64.b64decode(b64).decode("utf-8", "ignore") | |
| except Exception as e: | |
| logger.error(f"[IG] Could not base64-decode IG_COOKIES_B64: {e}") | |
| return "" | |
| def parse_netscape_cookies(text: str) -> dict: | |
| """Parse a netscape/cookies.txt body into a {name: value} dict.""" | |
| cookies = {} | |
| for line in text.splitlines(): | |
| raw = line.strip() | |
| if not raw: | |
| continue | |
| # "#HttpOnly_" lines are real cookies; other "#" lines are comments | |
| if raw.startswith("#HttpOnly_"): | |
| raw = raw[len("#HttpOnly_"):] | |
| elif raw.startswith("#"): | |
| continue | |
| parts = raw.split("\t") | |
| if len(parts) < 7: # some exports use spaces instead of tabs | |
| parts = raw.split() | |
| if len(parts) >= 7: | |
| cookies[parts[5]] = parts[6] | |
| return cookies | |
| def get_ig_cookies() -> dict: | |
| return parse_netscape_cookies(_decode_b64_cookies()) | |
| def sync_ig_cookies_to_db() -> None: | |
| """Mirror the HF env cookies (IG_COOKIES_B64) into MongoDB so the Telegram | |
| bot that uploads to Instagram always uses the latest cookies. Change the | |
| cookies on Hugging Face and they propagate to the bot automatically.""" | |
| b64 = os.getenv("IG_COOKIES_B64", "").strip() | |
| if not b64: | |
| logger.info("[IG] IG_COOKIES_B64 not set on HF β leaving MongoDB cookies as-is") | |
| return | |
| # only push cookies that actually contain a sessionid (avoid clobbering good ones) | |
| if "sessionid" not in get_ig_cookies(): | |
| logger.warning("[IG] IG_COOKIES_B64 has no 'sessionid' β not syncing to DB") | |
| return | |
| try: | |
| config.update_one( | |
| {"_id": "ig_cookies"}, | |
| {"$set": {"cookies_b64": b64}}, | |
| upsert=True, | |
| ) | |
| logger.info("[IG] β synced IG_COOKIES_B64 (env) β MongoDB for the bot") | |
| except Exception as e: | |
| logger.warning(f"[IG] could not sync cookies to DB: {e}") | |
| def _parse_id_csv(raw: str) -> list: | |
| """Parse a CSV of Telegram user ids into a list of ints (ignores junk).""" | |
| ids = [] | |
| for tok in (raw or "").replace(" ", "").split(","): | |
| if not tok: | |
| continue | |
| try: | |
| ids.append(int(tok)) | |
| except ValueError: | |
| logger.warning(f"[bot] ignoring non-numeric authorized id '{tok}'") | |
| return ids | |
| def sync_telegram_config_to_db() -> None: | |
| """Store the HF env Telegram settings into MongoDB (config._id='telegram'): | |
| β’ BOT_TOKEN β bot_token (the bot's login token) | |
| β’ USER_ID β authorized_user_ids (only this user may send videos to the bot) | |
| The Koyeb bot logs in with the token and writes back its own bot_id/bot_username, | |
| so HF learns where to send. Only provided fields are written (no clobbering).""" | |
| token = os.getenv("BOT_TOKEN", "").strip() | |
| # USER_ID is the new HF variable; AUTHORIZED_USER_IDS kept as an alias. | |
| ids = _parse_id_csv(os.getenv("USER_ID", "") or os.getenv("AUTHORIZED_USER_IDS", "")) | |
| fields = {} | |
| if token: | |
| fields["bot_token"] = token | |
| if ids: | |
| fields["authorized_user_ids"] = ids | |
| if not fields: | |
| logger.info("[bot] no BOT_TOKEN/USER_ID on HF β leaving MongoDB telegram config as-is") | |
| return | |
| try: | |
| config.update_one({"_id": "telegram"}, {"$set": fields}, upsert=True) | |
| logger.info(f"[bot] β stored Telegram config β MongoDB ({', '.join(fields.keys())})") | |
| except Exception as e: | |
| logger.warning(f"[bot] could not store Telegram config in DB: {e}") | |
| # βββββββββββββββββββββββββββββ SEND EDITED VIDEO β INSTAGRAM TELEGRAM BOT | |
| # Instagram blocks this (HF) server's IP, so the actual Reel upload is done by the | |
| # companion Telegram bot in koyeb/. This app just DELIVERS the finished video to | |
| # that bot as a Telegram file, using the SAME telethon user session it already | |
| # uses to fetch reels. The bot then verifies the sender's user id, reads the IG | |
| # cookies from MongoDB and uploads the Reel with the caption we attach here. | |
| # | |
| # The bot's @username (or numeric id) is stored in MongoDB | |
| # (config._id="telegram", key "bot_username"), so nothing is hardcoded. | |
| # IG_BOT_TARGET env overrides it. | |
| def get_bot_target(): | |
| """Resolve the Telegram bot the edited video is sent to. The Koyeb bot writes | |
| its own bot_username/bot_id into MongoDB after it logs in, so HF reads it from | |
| there. IG_BOT_TARGET env is a manual override (e.g. before Koyeb's first run).""" | |
| override = os.getenv("IG_BOT_TARGET", "").strip() | |
| if override: | |
| return override.lstrip("@") | |
| try: | |
| doc = config.find_one({"_id": "telegram"}) or {} | |
| except Exception as e: | |
| logger.warning(f"[bot] could not read telegram config from MongoDB: {e}") | |
| doc = {} | |
| target = doc.get("bot_username") or doc.get("bot_id") or "" | |
| if isinstance(target, str): | |
| target = target.strip() | |
| return target | |
| async def _send_video_to_bot(video_path: str, caption: str) -> None: | |
| target = get_bot_target() | |
| if not target: | |
| raise RuntimeError( | |
| "Telegram bot target unknown " | |
| "(set config.telegram.bot_username in MongoDB or IG_BOT_TARGET)" | |
| ) | |
| if not SESSION_STR: | |
| raise RuntimeError("SESSION (telethon user session) is not set β cannot reach the bot") | |
| # a bare numeric id β int so telethon resolves it as a peer | |
| entity = target | |
| if isinstance(target, str) and target.lstrip("-").isdigit(): | |
| entity = int(target) | |
| async with TelegramClient(StringSession(SESSION_STR), API_ID, API_HASH) as tg: | |
| bot = await tg.get_entity(entity) | |
| # force_document=True keeps the exact bytes (no Telegram re-compression), | |
| # so Instagram receives the same max-quality master we exported. | |
| await tg.send_file(bot, video_path, caption=caption, force_document=True) | |
| logger.info(f"[bot] π€ sent edited video β {target} for Instagram upload") | |
| def send_video_to_bot(video_path: str, caption: str) -> str: | |
| """Deliver the edited Reel to the Instagram Telegram bot (with light retries). | |
| Returns a status string; the real Instagram URL is produced asynchronously by | |
| the bot, which replies to the sender itself.""" | |
| try: | |
| attempts = max(1, int(os.getenv("IG_RETRIES", "3"))) | |
| except ValueError: | |
| attempts = 3 | |
| last_err = None | |
| for i in range(1, attempts + 1): | |
| try: | |
| asyncio.run(_send_video_to_bot(video_path, caption)) | |
| return "sent-to-telegram-bot" | |
| except Exception as e: | |
| last_err = e | |
| logger.warning(f"[bot] attempt {i}/{attempts} failed: {e}") | |
| if i < attempts: | |
| try: | |
| time.sleep(int(os.getenv("IG_RETRY_SLEEP", "10"))) | |
| except ValueError: | |
| time.sleep(10) | |
| raise last_err | |
| def generate_ig_caption(part: int) -> str: | |
| line = random.choice(CAPTIONS) | |
| tags = ("#funny #memes #comedy #reels #reelsindia #viral #trending #fun " | |
| "#instareels #explore #foryou #shorts #funnyvideos #comedyreels #laugh") | |
| return f"{line}\n.\n.\n{tags}" | |
| # βββββββββββββββββββββββββββββ PLATFORM-SPECIFIC MASTERS | |
| # One edited master is produced, then lightly adapted per platform without | |
| # re-rendering the whole video (only a fast scale pass when actually needed). | |
| def prepare_yt_file(master_path: str) -> str: | |
| """Optionally UPSCALE the master to MASTER_WIDTH (default 1440) so YouTube | |
| transcodes it with the higher-quality VP9 codec β much smaller visible | |
| quality drop right after upload. Returns the master unchanged if disabled.""" | |
| import shutil, subprocess | |
| try: | |
| target_w = int(os.getenv("MASTER_WIDTH", "1440") or 0) | |
| except ValueError: | |
| target_w = 0 | |
| if target_w <= 0: | |
| return master_path | |
| w = _probe_width(master_path) | |
| if w and w >= target_w: | |
| return master_path # already large enough, don't re-encode | |
| ff = shutil.which("ffmpeg") | |
| if not ff: | |
| logger.warning("[YT] ffmpeg not found β uploading master as-is") | |
| return master_path | |
| os.makedirs("edited", exist_ok=True) | |
| out = f"edited/yt_{uuid.uuid4().hex}.mp4" | |
| crf = os.getenv("CRF", "15") | |
| cmd = [ | |
| ff, "-y", "-i", master_path, | |
| "-vf", f"scale={target_w}:-2:flags=lanczos", # -2 keeps height even for yuv420p | |
| "-c:v", "libx264", "-preset", os.getenv("SCALE_PRESET", "fast"), | |
| "-crf", str(crf), "-pix_fmt", "yuv420p", "-profile:v", "high", | |
| "-movflags", "+faststart", "-c:a", "aac", "-b:a", "192k", out, | |
| ] | |
| try: | |
| r = subprocess.run(cmd, capture_output=True, text=True, timeout=1200) | |
| if r.returncode == 0 and os.path.exists(out) and os.path.getsize(out) > 0: | |
| _log_quality(out) | |
| return out | |
| logger.warning(f"[YT] upscale failed ({r.returncode}); using master. {r.stderr[-300:]}") | |
| except Exception as e: | |
| logger.warning(f"[YT] upscale error: {e}; using master") | |
| return master_path | |
| def prepare_ig_file(master_path: str) -> str: | |
| """Instagram always recompresses and prefers <=1080px wide. Downscale a big | |
| master so IG accepts it cleanly. Set IG_FORCE_9_16=1 to pad to exact 1080x1920.""" | |
| import shutil, subprocess | |
| force = os.getenv("IG_FORCE_9_16", "0") == "1" | |
| w = _probe_width(master_path) | |
| if not force and (not w or w <= 1080): | |
| return master_path # already IG-friendly, don't re-encode | |
| ff = shutil.which("ffmpeg") | |
| if not ff: | |
| return master_path | |
| os.makedirs("edited", exist_ok=True) | |
| out = f"edited/ig_{uuid.uuid4().hex}.mp4" | |
| if force: | |
| vf = ("scale=1080:1920:force_original_aspect_ratio=decrease," | |
| "pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black,setsar=1") | |
| else: | |
| vf = "scale=1080:-2:flags=lanczos" | |
| cmd = [ | |
| ff, "-y", "-i", master_path, "-vf", vf, | |
| "-c:v", "libx264", "-preset", os.getenv("SCALE_PRESET", "fast"), | |
| "-crf", "16", "-pix_fmt", "yuv420p", "-profile:v", "high", | |
| "-movflags", "+faststart", "-c:a", "aac", "-b:a", "192k", out, | |
| ] | |
| try: | |
| r = subprocess.run(cmd, capture_output=True, text=True, timeout=1200) | |
| if r.returncode == 0 and os.path.exists(out) and os.path.getsize(out) > 0: | |
| _log_quality(out) | |
| return out | |
| logger.warning(f"[IG] downscale failed ({r.returncode}); using master. {r.stderr[-300:]}") | |
| except Exception as e: | |
| logger.warning(f"[IG] downscale error: {e}; using master") | |
| return master_path | |
| def upload_to_both(yt_file: str, ig_file: str, yt_title: str, yt_desc: str, ig_caption: str) -> dict: | |
| """Upload the SAME video to YouTube and Instagram AT THE SAME TIME. | |
| Both run in parallel threads; one platform failing never blocks the other. | |
| Returns {"youtube": url?, "instagram": url?, "*_error": msg?}. | |
| """ | |
| results: dict = {} | |
| def _yt(): | |
| try: | |
| results["youtube"] = upload_to_youtube(yt_file, yt_title, yt_desc) | |
| except Exception as e: | |
| logger.error(f"[YT] β upload failed: {e}") | |
| results["youtube_error"] = str(e) | |
| def _ig(): | |
| # Instagram is uploaded by the companion Telegram bot (koyeb/). Here we | |
| # only deliver the edited video to that bot; the bot does the real upload. | |
| if not get_bot_target(): | |
| logger.warning("[IG] skipped β no Telegram bot target " | |
| "(set config.telegram.bot_username in MongoDB or IG_BOT_TARGET)") | |
| results["instagram_error"] = "no bot target" | |
| return | |
| try: | |
| results["instagram"] = send_video_to_bot(ig_file, ig_caption) | |
| except Exception as e: | |
| logger.error(f"[IG] β send-to-bot failed: {e}") | |
| results["instagram_error"] = str(e) | |
| t_yt, t_ig = Thread(target=_yt), Thread(target=_ig) | |
| t_yt.start(); t_ig.start() | |
| t_yt.join(); t_ig.join() | |
| return results | |
| def _load_slots(): | |
| """Daily upload times (IST). Override with UPLOAD_SLOTS="11:30,18:30,20:00". | |
| List as many times as you want (e.g. 8 entries within one hour).""" | |
| raw = os.getenv("UPLOAD_SLOTS", "").strip() | |
| if raw: | |
| slots = [] | |
| for tok in raw.split(","): | |
| tok = tok.strip() | |
| if not tok: | |
| continue | |
| try: | |
| h, m = tok.split(":") | |
| slots.append((int(h), int(m))) | |
| except Exception: | |
| logger.warning(f"[slots] ignoring invalid slot '{tok}'") | |
| if slots: | |
| return slots | |
| return [(11, 30), (14, 5), (20, 0)] | |
| def get_next_part(): | |
| last = meta.find_one(sort=[("part", -1)]) | |
| return 1 if not last else last["part"] + 1 | |
| def generate_description(title): | |
| return f"Watch this hilarious clip: {title}" | |
| def save_to_db(part, title, desc, link): | |
| meta.insert_one({"part": part, "title": title, "description": desc, "link": link, "uploaded": time.time()}) | |
| # βββββββββββββββββββββββββββββ MAIN AUTO LOOP | |
| def auto_loop(): | |
| asyncio.set_event_loop(asyncio.new_event_loop()) | |
| global UPLOAD_TIMES, NEXT_RESET | |
| from datetime import datetime, timedelta, timezone | |
| import time, os | |
| ist = timezone(timedelta(hours=5, minutes=30)) | |
| # Upload times (IST). Default = 3 golden slots; override with UPLOAD_SLOTS, | |
| # e.g. UPLOAD_SLOTS="14:00,14:05,14:10,..." to push 8 videos within one hour. | |
| DAILY_SLOTS = _load_slots() | |
| uploads_done_today = 0 | |
| NEXT_RESET = datetime.now(ist).replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) | |
| logger.info(f"[π ] Daily upload slots (IST): {DAILY_SLOTS}") | |
| logger.info( | |
| "[βΉοΈ] Quality note: every video is encoded with identical max-quality " | |
| "settings, so uploading 8 in one hour does NOT lower local quality. Any " | |
| "drop you see is YouTube/Instagram server-side transcoding right after " | |
| "upload (it finishes HD later β 'recovers next day'). MASTER_WIDTH=1440 " | |
| "makes YouTube use the higher-quality VP9 codec to minimise it." | |
| ) | |
| # Instagram is uploaded by the companion Telegram bot (koyeb/). Mirror ALL | |
| # HF env config into MongoDB (cookies + bot token/username/authorized ids) so | |
| # the bot is set up entirely from Hugging Face β Koyeb only needs MONGO_URI. | |
| sync_telegram_config_to_db() | |
| sync_ig_cookies_to_db() | |
| logger.info(f"[βΉοΈ] Instagram via Telegram bot: {get_bot_target() or '(not configured)'}") | |
| while True: | |
| try: | |
| now = datetime.now(ist) | |
| # π Reset daily | |
| if now >= NEXT_RESET: | |
| UPLOAD_TIMES.clear() | |
| uploads_done_today = 0 | |
| NEXT_RESET = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) | |
| logger.info("[π] Daily reset completed.") | |
| # β Stop if all uploads done | |
| if uploads_done_today >= len(DAILY_SLOTS): | |
| time.sleep(60) | |
| continue | |
| # π― Get next scheduled slot | |
| next_slot_hour, next_slot_min = DAILY_SLOTS[uploads_done_today] | |
| next_upload_time = now.replace(hour=next_slot_hour, minute=next_slot_min, second=0, microsecond=0) | |
| # If slot already passed β move to next | |
| if now > next_upload_time: | |
| uploads_done_today += 1 | |
| continue | |
| # β³ Sleep until next upload time | |
| sleep_seconds = (next_upload_time - now).total_seconds() | |
| if sleep_seconds > 0: | |
| logger.info(f"[β³] Sleeping until next slot: {next_upload_time.strftime('%I:%M %p')}") | |
| time.sleep(sleep_seconds) | |
| # π START UPLOAD | |
| logger.info(f"[π] Uploading at golden time: {next_upload_time.strftime('%I:%M %p')}") | |
| video_path, reel_type = asyncio.run(fetch_valid_reel()) | |
| if not video_path: | |
| logger.warning("[β οΈ] No valid reel found. Retrying in 5 mins...") | |
| time.sleep(300) | |
| continue | |
| edited = edit_video_raw(video_path) if reel_type == "raw" else edit_video(video_path) | |
| part = get_next_part() | |
| title = f"Try not to laugh || #{part} #funny #memes #comedy #shorts" | |
| desc = generate_description(title) | |
| ig_caption = generate_ig_caption(part) | |
| # Build the per-platform masters from the one edited video. | |
| yt_file = prepare_yt_file(edited) # optional 1440p upscale (VP9 on YouTube) | |
| ig_file = prepare_ig_file(edited) # <=1080px wide for Instagram | |
| # π Upload to YouTube AND Instagram AT THE SAME TIME (same video). | |
| results = upload_to_both(yt_file, ig_file, title, desc, ig_caption) | |
| link = results.get("youtube") or results.get("instagram") | |
| if link: | |
| save_to_db(part, title, desc, link) | |
| logger.info(f"[π€] Uploaded #{part}: {results}") | |
| UPLOAD_TIMES.append(datetime.now(ist)) | |
| else: | |
| logger.error(f"[β] Both uploads failed for #{part}: {results}") | |
| uploads_done_today += 1 # advance regardless so a bad item can't stall the loop | |
| # π§Ή Cleanup (source, master and any per-platform copies) | |
| for p in {video_path, edited, yt_file, ig_file}: | |
| try: | |
| if p and os.path.exists(p): | |
| os.remove(p) | |
| except OSError: | |
| pass | |
| # Small delay to stabilize | |
| time.sleep(int(os.getenv("POST_UPLOAD_SLEEP", "30"))) | |
| except Exception as e: | |
| logger.error(f"[β] Loop error: {e}") | |
| time.sleep(60) | |
| if __name__ == "__main__": | |
| import asyncio | |
| from threading import Thread | |
| Thread(target=lambda: app.run(host="0.0.0.0", port=7860, debug=False, use_reloader=False)).start() | |
| # β Run uploader loop in background | |
| Thread(target=auto_loop, daemon=True).start() |