import json import time import asyncio import aiohttp from contextlib import asynccontextmanager from fastapi import FastAPI, Request, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded from config import ORG, ACCOUNTS, CORS_ORIGINS, DEV_MODE from database import init_tables, exec, close_session from db_init import INIT_SQLS, INDEX_SQLS, FTS_SQLS, SEED_SQLS from ratelimit import limiter _KEEP_WARM_URLS = [ "https://mml-analysing-shyper-and-api-mid-structer-db.hf.space/health", "https://multimedia-cryptography-benchmarks-proxy.hf.space/health", "https://rayig-proxy.hf.space/health", "https://ToolKit-backend-proxy.hf.space/health", "https://Dgntsnfangskgsjafjyjysjtsjtsjwtjfsjyejyrky-proxy.hf.space/health", ] async def keep_warm(): while True: await asyncio.sleep(240) async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as sess: for url in _KEEP_WARM_URLS: try: async with sess.get(url) as resp: if resp.status == 200: await resp.read() except: pass _MIGRATIONS = [ "ALTER TABLE archives ADD COLUMN media_id INTEGER", "ALTER TABLE archives ADD COLUMN media_type TEXT", "ALTER TABLE archives ADD COLUMN file_name TEXT", "ALTER TABLE archives ADD COLUMN dataset_name TEXT", "ALTER TABLE archives ADD COLUMN account_key TEXT", "ALTER TABLE archives ADD COLUMN status TEXT DEFAULT 'archived'", "ALTER TABLE archives ADD COLUMN season_number INTEGER", "ALTER TABLE archives ADD COLUMN episode_number INTEGER", "ALTER TABLE archives ADD COLUMN source TEXT", "ALTER TABLE archives ADD COLUMN audio TEXT", "ALTER TABLE archives ADD COLUMN network TEXT", "ALTER TABLE archives ADD COLUMN highlights TEXT", "ALTER TABLE archives ADD COLUMN full_metadata TEXT", "ALTER TABLE archives ADD COLUMN storage_channel_id INTEGER", "ALTER TABLE archives ADD COLUMN storage_msg_id INTEGER", ] from routers import health, movies, series, search, stream from routers import auth_r, user, social, chat, parties from routers import store, payments from routers import notifications, achievements, leaderboard, comments from routers import archive _CACHEABLE_PATHS = ( "/api/v1/health", "/api/v1/movies/trending", "/api/v1/movies/featured", "/api/v1/movies/latest", "/api/v1/movies/genres", "/api/v1/stream/proxy/status", ) _INMEM_CACHE = {} _INMEM_TTL = {} async def cache_middleware(request: Request, call_next): path = request.url.path if request.method != "GET" or path == "/favicon.ico": return await call_next(request) now = time.time() cached = _INMEM_CACHE.get(path) if cached and now < _INMEM_TTL.get(path, 0): return JSONResponse(content=cached, headers={ "Cache-Control": "public, max-age=120", "X-Cache": "HIT", "Access-Control-Allow-Origin": "*", }) response = await call_next(request) if response.status_code != 200: return response cached_ttl = 30 for prefix in _CACHEABLE_PATHS: if path.startswith(prefix): cached_ttl = 120 break try: body = b"".join([chunk async for chunk in response.body_iterator]) response = JSONResponse( content=json.loads(body), status_code=response.status_code, headers=dict(response.headers), ) parsed = json.loads(body) _INMEM_CACHE[path] = parsed _INMEM_TTL[path] = now + cached_ttl except: pass response.headers["Cache-Control"] = f"public, max-age={cached_ttl}" return response @asynccontextmanager async def lifespan(app: FastAPI): task = asyncio.create_task(keep_warm()) try: r = await init_tables(INIT_SQLS) i = await init_tables(INDEX_SQLS) f = await init_tables(FTS_SQLS) s = await init_tables(SEED_SQLS) for migration in _MIGRATIONS: try: await exec(migration) except Exception: pass try: await exec("UPDATE archives SET status='archived' WHERE status IS NULL OR status=''") except Exception: pass except Exception as e: print(f"[init] error: {e}") yield task.cancel() try: await task except: pass await close_session() app = FastAPI(title="PopCorn Unified API", version="3.0.0", lifespan=lifespan) app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_middleware( CORSMiddleware, allow_origins=CORS_ORIGINS, allow_credentials=not DEV_MODE, allow_methods=["*"], allow_headers=["*"], ) app.middleware("http")(cache_middleware) app.include_router(health.router, prefix="/api/v1", tags=["health"]) app.include_router(auth_r.router, prefix="/api/v1/auth", tags=["auth"]) app.include_router(movies.router, prefix="/api/v1/movies", tags=["movies"]) app.include_router(series.router, prefix="/api/v1/series", tags=["series"]) app.include_router(search.router, prefix="/api/v1/search", tags=["search"]) app.include_router(stream.router, prefix="/api/v1/stream", tags=["stream"]) app.include_router(user.router, prefix="/api/v1/user", tags=["user"]) app.include_router(social.router, prefix="/api/v1/social", tags=["social"]) app.include_router(chat.router, prefix="/api/v1/chat", tags=["chat"]) app.include_router(parties.router, prefix="/api/v1/party", tags=["parties"]) app.include_router(store.router, prefix="/api/v1/store", tags=["store"]) app.include_router(payments.router, prefix="/api/v1/payments", tags=["payments"]) app.include_router(notifications.router, prefix="/api/v1/notifications", tags=["notifications"]) app.include_router(achievements.router, prefix="/api/v1/achievements", tags=["achievements"]) app.include_router(leaderboard.router, prefix="/api/v1/leaderboard", tags=["leaderboard"]) app.include_router(comments.router, tags=["comments"]) app.include_router(archive.router, prefix="/api/v1/archive", tags=["archive"]) @app.get("/") async def root(): return {"status": "ok", "org": ORG, "version": "3.0", "dev_mode": DEV_MODE, "accounts": list(ACCOUNTS.keys())} @app.get("/health") @limiter.limit("30/minute") async def root_health(request: Request): return {"ok": True, "data": {"status": "healthy", "version": "3.0.0", "dev_mode": DEV_MODE}} @app.get("/status") async def compat_status(request: Request): from routers.archive import full_status return await full_status(request) @app.get("/network") async def compat_network(request: Request): from routers.archive import network return await network(request) @app.get("/archive/progress") async def compat_archive_progress(request: Request, archive_id: str = None): from routers.archive import handle_progress_get return await handle_progress_get(request, archive_id) @app.get("/archive/active") async def compat_archive_active(request: Request): from routers.archive import handle_archive_active return await handle_archive_active(request) @app.get("/archive/stats") async def compat_archive_stats(request: Request): from routers.archive import handle_stats return await handle_stats(request) @app.get("/archive/storage") async def compat_archive_storage(request: Request): from routers.archive import handle_storage return await handle_storage(request) @app.get("/orchestrate") async def compat_orchestrate(request: Request, target: str = "all", action: str = "ping"): from routers.archive import orchestrate return await orchestrate(request, target, action) @app.post("/archive/file") async def compat_archive_file(request: Request): from routers.archive import handle_archive_file, ArchiveFileRequest body = await request.json() req = ArchiveFileRequest(**body) return await handle_archive_file(req, request) @app.post("/archive/progress/update") async def compat_archive_progress_update(request: Request): from routers.archive import handle_progress_update, ProgressUpdateRequest raw = await request.json() aid = raw.get("archive_id", "") if not aid: raise HTTPException(status_code=400, detail="Missing archive_id") req = ProgressUpdateRequest(archive_id=aid) return await handle_progress_update(req, request) @app.post("/archive/check-duplicate") async def compat_archive_check_dup(request: Request): from routers.archive import handle_check_duplicate, CheckDuplicateRequest body = await request.json() req = CheckDuplicateRequest(**body) return await handle_check_duplicate(req, request) @app.exception_handler(Exception) async def global_exception_handler(request, exc): return JSONResponse(status_code=500, content={"ok": False, "error": str(exc)[:300]})