Initial commit
Some checks failed
Docker / docker (push) Failing after 26s

Interface web pour noter rapidement les films non notés sur Trakt.
Enrichissement TMDB (titres/résumés FR), notation 1-10 en un clic,
bouton passer, filtres, tri, pagination.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dev
2026-03-04 13:33:58 +00:00
commit 26808fc2b0
13 changed files with 1327 additions and 0 deletions

190
main.py Normal file
View File

@@ -0,0 +1,190 @@
import os
import time
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from starlette.middleware.sessions import SessionMiddleware
from tmdb import TMDBClient
from trakt import TraktClient
load_dotenv()
app = FastAPI()
app.add_middleware(
SessionMiddleware,
secret_key=os.getenv("SECRET_KEY", "change-me"),
max_age=86400 * 30,
)
TRAKT_CLIENT_ID = os.getenv("TRAKT_CLIENT_ID", "")
TRAKT_CLIENT_SECRET = os.getenv("TRAKT_CLIENT_SECRET", "")
TRAKT_REDIRECT_URI = os.getenv("TRAKT_REDIRECT_URI", "http://localhost:8000/auth/callback")
TMDB_API_KEY = os.getenv("TMDB_API_KEY", "")
# Simple in-memory cache (per user token)
_cache: dict = {}
CACHE_TTL = 300 # 5 minutes
def cache_get(key: str):
if key in _cache:
data, ts = _cache[key]
if time.time() - ts < CACHE_TTL:
return data
del _cache[key]
return None
def cache_set(key: str, data):
_cache[key] = (data, time.time())
def cache_del(key: str):
_cache.pop(key, None)
@app.get("/")
async def root():
return FileResponse("static/index.html")
@app.get("/api/auth/status")
async def auth_status(request: Request):
return {"authenticated": bool(request.session.get("access_token"))}
@app.get("/auth/login")
async def auth_login():
url = (
f"https://trakt.tv/oauth/authorize"
f"?response_type=code"
f"&client_id={TRAKT_CLIENT_ID}"
f"&redirect_uri={TRAKT_REDIRECT_URI}"
)
return RedirectResponse(url)
@app.get("/auth/callback")
async def auth_callback(request: Request, code: str):
import httpx
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://api.trakt.tv/oauth/token",
json={
"code": code,
"client_id": TRAKT_CLIENT_ID,
"client_secret": TRAKT_CLIENT_SECRET,
"redirect_uri": TRAKT_REDIRECT_URI,
"grant_type": "authorization_code",
},
)
data = resp.json()
if "access_token" not in data:
raise HTTPException(400, f"OAuth failed: {data}")
request.session["access_token"] = data["access_token"]
return RedirectResponse("/")
@app.get("/auth/logout")
async def auth_logout(request: Request):
request.session.clear()
return RedirectResponse("/")
@app.get("/api/movies")
async def get_movies(
request: Request,
page: int = 1,
per_page: int = 20,
sort: str = "watched_at",
filter_type: str = "all",
exclude: str = "", # comma-separated trakt IDs to skip client-side
):
token = request.session.get("access_token")
if not token:
raise HTTPException(401, "Not authenticated")
cache_key = f"movies_{token[:16]}"
all_movies = cache_get(cache_key)
if all_movies is None:
trakt = TraktClient(TRAKT_CLIENT_ID, token)
watched, ratings = await trakt.get_watched_and_ratings()
rating_map = {r["movie"]["ids"]["trakt"]: r["rating"] for r in ratings}
all_movies = []
for w in watched:
m = w["movie"]
tid = m["ids"]["trakt"]
r = rating_map.get(tid)
if r is None or r == 10:
all_movies.append({
"trakt_id": tid,
"imdb_id": m["ids"].get("imdb"),
"tmdb_id": m["ids"].get("tmdb"),
"title": m["title"],
"year": m.get("year"),
"watched_at": w.get("last_watched_at"),
"plays": w.get("plays", 1),
"current_rating": r,
})
cache_set(cache_key, all_movies)
# Exclude skipped IDs (sent by client from localStorage)
excluded = {int(x) for x in exclude.split(",") if x.strip().lstrip("-").isdigit()}
# Filter
movies = [m for m in all_movies if m["trakt_id"] not in excluded]
if filter_type == "unrated":
movies = [m for m in movies if m["current_rating"] is None]
elif filter_type == "10":
movies = [m for m in movies if m["current_rating"] == 10]
# Sort
if sort == "watched_at":
movies = sorted(movies, key=lambda x: x["watched_at"] or "", reverse=True)
elif sort == "title":
movies = sorted(movies, key=lambda x: x["title"].lower())
elif sort == "year":
movies = sorted(movies, key=lambda x: x["year"] or 0, reverse=True)
total = len(movies)
start = (page - 1) * per_page
page_movies = movies[start: start + per_page]
tmdb = TMDBClient(TMDB_API_KEY)
enriched = await tmdb.enrich_movies(page_movies)
return {
"movies": enriched,
"total": total,
"page": page,
"per_page": per_page,
"total_pages": max(1, (total + per_page - 1) // per_page),
}
class RateBody(BaseModel):
rating: int
@app.post("/api/rate/{trakt_id}")
async def rate_movie(request: Request, trakt_id: int, body: RateBody):
token = request.session.get("access_token")
if not token:
raise HTTPException(401, "Not authenticated")
if not (1 <= body.rating <= 10):
raise HTTPException(400, "Rating must be between 1 and 10")
trakt = TraktClient(TRAKT_CLIENT_ID, token)
await trakt.rate_movie(trakt_id, body.rating)
cache_del(f"movies_{token[:16]}")
return {"success": True}
app.mount("/static", StaticFiles(directory="static"), name="static")