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:
@@ -21,3 +21,4 @@ frontend/.vite/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
cookies.txt
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
- **Прямые файлы** (`.zip`, `.pdf`, `.mp4`, …) качаются через `httpx` по chunk'ам.
|
||||
- **Видео с сайтов** (YouTube и сотни других) — через `yt-dlp`.
|
||||
- Прогресс течёт в UI по **SSE** в реальном времени.
|
||||
- Две вкладки: **Активные** (что качается сейчас и завершилось за последний час)
|
||||
и **История** (все завершённые загрузки).
|
||||
- Три вкладки: **Активные** (что качается сейчас и завершилось за последний час),
|
||||
**История** (все завершённые загрузки) и **Загрузчики** (какие стратегии скачивания
|
||||
сейчас доступны).
|
||||
- Если ссылка не прямой файл и ни один загрузчик её не поддерживает — загрузка
|
||||
завершается явной ошибкой (а не молчаливой попыткой угадать).
|
||||
|
||||
## Стек
|
||||
|
||||
@@ -29,10 +32,44 @@ app/
|
||||
models.py Pydantic-модели
|
||||
api/ роуты: downloads.py, events.py (SSE)
|
||||
core/ абстракции: queue.py, storage.py, events.py (EventBus)
|
||||
services/ downloader.py (httpx/yt-dlp), worker.py (пул воркеров)
|
||||
services/ downloader.py (реестр стратегий), worker.py (пул воркеров)
|
||||
extractors/ расширения для сайтов с непрямыми ссылками
|
||||
frontend/ SvelteKit SPA → собирается в frontend/build
|
||||
```
|
||||
|
||||
### Расширения: сайты с непрямыми ссылками
|
||||
|
||||
Выбор стратегии — цепочка обработчиков с приоритетами (`pick_downloader`):
|
||||
кастомные экстракторы (priority > 0) перехватывают URL раньше встроенных
|
||||
`HttpxDownloader` (прямые файлы) и `YtDlpDownloader` (универсальный фолбэк).
|
||||
|
||||
Добавить сайт = положить один файл в `app/services/extractors/`. Чаще всего хватает
|
||||
**резолвера** — достать прямую ссылку (и при нужде `Referer`/`Cookie`), а
|
||||
скачивание, прогресс и `(n)`-имена наследуются:
|
||||
|
||||
```python
|
||||
# app/services/extractors/my_site.py
|
||||
import re
|
||||
from app.services.downloader import Resolved, SiteExtractor, register
|
||||
|
||||
@register
|
||||
class MySite(SiteExtractor):
|
||||
priority = 100
|
||||
|
||||
@classmethod
|
||||
def matches(cls, url: str) -> bool:
|
||||
return "my-site.com/watch/" in url
|
||||
|
||||
async def resolve(self, url: str) -> Resolved:
|
||||
# ... найти настоящую ссылку (запрос/скрейпинг) ...
|
||||
return Resolved(download_url=real_url, headers={"Referer": url})
|
||||
```
|
||||
|
||||
Модули пакета авто-загружаются на старте (`load_extractors()`). Готовый образец —
|
||||
[google_drive.py](app/services/extractors/google_drive.py). Если сайту нужен
|
||||
нестандартный процесс (HLS, сегменты) — наследуйся прямо от `Downloader` и
|
||||
переопредели `download()` целиком.
|
||||
|
||||
## Запуск — готовые скрипты
|
||||
|
||||
В корне лежат скрипты-обёртки (Linux/macOS — `.sh`, Windows — `.bat`):
|
||||
@@ -80,6 +117,7 @@ cd frontend && npm run dev
|
||||
| `POST` | `/api/downloads` | Добавить ссылки (`{"urls": [...]}`) |
|
||||
| `GET` | `/api/downloads` | Список всех загрузок |
|
||||
| `DELETE` | `/api/downloads/{id}` | Удалить задачу |
|
||||
| `GET` | `/api/downloaders` | Список загруженных стратегий |
|
||||
| `GET` | `/api/events` | SSE-поток обновлений прогресса |
|
||||
| `GET` | `/api/health` | Проверка живости |
|
||||
|
||||
@@ -93,6 +131,11 @@ cd frontend && npm run dev
|
||||
| `PVDL_WORKERS` | `3` | Сколько воркеров параллельно |
|
||||
| `PVDL_HOST` | `127.0.0.1` | Хост сервера |
|
||||
| `PVDL_PORT` | `8000` | Порт сервера |
|
||||
| `PVDL_COOKIES_FROM_BROWSER` | — | Cookies для yt-dlp из браузера: `chrome`, `firefox`, `edge`… (можно `chrome:Профиль`) |
|
||||
| `PVDL_COOKIES_FILE` | — | Cookies для yt-dlp из файла (формат Netscape `cookies.txt`) |
|
||||
|
||||
> Cookies нужны для сайтов, которые блокируют анонимные запросы (возрастной гейт,
|
||||
> логин, гео) и отдают ошибки вроде `HTTP 410 Gone`. Достаточно одного из вариантов.
|
||||
|
||||
## Тесты
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Роут со списком зарегистрированных загрузчиков: GET /api/downloaders."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.models import DownloaderInfo
|
||||
from app.services.downloader import list_strategies
|
||||
|
||||
router = APIRouter(prefix="/api/downloaders", tags=["downloaders"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[DownloaderInfo])
|
||||
async def list_downloaders() -> list[dict]:
|
||||
"""Какие стратегии скачивания сейчас загружены (по убыванию приоритета)."""
|
||||
return list_strategies()
|
||||
@@ -21,6 +21,11 @@ def _env_int(name: str, default: int) -> int:
|
||||
return default
|
||||
|
||||
|
||||
def _env_path_opt(name: str) -> Path | None:
|
||||
value = os.environ.get(name)
|
||||
return Path(value).expanduser().resolve() if value else None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
# Куда складывать скачанные файлы.
|
||||
@@ -38,6 +43,11 @@ class Settings:
|
||||
# Хост/порт веб-сервера.
|
||||
host: str = os.environ.get("PVDL_HOST", "127.0.0.1")
|
||||
port: int = _env_int("PVDL_PORT", 8000)
|
||||
# Cookies для yt-dlp (сайты, блокирующие анонимные запросы: возрастной гейт,
|
||||
# логин, гео). Браузер: "chrome" | "firefox" | "edge" | … , можно "chrome:Профиль".
|
||||
cookies_from_browser: str | None = os.environ.get("PVDL_COOKIES_FROM_BROWSER") or None
|
||||
# Файл cookies в формате Netscape (cookies.txt).
|
||||
cookies_file: Path | None = _env_path_opt("PVDL_COOKIES_FILE")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
+9
-1
@@ -12,12 +12,13 @@ from fastapi.staticfiles import StaticFiles
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
from app.api import downloads, events
|
||||
from app.api import downloaders, downloads, events
|
||||
from app.config import settings
|
||||
from app.core.events import EventBus
|
||||
from app.core.queue import DownloadQueue
|
||||
from app.core.storage import SqliteStorage
|
||||
from app.models import DownloadStatus
|
||||
from app.services.extractors import load_extractors
|
||||
from app.services.worker import WorkerPool
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
@@ -26,6 +27,8 @@ logger = logging.getLogger("pvideodl")
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
load_extractors() # регистрируем расширения-экстракторы из app/services/extractors
|
||||
|
||||
storage = SqliteStorage(settings.db_path)
|
||||
await storage.init()
|
||||
|
||||
@@ -46,6 +49,10 @@ async def lifespan(app: FastAPI):
|
||||
await pool.start()
|
||||
settings.download_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("PVideoDl запущен. Файлы → %s", settings.download_dir)
|
||||
if settings.cookies_from_browser:
|
||||
logger.info("yt-dlp cookies: из браузера %s", settings.cookies_from_browser)
|
||||
if settings.cookies_file:
|
||||
logger.info("yt-dlp cookies: файл %s", settings.cookies_file)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -65,6 +72,7 @@ def create_app() -> FastAPI:
|
||||
)
|
||||
|
||||
app.include_router(downloads.router)
|
||||
app.include_router(downloaders.router)
|
||||
app.include_router(events.router)
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -46,6 +46,15 @@ class CreateDownloads(BaseModel):
|
||||
urls: list[str] = Field(default_factory=list, min_length=1)
|
||||
|
||||
|
||||
class DownloaderInfo(BaseModel):
|
||||
"""Зарегистрированная стратегия скачивания — для страницы «Загрузчики»."""
|
||||
|
||||
name: str
|
||||
kind: str # "direct" | "extractor" | "fallback"
|
||||
priority: int
|
||||
description: str | None = None
|
||||
|
||||
|
||||
# --- События шины (то, что улетает в SSE) ---
|
||||
|
||||
|
||||
|
||||
+257
-65
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
// Клиент к бэкенду. Базовый путь относительный — и в деве (через Vite-прокси),
|
||||
// и в проде (FastAPI отдаёт статику с того же origin) работает одинаково.
|
||||
import type { Download } from './types';
|
||||
import type { Download, DownloaderInfo } from './types';
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
@@ -36,3 +36,7 @@ export async function addDownloads(urls: string[]): Promise<Download[]> {
|
||||
export async function deleteDownload(id: string): Promise<void> {
|
||||
return handle(await fetch(`${BASE}/downloads/${id}`, { method: 'DELETE' }));
|
||||
}
|
||||
|
||||
export async function listDownloaders(): Promise<DownloaderInfo[]> {
|
||||
return handle(await fetch(`${BASE}/downloaders`));
|
||||
}
|
||||
|
||||
@@ -15,6 +15,13 @@ export interface Download {
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface DownloaderInfo {
|
||||
name: string;
|
||||
kind: 'direct' | 'extractor' | 'fallback' | string;
|
||||
priority: number;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export type DownloadEventType = 'created' | 'progress' | 'done' | 'failed' | 'deleted';
|
||||
|
||||
export interface DownloadEvent {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { DownloadCloud, History, Wifi, WifiOff } from 'lucide-svelte';
|
||||
import { DownloadCloud, History, Puzzle, Wifi, WifiOff } from 'lucide-svelte';
|
||||
import * as api from '$lib/api';
|
||||
import {
|
||||
setDownloads,
|
||||
@@ -35,7 +35,8 @@
|
||||
|
||||
const tabs = [
|
||||
{ href: '/', label: 'Активные', icon: DownloadCloud },
|
||||
{ href: '/history', label: 'История', icon: History }
|
||||
{ href: '/history', label: 'История', icon: History },
|
||||
{ href: '/downloaders', label: 'Загрузчики', icon: Puzzle }
|
||||
];
|
||||
const path = $derived($page.url.pathname);
|
||||
</script>
|
||||
@@ -63,7 +64,7 @@
|
||||
<nav class="mb-8 flex gap-1 border-b border-zinc-800">
|
||||
{#each tabs as tab (tab.href)}
|
||||
{@const active = path === tab.href}
|
||||
{@const count = tab.href === '/' ? $activeCount : $historyCount}
|
||||
{@const count = tab.href === '/' ? $activeCount : tab.href === '/history' ? $historyCount : null}
|
||||
<a
|
||||
href={tab.href}
|
||||
class="-mb-px inline-flex items-center gap-2 border-b-2 px-3 py-2.5 text-sm font-medium transition-colors {active
|
||||
@@ -72,7 +73,7 @@
|
||||
>
|
||||
<tab.icon class="size-4" />
|
||||
{tab.label}
|
||||
{#if $hydrated && count > 0}
|
||||
{#if $hydrated && count !== null && count > 0}
|
||||
<span class="rounded-full bg-zinc-800 px-1.5 py-0.5 text-xs font-normal text-zinc-400"
|
||||
>{count}</span
|
||||
>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Puzzle } from 'lucide-svelte';
|
||||
import * as api from '$lib/api';
|
||||
import { pushToast } from '$lib/stores';
|
||||
import type { DownloaderInfo } from '$lib/types';
|
||||
|
||||
let items = $state<DownloaderInfo[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(() => {
|
||||
api
|
||||
.listDownloaders()
|
||||
.then((d) => (items = d))
|
||||
.catch((e) => pushToast('error', `Не удалось загрузить загрузчики: ${e.message}`))
|
||||
.finally(() => (loading = false));
|
||||
});
|
||||
|
||||
const kind: Record<string, { label: string; cls: string }> = {
|
||||
direct: { label: 'Прямые файлы', cls: 'bg-sky-500/15 text-sky-300' },
|
||||
extractor: { label: 'Сайт', cls: 'bg-indigo-500/15 text-indigo-300' },
|
||||
fallback: { label: 'Универсальный', cls: 'bg-zinc-700/60 text-zinc-300' }
|
||||
};
|
||||
const kindInfo = (k: string) => kind[k] ?? { label: k, cls: 'bg-zinc-700/60 text-zinc-300' };
|
||||
</script>
|
||||
|
||||
<svelte:head><title>PVideoDl — загрузчики</title></svelte:head>
|
||||
|
||||
<p class="mb-5 text-sm text-zinc-500">
|
||||
Стратегии скачивания в порядке приоритета: ссылку берёт первый подходящий загрузчик.
|
||||
Если ссылка не прямой файл и ни один загрузчик её не поддерживает — загрузка завершится
|
||||
ошибкой.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
{#if loading}
|
||||
<p class="py-12 text-center text-sm text-zinc-500">Загрузка…</p>
|
||||
{:else if items.length === 0}
|
||||
<div class="flex flex-col items-center gap-3 py-16 text-center text-zinc-500">
|
||||
<Puzzle class="size-10 opacity-40" />
|
||||
<p class="text-sm">Загрузчиков нет.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each items as d (d.name)}
|
||||
<div
|
||||
class="rounded-xl border border-zinc-800 bg-zinc-900/60 p-4 transition-colors hover:border-zinc-700"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium text-zinc-100">{d.name}</p>
|
||||
{#if d.description}
|
||||
<p class="mt-0.5 text-sm text-zinc-500">{d.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {kindInfo(
|
||||
d.kind
|
||||
).cls}"
|
||||
>
|
||||
{kindInfo(d.kind).label}
|
||||
</span>
|
||||
<span class="text-xs text-zinc-600" title="Приоритет">#{d.priority}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -64,3 +64,12 @@ async def test_health(client):
|
||||
c, _ = client
|
||||
resp = await c.get("/api/health")
|
||||
assert resp.status_code == 200 and resp.json()["status"] == "ok"
|
||||
|
||||
|
||||
async def test_list_downloaders(client):
|
||||
c, _ = client
|
||||
resp = await c.get("/api/downloaders")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
kinds = {d["kind"] for d in data}
|
||||
assert "direct" in kinds and "fallback" in kinds # встроенные httpx + yt-dlp
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.downloader import (
|
||||
HttpxDownloader,
|
||||
UnsupportedURLError,
|
||||
YtDlpDownloader,
|
||||
_create_unique,
|
||||
_unique_path,
|
||||
_ytdlp_cookie_opts,
|
||||
list_strategies,
|
||||
pick_downloader,
|
||||
)
|
||||
from app.services.extractors import load_extractors
|
||||
from app.services.extractors.google_drive import GoogleDriveExtractor
|
||||
|
||||
|
||||
def test_unique_path_appends_counter():
|
||||
d = Path(tempfile.mkdtemp())
|
||||
assert _unique_path(d, "a.zip") == d / "a.zip"
|
||||
(d / "a.zip").write_bytes(b"")
|
||||
assert _unique_path(d, "a.zip") == d / "a (1).zip"
|
||||
(d / "a (1).zip").write_bytes(b"")
|
||||
assert _unique_path(d, "a.zip") == d / "a (2).zip"
|
||||
|
||||
|
||||
def test_create_unique_reserves_atomically():
|
||||
d = Path(tempfile.mkdtemp())
|
||||
p1, f1 = _create_unique(d, "file.bin")
|
||||
p2, f2 = _create_unique(d, "file.bin")
|
||||
try:
|
||||
assert p1 == d / "file.bin"
|
||||
assert p2 == d / "file (1).bin" # имя занято — следующий уходит в (1)
|
||||
assert p1.exists() and p2.exists() # оба зарезервированы сразу, не на старте записи
|
||||
finally:
|
||||
f1.close()
|
||||
f2.close()
|
||||
|
||||
|
||||
def test_create_unique_without_extension():
|
||||
d = Path(tempfile.mkdtemp())
|
||||
p1, f1 = _create_unique(d, "noext")
|
||||
p2, f2 = _create_unique(d, "noext")
|
||||
try:
|
||||
assert p1 == d / "noext"
|
||||
assert p2 == d / "noext (1)"
|
||||
finally:
|
||||
f1.close()
|
||||
f2.close()
|
||||
|
||||
|
||||
# --- Реестр стратегий и расширения ---
|
||||
|
||||
|
||||
def test_pick_downloader_chain():
|
||||
load_extractors() # подгружаем экстракторы (как делает lifespan)
|
||||
|
||||
gd = pick_downloader("https://drive.google.com/file/d/ABC123_xyz/view?usp=sharing")
|
||||
assert isinstance(gd, GoogleDriveExtractor) # расширение перехватывает раньше всех
|
||||
|
||||
assert isinstance(pick_downloader("https://e.com/archive.zip"), HttpxDownloader)
|
||||
assert isinstance(pick_downloader("https://youtube.com/watch?v=x"), YtDlpDownloader)
|
||||
|
||||
|
||||
def test_pick_downloader_raises_for_unsupported():
|
||||
# Не прямой файл и не из загрузчиков (example.com — резервный домен, yt-dlp
|
||||
# его не знает) -> явная ошибка, а не молчаливый фолбэк.
|
||||
with pytest.raises(UnsupportedURLError):
|
||||
pick_downloader("https://example.com/just-a-page")
|
||||
|
||||
|
||||
def test_ytdlp_cookie_opts():
|
||||
assert _ytdlp_cookie_opts(None, None) == {}
|
||||
assert _ytdlp_cookie_opts("chrome", None) == {
|
||||
"cookiesfrombrowser": ("chrome", None, None, None)
|
||||
}
|
||||
assert _ytdlp_cookie_opts("chrome:Work", None)["cookiesfrombrowser"] == (
|
||||
"chrome",
|
||||
"Work",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
assert _ytdlp_cookie_opts(None, "/tmp/c.txt") == {"cookiefile": "/tmp/c.txt"}
|
||||
both = _ytdlp_cookie_opts("firefox", "/tmp/c.txt")
|
||||
assert both["cookiefile"] == "/tmp/c.txt"
|
||||
assert both["cookiesfrombrowser"][0] == "firefox"
|
||||
|
||||
|
||||
def test_list_strategies_sorted_with_builtins():
|
||||
strategies = list_strategies()
|
||||
kinds = {s["kind"] for s in strategies}
|
||||
assert {"direct", "fallback"} <= kinds
|
||||
prios = [s["priority"] for s in strategies]
|
||||
assert prios == sorted(prios, reverse=True) # по убыванию приоритета
|
||||
|
||||
|
||||
def test_load_extractors_idempotent():
|
||||
from app.services.downloader import _REGISTRY
|
||||
|
||||
load_extractors()
|
||||
before = len(_REGISTRY)
|
||||
load_extractors() # повторный вызов не должен задваивать реестр
|
||||
assert len(_REGISTRY) == before
|
||||
|
||||
|
||||
def test_gdrive_matches():
|
||||
assert GoogleDriveExtractor.matches("https://drive.google.com/file/d/XyZ_1/view")
|
||||
assert not GoogleDriveExtractor.matches("https://example.com/file.zip")
|
||||
|
||||
|
||||
async def test_gdrive_resolves_to_direct_url():
|
||||
extractor = GoogleDriveExtractor()
|
||||
resolved = await extractor.resolve(
|
||||
"https://drive.google.com/file/d/ABC123_xyz/view?usp=sharing"
|
||||
)
|
||||
assert (
|
||||
resolved.download_url
|
||||
== "https://drive.google.com/uc?export=download&id=ABC123_xyz"
|
||||
)
|
||||
Reference in New Issue
Block a user