File size: 6,897 Bytes
bf4a976
 
 
 
 
adc6b45
bf4a976
 
 
 
 
 
adc6b45
 
 
 
 
 
 
bf4a976
 
 
adc6b45
 
 
bc35cfe
bf4a976
adc6b45
bf4a976
adc6b45
 
 
bf4a976
 
adc6b45
 
 
 
bf4a976
 
 
 
adc6b45
bf4a976
 
 
 
adc6b45
bf4a976
adc6b45
 
 
bf4a976
 
 
adc6b45
 
 
bf4a976
adc6b45
 
 
bf4a976
 
 
adc6b45
bf4a976
 
adc6b45
 
 
bf4a976
adc6b45
 
 
 
 
 
 
 
 
 
 
bf4a976
 
adc6b45
 
 
bf4a976
 
adc6b45
bf4a976
adc6b45
93118a7
adc6b45
 
 
 
 
 
 
 
bf4a976
adc6b45
bf4a976
 
 
 
 
 
 
 
 
 
adc6b45
 
 
 
 
 
 
 
 
 
bf4a976
 
adc6b45
bf4a976
adc6b45
 
 
bf4a976
 
adc6b45
bf4a976
 
adc6b45
 
 
 
 
 
 
 
 
 
 
 
 
 
bf4a976
adc6b45
bf4a976
adc6b45
 
 
 
 
bf4a976
adc6b45
bf4a976
 
adc6b45
 
bf4a976
 
 
 
adc6b45
bf4a976
 
 
 
 
 
 
 
 
adc6b45
 
 
 
 
 
 
 
 
 
bf4a976
 
 
 
 
 
adc6b45
bf4a976
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
import asyncio
import logging
import os
import shutil
from pathlib import Path
from urllib.parse import urlparse

import aiofiles
import httpx
from dotenv import load_dotenv
from telegram import Update
from telegram.constants import ChatAction
from telegram.ext import (
    Application,
    CommandHandler,
    ContextTypes,
    MessageHandler,
    filters,
)

load_dotenv()

BOT_TOKEN        = os.environ["BOT_TOKEN"]
# Point strictly to localhost on port 7860 where the local server co-exists
BOT_API_URL      = os.environ.get("BOT_API_URL", "http://127.0.0.1:7860/bot")
BOT_API_FILE_URL = os.environ.get("BOT_API_FILE_URL", "http://127.0.0.1:7860/file/bot")

DOWNLOAD_DIR = Path("/app/downloads")
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)

# Limit concurrent operations to save container resources
CONCURRENCY = int(os.environ.get("CONCURRENCY", "3"))
sem = asyncio.Semaphore(CONCURRENCY)

logging.basicConfig(
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    level=logging.INFO,
)
log = logging.getLogger("media-bot")

MEDIA_EXTS = {
    "photo": {".jpg", ".jpeg", ".png", ".webp"},
    "video": {".mp4", ".mkv", ".mov", ".webm", ".avi"},
    "audio": {".mp3", ".m4a", ".ogg", ".wav", ".flac", ".opus"},
}

async def cmd_start(update: Update, _):
    await update.message.reply_text(
        "👋 *Media Downloader Bot*\n\n"
        "Send me a link from Instagram, TikTok, YouTube, X, Reddit, Facebook... "
        "or a direct file URL. I will fetch it and send it back to you "
        "(supports up to 2 GB).",
        parse_mode="Markdown",
    )

def looks_like_direct_file(url: str) -> bool:
    path = urlparse(url).path.lower()
    return any(path.endswith(ext) for cat in MEDIA_EXTS.values() for ext in cat)

async def fetch_direct(url: str, dest: Path):
    """Fast async streaming for direct URLs bypassing yt-dlp overhead."""
    async with httpx.AsyncClient(follow_redirects=True, timeout=httpx.Timeout(600.0)) as client:
        async with client.stream("GET", url) as response:
            response.raise_for_status()
            async with aiofiles.open(dest, "wb") as f:
                async for chunk in response.aiter_bytes(1 << 20):  # 1 MiB chunks
                    await f.write(chunk)

