import asyncio import json import logging import os import re from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Request from fastapi.responses import HTMLResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from pydantic import BaseModel from arte_api import fetch_concerts, get_all_concerts, get_concerts_by_category, invalidate_cache, CATEGORIES from downloader import DownloadManager logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) AUTO_DL_INTERVAL = int(os.getenv("AUTO_DL_INTERVAL", "3600")) dm = DownloadManager() async def _run_auto_dl_check() -> int: cats = dm.get_watched_categories() if not cats: return 0 total = 0 for cat in cats: concerts = await get_concerts_by_category(cat) for c in concerts: if not dm.already_enqueued(c["url"]): m = re.search(r"\b(20\d{2})\b", c.get("subtitle", "")) year = int(m.group(1)) if m else c.get("tmdb_year") await dm.enqueue(c["url"], c["title"], c.get("subtitle", ""), year, cat) total += 1 return total async def _auto_dl_loop(): await asyncio.sleep(60) while True: try: n = await _run_auto_dl_check() if n: logger.info("Auto-DL: enqueued %d new concert(s)", n) except Exception as e: logger.warning("Auto-DL check failed: %s", e) await asyncio.sleep(AUTO_DL_INTERVAL) @asynccontextmanager async def lifespan(app: FastAPI): await dm.resume_pending() tasks = [ asyncio.create_task(dm.start_worker()), asyncio.create_task(_auto_dl_loop()), asyncio.create_task(get_all_concerts()), # pre-warm cache at startup ] yield for t in tasks: t.cancel() await asyncio.gather(*tasks, return_exceptions=True) app = FastAPI(title="Arte-dl", lifespan=lifespan) app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") # ------------------------------------------------------------------ pages @app.get("/", response_class=HTMLResponse) async def index(request: Request): return templates.TemplateResponse("index.html", {"request": request}) # ------------------------------------------------------------------ API: concerts @app.get("/api/categories") async def api_categories(): return CATEGORIES @app.get("/api/concerts") async def api_concerts(page: int = 1, search: str = "", page_size: int = 24, category: str = ""): return await fetch_concerts(page=page, search=search, page_size=page_size, category=category) @app.post("/api/refresh") async def api_refresh(): count = await invalidate_cache() return {"count": count} # ------------------------------------------------------------------ API: auto-download @app.get("/api/auto-dl") async def api_auto_dl_list(): return dm.get_watched_categories() @app.post("/api/auto-dl/check") async def api_auto_dl_check(): n = await _run_auto_dl_check() return {"enqueued": n} @app.post("/api/auto-dl/{category}") async def api_auto_dl_watch(category: str): if category not in CATEGORIES: raise HTTPException(status_code=404, detail="Unknown category") dm.watch_category(category) return {"watched": True} @app.delete("/api/auto-dl/{category}") async def api_auto_dl_unwatch(category: str): dm.unwatch_category(category) return {"watched": False} # ------------------------------------------------------------------ API: downloads class DownloadRequest(BaseModel): url: str title: str subtitle: str = "" year: int | None = None category: str = "" @app.post("/api/download") async def api_download(req: DownloadRequest): if not req.url: raise HTTPException(status_code=400, detail="url required") dl_id = await dm.enqueue(req.url, req.title, req.subtitle, req.year, req.category) return {"id": dl_id} @app.get("/api/downloads") async def api_downloads(): return dm.history() @app.get("/api/progress/{dl_id}") async def api_progress(dl_id: str): async def stream(): while True: s = dm.status(dl_id) yield f"data: {json.dumps(s)}\n\n" if s.get("state") in ("done", "error", "unknown"): break await asyncio.sleep(0.8) return StreamingResponse(stream(), media_type="text/event-stream")