"""Точка входа: сборка 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()