Download app.py from Vvbbnnn/ab: direct link, hf CLI and curl.
- Browser
- Download file 62.1 kB
-
https://huggingface.co/spaces/Vvbbnnn/ab/resolve/main/app.py
- Command line
-
hf download hf://spaces/Vvbbnnn/ab/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Vvbbnnn/ab/resolve/main/app.py
62.1 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 (Koyeb auto-registers its URL here) | |
| 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 = "AllLinksDLBot" | |
| 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 | |
| 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 | |
| duration = tg_duration_seconds(msg) | |
| if duration: | |
| if 20 <= duration <= 180: | |
| pathlib.Path("reels").mkdir(exist_ok=True) | |
| file_path = await msg.download_media(file="reels/") | |
| logger.info(f"β Downloaded video β {file_path}") | |
| return file_path, duration | |
| logger.info(f"β© Skipped due to invalid duration = {duration}s") | |
| return None, None | |
| # β 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 (netscape cookies in base64) | |
| # | |
| # Authentication uses an Instagram "Netscape cookies" export (the cookies.txt | |
| # format produced by browser extensions / yt-dlp) supplied as ONE base64 string | |
| # in the IG_COOKIES_B64 environment variable. We decode it, read the `sessionid` | |
| # cookie and log in with instagrapi. The device fingerprint is cached in MongoDB | |
| # so every restart reuses the same device β far fewer Instagram security checks. | |
| 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 get_ig_settings(): | |
| """Load cached instagrapi settings (stable device + session) from MongoDB.""" | |
| try: | |
| doc = tokens.find_one({"_id": "ig_settings"}) | |
| if doc and doc.get("settings_json"): | |
| return json.loads(doc["settings_json"]) | |
| except Exception as e: | |
| logger.warning(f"[IG] could not read cached settings: {e}") | |
| return None | |
| def save_ig_settings(cl) -> None: | |
| """Persist instagrapi settings so the next run reuses the same device/session.""" | |
| try: | |
| tokens.update_one( | |
| {"_id": "ig_settings"}, | |
| {"$set": {"settings_json": json.dumps(cl.get_settings())}}, | |
| upsert=True, | |
| ) | |
| except Exception as e: | |
| logger.warning(f"[IG] could not cache settings: {e}") | |
| # ββ Free proxy auto-discovery (proxifly/free-proxy-list): India first βββββββββ | |
| # Instagram blocks datacenter IPs (the SSL/Max-retries error from a cloud host). | |
| # When IG_PROXY is not set, we automatically pull proxies from proxifly's public | |
| # list, prefer India (IN), TEST each one against Instagram, and fall back to the | |
| # nearest countries until a working proxy is found. Free proxies are unreliable, | |
| # so the result is cached and automatically refreshed when an upload fails. | |
| _PROXY_SOURCES = ( | |
| "https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/countries/{cc}/data.txt", | |
| "https://raw.githubusercontent.com/proxifly/free-proxy-list/main/proxies/countries/{cc}/data.txt", | |
| ) | |
| # India first, then outward by geographic proximity. | |
| _PROXY_COUNTRY_ORDER = [ | |
| "IN", "NP", "BD", "LK", "PK", "BT", "MM", "MV", "AF", # India + immediate neighbours | |
| "CN", "TH", "SG", "MY", "ID", "VN", "HK", "PH", "KH", "LA", # nearby Asia | |
| "AE", "SA", "QA", "BH", "OM", "KW", "IR", "KZ", "UZ", # Gulf / Central Asia | |
| "TR", "RU", "JP", "KR", "TW", "GB", "DE", "NL", "FR", "US", # wider fallback | |
| ] | |
| _IG_PROXY_CACHE = {"proxy": None, "ts": 0.0} | |
| def _fetch_country_proxies(cc): | |
| """Download the proxifly proxy list for a country code β list of 'proto://ip:port'.""" | |
| for tmpl in _PROXY_SOURCES: | |
| try: | |
| r = requests.get(tmpl.format(cc=cc), timeout=15) | |
| if r.status_code == 200 and r.text.strip(): | |
| return [ln.strip() for ln in r.text.splitlines() | |
| if "://" in ln and not ln.strip().startswith("#")] | |
| except Exception as e: | |
| logger.warning(f"[proxy] fetch {cc} failed: {e}") | |
| return [] | |
| def _test_proxy(proxy_url): | |
| """True if `proxy_url` can actually reach Instagram (TLS not reset/blocked).""" | |
| try: | |
| timeout = int(os.getenv("IG_PROXY_TEST_TIMEOUT", "8")) | |
| except ValueError: | |
| timeout = 8 | |
| test_url = os.getenv("IG_PROXY_TEST_URL", "https://i.instagram.com/") | |
| proxies = {"http": proxy_url, "https": proxy_url} | |
| try: | |
| r = requests.get(test_url, proxies=proxies, timeout=timeout, | |
| headers={"User-Agent": "Mozilla/5.0 (Linux; Android 11)"}) | |
| return r.status_code < 500 | |
| except Exception: | |
| return False | |
| # Frequently-updated GitHub proxy repos (refreshed roughly hourly). Each entry is | |
| # (url, default_scheme); default_scheme is prepended to bare ip:port lines. | |
| _PROXY_REPOS = [ | |
| ("https://raw.githubusercontent.com/proxifly/free-proxy-list/main/proxies/all/data.txt", None), | |
| ("https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/all.txt", None), | |
| ("https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/socks5.txt", "socks5"), | |
| ("https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/http.txt", "http"), | |
| ("https://raw.githubusercontent.com/hookzof/socks5_list/master/proxy.txt", "socks5"), | |
| ("https://raw.githubusercontent.com/jetkai/proxy-list/main/online-proxies/txt/proxies-socks5.txt", "socks5"), | |
| ("https://raw.githubusercontent.com/mmpx12/proxy-list/master/socks5.txt", "socks5"), | |
| ("https://raw.githubusercontent.com/roosterkid/openproxylist/main/SOCKS5_RAW.txt", "socks5"), | |
| ("https://raw.githubusercontent.com/sunny9577/proxy-scraper/master/proxies.txt", "http"), | |
| ("https://raw.githubusercontent.com/vakhov/fresh-proxy-list/master/socks5.txt", "socks5"), | |
| ] | |
| _PROXY_RE = None | |
| def _parse_proxies(text, default_scheme): | |
| """Extract 'proto://ip:port' from any proxy-list text (handles bare ip:port, | |
| scheme-prefixed, and 'ip:port:Country' formats).""" | |
| import re | |
| global _PROXY_RE | |
| if _PROXY_RE is None: | |
| _PROXY_RE = re.compile(r'(?:(socks5|socks4|https?)://)?((?:\d{1,3}\.){3}\d{1,3}):(\d{2,5})') | |
| out = [] | |
| for line in text.splitlines(): | |
| line = line.strip() | |
| if not line or line.startswith("#"): | |
| continue | |
| m = _PROXY_RE.search(line) | |
| if not m: | |
| continue | |
| scheme = m.group(1) or default_scheme | |
| if scheme: | |
| out.append(f"{scheme}://{m.group(2)}:{m.group(3)}") | |
| return out | |
| def _test_proxy_many(proxies, workers): | |
| """Return the first proxy in `proxies` that can reach Instagram, else None.""" | |
| import concurrent.futures | |
| if not proxies: | |
| return None | |
| with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex: | |
| futs = {ex.submit(_test_proxy, p): p for p in proxies} | |
| for fut in concurrent.futures.as_completed(futs): | |
| try: | |
| ok = fut.result() | |
| except Exception: | |
| ok = False | |
| if ok: | |
| good = futs[fut] | |
| for f in futs: # best-effort: stop the rest | |
| f.cancel() | |
| return good | |
| return None | |
| def _aggregate_repo_proxies(): | |
| """Pull and merge proxies from every GitHub repo (deduped, shuffled).""" | |
| import concurrent.futures | |
| seen, out = set(), [] | |
| def fetch(item): | |
| url, scheme = item | |
| try: | |
| r = requests.get(url, timeout=20) | |
| if r.status_code == 200 and r.text.strip(): | |
| return _parse_proxies(r.text, scheme) | |
| except Exception as e: | |
| logger.warning(f"[proxy] repo fetch failed {url}: {e}") | |
| return [] | |
| with concurrent.futures.ThreadPoolExecutor(max_workers=len(_PROXY_REPOS)) as ex: | |
| for lst in ex.map(fetch, _PROXY_REPOS): | |
| for p in lst: | |
| if p not in seen: | |
| seen.add(p) | |
| out.append(p) | |
| random.shuffle(out) | |
| logger.info(f"[proxy] aggregated {len(out)} unique proxies from {len(_PROXY_REPOS)} repos") | |
| return out | |
| def find_working_ig_proxy(): | |
| """Find a proxy that can reach Instagram. India first (proxifly country lists), | |
| then nearest countries, then a big pool merged from many GitHub repos.""" | |
| try: | |
| max_test = int(os.getenv("IG_PROXY_MAX_TEST", "50")) | |
| workers = int(os.getenv("IG_PROXY_WORKERS", "40")) | |
| agg_max = int(os.getenv("IG_PROXY_AGG_MAX", "800")) | |
| budget = int(os.getenv("IG_PROXY_BUDGET", "120")) # max seconds to search | |
| except ValueError: | |
| max_test, workers, agg_max, budget = 50, 40, 800, 120 | |
| t0 = time.time() | |
| tier1_deadline = t0 + budget * 0.5 # give India/nearby half the budget first | |
| overall_deadline = t0 + budget | |
| # ββ Tier 1: India first, then nearest countries (proxifly, country-aware) ββ | |
| override = os.getenv("IG_PROXY_COUNTRIES", "").strip() | |
| countries = [c.strip().upper() for c in override.split(",") if c.strip()] or _PROXY_COUNTRY_ORDER | |
| for cc in countries: | |
| if time.time() > tier1_deadline: | |
| break | |
| proxies = _fetch_country_proxies(cc)[:max_test] | |
| if not proxies: | |
| continue | |
| logger.info(f"[proxy] testing {len(proxies)} proxies from {cc} (proxifly) ...") | |
| good = _test_proxy_many(proxies, workers) | |
| if good: | |
| logger.info(f"[proxy] β working proxy ({cc}): {good}") | |
| return good | |
| # ββ Tier 2: big aggregated pool from many frequently-updated repos ββ | |
| agg = _aggregate_repo_proxies() | |
| limit = min(len(agg), agg_max) | |
| logger.info(f"[proxy] India/nearby empty β testing up to {limit} aggregated proxies ...") | |
| for start in range(0, limit, 120): | |
| if time.time() > overall_deadline: | |
| logger.info("[proxy] search budget reached, stopping") | |
| break | |
| good = _test_proxy_many(agg[start:start + 120], workers) | |
| if good: | |
| logger.info(f"[proxy] β working proxy (aggregate): {good}") | |
| return good | |
| logger.error("[proxy] β no working free proxy found this round") | |
| return None | |
| def get_ig_proxy(force=False): | |
| """Return the proxy to use for Instagram. IG_PROXY env wins; otherwise auto | |
| discover one from proxifly (cached, refreshed on demand).""" | |
| env_proxy = os.getenv("IG_PROXY", "").strip() | |
| if env_proxy: | |
| return env_proxy | |
| if os.getenv("IG_AUTO_PROXY", "1") != "1": | |
| return None | |
| try: | |
| ttl = int(os.getenv("IG_PROXY_TTL", "1800")) | |
| except ValueError: | |
| ttl = 1800 | |
| now = time.time() | |
| if not force and _IG_PROXY_CACHE["proxy"] and (now - _IG_PROXY_CACHE["ts"] < ttl): | |
| return _IG_PROXY_CACHE["proxy"] | |
| proxy = find_working_ig_proxy() | |
| _IG_PROXY_CACHE["proxy"] = proxy | |
| _IG_PROXY_CACHE["ts"] = now | |
| return proxy | |
| def _build_ig_client(): | |
| """Build a logged-in instagrapi client from the base64 netscape cookies.""" | |
| from instagrapi import Client | |
| cookies = get_ig_cookies() | |
| sessionid = cookies.get("sessionid") | |
| if not sessionid: | |
| raise RuntimeError( | |
| "No 'sessionid' cookie found in IG_COOKIES_B64 β export the cookies " | |
| "while logged in to instagram.com (netscape/cookies.txt format)." | |
| ) | |
| cl = Client() | |
| cl.delay_range = [1, 3] # act human-ish between requests | |
| # Route Instagram traffic through a proxy (datacenter IPs are blocked by | |
| # Instagram at the TLS layer). Uses IG_PROXY if set, otherwise auto-discovers | |
| # a working free proxy from proxifly β India first, then nearest countries. | |
| proxy = get_ig_proxy() | |
| if proxy: | |
| try: | |
| cl.set_proxy(proxy) | |
| logger.info(f"[IG] using proxy {proxy}") | |
| except Exception as e: | |
| logger.warning(f"[IG] invalid proxy {proxy} ignored: {e}") | |
| else: | |
| logger.warning("[IG] no proxy available β Instagram will likely block this server IP") | |
| try: | |
| cl.request_timeout = int(os.getenv("IG_TIMEOUT", "20")) | |
| except ValueError: | |
| pass | |
| cached = get_ig_settings() | |
| if cached: | |
| # Restore the previous device fingerprint BEFORE login (login_by_sessionid | |
| # only overwrites the cookies key, it keeps uuids/device/user_agent). | |
| try: | |
| cl.set_settings(cached) | |
| except Exception as e: | |
| logger.warning(f"[IG] ignoring bad cached settings: {e}") | |
| cl.login_by_sessionid(sessionid) | |
| save_ig_settings(cl) | |
| logger.info("[IG] β Logged in via sessionid cookie") | |
| return cl | |
| 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}" | |
| def upload_to_instagram(video_path: str, caption: str) -> str: | |
| """Upload `video_path` to Instagram as a Reel and return its URL (with retries).""" | |
| 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: | |
| cl = _build_ig_client() | |
| media = cl.clip_upload(pathlib.Path(video_path), caption) | |
| save_ig_settings(cl) # persist refreshed session/cookies | |
| code = getattr(media, "code", None) | |
| url = f"https://www.instagram.com/reel/{code}/" if code else "https://www.instagram.com/" | |
| logger.info(f"[IG] β Reel uploaded: {url}") | |
| return url | |
| except Exception as e: | |
| last_err = e | |
| logger.warning(f"[IG] attempt {i}/{attempts} failed: {e}") | |
| # the (free) proxy likely died or got blocked β force a fresh one next try | |
| _IG_PROXY_CACHE["proxy"] = None | |
| if not os.getenv("IG_PROXY", "").strip(): | |
| logger.warning("[IG] β οΈ Free proxies are unreliable. For stable uploads set " | |
| "IG_PROXY to a residential/mobile proxy.") | |
| if i < attempts: | |
| try: | |
| time.sleep(int(os.getenv("IG_RETRY_SLEEP", "10"))) | |
| except ValueError: | |
| time.sleep(10) | |
| raise last_err | |
| # βββββββββββββββββββββββββββββ INSTAGRAM VIA KOYEB (companion 24/7 service) | |
| # Instagram blocks this (HF) server's IP but NOT Koyeb's. So we can hand the | |
| # edited video + cookies to a small Koyeb service over HTTPS (shared secret), | |
| # which uploads the Reel and replies ok/error. See koyeb/ for that service. | |
| def _koyeb_upload_url(): | |
| """Resolve the Koyeb upload URL. Explicit KOYEB_UPLOAD_URL wins; otherwise | |
| auto-discover it from MongoDB, where the Koyeb app registers its own public | |
| URL on startup / first request (so you never hardcode the URL or port).""" | |
| url = os.getenv("KOYEB_UPLOAD_URL", "").strip() | |
| if url: | |
| return url | |
| try: | |
| doc = sync.find_one({"_id": "koyeb"}) | |
| if doc and doc.get("upload_url"): | |
| return doc["upload_url"].strip() | |
| except Exception as e: | |
| logger.warning(f"[SYNC] could not auto-discover Koyeb URL: {e}") | |
| return "" | |
| def _koyeb_base_url(): | |
| url = _koyeb_upload_url() | |
| if not url: | |
| return "" | |
| return url[:-len("/upload")] if url.endswith("/upload") else url.rstrip("/") | |
| def koyeb_alive(): | |
| """Ping the Koyeb companion's /health so the two apps can confirm each other.""" | |
| base = _koyeb_base_url() | |
| if not base: | |
| return False | |
| try: | |
| r = requests.get(base + "/health", timeout=10) | |
| ok = r.ok | |
| logger.info(f"[SYNC] Koyeb health: {'alive β ' if ok else 'unreachable β'} ({r.status_code})") | |
| return ok | |
| except Exception as e: | |
| logger.warning(f"[SYNC] Koyeb health check failed: {e}") | |
| return False | |
| def upload_via_koyeb(video_path: str, caption: str) -> str: | |
| """Send the video + cookies to the Koyeb service and return the Reel URL. | |
| Raises with Koyeb's reported error on failure (this is the sync handshake).""" | |
| url = _koyeb_upload_url() | |
| if not url: | |
| raise RuntimeError("Koyeb URL unknown (KOYEB_UPLOAD_URL unset and none auto-registered in DB)") | |
| try: | |
| timeout = int(os.getenv("KOYEB_TIMEOUT", "300")) | |
| except ValueError: | |
| timeout = 300 | |
| headers = {} | |
| token = os.getenv("SYNC_TOKEN", "").strip() | |
| if token: | |
| headers["X-Auth-Token"] = token | |
| data = { | |
| "caption": caption, | |
| "cookies_b64": os.getenv("IG_COOKIES_B64", ""), # sent over HTTPS to Koyeb | |
| } | |
| logger.info(f"[SYNC] β sending {os.path.getsize(video_path)/1_000_000:.1f} MB video to Koyeb ...") | |
| with open(video_path, "rb") as fh: | |
| files = {"file": (os.path.basename(video_path), fh, "video/mp4")} | |
| r = requests.post(url, files=files, data=data, headers=headers, timeout=timeout) | |
| try: | |
| j = r.json() | |
| except Exception: | |
| raise RuntimeError(f"Koyeb bad response {r.status_code}: {r.text[:200]}") | |
| if r.status_code == 200 and j.get("status") == "ok": | |
| link = j.get("url") | |
| logger.info(f"[SYNC] β Koyeb says: uploaded β {link}") | |
| return link | |
| err = j.get("error", r.text[:200]) | |
| logger.error(f"[SYNC] β Koyeb says: FAILED β {err}") | |
| raise RuntimeError(f"Koyeb upload failed: {err}") | |
| def _ig_method() -> str: | |
| """'koyeb' or 'local'. IG_MODE overrides; otherwise auto-pick Koyeb when configured.""" | |
| mode = os.getenv("IG_MODE", "auto").strip().lower() | |
| if mode in ("koyeb", "local"): | |
| return mode | |
| return "koyeb" if _koyeb_upload_url() else "local" | |
| def instagram_upload(video_path: str, caption: str) -> str: | |
| """Dispatch the Instagram upload to Koyeb or local instagrapi.""" | |
| if _ig_method() == "local": | |
| return upload_to_instagram(video_path, caption) # has its own retries | |
| # Koyeb path β light retry around the network handshake | |
| try: | |
| attempts = max(1, int(os.getenv("IG_RETRIES", "2"))) | |
| except ValueError: | |
| attempts = 2 | |
| last_err = None | |
| for i in range(1, attempts + 1): | |
| try: | |
| return upload_via_koyeb(video_path, caption) | |
| except Exception as e: | |
| last_err = e | |
| logger.warning(f"[IG via Koyeb] attempt {i}/{attempts} failed: {e}") | |
| if i < attempts: | |
| time.sleep(8) | |
| raise last_err | |
| # βββββββββββββββββββββββββββββ 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(): | |
| method = _ig_method() | |
| have_cookies = bool(os.getenv("IG_COOKIES_B64", "").strip()) | |
| have_koyeb = bool(_koyeb_upload_url()) | |
| if method == "koyeb" and not have_koyeb: | |
| logger.warning("[IG] skipped β no Koyeb URL (set KOYEB_UPLOAD_URL or let Koyeb auto-register)") | |
| results["instagram_error"] = "no Koyeb URL" | |
| return | |
| if method == "local" and not have_cookies: | |
| logger.warning("[IG] skipped β IG_COOKIES_B64 not set") | |
| results["instagram_error"] = "IG_COOKIES_B64 not set" | |
| return | |
| try: | |
| results["instagram"] = instagram_upload(ig_file, ig_caption) | |
| except Exception as e: | |
| logger.error(f"[IG] β upload 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), (18, 30), (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 path + Koyeb companion handshake | |
| logger.info(f"[βΉοΈ] Instagram mode: {_ig_method()}") | |
| if _ig_method() == "koyeb": | |
| koyeb_alive() # ping the Koyeb companion so the two apps confirm each other | |
| 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 | |
| # Optional: expose THIS app's dashboard (port 7860) on a permanent 24/7 | |
| # Cloudflare link too. No-op unless CLOUDFLARE_TUNNEL_TOKEN or | |
| # CLOUDFLARE_QUICK=1 is set, so default behaviour is unchanged. | |
| try: | |
| from cf_tunnel import start_tunnel | |
| start_tunnel(7860) | |
| except Exception as e: | |
| logger.warning(f"[cf] could not start tunnel: {e}") | |
| 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() |