Spaces:
Sleeping
Sleeping
| """ | |
| VideoGrab v2.0 — Hugging Face Spaces | |
| FastAPI + встроенный HTML интерфейс. Без Gradio — без конфликтов версий. | |
| """ | |
| import os | |
| import re | |
| import base64 | |
| import json | |
| import urllib.request | |
| import asyncio | |
| from pathlib import Path | |
| import yt_dlp | |
| from fastapi import FastAPI, Request | |
| from fastapi.responses import HTMLResponse, FileResponse, JSONResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| DOWNLOAD_DIR = Path("/tmp/videograb_downloads") | |
| DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True) | |
| app = FastAPI(title="VideoGrab") | |
| app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) | |
| # ─── Boomstream ─────────────────────────────────────────────────────────────── | |
| def is_boomstream(url: str) -> bool: | |
| return "play.boomstream.com" in url | |
| def resolve_boomstream(url: str): | |
| req = urllib.request.Request(url, headers={ | |
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" | |
| }) | |
| with urllib.request.urlopen(req, timeout=15) as resp: | |
| html = resp.read().decode("utf-8", errors="replace") | |
| match = re.search(r'window\.boomstreamConfig\s*=\s*(\{.+?\});', html, re.DOTALL) | |
| if not match: | |
| raise ValueError("boomstreamConfig не найден на странице") | |
| config = json.loads(match.group(1)) | |
| hls_b64 = config.get("mediaData", {}).get("links", {}).get("hls") | |
| if not hls_b64: | |
| raise ValueError("HLS-ссылка не найдена в конфиге") | |
| m3u8_url = base64.b64decode(hls_b64).decode("utf-8") | |
| title = config.get("mediaData", {}).get("title") or "boomstream_video" | |
| return m3u8_url, title | |
| # ─── Format helpers ─────────────────────────────────────────────────────────── | |
| QUALITY_MAP = { | |
| "best": "bestvideo+bestaudio/best", | |
| "4k": "bestvideo[height<=2160]+bestaudio/best", | |
| "1440": "bestvideo[height<=1440]+bestaudio/best", | |
| "1080": "bestvideo[height<=1080]+bestaudio/best", | |
| "720": "bestvideo[height<=720]+bestaudio/best", | |
| "480": "bestvideo[height<=480]+bestaudio/best", | |
| "360": "bestvideo[height<=360]+bestaudio/best", | |
| "240": "bestvideo[height<=240]+bestaudio/best", | |
| "worst": "worst", | |
| } | |
| CODEC_MAP = { | |
| "auto": "", "h264": "[vcodec^=avc]", "h265": "[vcodec^=hev]", | |
| "vp9": "[vcodec=vp9]", "av1": "[vcodec^=av01]", | |
| } | |
| CONTAINER_MAP = { | |
| "auto": None, "mp4": "mp4", "mkv": "mkv", "webm": "webm", | |
| "mov": "mov", "avi": "avi", "flv": "flv", | |
| } | |
| AUDIO_Q_MAP = {"best": "0", "medium": "5", "low": "9"} | |
| def build_format(quality, codec, container, audio_only, audio_quality): | |
| if audio_only: | |
| return "bestaudio" | |
| base = QUALITY_MAP.get(quality, "bestvideo+bestaudio/best") | |
| cf = CODEC_MAP.get(codec, "") | |
| if cf: | |
| base = f"bestvideo{cf}+bestaudio/best" | |
| return base | |
| def find_latest_file(since_mtime: float): | |
| files = sorted(DOWNLOAD_DIR.rglob("*"), key=lambda f: f.stat().st_mtime, reverse=True) | |
| for f in files: | |
| if f.is_file() and f.stat().st_mtime >= since_mtime: | |
| return f | |
| return None | |
| # ─── Download logic ─────────────────────────────────────────────────────────── | |
| def do_download(url, audio_only, quality, codec, container, audio_quality): | |
| import time | |
| logs = [] | |
| if is_boomstream(url): | |
| try: | |
| url, title = resolve_boomstream(url) | |
| logs.append(f"Boomstream: найдена HLS-ссылка для «{title}»") | |
| except Exception as e: | |
| return None, [f"Boomstream: {e}"] | |
| fmt = build_format(quality, codec, container, audio_only, audio_quality) | |
| cont = CONTAINER_MAP.get(container) | |
| aq = AUDIO_Q_MAP.get(audio_quality, "0") | |
| start_mtime = time.time() - 1 | |
| ydl_opts = { | |
| "format": fmt, | |
| "outtmpl": str(DOWNLOAD_DIR / "%(title)s.%(ext)s"), | |
| "noplaylist": True, | |
| "quiet": True, | |
| } | |
| if audio_only: | |
| ydl_opts["postprocessors"] = [{ | |
| "key": "FFmpegExtractAudio", | |
| "preferredcodec": "mp3", | |
| "preferredquality": aq, | |
| }] | |
| elif cont: | |
| ydl_opts["merge_output_format"] = cont | |
| for attempt in range(2): | |
| try: | |
| with yt_dlp.YoutubeDL(ydl_opts) as ydl: | |
| info = ydl.extract_info(url, download=True) | |
| filepath = Path(ydl.prepare_filename(info)) | |
| if audio_only: | |
| filepath = filepath.with_suffix(".mp3") | |
| elif cont and filepath.suffix != f".{cont}": | |
| filepath = filepath.with_suffix(f".{cont}") | |
| if not filepath.exists(): | |
| filepath = find_latest_file(start_mtime) | |
| if filepath and filepath.exists(): | |
| size_mb = filepath.stat().st_size / 1024 / 1024 | |
| logs.append(f"Скачано: {filepath.name} ({size_mb:.1f} MB)") | |
| return filepath, logs | |
| else: | |
| return None, logs + ["Файл не найден после скачивания"] | |
| except Exception as e: | |
| if attempt == 0: | |
| logs.append("Формат недоступен, пробую fallback...") | |
| ydl_opts["format"] = "bestvideo+bestaudio/best" | |
| else: | |
| return None, logs + [f"Ошибка: {e}"] | |
| return None, logs + ["Не удалось скачать"] | |
| # ─── API routes ─────────────────────────────────────────────────────────────── | |
| async def api_download(request: Request): | |
| data = await request.json() | |
| url = data.get("url", "").strip() | |
| if not url: | |
| return JSONResponse({"success": False, "logs": ["URL не указан"]}) | |
| loop = asyncio.get_event_loop() | |
| filepath, logs = await loop.run_in_executor(None, do_download, | |
| url, data.get("audio_only", False), data.get("quality", "1080"), | |
| data.get("codec", "auto"), data.get("container", "mp4"), data.get("audio_quality", "best")) | |
| if filepath: | |
| return JSONResponse({"success": True, "logs": logs, "filename": filepath.name, "download_url": f"/file/{filepath.name}"}) | |
| return JSONResponse({"success": False, "logs": logs}) | |
| async def api_batch(request: Request): | |
| data = await request.json() | |
| urls = [u.strip() for u in data.get("urls", "").splitlines() if u.strip() and not u.startswith("#")] | |
| if not urls: | |
| return JSONResponse({"success": False, "logs": ["Нет корректных URL"]}) | |
| all_logs, results = [], [] | |
| loop = asyncio.get_event_loop() | |
| for url in urls: | |
| filepath, logs = await loop.run_in_executor(None, do_download, | |
| url, data.get("audio_only", False), data.get("quality", "1080"), | |
| data.get("codec", "auto"), data.get("container", "mp4"), data.get("audio_quality", "best")) | |
| all_logs.extend([f"[{url[:50]}]"] + logs) | |
| if filepath: | |
| results.append({"filename": filepath.name, "download_url": f"/file/{filepath.name}"}) | |
| return JSONResponse({"success": True, "logs": all_logs, "files": results}) | |
| async def api_playlist(request: Request): | |
| data = await request.json() | |
| url = data.get("url", "").strip() | |
| if not url: | |
| return JSONResponse({"success": False, "logs": ["URL не указан"]}) | |
| fmt = build_format(data.get("quality","1080"), data.get("codec","auto"), | |
| data.get("container","mp4"), data.get("audio_only",False), data.get("audio_quality","best")) | |
| cont = CONTAINER_MAP.get(data.get("container","mp4")) | |
| aq = AUDIO_Q_MAP.get(data.get("audio_quality","best"), "0") | |
| ydl_opts = {"format": fmt, | |
| "outtmpl": str(DOWNLOAD_DIR / "%(playlist)s/%(playlist_index)s - %(title)s.%(ext)s"), "quiet": True} | |
| if data.get("start"): ydl_opts["playliststart"] = int(data["start"]) | |
| if data.get("end"): ydl_opts["playlistend"] = int(data["end"]) | |
| if data.get("audio_only"): | |
| ydl_opts["postprocessors"] = [{"key":"FFmpegExtractAudio","preferredcodec":"mp3","preferredquality":aq}] | |
| elif cont: | |
| ydl_opts["merge_output_format"] = cont | |
| def run(): | |
| try: | |
| with yt_dlp.YoutubeDL(ydl_opts) as ydl: | |
| ydl.download([url]) | |
| return ["Плейлист скачан"] | |
| except Exception as e: | |
| return [f"Ошибка: {e}"] | |
| loop = asyncio.get_event_loop() | |
| logs = await loop.run_in_executor(None, run) | |
| return JSONResponse({"success": True, "logs": logs}) | |
| async def serve_file(filename: str): | |
| safe = Path(filename).name | |
| fp = DOWNLOAD_DIR / safe | |
| if not fp.exists(): | |
| return JSONResponse({"error": "Файл не найден"}, status_code=404) | |
| return FileResponse(str(fp), filename=safe) | |
| # ─── HTML UI ────────────────────────────────────────────────────────────────── | |
| HTML = r"""<!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>VideoGrab v2.0</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@700;900&family=Exo+2:wght@300;400;600&display=swap" rel="stylesheet"> | |
| <style> | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| :root{--c:#00ffff;--m:#ff00ff;--y:#ffff00;--bg:#1a0033;--bg2:#2d0052;--bg3:#4a0080} | |
| body{font-family:'Exo 2',sans-serif;background:var(--bg);color:var(--c);min-height:100vh;overflow-x:hidden} | |
| body::before{content:'';position:fixed;inset:0;background-image:linear-gradient(rgba(0,255,255,.07)1px,transparent 1px),linear-gradient(90deg,rgba(0,255,255,.07)1px,transparent 1px);background-size:50px 50px;animation:grid 20s linear infinite;pointer-events:none;z-index:0} | |
| @keyframes grid{to{background-position:50px 50px,50px 50px}} | |
| .orb{position:fixed;border-radius:50%;filter:blur(80px);opacity:.25;pointer-events:none;z-index:0} | |
| .o1{width:350px;height:350px;background:var(--c);top:-80px;right:-80px;animation:fl 15s ease-in-out infinite} | |
| .o2{width:280px;height:280px;background:var(--m);bottom:-60px;left:-60px;animation:fl 12s ease-in-out infinite reverse} | |
| @keyframes fl{0%,100%{transform:translate(0,0)}50%{transform:translate(-40px,40px)}} | |
| .wrap{max-width:900px;margin:0 auto;padding:30px 20px;position:relative;z-index:2} | |
| h1{font-family:'Orbitron',sans-serif;font-size:3rem;font-weight:900;background:linear-gradient(135deg,var(--c),var(--m),var(--y));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:3px;animation:glow 3s ease-in-out infinite} | |
| @keyframes glow{0%,100%{filter:brightness(1)}50%{filter:brightness(1.3)}} | |
| .sub{color:rgba(0,255,255,.6);font-weight:300;letter-spacing:2px;margin:6px 0 36px} | |
| .tabs{display:flex;gap:12px;margin-bottom:22px;flex-wrap:wrap} | |
| .tab{flex:1;min-width:150px;padding:14px;background:var(--bg2);border:2px solid transparent;border-radius:10px;cursor:pointer;font-family:'Orbitron',sans-serif;font-size:.78rem;font-weight:700;letter-spacing:1px;color:var(--c);transition:all .3s;text-align:center} | |
| .tab:hover,.tab.on{border-color:var(--c);background:linear-gradient(135deg,var(--bg3),var(--bg2));box-shadow:0 0 20px rgba(0,255,255,.4)} | |
| .panel{display:none;background:rgba(45,0,82,.6);border:2px solid var(--m);border-radius:16px;padding:32px;backdrop-filter:blur(10px);box-shadow:0 8px 32px rgba(255,0,255,.25);animation:sup .4s ease-out} | |
| .panel.on{display:block} | |
| @keyframes sup{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}} | |
| .fg{margin-bottom:17px} | |
| label{display:block;margin-bottom:7px;font-weight:600;color:var(--y);font-size:.82rem;letter-spacing:1px;text-transform:uppercase} | |
| .hint{font-size:.7rem;font-weight:300;text-transform:none;color:rgba(255,255,0,.5);letter-spacing:0} | |
| input,select,textarea{width:100%;padding:12px 14px;background:rgba(26,0,51,.85);border:2px solid rgba(0,255,255,.25);border-radius:8px;color:var(--c);font-family:'Exo 2',sans-serif;font-size:.95rem;transition:all .3s} | |
| input:focus,select:focus,textarea:focus{outline:none;border-color:var(--c);box-shadow:0 0 14px rgba(0,255,255,.3);background:rgba(26,0,51,1)} | |
| select option{background:#1a0033;color:#00ffff} | |
| textarea{min-height:110px;resize:vertical} | |
| .row2{display:grid;grid-template-columns:1fr 1fr;gap:16px} | |
| .cb{display:flex;align-items:center;gap:10px;margin-bottom:17px} | |
| .cb input[type=checkbox]{width:20px;height:20px;accent-color:var(--m);cursor:pointer} | |
| .cb label{margin:0;text-transform:none;color:var(--c);font-size:.9rem;font-weight:400;letter-spacing:0} | |
| .divider{border:none;border-top:1px solid rgba(0,255,255,.12);margin:18px 0} | |
| .stitle{font-family:'Orbitron',sans-serif;font-size:.7rem;color:rgba(0,255,255,.45);letter-spacing:2px;margin-bottom:12px} | |
| .btn{padding:14px 30px;background:linear-gradient(135deg,var(--m),var(--bg3));border:2px solid var(--m);border-radius:10px;color:#fff;font-family:'Orbitron',sans-serif;font-weight:700;font-size:.88rem;cursor:pointer;transition:all .3s;text-transform:uppercase;letter-spacing:2px;margin-right:8px;margin-top:6px} | |
| .btn:hover{transform:scale(1.04);box-shadow:0 0 22px rgba(255,0,255,.55)} | |
| .btn:active{transform:scale(.97)} | |
| .log{background:rgba(0,0,0,.55);border:2px solid var(--c);border-radius:10px;padding:14px;margin-top:18px;max-height:240px;overflow-y:auto;font-family:'Courier New',monospace;font-size:.82rem;display:none} | |
| .log.on{display:block;animation:sup .4s ease-out} | |
| .le{margin-bottom:5px;padding:3px 8px;border-left:3px solid var(--c)} | |
| .le.ok{border-color:#00ff00;color:#00ff00}.le.er{border-color:#ff4444;color:#ff4444}.le.info{border-color:var(--y);color:var(--y)} | |
| .dlink{display:inline-block;margin-top:8px;padding:10px 20px;background:rgba(0,255,0,.12);border:2px solid #00ff00;border-radius:8px;color:#00ff00;text-decoration:none;font-family:'Orbitron',sans-serif;font-size:.78rem;font-weight:700;letter-spacing:1px;transition:all .3s;margin-right:8px} | |
| .dlink:hover{background:rgba(0,255,0,.22);box-shadow:0 0 14px rgba(0,255,0,.4)} | |
| .spin{display:none;text-align:center;padding:16px;margin-top:14px} | |
| .spin.on{display:block} | |
| .spinner{width:44px;height:44px;margin:0 auto 10px;border:4px solid rgba(0,255,255,.15);border-top:4px solid var(--c);border-radius:50%;animation:spin 1s linear infinite} | |
| @keyframes spin{to{transform:rotate(360deg)}} | |
| .boom{display:none;margin-top:8px;padding:8px 14px;background:rgba(0,255,255,.08);border:1px solid var(--c);border-radius:7px;font-size:.8rem} | |
| .boom.on{display:block} | |
| @media(max-width:640px){h1{font-size:2rem}.tabs{flex-direction:column}.panel{padding:20px}.row2{grid-template-columns:1fr}} | |
| ::-webkit-scrollbar{width:8px}::-webkit-scrollbar-track{background:var(--bg)}::-webkit-scrollbar-thumb{background:var(--m);border-radius:4px} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="o1 orb"></div><div class="o2 orb"></div> | |
| <div class="wrap"> | |
| <h1>VIDEOGRAB</h1> | |
| <p class="sub">Скачивай видео из любой точки интернета</p> | |
| <div class="tabs"> | |
| <div class="tab on" onclick="tab('v',this)">📹 Одно Видео</div> | |
| <div class="tab" onclick="tab('p',this)">📋 Плейлист</div> | |
| <div class="tab" onclick="tab('b',this)">📦 Пакетная</div> | |
| </div> | |
| <div id="v" class="panel on"> | |
| <div class="fg"> | |
| <label>URL Видео</label> | |
| <input id="vUrl" type="text" placeholder="https://www.youtube.com/watch?v=... или https://play.boomstream.com/..." oninput="chkBoom('vUrl','vBoom')"> | |
| <div class="boom" id="vBoom">🔍 Boomstream — ссылка будет автоматически декодирована</div> | |
| </div> | |
| <div class="cb"><input type="checkbox" id="vAudio" onchange="togAudio('v')"><label for="vAudio">Только аудио (MP3)</label></div> | |
| <div id="vVidOpts"> | |
| <p class="stitle">🎬 Видео</p> | |
| <div class="row2"> | |
| <div class="fg"><label>Качество</label> | |
| <select id="vQ"> | |
| <option value="best">Лучшее доступное</option><option value="4k">4K (2160p)</option> | |
| <option value="1440">2K (1440p)</option><option value="1080" selected>Full HD (1080p)</option> | |
| <option value="720">HD (720p)</option><option value="480">SD (480p)</option> | |
| <option value="360">360p</option><option value="240">240p</option><option value="worst">Наихудшее</option> | |
| </select></div> | |
| <div class="fg"><label>Видеокодек</label> | |
| <select id="vC"> | |
| <option value="auto">Авто</option><option value="h264">H.264 / AVC — макс. совместимость</option> | |
| <option value="h265">H.265 / HEVC — меньше файл</option> | |
| <option value="vp9">VP9 — хорошее сжатие</option><option value="av1">AV1 — самый эффективный</option> | |
| </select></div> | |
| </div> | |
| <div class="fg"><label>Контейнер</label> | |
| <select id="vF"> | |
| <option value="mp4">MP4 — ♻️ открывается везде</option><option value="mkv">MKV — для архива</option> | |
| <option value="webm">WebM — компактный, для браузеров</option><option value="mov">MOV — для Apple</option> | |
| <option value="avi">AVI — устаревший</option><option value="auto">Авто</option> | |
| </select></div> | |
| </div> | |
| <div id="vAudOpts" style="display:none"> | |
| <p class="stitle">🎵 Аудио</p> | |
| <div class="fg"><label>Качество аудио</label> | |
| <select id="vAQ"> | |
| <option value="best">Лучшее — максимальный битрейт</option> | |
| <option value="medium">Среднее (~128 kbps)</option><option value="low">Низкое</option> | |
| </select></div> | |
| </div> | |
| <button class="btn" onclick="dlSingle()">⬇️ Скачать</button> | |
| <div class="spin" id="vSpin"><div class="spinner"></div><p>Скачивание...</p></div> | |
| <div class="log" id="vLog"></div> | |
| </div> | |
| <div id="p" class="panel"> | |
| <div class="fg"><label>URL Плейлиста</label><input id="pUrl" type="text" placeholder="https://www.youtube.com/playlist?list=..."></div> | |
| <div class="cb"><input type="checkbox" id="pAudio" onchange="togAudio('p')"><label for="pAudio">Только аудио (MP3)</label></div> | |
| <div class="row2"> | |
| <div class="fg"><label>Начать с №</label><input id="pS" type="number" min="1" placeholder="1"></div> | |
| <div class="fg"><label>Закончить на №</label><input id="pE" type="number" min="1" placeholder="Все"></div> | |
| </div> | |
| <div id="pVidOpts"> | |
| <hr class="divider"><p class="stitle">🎬 Видео</p> | |
| <div class="row2"> | |
| <div class="fg"><label>Качество</label> | |
| <select id="pQ"><option value="best">Лучшее</option><option value="1080" selected>1080p</option><option value="720">720p</option><option value="480">480p</option><option value="360">360p</option><option value="worst">Наихудшее</option></select></div> | |
| <div class="fg"><label>Кодек</label> | |
| <select id="pC"><option value="auto">Авто</option><option value="h264">H.264</option><option value="h265">H.265</option><option value="vp9">VP9</option><option value="av1">AV1</option></select></div> | |
| </div> | |
| <div class="fg"><label>Контейнер</label> | |
| <select id="pF"><option value="mp4">MP4</option><option value="mkv">MKV</option><option value="webm">WebM</option><option value="mov">MOV</option><option value="avi">AVI</option><option value="auto">Авто</option></select></div> | |
| </div> | |
| <div id="pAudOpts" style="display:none"> | |
| <hr class="divider"><p class="stitle">🎵 Аудио</p> | |
| <div class="fg"><label>Качество</label> | |
| <select id="pAQ"><option value="best">Лучшее</option><option value="medium">Среднее (~128 kbps)</option><option value="low">Низкое</option></select></div> | |
| </div> | |
| <button class="btn" onclick="dlPlaylist()">⬇️ Скачать Плейлист</button> | |
| <p style="margin-top:12px;font-size:.8rem;color:rgba(0,255,255,.4)">Файлы плейлиста сохраняются на сервере — отдельного скачивания нет</p> | |
| <div class="spin" id="pSpin"><div class="spinner"></div><p>Скачивание...</p></div> | |
| <div class="log" id="pLog"></div> | |
| </div> | |
| <div id="b" class="panel"> | |
| <div class="fg"><label>Список URL <span class="hint">(по одному на строку, # — комментарий)</span></label> | |
| <textarea id="bUrls" placeholder="https://www.youtube.com/watch?v=... https://play.boomstream.com/..."></textarea></div> | |
| <div class="cb"><input type="checkbox" id="bAudio" onchange="togAudio('b')"><label for="bAudio">Только аудио (MP3)</label></div> | |
| <div id="bVidOpts"> | |
| <hr class="divider"><p class="stitle">🎬 Видео</p> | |
| <div class="row2"> | |
| <div class="fg"><label>Качество</label> | |
| <select id="bQ"><option value="best">Лучшее</option><option value="1080" selected>1080p</option><option value="720">720p</option><option value="480">480p</option><option value="360">360p</option><option value="worst">Наихудшее</option></select></div> | |
| <div class="fg"><label>Кодек</label> | |
| <select id="bC"><option value="auto">Авто</option><option value="h264">H.264</option><option value="h265">H.265</option><option value="vp9">VP9</option><option value="av1">AV1</option></select></div> | |
| </div> | |
| <div class="fg"><label>Контейнер</label> | |
| <select id="bF"><option value="mp4">MP4</option><option value="mkv">MKV</option><option value="webm">WebM</option><option value="mov">MOV</option><option value="avi">AVI</option><option value="auto">Авто</option></select></div> | |
| </div> | |
| <div id="bAudOpts" style="display:none"> | |
| <hr class="divider"><p class="stitle">🎵 Аудио</p> | |
| <div class="fg"><label>Качество</label> | |
| <select id="bAQ"><option value="best">Лучшее</option><option value="medium">Среднее (~128 kbps)</option><option value="low">Низкое</option></select></div> | |
| </div> | |
| <button class="btn" onclick="dlBatch()">⬇️ Скачать Всё</button> | |
| <div class="spin" id="bSpin"><div class="spinner"></div><p>Скачивание...</p></div> | |
| <div class="log" id="bLog"></div> | |
| </div> | |
| <p style="margin-top:28px;color:rgba(0,255,255,.25);font-size:.78rem;text-align:center"> | |
| VideoGrab v2.0 · yt-dlp · YouTube, Vimeo, Boomstream и 1000+ сайтов | |
| </p> | |
| </div> | |
| <script> | |
| function tab(id,el){document.querySelectorAll('.panel').forEach(p=>p.classList.remove('on'));document.querySelectorAll('.tab').forEach(t=>t.classList.remove('on'));document.getElementById(id).classList.add('on');el.classList.add('on');} | |
| function togAudio(p){const on=document.getElementById(p+'Audio').checked;const vo=document.getElementById(p+'VidOpts');const ao=document.getElementById(p+'AudOpts');if(vo)vo.style.display=on?'none':'block';if(ao)ao.style.display=on?'block':'none';} | |
| function chkBoom(iid,bid){document.getElementById(bid).classList.toggle('on',/play\.boomstream\.com/.test(document.getElementById(iid).value));} | |
| function logLine(id,msg,type=''){const el=document.getElementById(id);el.classList.add('on');const e=document.createElement('div');e.className='le '+type;e.textContent='['+new Date().toLocaleTimeString()+'] '+msg;el.appendChild(e);el.scrollTop=el.scrollHeight;} | |
| function clearLog(id){const el=document.getElementById(id);el.innerHTML='';el.classList.remove('on');} | |
| function spin(id,on){document.getElementById(id).classList[on?'add':'remove']('on');} | |
| function g(id){return document.getElementById(id).value;} | |
| function classifyLog(l){return l.includes('Скачано')||l.includes('скачан')?'ok':l.includes('Ошибка')||l.includes('не найден')||l.includes('Не удалось')?'er':'info';} | |
| async function dlSingle(){ | |
| const url=g('vUrl').trim();if(!url)return alert('Введите URL'); | |
| clearLog('vLog');spin('vSpin',true); | |
| try{ | |
| const r=await fetch('/api/download',{method:'POST',headers:{'Content-Type':'application/json'}, | |
| body:JSON.stringify({url,audio_only:document.getElementById('vAudio').checked,quality:g('vQ'),codec:g('vC'),container:g('vF'),audio_quality:g('vAQ')})}); | |
| const d=await r.json(); | |
| d.logs.forEach(l=>logLine('vLog',l,classifyLog(l))); | |
| if(d.success&&d.download_url){const a=document.createElement('a');a.href=d.download_url;a.download=d.filename;a.className='dlink';a.textContent='💾 Скачать: '+d.filename;document.getElementById('vLog').appendChild(a);} | |
| }catch(e){logLine('vLog','Ошибка: '+e.message,'er');} | |
| spin('vSpin',false); | |
| } | |
| async function dlPlaylist(){ | |
| const url=g('pUrl').trim();if(!url)return alert('Введите URL'); | |
| clearLog('pLog');spin('pSpin',true); | |
| try{ | |
| const r=await fetch('/api/playlist',{method:'POST',headers:{'Content-Type':'application/json'}, | |
| body:JSON.stringify({url,audio_only:document.getElementById('pAudio').checked,quality:g('pQ'),codec:g('pC'),container:g('pF'),audio_quality:g('pAQ'),start:g('pS')||null,end:g('pE')||null})}); | |
| const d=await r.json(); | |
| d.logs.forEach(l=>logLine('pLog',l,classifyLog(l))); | |
| }catch(e){logLine('pLog','Ошибка: '+e.message,'er');} | |
| spin('pSpin',false); | |
| } | |
| async function dlBatch(){ | |
| const urls=g('bUrls').trim();if(!urls)return alert('Введите URL'); | |
| clearLog('bLog');spin('bSpin',true); | |
| try{ | |
| const r=await fetch('/api/batch',{method:'POST',headers:{'Content-Type':'application/json'}, | |
| body:JSON.stringify({urls,audio_only:document.getElementById('bAudio').checked,quality:g('bQ'),codec:g('bC'),container:g('bF'),audio_quality:g('bAQ')})}); | |
| const d=await r.json(); | |
| d.logs.forEach(l=>logLine('bLog',l,classifyLog(l))); | |
| (d.files||[]).forEach(f=>{const a=document.createElement('a');a.href=f.download_url;a.download=f.filename;a.className='dlink';a.textContent='💾 '+f.filename;document.getElementById('bLog').appendChild(a);}); | |
| }catch(e){logLine('bLog','Ошибка: '+e.message,'er');} | |
| spin('bSpin',false); | |
| } | |
| </script> | |
| </body> | |
| </html>""" | |
| async def root(): | |
| return HTML | |