Добавлено описание проекта PVideoDl, включая функциональность, стек технологий, архитектуру, инструкции по запуску и API. Обновлён README.md для лучшего понимания проекта.

This commit is contained in:
Leonid Pershin
2026-06-19 12:07:55 +03:00
parent e15d0af056
commit 3281fa2a5d
43 changed files with 4699 additions and 0 deletions
View File
+42
View File
@@ -0,0 +1,42 @@
"""Шина событий для трансляции прогресса в SSE.
Простая in-memory реализация на основе asyncio-очередей подписчиков (fan-out).
Каждый SSE-клиент подписывается, получает свою очередь и читает из неё события.
Завтра при необходимости тут окажется Redis pub/sub — интерфейс не изменится.
"""
from __future__ import annotations
import asyncio
import contextlib
from collections.abc import AsyncIterator
from app.models import DownloadEvent
class EventBus:
def __init__(self, max_queue: int = 1000) -> None:
self._subscribers: set[asyncio.Queue[DownloadEvent]] = set()
self._max_queue = max_queue
async def publish(self, event: DownloadEvent) -> None:
# Рассылаем всем подписчикам. Если чья-то очередь переполнена
# (медленный клиент) — дропаем самое старое событие, не блокируясь.
for queue in list(self._subscribers):
if queue.full():
with contextlib.suppress(asyncio.QueueEmpty):
queue.get_nowait()
queue.put_nowait(event)
@contextlib.asynccontextmanager
async def subscribe(self) -> AsyncIterator[asyncio.Queue[DownloadEvent]]:
queue: asyncio.Queue[DownloadEvent] = asyncio.Queue(maxsize=self._max_queue)
self._subscribers.add(queue)
try:
yield queue
finally:
self._subscribers.discard(queue)
@property
def subscriber_count(self) -> int:
return len(self._subscribers)
+31
View File
@@ -0,0 +1,31 @@
"""Абстракция очереди задач.
Сегодня — обёртка над asyncio.Queue. Завтра можно подменить на Redis/RabbitMQ,
не трогая вызовы в сервисах: интерфейс DownloadQueue остаётся прежним.
"""
from __future__ import annotations
import asyncio
class DownloadQueue:
"""Очередь идентификаторов загрузок, ожидающих обработки воркерами."""
def __init__(self) -> None:
self._queue: asyncio.Queue[str] = asyncio.Queue()
async def add(self, download_id: str) -> None:
await self._queue.put(download_id)
async def get(self) -> str:
return await self._queue.get()
def task_done(self) -> None:
self._queue.task_done()
async def join(self) -> None:
await self._queue.join()
def qsize(self) -> int:
return self._queue.qsize()
+164
View File
@@ -0,0 +1,164 @@
"""Абстракция хранилища загрузок.
Интерфейс Storage + реализация на aiosqlite. Сегодня SQLite, завтра Postgres —
сервисы и роуты зовут только методы Storage и не знают о бэкенде.
"""
from __future__ import annotations
import json
from abc import ABC, abstractmethod
from datetime import datetime
from pathlib import Path
from typing import Any
import aiosqlite
from app.models import Download, DownloadStatus
class Storage(ABC):
@abstractmethod
async def init(self) -> None: ...
@abstractmethod
async def close(self) -> None: ...
@abstractmethod
async def create(self, download: Download) -> None: ...
@abstractmethod
async def update(self, download_id: str, **fields: Any) -> Download | None: ...
@abstractmethod
async def get(self, download_id: str) -> Download | None: ...
@abstractmethod
async def list(self) -> list[Download]: ...
@abstractmethod
async def delete(self, download_id: str) -> bool: ...
_COLUMNS = (
"id",
"url",
"filename",
"status",
"progress",
"size_bytes",
"downloaded_bytes",
"speed",
"eta",
"error",
"created_at",
)
def _row_to_download(row: aiosqlite.Row) -> Download:
data = dict(row)
data["status"] = DownloadStatus(data["status"])
data["created_at"] = datetime.fromisoformat(data["created_at"])
return Download.model_validate(data)
def _to_db_value(key: str, value: Any) -> Any:
if isinstance(value, DownloadStatus):
return value.value
if isinstance(value, datetime):
return value.isoformat()
return value
class SqliteStorage(Storage):
def __init__(self, db_path: Path) -> None:
self._db_path = db_path
self._db: aiosqlite.Connection | None = None
@property
def _conn(self) -> aiosqlite.Connection:
if self._db is None:
raise RuntimeError("Storage не инициализирован — вызовите init()")
return self._db
async def init(self) -> None:
self._db_path.parent.mkdir(parents=True, exist_ok=True)
self._db = await aiosqlite.connect(self._db_path)
self._db.row_factory = aiosqlite.Row
await self._db.execute(
"""
CREATE TABLE IF NOT EXISTS 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 self._db.execute(
"UPDATE downloads SET status = ?, error = ? WHERE status = ?",
(
DownloadStatus.FAILED.value,
"Прервано при перезапуске приложения",
DownloadStatus.DOWNLOADING.value,
),
)
await self._db.commit()
async def close(self) -> None:
if self._db is not None:
await self._db.close()
self._db = None
async def create(self, download: Download) -> None:
await self._conn.execute(
f"INSERT INTO downloads ({', '.join(_COLUMNS)}) "
f"VALUES ({', '.join('?' for _ in _COLUMNS)})",
tuple(_to_db_value(c, getattr(download, c)) for c in _COLUMNS),
)
await self._conn.commit()
async def update(self, download_id: str, **fields: Any) -> Download | None:
if not fields:
return await self.get(download_id)
allowed = {k: v for k, v in fields.items() if k in _COLUMNS and k != "id"}
if not allowed:
return await self.get(download_id)
assignments = ", ".join(f"{k} = ?" for k in allowed)
params = [_to_db_value(k, v) for k, v in allowed.items()]
params.append(download_id)
await self._conn.execute(
f"UPDATE downloads SET {assignments} WHERE id = ?", params
)
await self._conn.commit()
return await self.get(download_id)
async def get(self, download_id: str) -> Download | None:
async with self._conn.execute(
"SELECT * FROM downloads WHERE id = ?", (download_id,)
) as cursor:
row = await cursor.fetchone()
return _row_to_download(row) if row else None
async def list(self) -> list[Download]:
async with self._conn.execute(
"SELECT * FROM downloads ORDER BY created_at DESC"
) as cursor:
rows = await cursor.fetchall()
return [_row_to_download(r) for r in rows]
async def delete(self, download_id: str) -> bool:
cursor = await self._conn.execute(
"DELETE FROM downloads WHERE id = ?", (download_id,)
)
await self._conn.commit()
return cursor.rowcount > 0