Files

130 lines
4.6 KiB
Python

"""Точка входа: сборка 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 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")
logger = logging.getLogger("pvideodl")
@asynccontextmanager
async def lifespan(app: FastAPI):
load_extractors() # регистрируем расширения-экстракторы из app/services/extractors
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)
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:
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(downloaders.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()