DS27's picture
Upload 5 files
8b01927 verified
"""
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 ───────────────────────────────────────────────────────────────
@app.post("/api/download")
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})
@app.post("/api/batch")
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})
@app.post("/api/playlist")
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})
@app.get("/file/{filename}")
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=...&#10;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>"""
@app.get("/", response_class=HTMLResponse)
async def root():
return HTML