408 lines
17 KiB
Python
408 lines
17 KiB
Python
"""Логика скачивания.
|
||
|
||
Две стратегии за общим интерфейсом:
|
||
- HttpxDownloader — прямые ссылки на файлы, прогресс по chunk'ам.
|
||
- YtDlpDownloader — видео/медиа с сайтов (YouTube и сотни других) через yt-dlp.
|
||
|
||
`pick_downloader()` выбирает стратегию по URL. Прогресс отдаётся через
|
||
async-колбэк on_progress, который дёргается не чаще, чем раз в progress_interval.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import functools
|
||
import os
|
||
import re
|
||
import time
|
||
from collections.abc import Awaitable, Callable
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import BinaryIO
|
||
from urllib.parse import unquote, urlparse
|
||
|
||
import httpx
|
||
|
||
from app.config import settings
|
||
|
||
# Расширения, которые качаем напрямую через httpx, а не через yt-dlp.
|
||
_DIRECT_EXTENSIONS = {
|
||
".zip", ".rar", ".7z", ".tar", ".gz", ".tgz", ".bz2", ".xz",
|
||
".iso", ".dmg", ".exe", ".msi", ".apk", ".deb", ".rpm", ".appimage",
|
||
".pdf", ".epub", ".mobi", ".djvu",
|
||
".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp", ".tiff",
|
||
".mp3", ".flac", ".wav", ".ogg", ".m4a", ".aac",
|
||
".mp4", ".mkv", ".webm", ".mov", ".avi", ".flv", ".m4v", # прямые ссылки на медиа
|
||
".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".csv", ".txt", ".json",
|
||
".bin", ".img", ".dll", ".so",
|
||
}
|
||
|
||
OnProgress = Callable[["Progress"], Awaitable[None]]
|
||
|
||
|
||
@dataclass
|
||
class Progress:
|
||
downloaded_bytes: int
|
||
total_bytes: int | None
|
||
speed: float | None
|
||
eta: float | None
|
||
filename: str | None = None
|
||
|
||
|
||
@dataclass
|
||
class DownloadResult:
|
||
filename: str
|
||
size_bytes: int | None
|
||
path: Path
|
||
|
||
|
||
@dataclass
|
||
class Resolved:
|
||
"""Что вернул резолвер сайта: куда реально идти за файлом.
|
||
|
||
headers — если для прямой ссылки нужен Referer/Cookie/авторизация.
|
||
filename — если сайт знает «правильное» имя (иначе возьмём из ответа).
|
||
"""
|
||
|
||
download_url: str
|
||
filename: str | None = None
|
||
headers: dict[str, str] | None = None
|
||
|
||
|
||
def _safe_filename(name: str) -> str:
|
||
"""Чистим имя файла от разделителей пути и опасных символов."""
|
||
name = unquote(name).strip().replace("\\", "/").split("/")[-1]
|
||
name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name)
|
||
name = name.strip(". ") or "download"
|
||
return name[:200]
|
||
|
||
|
||
def _unique_path(directory: Path, filename: str) -> Path:
|
||
"""Свободное имя по-браузерному: file.ext, file (1).ext, file (2).ext, ...
|
||
|
||
Best-effort: только подбирает имя, не резервирует его. Для случаев, где файл
|
||
пишет внешний инструмент (yt-dlp) и держать дескриптор нельзя.
|
||
"""
|
||
candidate = directory / filename
|
||
if not candidate.exists():
|
||
return candidate
|
||
stem, suffix = candidate.stem, candidate.suffix
|
||
i = 1
|
||
while True:
|
||
candidate = directory / f"{stem} ({i}){suffix}"
|
||
if not candidate.exists():
|
||
return candidate
|
||
i += 1
|
||
|
||
|
||
def _create_unique(directory: Path, filename: str) -> tuple[Path, BinaryIO]:
|
||
"""Атомарно создать НОВЫЙ файл, разводя дубли как браузер: name (1).ext и т.д.
|
||
|
||
Эксклюзивное создание (режим "xb") закрывает гонку между воркерами: имя
|
||
не просто подобрано, а сразу занято — параллельная загрузка не затрёт.
|
||
"""
|
||
directory.mkdir(parents=True, exist_ok=True)
|
||
candidate = directory / filename
|
||
stem, suffix = candidate.stem, candidate.suffix
|
||
i = 0
|
||
while True:
|
||
try:
|
||
return candidate, candidate.open("xb")
|
||
except FileExistsError:
|
||
i += 1
|
||
candidate = directory / f"{stem} ({i}){suffix}"
|
||
|
||
|
||
def is_direct_file(url: str) -> bool:
|
||
path = urlparse(url).path.lower()
|
||
return any(path.endswith(ext) for ext in _DIRECT_EXTENSIONS)
|
||
|
||
|
||
def _filename_from_response(resp: httpx.Response) -> str:
|
||
cd = resp.headers.get("content-disposition", "")
|
||
match = re.search(r"filename\*=(?:UTF-8'')?([^;]+)|filename=\"?([^\";]+)\"?", cd)
|
||
if match:
|
||
raw = match.group(1) or match.group(2)
|
||
if raw:
|
||
return _safe_filename(raw)
|
||
name = urlparse(str(resp.url)).path
|
||
return _safe_filename(name) if name and name != "/" else "download"
|
||
|
||
|
||
async def _stream_to_file(
|
||
url: str,
|
||
on_progress: OnProgress,
|
||
*,
|
||
headers: dict[str, str] | None = None,
|
||
filename: str | None = None,
|
||
) -> DownloadResult:
|
||
"""Общее ядро скачивания по прямой ссылке: стрим по chunk'ам, прогресс,
|
||
(n)-имена. Используется и httpx-загрузчиком, и резолверами сайтов."""
|
||
directory = settings.download_dir
|
||
directory.mkdir(parents=True, exist_ok=True)
|
||
timeout = httpx.Timeout(30.0, read=None)
|
||
async with httpx.AsyncClient(
|
||
follow_redirects=True, timeout=timeout, headers=headers
|
||
) as client:
|
||
async with client.stream("GET", url) as resp:
|
||
resp.raise_for_status()
|
||
name = _safe_filename(filename) if filename else _filename_from_response(resp)
|
||
total = (
|
||
int(resp.headers["content-length"])
|
||
if "content-length" in resp.headers
|
||
else None
|
||
)
|
||
target, fh = _create_unique(directory, name)
|
||
|
||
downloaded = 0
|
||
start = time.monotonic()
|
||
last_emit = 0.0
|
||
try:
|
||
with fh:
|
||
async for chunk in resp.aiter_bytes(settings.chunk_size):
|
||
fh.write(chunk)
|
||
downloaded += len(chunk)
|
||
now = time.monotonic()
|
||
if now - last_emit >= settings.progress_interval:
|
||
elapsed = now - start
|
||
speed = downloaded / elapsed if elapsed > 0 else None
|
||
eta = (
|
||
(total - downloaded) / speed
|
||
if total and speed and speed > 0
|
||
else None
|
||
)
|
||
await on_progress(
|
||
Progress(downloaded, total, speed, eta, target.name)
|
||
)
|
||
last_emit = now
|
||
except BaseException:
|
||
target.unlink(missing_ok=True)
|
||
raise
|
||
|
||
elapsed = time.monotonic() - start
|
||
speed = downloaded / elapsed if elapsed > 0 else None
|
||
await on_progress(Progress(downloaded, total or downloaded, speed, 0, target.name))
|
||
return DownloadResult(target.name, downloaded, target)
|
||
|
||
|
||
class UnsupportedURLError(Exception):
|
||
"""Ни одна стратегия не берётся за URL: не прямой файл и не из загрузчиков."""
|
||
|
||
|
||
# --- Реестр стратегий ---------------------------------------------------------
|
||
# Расширения регистрируются декоратором @register. pick_downloader() выбирает
|
||
# первую подходящую по убыванию priority. Кастомные экстракторы (priority > 0)
|
||
# перехватывают URL раньше встроенных httpx/yt-dlp.
|
||
|
||
_REGISTRY: list[type["Downloader"]] = []
|
||
|
||
|
||
def register(cls: type["Downloader"]) -> type["Downloader"]:
|
||
_REGISTRY.append(cls)
|
||
return cls
|
||
|
||
|
||
class Downloader:
|
||
"""Базовый интерфейс стратегии. priority — кто раньше перехватывает URL
|
||
(больше = раньше); matches() — берётся ли эта стратегия за данный URL.
|
||
|
||
label/kind — для страницы «Загрузчики» (kind: direct | extractor | fallback)."""
|
||
|
||
priority: int = 0
|
||
label: str = "" # человекочитаемое имя; пусто -> берём имя класса
|
||
kind: str = "other"
|
||
|
||
@classmethod
|
||
def matches(cls, url: str) -> bool:
|
||
return False
|
||
|
||
async def download(self, url: str, on_progress: OnProgress) -> DownloadResult:
|
||
raise NotImplementedError
|
||
|
||
|
||
class SiteExtractor(Downloader):
|
||
"""Удобная база для сайтов: реализуй matches() и resolve() — достать прямую
|
||
ссылку (и при нужде Referer/Cookie). Скачивание, прогресс и (n)-имена общие.
|
||
|
||
Если сайту нужен нестандартный процесс (HLS, сегменты) — наследуйся прямо от
|
||
Downloader и переопредели download() целиком."""
|
||
|
||
kind = "extractor"
|
||
|
||
async def resolve(self, url: str) -> Resolved:
|
||
raise NotImplementedError
|
||
|
||
async def download(self, url: str, on_progress: OnProgress) -> DownloadResult:
|
||
resolved = await self.resolve(url)
|
||
return await _stream_to_file(
|
||
resolved.download_url,
|
||
on_progress,
|
||
headers=resolved.headers,
|
||
filename=resolved.filename,
|
||
)
|
||
|
||
|
||
@register
|
||
class HttpxDownloader(Downloader):
|
||
"""Прямые ссылки на файлы (.zip, .pdf, .mp4 …) — стрим по chunk'ам."""
|
||
|
||
priority = 10
|
||
label = "Прямые файлы"
|
||
kind = "direct"
|
||
|
||
@classmethod
|
||
def matches(cls, url: str) -> bool:
|
||
return is_direct_file(url)
|
||
|
||
async def download(self, url: str, on_progress: OnProgress) -> DownloadResult:
|
||
return await _stream_to_file(url, on_progress)
|
||
|
||
|
||
@functools.lru_cache(maxsize=1)
|
||
def _ytdlp_site_extractors() -> tuple:
|
||
"""Профильные экстракторы yt-dlp без generic (он матчит почти любой http-URL).
|
||
|
||
Грузим один раз и кешируем — список большой и тянется лениво.
|
||
"""
|
||
from yt_dlp.extractor import gen_extractor_classes
|
||
|
||
return tuple(ie for ie in gen_extractor_classes() if ie.IE_NAME != "generic")
|
||
|
||
|
||
def _ytdlp_supports(url: str) -> bool:
|
||
"""Есть ли у yt-dlp профильный экстрактор под этот URL (generic не в счёт)."""
|
||
try:
|
||
return any(ie.suitable(url) for ie in _ytdlp_site_extractors())
|
||
except Exception: # noqa: BLE001 — проблемы yt-dlp не должны ронять выбор
|
||
return False
|
||
|
||
|
||
def _ytdlp_cookie_opts(from_browser: str | None, cookies_file: Path | str | None) -> dict:
|
||
"""Опции cookies для yt-dlp из настроек — для сайтов, блокирующих анонимов."""
|
||
opts: dict = {}
|
||
if cookies_file:
|
||
opts["cookiefile"] = str(cookies_file)
|
||
if from_browser:
|
||
browser, _, profile = from_browser.partition(":")
|
||
# yt-dlp ждёт кортеж (browser, profile, keyring, container).
|
||
opts["cookiesfrombrowser"] = (browser.strip(), profile.strip() or None, None, None)
|
||
return opts
|
||
|
||
|
||
@register
|
||
class YtDlpDownloader(Downloader):
|
||
"""yt-dlp: YouTube и сотни сайтов. Берётся за URL последним и только если у
|
||
yt-dlp есть профильный экстрактор под него (generic-угадывание не считаем —
|
||
иначе «ловит всё» и ошибки про неподдерживаемую ссылку не будет).
|
||
|
||
yt-dlp синхронный, поэтому крутим его в потоке, а progress-хуки прокидываем
|
||
обратно в event loop через run_coroutine_threadsafe."""
|
||
|
||
priority = -100
|
||
label = "yt-dlp (видео и сайты)"
|
||
kind = "fallback"
|
||
|
||
@classmethod
|
||
def matches(cls, url: str) -> bool:
|
||
return _ytdlp_supports(url)
|
||
|
||
def __init__(self) -> None:
|
||
self._dir = settings.download_dir
|
||
self._interval = settings.progress_interval
|
||
|
||
async def download(self, url: str, on_progress: OnProgress) -> DownloadResult:
|
||
self._dir.mkdir(parents=True, exist_ok=True)
|
||
loop = asyncio.get_running_loop()
|
||
last_emit = 0.0
|
||
result_holder: dict[str, object] = {}
|
||
|
||
def hook(d: dict) -> None:
|
||
nonlocal last_emit
|
||
status = d.get("status")
|
||
if status == "downloading":
|
||
now = time.monotonic()
|
||
if now - last_emit < self._interval:
|
||
return
|
||
last_emit = now
|
||
downloaded = d.get("downloaded_bytes") or 0
|
||
total = d.get("total_bytes") or d.get("total_bytes_estimate")
|
||
progress = Progress(
|
||
downloaded_bytes=downloaded,
|
||
total_bytes=total,
|
||
speed=d.get("speed"),
|
||
eta=d.get("eta"),
|
||
filename=os.path.basename(d.get("filename") or "") or None,
|
||
)
|
||
asyncio.run_coroutine_threadsafe(on_progress(progress), loop)
|
||
elif status == "finished":
|
||
result_holder["path"] = d.get("filename")
|
||
|
||
def run_blocking() -> DownloadResult:
|
||
# Импортируем лениво, чтобы httpx-only сценарий не тянул yt-dlp.
|
||
from yt_dlp import YoutubeDL
|
||
|
||
base_opts = {
|
||
"noprogress": True,
|
||
"quiet": True,
|
||
"no_warnings": True,
|
||
"noplaylist": True,
|
||
**_ytdlp_cookie_opts(settings.cookies_from_browser, settings.cookies_file),
|
||
}
|
||
if settings.impersonate:
|
||
# Глобально: браузерный отпечаток на ВСЕ запросы (страница + медиа),
|
||
# иначе CDN рвёт скачивание видео-данных.
|
||
from yt_dlp.networking.impersonate import ImpersonateTarget
|
||
|
||
base_opts["impersonate"] = ImpersonateTarget.from_str(settings.impersonate)
|
||
default_tmpl = str(self._dir / "%(title)s [%(id)s].%(ext)s")
|
||
|
||
# Фаза 1: узнаём имя файла, не качая, чтобы развести дубли как браузер.
|
||
with YoutubeDL({**base_opts, "outtmpl": default_tmpl}) as probe:
|
||
info = probe.extract_info(url, download=False)
|
||
predicted = Path(probe.prepare_filename(info))
|
||
target = _unique_path(self._dir, predicted.name)
|
||
# %(ext)s оставляем yt-dlp (контейнер может смениться при склейке),
|
||
# а литеральную часть имени экранируем: % -> %% (вдруг в названии есть %).
|
||
stem = str(target.with_suffix("")).replace("%", "%%")
|
||
outtmpl = f"{stem}.%(ext)s"
|
||
|
||
# Фаза 2: качаем в выбранный путь.
|
||
with YoutubeDL({**base_opts, "outtmpl": outtmpl, "progress_hooks": [hook]}) as ydl:
|
||
info = ydl.extract_info(url, download=True)
|
||
final_path = result_holder.get("path") or ydl.prepare_filename(info)
|
||
path = Path(str(final_path))
|
||
size = path.stat().st_size if path.exists() else None
|
||
return DownloadResult(path.name, size, path)
|
||
|
||
return await loop.run_in_executor(None, run_blocking)
|
||
|
||
|
||
def pick_downloader(url: str) -> Downloader:
|
||
"""Первая подходящая стратегия по убыванию priority.
|
||
|
||
Кастомные экстракторы (priority > 0) перехватывают раньше httpx (10) и
|
||
yt-dlp (-100). Если не взялся никто — URL не поддерживается."""
|
||
for cls in sorted(_REGISTRY, key=lambda c: c.priority, reverse=True):
|
||
if cls.matches(url):
|
||
return cls()
|
||
raise UnsupportedURLError(
|
||
"Не могу скачать эту ссылку: это не прямой файл и ни один загрузчик "
|
||
"её не поддерживает."
|
||
)
|
||
|
||
|
||
def list_strategies() -> list[dict]:
|
||
"""Описание зарегистрированных стратегий для страницы «Загрузчики»."""
|
||
result: list[dict] = []
|
||
for cls in sorted(_REGISTRY, key=lambda c: c.priority, reverse=True):
|
||
doc = (cls.__doc__ or "").strip().split("\n")[0].strip()
|
||
result.append(
|
||
{
|
||
"name": cls.label or cls.__name__,
|
||
"kind": cls.kind,
|
||
"priority": cls.priority,
|
||
"description": doc or None,
|
||
}
|
||
)
|
||
return result
|