Update PVideoDl to support custom download strategies and enhance cookie handling. Added new downloader registration system, updated README for clarity on downloaders, and introduced cookies configuration in settings. Frontend now includes a dedicated tab for download strategies and API endpoints for listing them.

This commit is contained in:
Leonid Pershin
2026-06-20 09:08:31 +03:00
parent 3e47e95fc4
commit eae4def0bf
15 changed files with 639 additions and 74 deletions
+257 -65
View File
@@ -11,12 +11,14 @@ async-колбэк on_progress, который дёргается не чаще,
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
@@ -54,6 +56,19 @@ class DownloadResult:
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]
@@ -63,7 +78,11 @@ def _safe_filename(name: str) -> str:
def _unique_path(directory: Path, filename: str) -> Path:
"""Не перезатираем существующие файлы — добавляем (1), (2), ..."""
"""Свободное имя по-браузерному: file.ext, file (1).ext, file (2).ext, ...
Best-effort: только подбирает имя, не резервирует его. Для случаев, где файл
пишет внешний инструмент (yt-dlp) и держать дескриптор нельзя.
"""
candidate = directory / filename
if not candidate.exists():
return candidate
@@ -76,78 +95,216 @@ def _unique_path(directory: Path, filename: str) -> Path:
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 HttpxDownloader(Downloader):
def __init__(self) -> None:
self._dir = settings.download_dir
self._chunk = settings.chunk_size
self._interval = settings.progress_interval
class SiteExtractor(Downloader):
"""Удобная база для сайтов: реализуй matches() и resolve() — достать прямую
ссылку (и при нужде Referer/Cookie). Скачивание, прогресс и (n)-имена общие.
def _filename_from_response(self, url: str, 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"
Если сайту нужен нестандартный процесс (HLS, сегменты) — наследуйся прямо от
Downloader и переопредели download() целиком."""
kind = "extractor"
async def resolve(self, url: str) -> Resolved:
raise NotImplementedError
async def download(self, url: str, on_progress: OnProgress) -> DownloadResult:
self._dir.mkdir(parents=True, exist_ok=True)
timeout = httpx.Timeout(30.0, read=None)
async with httpx.AsyncClient(follow_redirects=True, timeout=timeout) as client:
async with client.stream("GET", url) as resp:
resp.raise_for_status()
filename = self._filename_from_response(url, resp)
total = int(resp.headers["content-length"]) if "content-length" in resp.headers else None
target = _unique_path(self._dir, filename)
downloaded = 0
start = time.monotonic()
last_emit = 0.0
try:
with target.open("wb") as fh:
async for chunk in resp.aiter_bytes(self._chunk):
fh.write(chunk)
downloaded += len(chunk)
now = time.monotonic()
if now - last_emit >= self._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)
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. yt-dlp синхронный, поэтому крутим его в потоке,
а progress-хуки прокидываем обратно в event loop через call_soon_threadsafe."""
"""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
@@ -184,19 +341,29 @@ class YtDlpDownloader(Downloader):
# Импортируем лениво, чтобы httpx-only сценарий не тянул yt-dlp.
from yt_dlp import YoutubeDL
ydl_opts = {
"outtmpl": str(self._dir / "%(title)s [%(id)s].%(ext)s"),
"progress_hooks": [hook],
base_opts = {
"noprogress": True,
"quiet": True,
"no_warnings": True,
"noplaylist": True,
**_ytdlp_cookie_opts(settings.cookies_from_browser, settings.cookies_file),
}
with YoutubeDL(ydl_opts) as ydl:
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")
if not final_path:
final_path = ydl.prepare_filename(info)
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)
@@ -205,5 +372,30 @@ class YtDlpDownloader(Downloader):
def pick_downloader(url: str) -> Downloader:
"""Прямые ссылки на файлы — httpx, всё остальное (страницы сайтов) — yt-dlp."""
return HttpxDownloader() if is_direct_file(url) else YtDlpDownloader()
"""Первая подходящая стратегия по убыванию 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
+32
View File
@@ -0,0 +1,32 @@
"""Расширения-экстракторы для сайтов с непрямыми ссылками.
Каждый модуль здесь регистрирует свою стратегию декоратором @register из
app.services.downloader. Добавить сайт = положить сюда один файл; ядро не
трогаем. load_extractors() импортирует все модули пакета — её зовут на старте
приложения (lifespan в app/main.py).
"""
from __future__ import annotations
import importlib
import logging
import pkgutil
logger = logging.getLogger("pvideodl.extractors")
def load_extractors() -> list[str]:
"""Импортировать все модули пакета — при импорте они себя регистрируют.
Идемпотентно: при повторном вызове модули уже в sys.modules, их тело (а с ним
и @register) заново не выполняется — реестр не задваивается.
"""
loaded: list[str] = []
for info in pkgutil.iter_modules(__path__, __name__ + "."):
if info.name.rsplit(".", 1)[-1].startswith("_"):
continue
importlib.import_module(info.name)
loaded.append(info.name)
if loaded:
logger.info("Загружено экстракторов: %d (%s)", len(loaded), ", ".join(loaded))
return loaded
+41
View File
@@ -0,0 +1,41 @@
"""Пример расширения: файлы Google Drive по ссылке-«просмотру».
Образец для своих экстракторов. Здесь резолв чисто строковый (URL -> прямая
ссылка), без скрейпинга — поэтому тестируется офлайн.
https://drive.google.com/file/d/<ID>/view
-> https://drive.google.com/uc?export=download&id=<ID>
Оговорка: для больших файлов Drive отдаёт HTML-страницу с подтверждением
антивирусной проверки (нужны confirm-токен и cookie) — здесь не покрыто;
для небольших файлов прямая ссылка работает сразу.
"""
from __future__ import annotations
import re
from app.services.downloader import Resolved, SiteExtractor, register
_FILE_ID = re.compile(r"drive\.google\.com/file/d/([\w-]+)")
@register
class GoogleDriveExtractor(SiteExtractor):
"""Google Drive: ссылка-просмотр файла -> прямая загрузка."""
priority = 100
label = "Google Drive"
@classmethod
def matches(cls, url: str) -> bool:
return bool(_FILE_ID.search(url))
async def resolve(self, url: str) -> Resolved:
match = _FILE_ID.search(url)
if not match: # matches() уже проверил, но без него resolve честно падает
raise ValueError(f"Не ссылка на файл Google Drive: {url}")
file_id = match.group(1)
return Resolved(
download_url=f"https://drive.google.com/uc?export=download&id={file_id}"
)