diff --git a/.gitignore b/.gitignore index ce7d253..ab1231d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ frontend/.vite/ .idea/ .DS_Store Thumbs.db +cookies.txt diff --git a/README.md b/README.md index b7a127a..0b47516 100644 --- a/README.md +++ b/README.md @@ -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`. Достаточно одного из вариантов. ## Тесты diff --git a/app/api/downloaders.py b/app/api/downloaders.py new file mode 100644 index 0000000..6e20934 --- /dev/null +++ b/app/api/downloaders.py @@ -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() diff --git a/app/config.py b/app/config.py index 1028877..9306297 100644 --- a/app/config.py +++ b/app/config.py @@ -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() diff --git a/app/main.py b/app/main.py index 0ba0e05..86e89a3 100644 --- a/app/main.py +++ b/app/main.py @@ -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") diff --git a/app/models.py b/app/models.py index 09f3d28..55ed798 100644 --- a/app/models.py +++ b/app/models.py @@ -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) --- diff --git a/app/services/downloader.py b/app/services/downloader.py index 415c5ac..9a45531 100644 --- a/app/services/downloader.py +++ b/app/services/downloader.py @@ -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 diff --git a/app/services/extractors/__init__.py b/app/services/extractors/__init__.py new file mode 100644 index 0000000..43be676 --- /dev/null +++ b/app/services/extractors/__init__.py @@ -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 diff --git a/app/services/extractors/google_drive.py b/app/services/extractors/google_drive.py new file mode 100644 index 0000000..e116aee --- /dev/null +++ b/app/services/extractors/google_drive.py @@ -0,0 +1,41 @@ +"""Пример расширения: файлы Google Drive по ссылке-«просмотру». + +Образец для своих экстракторов. Здесь резолв чисто строковый (URL -> прямая +ссылка), без скрейпинга — поэтому тестируется офлайн. + + https://drive.google.com/file/d//view + -> https://drive.google.com/uc?export=download&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}" + ) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4c2fd87..3155712 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 { export async function deleteDownload(id: string): Promise { return handle(await fetch(`${BASE}/downloads/${id}`, { method: 'DELETE' })); } + +export async function listDownloaders(): Promise { + return handle(await fetch(`${BASE}/downloaders`)); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index e2d3dbb..d9e3326 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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 { diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 1e41336..59dac24 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -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); @@ -63,7 +64,7 @@