async def fetch_with_ytdlp(url: str, dest_dir: Path) -> list[Path]:
    """Executes yt-dlp in a managed subprocess with multi-threading optimized commands."""
    out_template = str(dest_dir / "%(title).200B.%(ext)s")
    cmd = [
        "yt-dlp",
        "--no-warnings",
        "--no-playlist",
        "--no-progress",
        "--no-mtime",
        "--concurrent-fragments", "4",  # Parallel fragment downloads for speed
        "--retries", "5",
        "--embed-metadata",
        "--embed-thumbnail",
        "--merge-output-format", "mp4",
        "--format", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
        "--output", out_template,
    ]

    # Automatically check and inject cookies if available
    cookies_file = Path("/app/cookies.txt")
    if cookies_file.exists() and cookies_file.stat().st_size > 0:
        cmd.extend(["--cookies", str(cookies_file)])
        log.info("Using exported cookies for extraction.")

    cmd.append(url)

    proc = await asyncio.create_subprocess_exec(
        *cmd,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    _, stderr = await proc.communicate()
    if proc.returncode != 0:
        raise RuntimeError(stderr.decode(errors="replace").strip()[:1500])

    return [p for p in dest_dir.iterdir() if p.is_file()]

def classify(path: Path):
    ext = path.suffix.lower()
    for kind, exts in MEDIA_EXTS.items():
        if ext in exts:
            return kind
    return "document"

async def send_file(update: Update, path: Path):
    kind = classify(path)
    caption = path.name
    with path.open("rb") as fh:
        if kind == "photo":
            await update.message.reply_photo(photo=fh, caption=caption)
        elif kind == "video":
            await update.message.reply_video(video=fh, caption=caption, supports_streaming=True)
        elif kind == "audio":
            await update.message.reply_audio(audio=fh, caption=caption)
        else:
            await update.message.reply_document(document=fh, caption=caption)

async def handle_link(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
    url = update.message.text.strip()
    user = update.message.from_user
    log.info("User %s requested URL: %s", user.id, url)

    status = await update.message.reply_text("⏳ Downloading... Please wait.")

    async with sem:
        tmp_dir = DOWNLOAD_DIR / f"{update.update_id}"
        tmp_dir.mkdir(parents=True, exist_ok=True)
        try:
            # Keeps the interactive "uploading document" continuous during high latency
            ping_task = asyncio.create_task(_keep_pinging(update))

            try:
                if looks_like_direct_file(url):
                    name = Path(urlparse(url).path).name or "file"
                    dest = tmp_dir / name
                    await fetch_direct(url, dest)
                    files = [dest]
                else:
                    files = await fetch_with_ytdlp(url, tmp_dir)
            finally:
                ping_task.cancel()

            if not files:
                await status.edit_text("⚠️ No downloadable media found.")
                return

            await status.edit_text(f"📤 Uploading {len(files)} file(s) to Telegram...")
            await update.message.chat.send_action(ChatAction.UPLOAD_DOCUMENT)

            for f in files:
                await send_file(update, f)

            await status.delete()
        except Exception as e:
            log.exception("Task failed.")
            await status.edit_text(f"❌ Error: {e}"[:4000])
        finally:
            shutil.rmtree(tmp_dir, ignore_errors=True)

async def _keep_pinging(update: Update):
    """Refreshes Telegram status action every 4 seconds for huge transfers."""
    try:
        while True:
            await update.message.chat.send_action(ChatAction.UPLOAD_DOCUMENT)
            await asyncio.sleep(4)
    except asyncio.CancelledError:
        pass

def build_app() -> Application:
    return (
        Application.builder()
        .token(BOT_TOKEN)
        .base_url(BOT_API_URL)
        .base_file_url(BOT_API_FILE_URL)
        .local_mode(True)  # Activates Local Server Mode for 2 GB files
        .read_timeout(600)
        .write_timeout(600)
        .connect_timeout(60)
        .pool_timeout(60)
        .build()
    )

def main():
    app = build_app()
    app.add_handler(CommandHandler("start", cmd_start))
    app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_link))
    log.info("Bot infrastructure successfully initialized.")
    app.run_polling(allowed_updates=Update.ALL_TYPES)

if __name__ == "__main__":
    main()