Compare commits

...
4 Commits
29 changed files with 1153 additions and 168 deletions
+1
View File
@@ -21,3 +21,4 @@ frontend/.vite/
.idea/
.DS_Store
Thumbs.db
cookies.txt
+69 -5
View File
@@ -7,10 +7,15 @@
- **Прямые файлы** (`.zip`, `.pdf`, `.mp4`, …) качаются через `httpx` по chunk'ам.
- **Видео с сайтов** (YouTube и сотни других) — через `yt-dlp`.
- Прогресс течёт в 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
- Менеджер зависимостей: `uv`
@@ -27,11 +32,60 @@ app/
models.py Pydantic-модели
api/ роуты: downloads.py, events.py (SSE)
core/ абстракции: queue.py, storage.py, events.py (EventBus)
services/ downloader.py (httpx/yt-dlp), worker.py (пул воркеров)
services/ downloader.py (реестр стратегий), worker.py (пул воркеров)
extractors/ расширения для сайтов с непрямыми ссылками
frontend/ SvelteKit SPA → собирается в frontend/build
```
## Запуск (прод, «для себя») — одна команда
### Расширения: сайты с непрямыми ссылками
Выбор стратегии — цепочка обработчиков с приоритетами (`pick_downloader`):
кастомные экстракторы (priority > 0) перехватывают URL раньше встроенных
`HttpxDownloader` (прямые файлы) и `YtDlpDownloader` (универсальный фолбэк).
Добавить сайт = положить один файл в `app/services/extractors/`. Чаще всего хватает
**резолвера** — достать прямую ссылку (и при нужде `Referer`/`Cookie`), а
скачивание, прогресс и `(n)`-имена наследуются:
```python
# app/services/extractors/my_site.py
import re
from app.services.downloader import Resolved, SiteExtractor, register
@register
class MySite(SiteExtractor):
priority = 100
@classmethod
def matches(cls, url: str) -> bool:
return "my-site.com/watch/" in url
async def resolve(self, url: str) -> Resolved:
# ... найти настоящую ссылку (запрос/скрейпинг) ...
return Resolved(download_url=real_url, headers={"Referer": url})
```
Модули пакета авто-загружаются на старте (`load_extractors()`). Готовый образец —
[google_drive.py](app/services/extractors/google_drive.py). Если сайту нужен
нестандартный процесс (HLS, сегменты) — наследуйся прямо от `Downloader` и
переопредели `download()` целиком.
## Запуск — готовые скрипты
В корне лежат скрипты-обёртки (Linux/macOS — `.sh`, Windows — `.bat`):
```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:
@@ -44,8 +98,6 @@ cd frontend && npm install && npm run build && cd ..
uv run pvideodl
```
Открыть <http://127.0.0.1:8000>. Файлы складываются в `downloads/`.
## Запуск (дев) — два процесса
```bash
@@ -65,6 +117,7 @@ cd frontend && npm run dev
| `POST` | `/api/downloads` | Добавить ссылки (`{"urls": [...]}`) |
| `GET` | `/api/downloads` | Список всех загрузок |
| `DELETE` | `/api/downloads/{id}` | Удалить задачу |
| `GET` | `/api/downloaders` | Список загруженных стратегий |
| `GET` | `/api/events` | SSE-поток обновлений прогресса |
| `GET` | `/api/health` | Проверка живости |
@@ -78,6 +131,17 @@ cd frontend && npm run dev
| `PVDL_WORKERS` | `3` | Сколько воркеров параллельно |
| `PVDL_HOST` | `127.0.0.1` | Хост сервера |
| `PVDL_PORT` | `8000` | Порт сервера |
| `PVDL_COOKIES_FROM_BROWSER` | — | Cookies для yt-dlp из браузера: `chrome`, `firefox`, `edge`… (можно `chrome:Профиль`) |
| `PVDL_COOKIES_FILE` | — | Cookies для yt-dlp из файла (формат Netscape `cookies.txt`) |
| `PVDL_IMPERSONATE` | `chrome` | Импперсонация браузера для yt-dlp (`chrome`, `edge`, `safari`…); пусто — выключить |
> Cookies нужны для сайтов, которые блокируют анонимные запросы (возрастной гейт,
> логин, гео). Достаточно одного из вариантов.
>
> Сайты с анти-ботом по TLS-отпечатку (например PornHub, иначе `HTTP 410 Gone` или
> обрыв соединения при скачивании видео) требуют импперсонации браузера. Она включена
> по умолчанию (`PVDL_IMPERSONATE=chrome`) через `curl_cffi` и применяется ко всем
> запросам yt-dlp. Выключить — `PVDL_IMPERSONATE=` (пусто).
## Тесты
+16
View File
@@ -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()
+13
View File
@@ -21,6 +21,11 @@ def _env_int(name: str, default: int) -> int:
return default
def _env_path_opt(name: str) -> Path | None:
value = os.environ.get(name)
return Path(value).expanduser().resolve() if value else None
@dataclass(frozen=True)
class Settings:
# Куда складывать скачанные файлы.
@@ -38,6 +43,14 @@ class Settings:
# Хост/порт веб-сервера.
host: str = os.environ.get("PVDL_HOST", "127.0.0.1")
port: int = _env_int("PVDL_PORT", 8000)
# Cookies для yt-dlp (сайты, блокирующие анонимные запросы: возрастной гейт,
# логин, гео). Браузер: "chrome" | "firefox" | "edge" | … , можно "chrome:Профиль".
cookies_from_browser: str | None = os.environ.get("PVDL_COOKIES_FROM_BROWSER") or None
# Файл cookies в формате Netscape (cookies.txt).
cookies_file: Path | None = _env_path_opt("PVDL_COOKIES_FILE")
# Импперсонация браузера (TLS-отпечаток) для yt-dlp — нужна сайтам с анти-ботом
# (иначе CDN рвёт соединение / 410). "chrome" по умолчанию; пусто — выключить.
impersonate: str | None = os.environ.get("PVDL_IMPERSONATE", "chrome") or None
settings = Settings()
+24 -3
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
import json
from abc import ABC, abstractmethod
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -17,6 +17,10 @@ import aiosqlite
from app.models import Download, DownloadStatus
def _now() -> datetime:
return datetime.now(timezone.utc)
class Storage(ABC):
@abstractmethod
async def init(self) -> None: ...
@@ -52,6 +56,7 @@ _COLUMNS = (
"eta",
"error",
"created_at",
"finished_at",
)
@@ -59,6 +64,8 @@ def _row_to_download(row: aiosqlite.Row) -> Download:
data = dict(row)
data["status"] = DownloadStatus(data["status"])
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)
@@ -98,22 +105,36 @@ class SqliteStorage(Storage):
speed REAL,
eta REAL,
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(
"UPDATE downloads SET status = ?, error = ? WHERE status = ?",
"UPDATE downloads SET status = ?, error = ?, finished_at = ? "
"WHERE status = ?",
(
DownloadStatus.FAILED.value,
"Прервано при перезапуске приложения",
_now().isoformat(),
DownloadStatus.DOWNLOADING.value,
),
)
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:
if self._db is not None:
await self._db.close()
+11 -1
View File
@@ -12,12 +12,13 @@ from fastapi.staticfiles import StaticFiles
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from app.api import downloads, events
from app.api import downloaders, downloads, events
from app.config import settings
from app.core.events import EventBus
from app.core.queue import DownloadQueue
from app.core.storage import SqliteStorage
from app.models import DownloadStatus
from app.services.extractors import load_extractors
from app.services.worker import WorkerPool
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
@@ -26,6 +27,8 @@ logger = logging.getLogger("pvideodl")
@asynccontextmanager
async def lifespan(app: FastAPI):
load_extractors() # регистрируем расширения-экстракторы из app/services/extractors
storage = SqliteStorage(settings.db_path)
await storage.init()
@@ -46,6 +49,12 @@ async def lifespan(app: FastAPI):
await pool.start()
settings.download_dir.mkdir(parents=True, exist_ok=True)
logger.info("PVideoDl запущен. Файлы → %s", settings.download_dir)
if settings.cookies_from_browser:
logger.info("yt-dlp cookies: из браузера %s", settings.cookies_from_browser)
if settings.cookies_file:
logger.info("yt-dlp cookies: файл %s", settings.cookies_file)
if settings.impersonate:
logger.info("yt-dlp импперсонация: %s", settings.impersonate)
try:
yield
finally:
@@ -65,6 +74,7 @@ def create_app() -> FastAPI:
)
app.include_router(downloads.router)
app.include_router(downloaders.router)
app.include_router(events.router)
@app.get("/api/health")
+10
View File
@@ -37,6 +37,7 @@ class Download(BaseModel):
eta: float | None = None # секунд до конца, оценка
error: str | None = None
created_at: datetime = Field(default_factory=_now)
finished_at: datetime | None = None # момент перехода в done/failed
class CreateDownloads(BaseModel):
@@ -45,6 +46,15 @@ class CreateDownloads(BaseModel):
urls: list[str] = Field(default_factory=list, min_length=1)
class DownloaderInfo(BaseModel):
"""Зарегистрированная стратегия скачивания — для страницы «Загрузчики»."""
name: str
kind: str # "direct" | "extractor" | "fallback"
priority: int
description: str | None = None
# --- События шины (то, что улетает в SSE) ---
+233 -35
View File
@@ -11,12 +11,14 @@ async-колбэк on_progress, который дёргается не чаще,
from __future__ import annotations
import asyncio
import functools
import os
import re
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from pathlib import Path
from typing import BinaryIO
from urllib.parse import unquote, urlparse
import httpx
@@ -54,6 +56,19 @@ class DownloadResult:
path: Path
@dataclass
class Resolved:
"""Что вернул резолвер сайта: куда реально идти за файлом.
headers — если для прямой ссылки нужен Referer/Cookie/авторизация.
filename — если сайт знает «правильное» имя (иначе возьмём из ответа).
"""
download_url: str
filename: str | None = None
headers: dict[str, str] | None = None
def _safe_filename(name: str) -> str:
"""Чистим имя файла от разделителей пути и опасных символов."""
name = unquote(name).strip().replace("\\", "/").split("/")[-1]
@@ -63,7 +78,11 @@ def _safe_filename(name: str) -> str:
def _unique_path(directory: Path, filename: str) -> Path:
"""Не перезатираем существующие файлы — добавляем (1), (2), ..."""
"""Свободное имя по-браузерному: file.ext, file (1).ext, file (2).ext, ...
Best-effort: только подбирает имя, не резервирует его. Для случаев, где файл
пишет внешний инструмент (yt-dlp) и держать дескриптор нельзя.
"""
candidate = directory / filename
if not candidate.exists():
return candidate
@@ -76,25 +95,30 @@ def _unique_path(directory: Path, filename: str) -> Path:
i += 1
def _create_unique(directory: Path, filename: str) -> tuple[Path, BinaryIO]:
"""Атомарно создать НОВЫЙ файл, разводя дубли как браузер: name (1).ext и т.д.
Эксклюзивное создание (режим "xb") закрывает гонку между воркерами: имя
не просто подобрано, а сразу занято — параллельная загрузка не затрёт.
"""
directory.mkdir(parents=True, exist_ok=True)
candidate = directory / filename
stem, suffix = candidate.stem, candidate.suffix
i = 0
while True:
try:
return candidate, candidate.open("xb")
except FileExistsError:
i += 1
candidate = directory / f"{stem} ({i}){suffix}"
def is_direct_file(url: str) -> bool:
path = urlparse(url).path.lower()
return any(path.endswith(ext) for ext in _DIRECT_EXTENSIONS)
class Downloader:
"""Базовый интерфейс стратегии скачивания."""
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:
def _filename_from_response(resp: httpx.Response) -> str:
cd = resp.headers.get("content-disposition", "")
match = re.search(r"filename\*=(?:UTF-8'')?([^;]+)|filename=\"?([^\";]+)\"?", cd)
if match:
@@ -104,26 +128,42 @@ class HttpxDownloader(Downloader):
name = urlparse(str(resp.url)).path
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)
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:
resp.raise_for_status()
filename = self._filename_from_response(url, resp)
total = int(resp.headers["content-length"]) if "content-length" in resp.headers else None
target = _unique_path(self._dir, filename)
name = _safe_filename(filename) if filename else _filename_from_response(resp)
total = (
int(resp.headers["content-length"])
if "content-length" in resp.headers
else None
)
target, fh = _create_unique(directory, name)
downloaded = 0
start = time.monotonic()
last_emit = 0.0
try:
with target.open("wb") as fh:
async for chunk in resp.aiter_bytes(self._chunk):
with fh:
async for chunk in resp.aiter_bytes(settings.chunk_size):
fh.write(chunk)
downloaded += len(chunk)
now = time.monotonic()
if now - last_emit >= self._interval:
if now - last_emit >= settings.progress_interval:
elapsed = now - start
speed = downloaded / elapsed if elapsed > 0 else None
eta = (
@@ -145,9 +185,126 @@ class HttpxDownloader(Downloader):
return DownloadResult(target.name, downloaded, target)
class UnsupportedURLError(Exception):
"""Ни одна стратегия не берётся за URL: не прямой файл и не из загрузчиков."""
# --- Реестр стратегий ---------------------------------------------------------
# Расширения регистрируются декоратором @register. pick_downloader() выбирает
# первую подходящую по убыванию priority. Кастомные экстракторы (priority > 0)
# перехватывают URL раньше встроенных httpx/yt-dlp.
_REGISTRY: list[type["Downloader"]] = []
def register(cls: type["Downloader"]) -> type["Downloader"]:
_REGISTRY.append(cls)
return cls
class Downloader:
"""Базовый интерфейс стратегии. priority — кто раньше перехватывает URL
(больше = раньше); matches() — берётся ли эта стратегия за данный URL.
label/kind — для страницы «Загрузчики» (kind: direct | extractor | fallback)."""
priority: int = 0
label: str = "" # человекочитаемое имя; пусто -> берём имя класса
kind: str = "other"
@classmethod
def matches(cls, url: str) -> bool:
return False
async def download(self, url: str, on_progress: OnProgress) -> DownloadResult:
raise NotImplementedError
class SiteExtractor(Downloader):
"""Удобная база для сайтов: реализуй matches() и resolve() — достать прямую
ссылку (и при нужде Referer/Cookie). Скачивание, прогресс и (n)-имена общие.
Если сайту нужен нестандартный процесс (HLS, сегменты) — наследуйся прямо от
Downloader и переопредели download() целиком."""
kind = "extractor"
async def resolve(self, url: str) -> Resolved:
raise NotImplementedError
async def download(self, url: str, on_progress: OnProgress) -> DownloadResult:
resolved = await self.resolve(url)
return await _stream_to_file(
resolved.download_url,
on_progress,
headers=resolved.headers,
filename=resolved.filename,
)
@register
class HttpxDownloader(Downloader):
"""Прямые ссылки на файлы (.zip, .pdf, .mp4 …) — стрим по chunk'ам."""
priority = 10
label = "Прямые файлы"
kind = "direct"
@classmethod
def matches(cls, url: str) -> bool:
return is_direct_file(url)
async def download(self, url: str, on_progress: OnProgress) -> DownloadResult:
return await _stream_to_file(url, on_progress)
@functools.lru_cache(maxsize=1)
def _ytdlp_site_extractors() -> tuple:
"""Профильные экстракторы yt-dlp без generic (он матчит почти любой http-URL).
Грузим один раз и кешируем — список большой и тянется лениво.
"""
from yt_dlp.extractor import gen_extractor_classes
return tuple(ie for ie in gen_extractor_classes() if ie.IE_NAME != "generic")
def _ytdlp_supports(url: str) -> bool:
"""Есть ли у yt-dlp профильный экстрактор под этот URL (generic не в счёт)."""
try:
return any(ie.suitable(url) for ie in _ytdlp_site_extractors())
except Exception: # noqa: BLE001 — проблемы yt-dlp не должны ронять выбор
return False
def _ytdlp_cookie_opts(from_browser: str | None, cookies_file: Path | str | None) -> dict:
"""Опции cookies для yt-dlp из настроек — для сайтов, блокирующих анонимов."""
opts: dict = {}
if cookies_file:
opts["cookiefile"] = str(cookies_file)
if from_browser:
browser, _, profile = from_browser.partition(":")
# yt-dlp ждёт кортеж (browser, profile, keyring, container).
opts["cookiesfrombrowser"] = (browser.strip(), profile.strip() or None, None, None)
return opts
@register
class YtDlpDownloader(Downloader):
"""Скачивание через yt-dlp. yt-dlp синхронный, поэтому крутим его в потоке,
а progress-хуки прокидываем обратно в event loop через call_soon_threadsafe."""
"""yt-dlp: YouTube и сотни сайтов. Берётся за URL последним и только если у
yt-dlp есть профильный экстрактор под него (generic-угадывание не считаем —
иначе «ловит всё» и ошибки про неподдерживаемую ссылку не будет).
yt-dlp синхронный, поэтому крутим его в потоке, а progress-хуки прокидываем
обратно в event loop через run_coroutine_threadsafe."""
priority = -100
label = "yt-dlp (видео и сайты)"
kind = "fallback"
@classmethod
def matches(cls, url: str) -> bool:
return _ytdlp_supports(url)
def __init__(self) -> None:
self._dir = settings.download_dir
@@ -184,19 +341,35 @@ class YtDlpDownloader(Downloader):
# Импортируем лениво, чтобы httpx-only сценарий не тянул yt-dlp.
from yt_dlp import YoutubeDL
ydl_opts = {
"outtmpl": str(self._dir / "%(title)s [%(id)s].%(ext)s"),
"progress_hooks": [hook],
base_opts = {
"noprogress": True,
"quiet": True,
"no_warnings": True,
"noplaylist": True,
**_ytdlp_cookie_opts(settings.cookies_from_browser, settings.cookies_file),
}
with YoutubeDL(ydl_opts) as ydl:
if settings.impersonate:
# Глобально: браузерный отпечаток на ВСЕ запросы (страница + медиа),
# иначе CDN рвёт скачивание видео-данных.
from yt_dlp.networking.impersonate import ImpersonateTarget
base_opts["impersonate"] = ImpersonateTarget.from_str(settings.impersonate)
default_tmpl = str(self._dir / "%(title)s [%(id)s].%(ext)s")
# Фаза 1: узнаём имя файла, не качая, чтобы развести дубли как браузер.
with YoutubeDL({**base_opts, "outtmpl": default_tmpl}) as probe:
info = probe.extract_info(url, download=False)
predicted = Path(probe.prepare_filename(info))
target = _unique_path(self._dir, predicted.name)
# %(ext)s оставляем yt-dlp (контейнер может смениться при склейке),
# а литеральную часть имени экранируем: % -> %% (вдруг в названии есть %).
stem = str(target.with_suffix("")).replace("%", "%%")
outtmpl = f"{stem}.%(ext)s"
# Фаза 2: качаем в выбранный путь.
with YoutubeDL({**base_opts, "outtmpl": outtmpl, "progress_hooks": [hook]}) as ydl:
info = ydl.extract_info(url, download=True)
final_path = result_holder.get("path")
if not final_path:
final_path = ydl.prepare_filename(info)
final_path = result_holder.get("path") or ydl.prepare_filename(info)
path = Path(str(final_path))
size = path.stat().st_size if path.exists() else None
return DownloadResult(path.name, size, path)
@@ -205,5 +378,30 @@ class YtDlpDownloader(Downloader):
def pick_downloader(url: str) -> Downloader:
"""Прямые ссылки на файлы — httpx, всё остальное (страницы сайтов) — yt-dlp."""
return HttpxDownloader() if is_direct_file(url) else YtDlpDownloader()
"""Первая подходящая стратегия по убыванию priority.
Кастомные экстракторы (priority > 0) перехватывают раньше httpx (10) и
yt-dlp (-100). Если не взялся никто — URL не поддерживается."""
for cls in sorted(_REGISTRY, key=lambda c: c.priority, reverse=True):
if cls.matches(url):
return cls()
raise UnsupportedURLError(
"Не могу скачать эту ссылку: это не прямой файл и ни один загрузчик "
"её не поддерживает."
)
def list_strategies() -> list[dict]:
"""Описание зарегистрированных стратегий для страницы «Загрузчики»."""
result: list[dict] = []
for cls in sorted(_REGISTRY, key=lambda c: c.priority, reverse=True):
doc = (cls.__doc__ or "").strip().split("\n")[0].strip()
result.append(
{
"name": cls.label or cls.__name__,
"kind": cls.kind,
"priority": cls.priority,
"description": doc or None,
}
)
return result
+32
View File
@@ -0,0 +1,32 @@
"""Расширения-экстракторы для сайтов с непрямыми ссылками.
Каждый модуль здесь регистрирует свою стратегию декоратором @register из
app.services.downloader. Добавить сайт = положить сюда один файл; ядро не
трогаем. load_extractors() импортирует все модули пакета — её зовут на старте
приложения (lifespan в app/main.py).
"""
from __future__ import annotations
import importlib
import logging
import pkgutil
logger = logging.getLogger("pvideodl.extractors")
def load_extractors() -> list[str]:
"""Импортировать все модули пакета — при импорте они себя регистрируют.
Идемпотентно: при повторном вызове модули уже в sys.modules, их тело (а с ним
и @register) заново не выполняется — реестр не задваивается.
"""
loaded: list[str] = []
for info in pkgutil.iter_modules(__path__, __name__ + "."):
if info.name.rsplit(".", 1)[-1].startswith("_"):
continue
importlib.import_module(info.name)
loaded.append(info.name)
if loaded:
logger.info("Загружено экстракторов: %d (%s)", len(loaded), ", ".join(loaded))
return loaded
+41
View File
@@ -0,0 +1,41 @@
"""Пример расширения: файлы Google Drive по ссылке-«просмотру».
Образец для своих экстракторов. Здесь резолв чисто строковый (URL -> прямая
ссылка), без скрейпинга — поэтому тестируется офлайн.
https://drive.google.com/file/d/<ID>/view
-> https://drive.google.com/uc?export=download&id=<ID>
Оговорка: для больших файлов Drive отдаёт HTML-страницу с подтверждением
антивирусной проверки (нужны confirm-токен и cookie) — здесь не покрыто;
для небольших файлов прямая ссылка работает сразу.
"""
from __future__ import annotations
import re
from app.services.downloader import Resolved, SiteExtractor, register
_FILE_ID = re.compile(r"drive\.google\.com/file/d/([\w-]+)")
@register
class GoogleDriveExtractor(SiteExtractor):
"""Google Drive: ссылка-просмотр файла -> прямая загрузка."""
priority = 100
label = "Google Drive"
@classmethod
def matches(cls, url: str) -> bool:
return bool(_FILE_ID.search(url))
async def resolve(self, url: str) -> Resolved:
match = _FILE_ID.search(url)
if not match: # matches() уже проверил, но без него resolve честно падает
raise ValueError(f"Не ссылка на файл Google Drive: {url}")
file_id = match.group(1)
return Resolved(
download_url=f"https://drive.google.com/uc?export=download&id={file_id}"
)
+8 -2
View File
@@ -13,7 +13,7 @@ import logging
from app.core.events import EventBus
from app.core.queue import DownloadQueue
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
logger = logging.getLogger("pvideodl.worker")
@@ -89,7 +89,12 @@ class WorkerPool:
except Exception as exc: # noqa: BLE001
logger.warning("Ошибка скачивания %s: %s", download.url, exc)
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)
return
@@ -104,6 +109,7 @@ class WorkerPool:
speed=None,
eta=0,
error=None,
finished_at=_now(),
)
await self._publish("done", done)
+21
View File
@@ -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
Executable
+17
View File
@@ -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
+29
View File
@@ -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]);
}
+5 -1
View File
@@ -1,6 +1,6 @@
// Клиент к бэкенду. Базовый путь относительный — и в деве (через Vite-прокси),
// и в проде (FastAPI отдаёт статику с того же origin) работает одинаково.
import type { Download } from './types';
import type { Download, DownloaderInfo } from './types';
const BASE = '/api';
@@ -36,3 +36,7 @@ export async function addDownloads(urls: string[]): Promise<Download[]> {
export async function deleteDownload(id: string): Promise<void> {
return handle(await fetch(`${BASE}/downloads/${id}`, { method: 'DELETE' }));
}
export async function listDownloaders(): Promise<DownloaderInfo[]> {
return handle(await fetch(`${BASE}/downloaders`));
}
+40 -3
View File
@@ -5,11 +5,48 @@ import type { Download, DownloadEvent } from './types';
// Карта загрузок id -> Download. Карта, а не массив — точечные апдейты по id из SSE.
const downloadsMap = writable<Map<string, Download>>(new Map());
// Отсортированный список для рендера (новые сверху).
export const downloads = derived(downloadsMap, ($m) =>
[...$m.values()].sort((a, b) => b.created_at.localeCompare(a.created_at))
// Завершённую (done/failed) загрузку держим на главной как «недавнюю» столько времени.
const RECENT_FINISHED_MS = 60 * 60 * 1000; // 1 час
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 {
downloadsMap.set(new Map(items.map((d) => [d.id, d])));
}
+8
View File
@@ -12,6 +12,14 @@ export interface Download {
eta: number | null; // секунд
error: string | null;
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';
+80
View File
@@ -1,6 +1,86 @@
<script lang="ts">
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();
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>
<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()}
</main>
+9 -77
View File
@@ -1,96 +1,28 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Inbox, Wifi, WifiOff } from 'lucide-svelte';
import * as api from '$lib/api';
import {
downloads,
connected,
setDownloads,
removeLocal,
pushToast
} from '$lib/stores';
import { connectSSE, disconnectSSE } from '$lib/sse';
import type { Download } from '$lib/types';
import { Inbox } from 'lucide-svelte';
import { activeDownloads, hydrated } from '$lib/stores';
import { addDownloads, deleteDownload, retryDownload } from '$lib/actions';
import AddLinksForm from '$lib/components/AddLinksForm.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>
<svelte:head><title>PVideoDl — скачивалка</title></svelte:head>
<Toaster />
<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} />
<AddLinksForm onSubmit={addDownloads} />
<section class="mt-8">
{#if loading}
{#if !$hydrated}
<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">
<Inbox class="size-10 opacity-40" />
<p class="text-sm">Пока пусто. Вставьте ссылки выше и нажмите «Скачать».</p>
<p class="text-sm">Сейчас ничего не качается. Вставьте ссылки выше и нажмите «Скачать».</p>
</div>
{:else}
<div class="flex flex-col gap-3">
{#each $downloads as download (download.id)}
<DownloadCard {download} onDelete={handleDelete} onRetry={handleRetry} />
{#each $activeDownloads as download (download.id)}
<DownloadCard {download} onDelete={deleteDownload} onRetry={retryDownload} />
{/each}
</div>
{/if}
</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>
+25
View File
@@ -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>
+1
View File
@@ -12,6 +12,7 @@ dependencies = [
"aiosqlite>=0.20",
"sse-starlette>=2.1",
"yt-dlp>=2024.12.0",
"curl-cffi>=0.15.0",
]
[project.scripts]
+23
View File
@@ -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
Executable
+18
View File
@@ -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
+9
View File
@@ -64,3 +64,12 @@ async def test_health(client):
c, _ = client
resp = await c.get("/api/health")
assert resp.status_code == 200 and resp.json()["status"] == "ok"
async def test_list_downloaders(client):
c, _ = client
resp = await c.get("/api/downloaders")
assert resp.status_code == 200
data = resp.json()
kinds = {d["kind"] for d in data}
assert "direct" in kinds and "fallback" in kinds # встроенные httpx + yt-dlp
+121
View File
@@ -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"
)
+42
View File
@@ -1,6 +1,8 @@
import tempfile
from datetime import datetime, timezone
from pathlib import Path
import aiosqlite
import pytest
from app.core.storage import SqliteStorage
@@ -57,6 +59,45 @@ async def test_delete(storage: SqliteStorage):
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():
path = Path(tempfile.mkdtemp()) / "test.db"
st = SqliteStorage(path)
@@ -69,4 +110,5 @@ async def test_downloading_marked_failed_on_init():
await st2.init() # должен пометить "качающиеся" записи как FAILED
got = await st2.get(d.id)
assert got is not None and got.status == DownloadStatus.FAILED
assert got.finished_at is not None # прерванная загрузка тоже «завершилась»
await st2.close()
Generated
+135
View File
@@ -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" },
]
[[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]]
name = "click"
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" },
]
[[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]]
name = "fastapi"
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" },
]
[[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]]
name = "packaging"
version = "26.2"
@@ -203,6 +314,7 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
{ name = "curl-cffi" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pydantic" },
@@ -220,6 +332,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiosqlite", specifier = ">=0.20" },
{ name = "curl-cffi", specifier = ">=0.15.0" },
{ name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "pydantic", specifier = ">=2.9" },
@@ -234,6 +347,15 @@ dev = [
{ 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]]
name = "pydantic"
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" },
]
[[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]]
name = "sse-starlette"
version = "3.4.4"
View File