Compare commits
4
Commits
3281fa2a5d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea906bbf0e | ||
|
|
eae4def0bf | ||
|
|
3e47e95fc4 | ||
|
|
303de2b26c |
@@ -21,3 +21,4 @@ frontend/.vite/
|
|||||||
.idea/
|
.idea/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
cookies.txt
|
||||||
|
|||||||
@@ -7,10 +7,15 @@
|
|||||||
- **Прямые файлы** (`.zip`, `.pdf`, `.mp4`, …) качаются через `httpx` по chunk'ам.
|
- **Прямые файлы** (`.zip`, `.pdf`, `.mp4`, …) качаются через `httpx` по chunk'ам.
|
||||||
- **Видео с сайтов** (YouTube и сотни других) — через `yt-dlp`.
|
- **Видео с сайтов** (YouTube и сотни других) — через `yt-dlp`.
|
||||||
- Прогресс течёт в UI по **SSE** в реальном времени.
|
- Прогресс течёт в UI по **SSE** в реальном времени.
|
||||||
|
- Три вкладки: **Активные** (что качается сейчас и завершилось за последний час),
|
||||||
|
**История** (все завершённые загрузки) и **Загрузчики** (какие стратегии скачивания
|
||||||
|
сейчас доступны).
|
||||||
|
- Если ссылка не прямой файл и ни один загрузчик её не поддерживает — загрузка
|
||||||
|
завершается явной ошибкой (а не молчаливой попыткой угадать).
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
- Бэкенд: Python 3.12, FastAPI, Uvicorn, httpx, yt-dlp, aiosqlite, sse-starlette
|
- Бэкенд: Python 3.12, FastAPI, Uvicorn, httpx, yt-dlp (+ curl_cffi для импперсонации), aiosqlite, sse-starlette
|
||||||
- Фронтенд: SvelteKit (SPA), TypeScript, Tailwind CSS v4, lucide-svelte
|
- Фронтенд: SvelteKit (SPA), TypeScript, Tailwind CSS v4, lucide-svelte
|
||||||
- Менеджер зависимостей: `uv`
|
- Менеджер зависимостей: `uv`
|
||||||
|
|
||||||
@@ -27,11 +32,60 @@ app/
|
|||||||
models.py Pydantic-модели
|
models.py Pydantic-модели
|
||||||
api/ роуты: downloads.py, events.py (SSE)
|
api/ роуты: downloads.py, events.py (SSE)
|
||||||
core/ абстракции: queue.py, storage.py, events.py (EventBus)
|
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
|
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`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Прод: зависимости -> сборка фронта (если её нет) -> сервер
|
||||||
|
./run.sh # Windows: run.bat
|
||||||
|
|
||||||
|
# Дев: бэкенд (:8000) + Vite dev-сервер (:5173) с hot-reload
|
||||||
|
./dev.sh # Windows: dev.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
`run` открывает <http://127.0.0.1:8000>, `dev` — <http://localhost:5173>.
|
||||||
|
Файлы складываются в `downloads/`.
|
||||||
|
|
||||||
|
## Запуск (прод, «для себя») — вручную
|
||||||
|
|
||||||
Фронт собирается в статику, FastAPI отдаёт её с того же origin:
|
Фронт собирается в статику, FastAPI отдаёт её с того же origin:
|
||||||
|
|
||||||
@@ -44,8 +98,6 @@ cd frontend && npm install && npm run build && cd ..
|
|||||||
uv run pvideodl
|
uv run pvideodl
|
||||||
```
|
```
|
||||||
|
|
||||||
Открыть <http://127.0.0.1:8000>. Файлы складываются в `downloads/`.
|
|
||||||
|
|
||||||
## Запуск (дев) — два процесса
|
## Запуск (дев) — два процесса
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -65,6 +117,7 @@ cd frontend && npm run dev
|
|||||||
| `POST` | `/api/downloads` | Добавить ссылки (`{"urls": [...]}`) |
|
| `POST` | `/api/downloads` | Добавить ссылки (`{"urls": [...]}`) |
|
||||||
| `GET` | `/api/downloads` | Список всех загрузок |
|
| `GET` | `/api/downloads` | Список всех загрузок |
|
||||||
| `DELETE` | `/api/downloads/{id}` | Удалить задачу |
|
| `DELETE` | `/api/downloads/{id}` | Удалить задачу |
|
||||||
|
| `GET` | `/api/downloaders` | Список загруженных стратегий |
|
||||||
| `GET` | `/api/events` | SSE-поток обновлений прогресса |
|
| `GET` | `/api/events` | SSE-поток обновлений прогресса |
|
||||||
| `GET` | `/api/health` | Проверка живости |
|
| `GET` | `/api/health` | Проверка живости |
|
||||||
|
|
||||||
@@ -78,6 +131,17 @@ cd frontend && npm run dev
|
|||||||
| `PVDL_WORKERS` | `3` | Сколько воркеров параллельно |
|
| `PVDL_WORKERS` | `3` | Сколько воркеров параллельно |
|
||||||
| `PVDL_HOST` | `127.0.0.1` | Хост сервера |
|
| `PVDL_HOST` | `127.0.0.1` | Хост сервера |
|
||||||
| `PVDL_PORT` | `8000` | Порт сервера |
|
| `PVDL_PORT` | `8000` | Порт сервера |
|
||||||
|
| `PVDL_COOKIES_FROM_BROWSER` | — | Cookies для yt-dlp из браузера: `chrome`, `firefox`, `edge`… (можно `chrome:Профиль`) |
|
||||||
|
| `PVDL_COOKIES_FILE` | — | Cookies для yt-dlp из файла (формат Netscape `cookies.txt`) |
|
||||||
|
| `PVDL_IMPERSONATE` | `chrome` | Импперсонация браузера для yt-dlp (`chrome`, `edge`, `safari`…); пусто — выключить |
|
||||||
|
|
||||||
|
> Cookies нужны для сайтов, которые блокируют анонимные запросы (возрастной гейт,
|
||||||
|
> логин, гео). Достаточно одного из вариантов.
|
||||||
|
>
|
||||||
|
> Сайты с анти-ботом по TLS-отпечатку (например PornHub, иначе `HTTP 410 Gone` или
|
||||||
|
> обрыв соединения при скачивании видео) требуют импперсонации браузера. Она включена
|
||||||
|
> по умолчанию (`PVDL_IMPERSONATE=chrome`) через `curl_cffi` и применяется ко всем
|
||||||
|
> запросам yt-dlp. Выключить — `PVDL_IMPERSONATE=` (пусто).
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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)
|
@dataclass(frozen=True)
|
||||||
class Settings:
|
class Settings:
|
||||||
# Куда складывать скачанные файлы.
|
# Куда складывать скачанные файлы.
|
||||||
@@ -38,6 +43,14 @@ class Settings:
|
|||||||
# Хост/порт веб-сервера.
|
# Хост/порт веб-сервера.
|
||||||
host: str = os.environ.get("PVDL_HOST", "127.0.0.1")
|
host: str = os.environ.get("PVDL_HOST", "127.0.0.1")
|
||||||
port: int = _env_int("PVDL_PORT", 8000)
|
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")
|
||||||
|
# Импперсонация браузера (TLS-отпечаток) для yt-dlp — нужна сайтам с анти-ботом
|
||||||
|
# (иначе CDN рвёт соединение / 410). "chrome" по умолчанию; пусто — выключить.
|
||||||
|
impersonate: str | None = os.environ.get("PVDL_IMPERSONATE", "chrome") or None
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
+24
-3
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -17,6 +17,10 @@ import aiosqlite
|
|||||||
from app.models import Download, DownloadStatus
|
from app.models import Download, DownloadStatus
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
class Storage(ABC):
|
class Storage(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def init(self) -> None: ...
|
async def init(self) -> None: ...
|
||||||
@@ -52,6 +56,7 @@ _COLUMNS = (
|
|||||||
"eta",
|
"eta",
|
||||||
"error",
|
"error",
|
||||||
"created_at",
|
"created_at",
|
||||||
|
"finished_at",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -59,6 +64,8 @@ def _row_to_download(row: aiosqlite.Row) -> Download:
|
|||||||
data = dict(row)
|
data = dict(row)
|
||||||
data["status"] = DownloadStatus(data["status"])
|
data["status"] = DownloadStatus(data["status"])
|
||||||
data["created_at"] = datetime.fromisoformat(data["created_at"])
|
data["created_at"] = datetime.fromisoformat(data["created_at"])
|
||||||
|
if data.get("finished_at"):
|
||||||
|
data["finished_at"] = datetime.fromisoformat(data["finished_at"])
|
||||||
return Download.model_validate(data)
|
return Download.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
@@ -98,22 +105,36 @@ class SqliteStorage(Storage):
|
|||||||
speed REAL,
|
speed REAL,
|
||||||
eta REAL,
|
eta REAL,
|
||||||
error TEXT,
|
error TEXT,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL,
|
||||||
|
finished_at TEXT
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
# Миграция старых БД, созданных до появления finished_at.
|
||||||
|
await self._migrate_add_column("finished_at", "TEXT")
|
||||||
# На старте всё, что осталось "качающимся" после прошлого запуска,
|
# На старте всё, что осталось "качающимся" после прошлого запуска,
|
||||||
# помечаем упавшим — воркеры этого процесса о них не знают.
|
# помечаем упавшим — воркеры этого процесса о них не знают.
|
||||||
await self._db.execute(
|
await self._db.execute(
|
||||||
"UPDATE downloads SET status = ?, error = ? WHERE status = ?",
|
"UPDATE downloads SET status = ?, error = ?, finished_at = ? "
|
||||||
|
"WHERE status = ?",
|
||||||
(
|
(
|
||||||
DownloadStatus.FAILED.value,
|
DownloadStatus.FAILED.value,
|
||||||
"Прервано при перезапуске приложения",
|
"Прервано при перезапуске приложения",
|
||||||
|
_now().isoformat(),
|
||||||
DownloadStatus.DOWNLOADING.value,
|
DownloadStatus.DOWNLOADING.value,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
await self._db.commit()
|
await self._db.commit()
|
||||||
|
|
||||||
|
async def _migrate_add_column(self, name: str, decl: str) -> None:
|
||||||
|
"""Добавить колонку, если её ещё нет (idempotent-миграция старых БД)."""
|
||||||
|
async with self._conn.execute("PRAGMA table_info(downloads)") as cursor:
|
||||||
|
cols = {row["name"] for row in await cursor.fetchall()}
|
||||||
|
if name not in cols:
|
||||||
|
await self._conn.execute(
|
||||||
|
f"ALTER TABLE downloads ADD COLUMN {name} {decl}"
|
||||||
|
)
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
if self._db is not None:
|
if self._db is not None:
|
||||||
await self._db.close()
|
await self._db.close()
|
||||||
|
|||||||
+11
-1
@@ -12,12 +12,13 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse, Response
|
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.config import settings
|
||||||
from app.core.events import EventBus
|
from app.core.events import EventBus
|
||||||
from app.core.queue import DownloadQueue
|
from app.core.queue import DownloadQueue
|
||||||
from app.core.storage import SqliteStorage
|
from app.core.storage import SqliteStorage
|
||||||
from app.models import DownloadStatus
|
from app.models import DownloadStatus
|
||||||
|
from app.services.extractors import load_extractors
|
||||||
from app.services.worker import WorkerPool
|
from app.services.worker import WorkerPool
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||||
@@ -26,6 +27,8 @@ logger = logging.getLogger("pvideodl")
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
load_extractors() # регистрируем расширения-экстракторы из app/services/extractors
|
||||||
|
|
||||||
storage = SqliteStorage(settings.db_path)
|
storage = SqliteStorage(settings.db_path)
|
||||||
await storage.init()
|
await storage.init()
|
||||||
|
|
||||||
@@ -46,6 +49,12 @@ async def lifespan(app: FastAPI):
|
|||||||
await pool.start()
|
await pool.start()
|
||||||
settings.download_dir.mkdir(parents=True, exist_ok=True)
|
settings.download_dir.mkdir(parents=True, exist_ok=True)
|
||||||
logger.info("PVideoDl запущен. Файлы → %s", settings.download_dir)
|
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)
|
||||||
|
if settings.impersonate:
|
||||||
|
logger.info("yt-dlp импперсонация: %s", settings.impersonate)
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
@@ -65,6 +74,7 @@ def create_app() -> FastAPI:
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(downloads.router)
|
app.include_router(downloads.router)
|
||||||
|
app.include_router(downloaders.router)
|
||||||
app.include_router(events.router)
|
app.include_router(events.router)
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class Download(BaseModel):
|
|||||||
eta: float | None = None # секунд до конца, оценка
|
eta: float | None = None # секунд до конца, оценка
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
created_at: datetime = Field(default_factory=_now)
|
created_at: datetime = Field(default_factory=_now)
|
||||||
|
finished_at: datetime | None = None # момент перехода в done/failed
|
||||||
|
|
||||||
|
|
||||||
class CreateDownloads(BaseModel):
|
class CreateDownloads(BaseModel):
|
||||||
@@ -45,6 +46,15 @@ class CreateDownloads(BaseModel):
|
|||||||
urls: list[str] = Field(default_factory=list, min_length=1)
|
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) ---
|
# --- События шины (то, что улетает в SSE) ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+233
-35
@@ -11,12 +11,14 @@ async-колбэк on_progress, который дёргается не чаще,
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import functools
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import BinaryIO
|
||||||
from urllib.parse import unquote, urlparse
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -54,6 +56,19 @@ class DownloadResult:
|
|||||||
path: Path
|
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:
|
def _safe_filename(name: str) -> str:
|
||||||
"""Чистим имя файла от разделителей пути и опасных символов."""
|
"""Чистим имя файла от разделителей пути и опасных символов."""
|
||||||
name = unquote(name).strip().replace("\\", "/").split("/")[-1]
|
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:
|
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
|
candidate = directory / filename
|
||||||
if not candidate.exists():
|
if not candidate.exists():
|
||||||
return candidate
|
return candidate
|
||||||
@@ -76,25 +95,30 @@ def _unique_path(directory: Path, filename: str) -> Path:
|
|||||||
i += 1
|
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:
|
def is_direct_file(url: str) -> bool:
|
||||||
path = urlparse(url).path.lower()
|
path = urlparse(url).path.lower()
|
||||||
return any(path.endswith(ext) for ext in _DIRECT_EXTENSIONS)
|
return any(path.endswith(ext) for ext in _DIRECT_EXTENSIONS)
|
||||||
|
|
||||||
|
|
||||||
class Downloader:
|
def _filename_from_response(resp: httpx.Response) -> str:
|
||||||
"""Базовый интерфейс стратегии скачивания."""
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
def _filename_from_response(self, url: str, resp: httpx.Response) -> str:
|
|
||||||
cd = resp.headers.get("content-disposition", "")
|
cd = resp.headers.get("content-disposition", "")
|
||||||
match = re.search(r"filename\*=(?:UTF-8'')?([^;]+)|filename=\"?([^\";]+)\"?", cd)
|
match = re.search(r"filename\*=(?:UTF-8'')?([^;]+)|filename=\"?([^\";]+)\"?", cd)
|
||||||
if match:
|
if match:
|
||||||
@@ -104,26 +128,42 @@ class HttpxDownloader(Downloader):
|
|||||||
name = urlparse(str(resp.url)).path
|
name = urlparse(str(resp.url)).path
|
||||||
return _safe_filename(name) if name and name != "/" else "download"
|
return _safe_filename(name) if name and name != "/" else "download"
|
||||||
|
|
||||||
async def download(self, url: str, on_progress: OnProgress) -> DownloadResult:
|
|
||||||
self._dir.mkdir(parents=True, exist_ok=True)
|
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)
|
timeout = httpx.Timeout(30.0, read=None)
|
||||||
async with httpx.AsyncClient(follow_redirects=True, timeout=timeout) as client:
|
async with httpx.AsyncClient(
|
||||||
|
follow_redirects=True, timeout=timeout, headers=headers
|
||||||
|
) as client:
|
||||||
async with client.stream("GET", url) as resp:
|
async with client.stream("GET", url) as resp:
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
filename = self._filename_from_response(url, resp)
|
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
|
total = (
|
||||||
target = _unique_path(self._dir, filename)
|
int(resp.headers["content-length"])
|
||||||
|
if "content-length" in resp.headers
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
target, fh = _create_unique(directory, name)
|
||||||
|
|
||||||
downloaded = 0
|
downloaded = 0
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
last_emit = 0.0
|
last_emit = 0.0
|
||||||
try:
|
try:
|
||||||
with target.open("wb") as fh:
|
with fh:
|
||||||
async for chunk in resp.aiter_bytes(self._chunk):
|
async for chunk in resp.aiter_bytes(settings.chunk_size):
|
||||||
fh.write(chunk)
|
fh.write(chunk)
|
||||||
downloaded += len(chunk)
|
downloaded += len(chunk)
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if now - last_emit >= self._interval:
|
if now - last_emit >= settings.progress_interval:
|
||||||
elapsed = now - start
|
elapsed = now - start
|
||||||
speed = downloaded / elapsed if elapsed > 0 else None
|
speed = downloaded / elapsed if elapsed > 0 else None
|
||||||
eta = (
|
eta = (
|
||||||
@@ -145,9 +185,126 @@ class HttpxDownloader(Downloader):
|
|||||||
return DownloadResult(target.name, downloaded, target)
|
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):
|
class YtDlpDownloader(Downloader):
|
||||||
"""Скачивание через yt-dlp. yt-dlp синхронный, поэтому крутим его в потоке,
|
"""yt-dlp: YouTube и сотни сайтов. Берётся за URL последним и только если у
|
||||||
а progress-хуки прокидываем обратно в event loop через call_soon_threadsafe."""
|
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:
|
def __init__(self) -> None:
|
||||||
self._dir = settings.download_dir
|
self._dir = settings.download_dir
|
||||||
@@ -184,19 +341,35 @@ class YtDlpDownloader(Downloader):
|
|||||||
# Импортируем лениво, чтобы httpx-only сценарий не тянул yt-dlp.
|
# Импортируем лениво, чтобы httpx-only сценарий не тянул yt-dlp.
|
||||||
from yt_dlp import YoutubeDL
|
from yt_dlp import YoutubeDL
|
||||||
|
|
||||||
ydl_opts = {
|
base_opts = {
|
||||||
"outtmpl": str(self._dir / "%(title)s [%(id)s].%(ext)s"),
|
|
||||||
"progress_hooks": [hook],
|
|
||||||
"noprogress": True,
|
"noprogress": True,
|
||||||
"quiet": True,
|
"quiet": True,
|
||||||
"no_warnings": True,
|
"no_warnings": True,
|
||||||
"noplaylist": True,
|
"noplaylist": True,
|
||||||
|
**_ytdlp_cookie_opts(settings.cookies_from_browser, settings.cookies_file),
|
||||||
}
|
}
|
||||||
with YoutubeDL(ydl_opts) as ydl:
|
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)
|
info = ydl.extract_info(url, download=True)
|
||||||
final_path = result_holder.get("path")
|
final_path = result_holder.get("path") or ydl.prepare_filename(info)
|
||||||
if not final_path:
|
|
||||||
final_path = ydl.prepare_filename(info)
|
|
||||||
path = Path(str(final_path))
|
path = Path(str(final_path))
|
||||||
size = path.stat().st_size if path.exists() else None
|
size = path.stat().st_size if path.exists() else None
|
||||||
return DownloadResult(path.name, size, path)
|
return DownloadResult(path.name, size, path)
|
||||||
@@ -205,5 +378,30 @@ class YtDlpDownloader(Downloader):
|
|||||||
|
|
||||||
|
|
||||||
def pick_downloader(url: str) -> Downloader:
|
def pick_downloader(url: str) -> Downloader:
|
||||||
"""Прямые ссылки на файлы — httpx, всё остальное (страницы сайтов) — yt-dlp."""
|
"""Первая подходящая стратегия по убыванию priority.
|
||||||
return HttpxDownloader() if is_direct_file(url) else YtDlpDownloader()
|
|
||||||
|
Кастомные экстракторы (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}"
|
||||||
|
)
|
||||||
@@ -13,7 +13,7 @@ import logging
|
|||||||
from app.core.events import EventBus
|
from app.core.events import EventBus
|
||||||
from app.core.queue import DownloadQueue
|
from app.core.queue import DownloadQueue
|
||||||
from app.core.storage import Storage
|
from app.core.storage import Storage
|
||||||
from app.models import Download, DownloadEvent, DownloadStatus
|
from app.models import Download, DownloadEvent, DownloadStatus, _now
|
||||||
from app.services.downloader import Progress, pick_downloader
|
from app.services.downloader import Progress, pick_downloader
|
||||||
|
|
||||||
logger = logging.getLogger("pvideodl.worker")
|
logger = logging.getLogger("pvideodl.worker")
|
||||||
@@ -89,7 +89,12 @@ class WorkerPool:
|
|||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.warning("Ошибка скачивания %s: %s", download.url, exc)
|
logger.warning("Ошибка скачивания %s: %s", download.url, exc)
|
||||||
failed = await self._storage.update(
|
failed = await self._storage.update(
|
||||||
download_id, status=DownloadStatus.FAILED, error=str(exc), speed=None, eta=None
|
download_id,
|
||||||
|
status=DownloadStatus.FAILED,
|
||||||
|
error=str(exc),
|
||||||
|
speed=None,
|
||||||
|
eta=None,
|
||||||
|
finished_at=_now(),
|
||||||
)
|
)
|
||||||
await self._publish("failed", failed)
|
await self._publish("failed", failed)
|
||||||
return
|
return
|
||||||
@@ -104,6 +109,7 @@ class WorkerPool:
|
|||||||
speed=None,
|
speed=None,
|
||||||
eta=0,
|
eta=0,
|
||||||
error=None,
|
error=None,
|
||||||
|
finished_at=_now(),
|
||||||
)
|
)
|
||||||
await self._publish("done", done)
|
await self._publish("done", done)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@echo off
|
||||||
|
rem Dev mode: backend (:8000) in a new window + Vite dev server (:5173).
|
||||||
|
rem Open http://localhost:5173 - Vite proxies /api to the backend.
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
echo [1/2] uv sync
|
||||||
|
call uv sync
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
|
if exist "frontend\node_modules" goto dev
|
||||||
|
pushd frontend
|
||||||
|
call npm install
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
popd
|
||||||
|
|
||||||
|
:dev
|
||||||
|
echo [2/2] starting backend (:8000) in a new window, then Vite dev (:5173)
|
||||||
|
start "PVideoDl backend" cmd /k "uv run python -m app.main"
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Дев-режим: бэкенд (:8000) + Vite dev-сервер (:5173) с hot-reload фронта.
|
||||||
|
# Откройте http://localhost:5173 — Vite проксирует /api на бэкенд.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
uv sync
|
||||||
|
[ -d frontend/node_modules ] || (cd frontend && npm install)
|
||||||
|
|
||||||
|
echo "==> Бэкенд → http://127.0.0.1:8000 (в фоне)"
|
||||||
|
uv run python -m app.main &
|
||||||
|
backend_pid=$!
|
||||||
|
trap 'kill "$backend_pid" 2>/dev/null' EXIT INT TERM
|
||||||
|
|
||||||
|
echo "==> Vite dev → http://localhost:5173"
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Действия над загрузками, общие для всех вкладок: добавление, удаление, ретрай.
|
||||||
|
// Обёртки над api + сторами + тостами — чтобы страницы не дублировали логику.
|
||||||
|
import * as api from './api';
|
||||||
|
import { removeLocal, pushToast } from './stores';
|
||||||
|
import type { Download } from './types';
|
||||||
|
|
||||||
|
export async function addDownloads(urls: string[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
const created = await api.addDownloads(urls);
|
||||||
|
pushToast('info', `Добавлено в очередь: ${created.length}`);
|
||||||
|
} catch (e) {
|
||||||
|
pushToast('error', (e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDownload(id: string): Promise<void> {
|
||||||
|
removeLocal(id); // оптимистично убираем из UI
|
||||||
|
try {
|
||||||
|
await api.deleteDownload(id);
|
||||||
|
} catch (e) {
|
||||||
|
pushToast('error', `Не удалось удалить: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryDownload(d: Download): Promise<void> {
|
||||||
|
// Ретрая на бэкенде пока нет — пересоздаём задачу с тем же URL.
|
||||||
|
await deleteDownload(d.id);
|
||||||
|
await addDownloads([d.url]);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Клиент к бэкенду. Базовый путь относительный — и в деве (через Vite-прокси),
|
// Клиент к бэкенду. Базовый путь относительный — и в деве (через Vite-прокси),
|
||||||
// и в проде (FastAPI отдаёт статику с того же origin) работает одинаково.
|
// и в проде (FastAPI отдаёт статику с того же origin) работает одинаково.
|
||||||
import type { Download } from './types';
|
import type { Download, DownloaderInfo } from './types';
|
||||||
|
|
||||||
const BASE = '/api';
|
const BASE = '/api';
|
||||||
|
|
||||||
@@ -36,3 +36,7 @@ export async function addDownloads(urls: string[]): Promise<Download[]> {
|
|||||||
export async function deleteDownload(id: string): Promise<void> {
|
export async function deleteDownload(id: string): Promise<void> {
|
||||||
return handle(await fetch(`${BASE}/downloads/${id}`, { method: 'DELETE' }));
|
return handle(await fetch(`${BASE}/downloads/${id}`, { method: 'DELETE' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listDownloaders(): Promise<DownloaderInfo[]> {
|
||||||
|
return handle(await fetch(`${BASE}/downloaders`));
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,11 +5,48 @@ import type { Download, DownloadEvent } from './types';
|
|||||||
// Карта загрузок id -> Download. Карта, а не массив — точечные апдейты по id из SSE.
|
// Карта загрузок id -> Download. Карта, а не массив — точечные апдейты по id из SSE.
|
||||||
const downloadsMap = writable<Map<string, Download>>(new Map());
|
const downloadsMap = writable<Map<string, Download>>(new Map());
|
||||||
|
|
||||||
// Отсортированный список для рендера (новые сверху).
|
// Завершённую (done/failed) загрузку держим на главной как «недавнюю» столько времени.
|
||||||
export const downloads = derived(downloadsMap, ($m) =>
|
const RECENT_FINISHED_MS = 60 * 60 * 1000; // 1 час
|
||||||
[...$m.values()].sort((a, b) => b.created_at.localeCompare(a.created_at))
|
|
||||||
|
const FINISHED = new Set(['done', 'failed']);
|
||||||
|
|
||||||
|
// «Часы» для derived-сторов: тикают, чтобы недавно завершённые сами уходили
|
||||||
|
// с главной по истечении окна, даже без новых SSE-событий.
|
||||||
|
export const now = writable(Date.now());
|
||||||
|
|
||||||
|
/** Запустить тиканье часов. Возвращает функцию остановки (для onMount cleanup). */
|
||||||
|
export function startClock(): () => void {
|
||||||
|
const id = setInterval(() => now.set(Date.now()), 30_000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecentlyFinished(d: Download, nowMs: number): boolean {
|
||||||
|
if (!d.finished_at) return false;
|
||||||
|
return nowMs - Date.parse(d.finished_at) < RECENT_FINISHED_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Главная: то, что качается сейчас (или в очереди), плюс недавно завершённое.
|
||||||
|
export const activeDownloads = derived([downloadsMap, now], ([$m, $now]) =>
|
||||||
|
[...$m.values()]
|
||||||
|
.filter((d) => !FINISHED.has(d.status) || isRecentlyFinished(d, $now))
|
||||||
|
.sort((a, b) => b.created_at.localeCompare(a.created_at))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// История: всё завершённое (done/failed), свежее — сверху.
|
||||||
|
export const historyDownloads = derived(downloadsMap, ($m) =>
|
||||||
|
[...$m.values()]
|
||||||
|
.filter((d) => FINISHED.has(d.status))
|
||||||
|
.sort((a, b) =>
|
||||||
|
(b.finished_at ?? b.created_at).localeCompare(a.finished_at ?? a.created_at)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const activeCount = derived(activeDownloads, ($d) => $d.length);
|
||||||
|
export const historyCount = derived(historyDownloads, ($d) => $d.length);
|
||||||
|
|
||||||
|
// Первичная гидрация завершена — чтобы отличать «грузим список» от «список пуст».
|
||||||
|
export const hydrated = writable(false);
|
||||||
|
|
||||||
export function setDownloads(items: Download[]): void {
|
export function setDownloads(items: Download[]): void {
|
||||||
downloadsMap.set(new Map(items.map((d) => [d.id, d])));
|
downloadsMap.set(new Map(items.map((d) => [d.id, d])));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ export interface Download {
|
|||||||
eta: number | null; // секунд
|
eta: number | null; // секунд
|
||||||
error: string | null;
|
error: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
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 type DownloadEventType = 'created' | 'progress' | 'done' | 'failed' | 'deleted';
|
||||||
|
|||||||
@@ -1,6 +1,86 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { DownloadCloud, History, Puzzle, Wifi, WifiOff } from 'lucide-svelte';
|
||||||
|
import * as api from '$lib/api';
|
||||||
|
import {
|
||||||
|
setDownloads,
|
||||||
|
pushToast,
|
||||||
|
connected,
|
||||||
|
activeCount,
|
||||||
|
historyCount,
|
||||||
|
hydrated,
|
||||||
|
startClock
|
||||||
|
} from '$lib/stores';
|
||||||
|
import { connectSSE, disconnectSSE } from '$lib/sse';
|
||||||
|
import Toaster from '$lib/components/Toaster.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Гидрация: текущий список + подписка на живые обновления + часы.
|
||||||
|
api
|
||||||
|
.listDownloads()
|
||||||
|
.then(setDownloads)
|
||||||
|
.catch((e) => pushToast('error', `Не удалось загрузить список: ${e.message}`))
|
||||||
|
.finally(() => hydrated.set(true));
|
||||||
|
connectSSE();
|
||||||
|
const stopClock = startClock();
|
||||||
|
return () => {
|
||||||
|
disconnectSSE();
|
||||||
|
stopClock();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ href: '/', label: 'Активные', icon: DownloadCloud },
|
||||||
|
{ href: '/history', label: 'История', icon: History },
|
||||||
|
{ href: '/downloaders', label: 'Загрузчики', icon: Puzzle }
|
||||||
|
];
|
||||||
|
const path = $derived($page.url.pathname);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<Toaster />
|
||||||
|
|
||||||
|
<main class="mx-auto max-w-3xl px-4 py-10">
|
||||||
|
<header class="mb-6 flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight text-zinc-50">PVideoDl</h1>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">Локальная скачивалка файлов и видео</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 text-xs {$connected
|
||||||
|
? 'text-emerald-400'
|
||||||
|
: 'text-zinc-500'}"
|
||||||
|
title={$connected ? 'Поток обновлений активен' : 'Нет соединения с сервером'}
|
||||||
|
>
|
||||||
|
{#if $connected}<Wifi class="size-3.5" />онлайн{:else}<WifiOff
|
||||||
|
class="size-3.5"
|
||||||
|
/>оффлайн{/if}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<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 : 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
|
||||||
|
? 'border-indigo-500 text-zinc-100'
|
||||||
|
: 'border-transparent text-zinc-500 hover:text-zinc-300'}"
|
||||||
|
>
|
||||||
|
<tab.icon class="size-4" />
|
||||||
|
{tab.label}
|
||||||
|
{#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
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
|||||||
@@ -1,96 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { Inbox } from 'lucide-svelte';
|
||||||
import { Inbox, Wifi, WifiOff } from 'lucide-svelte';
|
import { activeDownloads, hydrated } from '$lib/stores';
|
||||||
import * as api from '$lib/api';
|
import { addDownloads, deleteDownload, retryDownload } from '$lib/actions';
|
||||||
import {
|
|
||||||
downloads,
|
|
||||||
connected,
|
|
||||||
setDownloads,
|
|
||||||
removeLocal,
|
|
||||||
pushToast
|
|
||||||
} from '$lib/stores';
|
|
||||||
import { connectSSE, disconnectSSE } from '$lib/sse';
|
|
||||||
import type { Download } from '$lib/types';
|
|
||||||
import AddLinksForm from '$lib/components/AddLinksForm.svelte';
|
import AddLinksForm from '$lib/components/AddLinksForm.svelte';
|
||||||
import DownloadCard from '$lib/components/DownloadCard.svelte';
|
import DownloadCard from '$lib/components/DownloadCard.svelte';
|
||||||
import Toaster from '$lib/components/Toaster.svelte';
|
|
||||||
|
|
||||||
let loading = $state(true);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
// Гидрация: текущий список + подписка на живые обновления.
|
|
||||||
api
|
|
||||||
.listDownloads()
|
|
||||||
.then(setDownloads)
|
|
||||||
.catch((e) => pushToast('error', `Не удалось загрузить список: ${e.message}`))
|
|
||||||
.finally(() => (loading = false));
|
|
||||||
connectSSE();
|
|
||||||
return disconnectSSE;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleAdd(urls: string[]) {
|
|
||||||
try {
|
|
||||||
const created = await api.addDownloads(urls);
|
|
||||||
pushToast('info', `Добавлено в очередь: ${created.length}`);
|
|
||||||
} catch (e) {
|
|
||||||
pushToast('error', (e as Error).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
|
||||||
removeLocal(id); // оптимистично убираем из UI
|
|
||||||
try {
|
|
||||||
await api.deleteDownload(id);
|
|
||||||
} catch (e) {
|
|
||||||
pushToast('error', `Не удалось удалить: ${(e as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRetry(d: Download) {
|
|
||||||
// Ретрая на бэкенде пока нет — пересоздаём задачу с тем же URL.
|
|
||||||
await handleDelete(d.id);
|
|
||||||
await handleAdd([d.url]);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>PVideoDl — скачивалка</title></svelte:head>
|
<svelte:head><title>PVideoDl — скачивалка</title></svelte:head>
|
||||||
|
|
||||||
<Toaster />
|
<AddLinksForm onSubmit={addDownloads} />
|
||||||
|
|
||||||
<main class="mx-auto max-w-3xl px-4 py-10">
|
|
||||||
<header class="mb-8 flex items-end justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-semibold tracking-tight text-zinc-50">PVideoDl</h1>
|
|
||||||
<p class="mt-1 text-sm text-zinc-500">Локальная скачивалка файлов и видео</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center gap-1.5 text-xs {$connected
|
|
||||||
? 'text-emerald-400'
|
|
||||||
: 'text-zinc-500'}"
|
|
||||||
title={$connected ? 'Поток обновлений активен' : 'Нет соединения с сервером'}
|
|
||||||
>
|
|
||||||
{#if $connected}<Wifi class="size-3.5" />онлайн{:else}<WifiOff
|
|
||||||
class="size-3.5"
|
|
||||||
/>оффлайн{/if}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<AddLinksForm onSubmit={handleAdd} />
|
|
||||||
|
|
||||||
<section class="mt-8">
|
<section class="mt-8">
|
||||||
{#if loading}
|
{#if !$hydrated}
|
||||||
<p class="py-12 text-center text-sm text-zinc-500">Загрузка…</p>
|
<p class="py-12 text-center text-sm text-zinc-500">Загрузка…</p>
|
||||||
{:else if $downloads.length === 0}
|
{:else if $activeDownloads.length === 0}
|
||||||
<div class="flex flex-col items-center gap-3 py-16 text-center text-zinc-500">
|
<div class="flex flex-col items-center gap-3 py-16 text-center text-zinc-500">
|
||||||
<Inbox class="size-10 opacity-40" />
|
<Inbox class="size-10 opacity-40" />
|
||||||
<p class="text-sm">Пока пусто. Вставьте ссылки выше и нажмите «Скачать».</p>
|
<p class="text-sm">Сейчас ничего не качается. Вставьте ссылки выше и нажмите «Скачать».</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
{#each $downloads as download (download.id)}
|
{#each $activeDownloads as download (download.id)}
|
||||||
<DownloadCard {download} onDelete={handleDelete} onRetry={handleRetry} />
|
<DownloadCard {download} onDelete={deleteDownload} onRetry={retryDownload} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { History } from 'lucide-svelte';
|
||||||
|
import { historyDownloads, hydrated } from '$lib/stores';
|
||||||
|
import { deleteDownload, retryDownload } from '$lib/actions';
|
||||||
|
import DownloadCard from '$lib/components/DownloadCard.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>PVideoDl — история</title></svelte:head>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
{#if !$hydrated}
|
||||||
|
<p class="py-12 text-center text-sm text-zinc-500">Загрузка…</p>
|
||||||
|
{:else if $historyDownloads.length === 0}
|
||||||
|
<div class="flex flex-col items-center gap-3 py-16 text-center text-zinc-500">
|
||||||
|
<History class="size-10 opacity-40" />
|
||||||
|
<p class="text-sm">История пуста — здесь появятся завершённые загрузки.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#each $historyDownloads as download (download.id)}
|
||||||
|
<DownloadCard {download} onDelete={deleteDownload} onRetry={retryDownload} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -12,6 +12,7 @@ dependencies = [
|
|||||||
"aiosqlite>=0.20",
|
"aiosqlite>=0.20",
|
||||||
"sse-starlette>=2.1",
|
"sse-starlette>=2.1",
|
||||||
"yt-dlp>=2024.12.0",
|
"yt-dlp>=2024.12.0",
|
||||||
|
"curl-cffi>=0.15.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
@echo off
|
||||||
|
rem Production launch: install deps, build frontend if needed, run the server.
|
||||||
|
rem Downloaded files go to downloads\ - UI at http://127.0.0.1:8000
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
echo [1/3] uv sync
|
||||||
|
call uv sync
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
|
if exist "frontend\build" goto serve
|
||||||
|
|
||||||
|
echo [2/3] building frontend (frontend\build missing)
|
||||||
|
pushd frontend
|
||||||
|
call npm install
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
call npm run build
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
popd
|
||||||
|
|
||||||
|
:serve
|
||||||
|
echo [3/3] starting PVideoDl at http://127.0.0.1:8000
|
||||||
|
uv run pvideodl
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Прод-запуск одной командой: зависимости -> сборка фронта (если нужна) -> сервер.
|
||||||
|
# Файлы складываются в downloads/, UI на http://127.0.0.1:8000
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "==> uv sync"
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
if [ ! -d frontend/build ]; then
|
||||||
|
echo "==> Сборка фронтенда (frontend/build отсутствует)"
|
||||||
|
(cd frontend && npm install && npm run build)
|
||||||
|
else
|
||||||
|
echo "==> Фронтенд уже собран — пропускаю сборку (удалите frontend/build для пересборки)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Запуск PVideoDl на http://127.0.0.1:8000"
|
||||||
|
exec uv run pvideodl
|
||||||
@@ -64,3 +64,12 @@ async def test_health(client):
|
|||||||
c, _ = client
|
c, _ = client
|
||||||
resp = await c.get("/api/health")
|
resp = await c.get("/api/health")
|
||||||
assert resp.status_code == 200 and resp.json()["status"] == "ok"
|
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"
|
||||||
|
)
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import tempfile
|
import tempfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.core.storage import SqliteStorage
|
from app.core.storage import SqliteStorage
|
||||||
@@ -57,6 +59,45 @@ async def test_delete(storage: SqliteStorage):
|
|||||||
assert await storage.get(d.id) is None
|
assert await storage.get(d.id) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_finished_at_roundtrip(storage: SqliteStorage):
|
||||||
|
d = Download(url="https://example.com/a.zip")
|
||||||
|
await storage.create(d)
|
||||||
|
assert (await storage.get(d.id)).finished_at is None
|
||||||
|
|
||||||
|
ts = datetime.now(timezone.utc)
|
||||||
|
snap = await storage.update(d.id, status=DownloadStatus.DONE, finished_at=ts)
|
||||||
|
assert snap is not None and snap.finished_at == ts
|
||||||
|
|
||||||
|
|
||||||
|
async def test_migrates_legacy_db_without_finished_at():
|
||||||
|
# БД, созданная до появления колонки finished_at.
|
||||||
|
path = Path(tempfile.mkdtemp()) / "legacy.db"
|
||||||
|
db = await aiosqlite.connect(path)
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE downloads (
|
||||||
|
id TEXT PRIMARY KEY, url TEXT NOT NULL, filename TEXT,
|
||||||
|
status TEXT NOT NULL, progress REAL NOT NULL DEFAULT 0,
|
||||||
|
size_bytes INTEGER, downloaded_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
speed REAL, eta REAL, error TEXT, created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO downloads (id, url, status, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
("x1", "https://e.com/a.zip", "done", datetime.now(timezone.utc).isoformat()),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
st = SqliteStorage(path)
|
||||||
|
await st.init() # должен добавить колонку, не уронив существующие данные
|
||||||
|
got = await st.get("x1")
|
||||||
|
assert got is not None and got.status == DownloadStatus.DONE
|
||||||
|
assert got.finished_at is None
|
||||||
|
await st.close()
|
||||||
|
|
||||||
|
|
||||||
async def test_downloading_marked_failed_on_init():
|
async def test_downloading_marked_failed_on_init():
|
||||||
path = Path(tempfile.mkdtemp()) / "test.db"
|
path = Path(tempfile.mkdtemp()) / "test.db"
|
||||||
st = SqliteStorage(path)
|
st = SqliteStorage(path)
|
||||||
@@ -69,4 +110,5 @@ async def test_downloading_marked_failed_on_init():
|
|||||||
await st2.init() # должен пометить "качающиеся" записи как FAILED
|
await st2.init() # должен пометить "качающиеся" записи как FAILED
|
||||||
got = await st2.get(d.id)
|
got = await st2.get(d.id)
|
||||||
assert got is not None and got.status == DownloadStatus.FAILED
|
assert got is not None and got.status == DownloadStatus.FAILED
|
||||||
|
assert got.finished_at is not None # прерванная загрузка тоже «завершилась»
|
||||||
await st2.close()
|
await st2.close()
|
||||||
|
|||||||
@@ -51,6 +51,63 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
|
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cffi"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.4.1"
|
version = "8.4.1"
|
||||||
@@ -72,6 +129,39 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "curl-cffi"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "cffi" },
|
||||||
|
{ name = "rich" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/48/5b/89fcfebd3e5e85134147ac99e9f2b2271165fd4d71984fc65da5f17819b7/curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded", size = 196437, upload-time = "2026-04-03T11:12:31.525Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/42/54ddd442c795f30ce5dd4e49f87ce77505958d3777cd96a91567a3975d2a/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28", size = 2795267, upload-time = "2026-04-03T11:11:46.48Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/2d/3915e238579b3c5a92cead5c79130c3b8d20caaba7616cc4d894650e1d6b/curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b", size = 2573544, upload-time = "2026-04-03T11:11:47.951Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/b3/9d2f1057749a1b07ba1989db3c1503ce8bed998310bae9aea2c43aa64f20/curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527", size = 10515369, upload-time = "2026-04-03T11:11:50.126Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/1d/6d10dded5ce3fd8157e558ebd97d09e551b77a62cdc1c31e93d0a633cee5/curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3", size = 10160045, upload-time = "2026-04-03T11:11:52.664Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/12/c70b835487ace3b9ba1502631912e3440082b8ae3a162f60b59cb0b6444d/curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc", size = 11090433, upload-time = "2026-04-03T11:11:55.049Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/0d/78edcc4f71934225db99df68197a107386d59080742fc7bf6bb4d007924f/curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a", size = 10479178, upload-time = "2026-04-03T11:11:57.685Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/84/1e101c1acb1ea2f0b4992f5c3024f596d8e21db0d53540b9d583f673c4e7/curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b", size = 10317051, upload-time = "2026-04-03T11:12:00.295Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/42/8ef236b22a6c23d096c85a1dc507efe37bfdfc7a2f8a4b34efb590197369/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13", size = 11299660, upload-time = "2026-04-03T11:12:02.791Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/01/56aeb055d962da87a1be0d74c6c644e251c7e88129b5471dc44ac724e678/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61", size = 11945049, upload-time = "2026-04-03T11:12:05.912Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/8c/2abf99a38d6340d66cf0557e0c750ef3f8883dfc5d450087e01c85861343/curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572", size = 1661649, upload-time = "2026-04-03T11:12:07.948Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/39/dfd54f2240d3a9b96d77bacc62b97813b35e2aa8ecf5cd5013c683f1ba96/curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d", size = 1410741, upload-time = "2026-04-03T11:12:10.073Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427, upload-time = "2026-04-03T11:12:12.142Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/56/132225cb3491d07cc6adcce5fe395e059bde87c68cff1ef87a31c88c7819/curl_cffi-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:967ad7355bd8e9586f8c2d02eaa99953747549e7ea4a9b25cd53353e6b67fe6d", size = 2795723, upload-time = "2026-04-03T11:12:13.668Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/8f/f4f83cd303bef7e8f1749512e5dd157e7e5d08b0a36c8211f9640a2757bf/curl_cffi-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e63539d0d839d0a8c5eacf86229bc68c57803547f35e0db7ee0986328b478c3", size = 2573739, upload-time = "2026-04-03T11:12:15.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/5c/643d65c7fc9acd742876aa55c2d7823c438cb7665810acd2e66c9976c4d9/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08c799b89740b9bc49c09fbc3d5907f13ac1f845ca52620507ef9466d4639dd5", size = 10521046, upload-time = "2026-04-03T11:12:17.034Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/0b/9b8037113c93f4c5323096163471fa7c35c7676c3f608eeaf1287cd99d58/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b7a92767a888ee90147e18964b396d8435ff42737030d6fb00824ffd6094805", size = 11096115, upload-time = "2026-04-03T11:12:19.694Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/96/fff2fcbd924ef4042e0d67379f751a8a4e3186a91e75e35a4cf218b306ee/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:829cc357061ecb99cc2d406301f609a039e05665322f5c025ec67c38b0dc49ce", size = 11305346, upload-time = "2026-04-03T11:12:22.151Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/1b/304b253a45ab28691c8c5e8cca1e6cbb9cf8e46dfceae4648dd536f75e73/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:408d6f14e346841cd889c2e0962832bb235ba3b6749ebf609f347f747da5e60f", size = 11949834, upload-time = "2026-04-03T11:12:24.986Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/ff/4723d92f08259c707a974aba27a08d0a822b9555e35ca581bf18d055a364/curl_cffi-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b624c7ce087bfda967a013ed0a64702a525444e5b6e97d23534d567ccc6525aa", size = 1702771, upload-time = "2026-04-03T11:12:28.201Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/8c/36bbe06d66fa2b765e4a07199f643a59a9cd1a754207a96335402a9520f4/curl_cffi-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0b6c0543b993996670e9e4b78e305a2d60809d5681903ffb5568e21a387434d3", size = 1466312, upload-time = "2026-04-03T11:12:30.054Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.137.1"
|
version = "0.137.1"
|
||||||
@@ -179,6 +269,27 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markdown-it-py"
|
||||||
|
version = "4.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mdurl" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mdurl"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.2"
|
version = "26.2"
|
||||||
@@ -203,6 +314,7 @@ version = "0.1.0"
|
|||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
|
{ name = "curl-cffi" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
@@ -220,6 +332,7 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiosqlite", specifier = ">=0.20" },
|
{ name = "aiosqlite", specifier = ">=0.20" },
|
||||||
|
{ name = "curl-cffi", specifier = ">=0.15.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.115" },
|
{ name = "fastapi", specifier = ">=0.115" },
|
||||||
{ name = "httpx", specifier = ">=0.27" },
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
{ name = "pydantic", specifier = ">=2.9" },
|
{ name = "pydantic", specifier = ">=2.9" },
|
||||||
@@ -234,6 +347,15 @@ dev = [
|
|||||||
{ name = "pytest-asyncio", specifier = ">=0.24" },
|
{ name = "pytest-asyncio", specifier = ">=0.24" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycparser"
|
||||||
|
version = "3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.13.4"
|
version = "2.13.4"
|
||||||
@@ -417,6 +539,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rich"
|
||||||
|
version = "15.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown-it-py" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sse-starlette"
|
name = "sse-starlette"
|
||||||
version = "3.4.4"
|
version = "3.4.4"
|
||||||
|
|||||||
Reference in New Issue
Block a user