218 lines
6.0 KiB
Python
218 lines
6.0 KiB
Python
import asyncio
|
|
import logging
|
|
import time
|
|
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
from fastapi import Body, Cookie, FastAPI, HTTPException, Request, Response
|
|
from fastapi.responses import StreamingResponse
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from . import db, events, search, suggestions, sync
|
|
from .config import IMAGE_BASE, STATIC_DIR
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
log = logging.getLogger("app")
|
|
|
|
app = FastAPI(title="Netflix Horror DE")
|
|
scheduler = AsyncIOScheduler()
|
|
sync_lock = asyncio.Lock()
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["http://localhost:5173"],
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
async def guarded_sync():
|
|
if sync_lock.locked():
|
|
log.info("Sync läuft bereits")
|
|
return None
|
|
async with sync_lock:
|
|
return await sync.run()
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def startup():
|
|
db.init()
|
|
suggestions.init()
|
|
scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync")
|
|
scheduler.start()
|
|
if not db.get_meta("last_sync"):
|
|
log.info("Leere Datenbank, starte ersten Sync im Hintergrund")
|
|
asyncio.create_task(guarded_sync())
|
|
|
|
|
|
@app.get("/api/health")
|
|
def health():
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/status")
|
|
def status():
|
|
return {
|
|
"last_sync": db.get_meta("last_sync"),
|
|
"movie_count": int(db.get_meta("movie_count") or 0),
|
|
"canon_count": int(db.get_meta("canon_count") or 0),
|
|
"syncing": sync_lock.locked(),
|
|
"image_base": IMAGE_BASE,
|
|
}
|
|
|
|
|
|
@app.get("/api/movies")
|
|
def movies():
|
|
return db.all_movies()
|
|
|
|
|
|
@app.get("/api/movies/{movie_id}")
|
|
def movie(movie_id: int):
|
|
found = db.get_movie(movie_id)
|
|
if not found:
|
|
raise HTTPException(status_code=404, detail="Film nicht gefunden")
|
|
return found
|
|
|
|
|
|
@app.get("/api/search")
|
|
async def search_movies(q: str):
|
|
"""Freie TMDB-Suche für Filme außerhalb des Katalogs."""
|
|
if len(q.strip()) < 2:
|
|
return []
|
|
return await search.search(q.strip())
|
|
|
|
|
|
@app.get("/api/search/{movie_id}")
|
|
async def search_detail(movie_id: int):
|
|
"""Volle Daten zu einem Suchtreffer. Erst der Katalog, dann TMDB."""
|
|
found = db.get_movie(movie_id)
|
|
if found:
|
|
return found
|
|
try:
|
|
return await search.detail(movie_id)
|
|
except Exception:
|
|
raise HTTPException(status_code=404, detail="Film nicht gefunden")
|
|
|
|
|
|
def sitzung(response: Response, sid):
|
|
"""Vorhandene Sitzung weiterverwenden, sonst eine neue setzen."""
|
|
if sid:
|
|
return sid
|
|
neu = suggestions.neue_session()
|
|
response.set_cookie(
|
|
suggestions.COOKIE,
|
|
neu,
|
|
max_age=60 * 60 * 24 * 365,
|
|
httponly=True,
|
|
samesite="lax",
|
|
)
|
|
return neu
|
|
|
|
|
|
# Der letzte Dreh, damit eine Seite mitten in der Animation nachziehen kann
|
|
letzter_dreh = {"ergebnis": None, "start": 0.0}
|
|
|
|
|
|
@app.get("/api/events")
|
|
async def ereignisse(request: Request):
|
|
queue = events.anmelden()
|
|
return StreamingResponse(
|
|
events.stream(queue, request),
|
|
media_type="text/event-stream",
|
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
)
|
|
|
|
|
|
@app.post("/api/spin")
|
|
def drehen():
|
|
"""Einer dreht, alle sehen dasselbe Ergebnis."""
|
|
laeuft = (
|
|
letzter_dreh["ergebnis"]
|
|
and time.monotonic() - letzter_dreh["start"] < suggestions.DREH_DAUER
|
|
)
|
|
if laeuft:
|
|
return {"started": False, "reason": "dreht bereits"}
|
|
|
|
ergebnis = suggestions.ziehen()
|
|
if not ergebnis:
|
|
raise HTTPException(status_code=400, detail="Keine Vorschläge")
|
|
|
|
letzter_dreh["ergebnis"] = ergebnis
|
|
letzter_dreh["start"] = time.monotonic()
|
|
events.senden("spin", ergebnis)
|
|
return {"started": True, **ergebnis}
|
|
|
|
|
|
@app.get("/api/spin")
|
|
def letzter():
|
|
"""Für Seiten, die mitten im Dreh geladen werden."""
|
|
ergebnis = letzter_dreh["ergebnis"]
|
|
if not ergebnis:
|
|
return {"running": False, "result": None}
|
|
vergangen = time.monotonic() - letzter_dreh["start"]
|
|
return {
|
|
"running": vergangen < suggestions.DREH_DAUER,
|
|
# Wer später dazukommt, überspringt den bereits gelaufenen Teil
|
|
"elapsed": round(vergangen, 2),
|
|
"result": ergebnis,
|
|
}
|
|
|
|
|
|
@app.get("/api/suggestions")
|
|
def liste_vorschlaege(response: Response, filmabend_sid: str | None = Cookie(None)):
|
|
sid = sitzung(response, filmabend_sid)
|
|
eintraege = suggestions.alle()
|
|
return {
|
|
"items": [
|
|
{k: v for k, v in e.items() if k != "session_id"} for e in eintraege
|
|
],
|
|
# Damit die Oberfläche den eigenen Vorschlag hervorheben kann
|
|
"mine": next(
|
|
(e["id"] for e in eintraege if e["session_id"] == sid),
|
|
None,
|
|
),
|
|
}
|
|
|
|
|
|
@app.post("/api/suggestions")
|
|
async def vorschlagen(
|
|
response: Response,
|
|
movie_id: int = Body(..., embed=True),
|
|
name: str | None = Body(None, embed=True),
|
|
filmabend_sid: str | None = Cookie(None),
|
|
):
|
|
sid = sitzung(response, filmabend_sid)
|
|
|
|
movie = db.get_movie(movie_id)
|
|
if not movie:
|
|
try:
|
|
movie = await search.detail(movie_id)
|
|
except Exception:
|
|
raise HTTPException(status_code=404, detail="Film nicht gefunden")
|
|
|
|
# Ein zweiter Vorschlag ersetzt den ersten, statt abgelehnt zu werden
|
|
eintrag = suggestions.ersetzen(sid, movie, name)
|
|
events.senden("suggestions")
|
|
return {k: v for k, v in eintrag.items() if k != "session_id"}
|
|
|
|
|
|
@app.delete("/api/suggestions")
|
|
def vorschlag_loeschen(response: Response, filmabend_sid: str | None = Cookie(None)):
|
|
sid = sitzung(response, filmabend_sid)
|
|
entfernt = suggestions.loeschen(sid)
|
|
if entfernt:
|
|
events.senden("suggestions")
|
|
return {"removed": entfernt}
|
|
|
|
|
|
@app.post("/api/sync")
|
|
async def trigger_sync():
|
|
if sync_lock.locked():
|
|
return {"started": False, "reason": "läuft bereits"}
|
|
asyncio.create_task(guarded_sync())
|
|
return {"started": True}
|
|
|
|
|
|
if STATIC_DIR.is_dir():
|
|
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
|