Добавлено описание проекта PVideoDl, включая функциональность, стек технологий, архитектуру, инструкции по запуску и API. Обновлён README.md для лучшего понимания проекта.
This commit is contained in:
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user