Добавлено описание проекта 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
+119
View File
@@ -0,0 +1,119 @@
"""Точка входа: сборка FastAPI-приложения, lifespan, роутеры, отдача статики."""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from app.api import 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.worker import WorkerPool
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger("pvideodl")
@asynccontextmanager
async def lifespan(app: FastAPI):
storage = SqliteStorage(settings.db_path)
await storage.init()
queue = DownloadQueue()
event_bus = EventBus()
pool = WorkerPool(queue, storage, event_bus, settings.workers)
app.state.storage = storage
app.state.queue = queue
app.state.events = event_bus
app.state.pool = pool
# Перезаливаем в очередь всё, что осталось в pending с прошлого запуска.
for download in await storage.list():
if download.status == DownloadStatus.PENDING:
await queue.add(download.id)
await pool.start()
settings.download_dir.mkdir(parents=True, exist_ok=True)
logger.info("PVideoDl запущен. Файлы → %s", settings.download_dir)
try:
yield
finally:
await pool.stop()
await storage.close()
def create_app() -> FastAPI:
app = FastAPI(title="PVideoDl", version="0.1.0", lifespan=lifespan)
# В деве фронт крутится на Vite (5173) и ходит на /api — пускаем его.
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(downloads.router)
app.include_router(events.router)
@app.get("/api/health")
async def health() -> dict:
return {"status": "ok"}
_mount_frontend(app)
return app
def _mount_frontend(app: FastAPI) -> None:
"""Отдаём собранную статику SvelteKit, если она есть. SPA-fallback на index.html."""
static_dir = settings.static_dir
index = static_dir / "index.html"
if not index.exists():
@app.get("/")
async def no_frontend() -> JSONResponse:
return JSONResponse(
{
"message": "Фронтенд не собран. Соберите его (npm run build в frontend/) "
"или откройте API напрямую.",
"api": "/api/downloads",
}
)
return
app.mount("/_app", StaticFiles(directory=static_dir / "_app"), name="_app")
@app.get("/{full_path:path}")
async def spa(full_path: str, request: Request) -> Response:
# Несуществующий /api путь — это 404, а не SPA-страница.
if full_path.startswith("api/"):
return JSONResponse({"detail": "Not Found"}, status_code=404)
# Реальный файл из статики (favicon, изображения и т.п.) — отдаём как есть.
candidate = (static_dir / full_path).resolve()
if candidate.is_file() and static_dir.resolve() in candidate.parents:
return FileResponse(candidate)
return FileResponse(index)
app = create_app()
def run() -> None:
"""Запуск одной командой: `pvideodl` или `python -m app.main`."""
import uvicorn
uvicorn.run(app, host=settings.host, port=settings.port)
if __name__ == "__main__":
run()