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

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# 1. Go to https://trakt.tv/oauth/applications/new
# 2. Create an app with Redirect URI: http://localhost:8000/auth/callback
# 3. Copy Client ID and Secret below
TRAKT_CLIENT_ID=your_trakt_client_id
TRAKT_CLIENT_SECRET=your_trakt_client_secret
TRAKT_REDIRECT_URI=http://localhost:8000/auth/callback
# From https://www.themoviedb.org/settings/api
TMDB_API_KEY=your_tmdb_api_key
# Random string for session signing
SECRET_KEY=change-me-to-something-random

View File

@@ -0,0 +1,39 @@
name: Docker
on:
push:
branches:
- main
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: install docker cli
run: |
apt-get update -qq
apt-get install -y -qq ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bullseye stable" > /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce-cli
- name: checkout
uses: actions/checkout@v3
- name: login
uses: docker/login-action@v2
with:
registry: forge.dilain.com
username: laurent
password: ${{ secrets.REGISTRY_TOKEN }}
- name: build and push
run: |
docker build \
-t forge.dilain.com/laurent/trakt-rater:main \
-t forge.dilain.com/laurent/trakt-rater:latest \
.
docker push forge.dilain.com/laurent/trakt-rater:main
docker push forge.dilain.com/laurent/trakt-rater:latest

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.env
.venv/
__pycache__/
*.pyc

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py trakt.py tmdb.py ./
COPY static/ ./static/
ENV TRAKT_CLIENT_ID=""
ENV TRAKT_CLIENT_SECRET=""
ENV TRAKT_REDIRECT_URI="http://localhost:8000/auth/callback"
ENV TMDB_API_KEY=""
ENV SECRET_KEY="change-me"
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

153
README.md Normal file
View File

@@ -0,0 +1,153 @@
# trakt-rater
Interface web pour noter rapidement les films non notés sur Trakt.
Affiche tous les films de ton historique Trakt qui n'ont pas encore de note (ou notés 10/10 à revoir), enrichis avec les titres et résumés en français via TMDB. Un clic suffit pour noter et passer au suivant.
---
## Fonctionnalités
- Films filtrés depuis ton historique Trakt : non notés et notés 10/10
- Titres français, jaquettes et résumés via TMDB (fallback anglais si traduction absente)
- Note en un clic (110) — le film disparaît immédiatement
- Bouton « Passer » pour ignorer un film sans le noter (persistant dans le navigateur)
- Filtres : tous / non notés / 10/10
- Tri : date de visionnage / titre / année
- Pagination
---
## Prérequis
- Un compte [Trakt](https://trakt.tv) avec une application OAuth configurée
- Une clé API [TMDB](https://www.themoviedb.org/settings/api) (gratuite)
### Créer l'application Trakt
1. Aller sur [trakt.tv/oauth/applications/new](https://trakt.tv/oauth/applications/new)
2. Remplir le nom (ex. `trakt-rater`)
3. Mettre comme **Redirect URI** : `http://localhost:8000/auth/callback`
*(ou l'URL publique de ton instance si derrière un reverse proxy)*
4. Récupérer le **Client ID** et le **Client Secret**
---
## Docker
La méthode recommandée.
### Démarrage rapide
```bash
# 1. Copier le compose et remplir les variables
cp docker-compose.yml docker-compose.local.yml
$EDITOR docker-compose.local.yml
# 2. Build de l'image
docker build -t trakt-rater:latest .
# 3. Démarrer
docker compose -f docker-compose.local.yml up -d
```
Logs :
```bash
docker logs -f trakt-rater
```
Puis ouvrir [http://localhost:8000](http://localhost:8000) et se connecter avec Trakt.
### Variables d'environnement
| Variable | Défaut | Description |
|-----------------------|--------------------------------------|----------------------------------------------------------|
| `TRAKT_CLIENT_ID` | *(requis)* | Client ID de l'application Trakt |
| `TRAKT_CLIENT_SECRET` | *(requis)* | Client Secret de l'application Trakt |
| `TRAKT_REDIRECT_URI` | `http://localhost:8000/auth/callback`| Doit correspondre exactement à l'URI configurée sur Trakt |
| `TMDB_API_KEY` | *(requis)* | Clé API TMDB |
| `SECRET_KEY` | `change-me` | Clé de signature des sessions (à changer) |
---
## Unraid
### Méthode 1 — Docker Compose Manager (recommandée)
> **Important :** le Compose Manager d'Unraid pipe le fichier via stdin, donc `build: .` ne fonctionnera pas. Il faut builder l'image manuellement d'abord.
**Étape 1 — Builder l'image sur Unraid** (terminal, à refaire après chaque mise à jour) :
```bash
cd /mnt/user/appdata
git clone <url-du-dépôt> trakt-rater-src
docker build -t trakt-rater:latest /mnt/user/appdata/trakt-rater-src/
```
**Étape 2 — Ajouter le stack** dans le Docker Compose Manager, coller :
```yaml
services:
trakt-rater:
image: trakt-rater:latest
container_name: trakt-rater
restart: unless-stopped
ports:
- "8000:8000"
environment:
TRAKT_CLIENT_ID: "ton_client_id"
TRAKT_CLIENT_SECRET: "ton_client_secret"
TRAKT_REDIRECT_URI: "http://unraid.local:8000/auth/callback"
TMDB_API_KEY: "ta_clé_tmdb"
SECRET_KEY: "une-chaine-aléatoire"
```
> **Note :** penser à mettre à jour le `TRAKT_REDIRECT_URI` avec l'adresse de ton Unraid, et à l'ajouter dans les paramètres de l'application Trakt.
**Mettre à jour l'image** après un changement de code :
```bash
cd /mnt/user/appdata/trakt-rater-src
git pull
docker build -t trakt-rater:latest .
docker restart trakt-rater
```
---
## Sans Docker
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
# Remplir .env avec tes clés
uvicorn main:app --reload
```
---
## Structure du projet
```
main.py Application FastAPI (routes, cache, OAuth)
trakt.py Client Trakt API
tmdb.py Client TMDB API (enrichissement FR)
static/
index.html Interface web
style.css Thème sombre
app.js Logique frontend
requirements.txt Dépendances Python
Dockerfile Image Docker
docker-compose.yml Stack Docker Compose
.env.example Template de configuration
```
---
## License
MIT

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
services:
trakt-rater:
image: forge.dilain.com/laurent/trakt-rater:latest
container_name: trakt-rater
restart: unless-stopped
ports:
- "8000:8000"
environment:
TRAKT_CLIENT_ID: ""
TRAKT_CLIENT_SECRET: ""
# Must match the Redirect URI set in your Trakt application settings
TRAKT_REDIRECT_URI: "http://localhost:8000/auth/callback"
TMDB_API_KEY: ""
# Random string used to sign sessions — change this
SECRET_KEY: "change-me-to-something-random"

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")

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
fastapi
uvicorn[standard]
httpx
python-dotenv
itsdangerous

315
static/app.js Normal file
View File

@@ -0,0 +1,315 @@
/* ── State ──────────────────────────────────────────────── */
let currentPage = 1;
let currentFilter = 'all';
let currentSort = 'watched_at';
let totalPages = 1;
let totalCount = 0;
let showSkipped = false;
/* Rating value → color (index 0 unused) */
const COLORS = [
null,
'#f43f5e', '#f43f5e', // 1-2
'#fb7185', // 3
'#fb923c', // 4
'#f59e0b', // 5
'#eab308', // 6
'#84cc16', // 7
'#22c55e', // 8
'#10b981', // 9
'#06b6d4', // 10
];
/* ── Skipped (localStorage) ─────────────────────────────── */
function getSkipped() {
try { return new Set(JSON.parse(localStorage.getItem('skipped') || '[]')); }
catch { return new Set(); }
}
function saveSkipped(set) {
localStorage.setItem('skipped', JSON.stringify([...set]));
document.getElementById('skipped-count').textContent = set.size;
}
function skipMovie(traktId) {
const s = getSkipped();
s.add(traktId);
saveSkipped(s);
}
function unskipMovie(traktId) {
const s = getSkipped();
s.delete(traktId);
saveSkipped(s);
}
function toggleSkipped() {
showSkipped = !showSkipped;
const btn = document.getElementById('skipped-toggle');
btn.style.color = showSkipped ? 'var(--accent)' : '';
btn.style.borderColor = showSkipped ? 'var(--accent)' : '';
currentPage = 1;
loadMovies();
}
/* ── Boot ───────────────────────────────────────────────── */
async function init() {
const { authenticated } = await fetch('/api/auth/status').then(r => r.json());
if (!authenticated) {
show('login-screen');
} else {
show('app');
document.getElementById('skipped-count').textContent = getSkipped().size;
document.getElementById('filter-select').addEventListener('change', e => {
currentFilter = e.target.value;
currentPage = 1;
loadMovies();
});
document.getElementById('sort-select').addEventListener('change', e => {
currentSort = e.target.value;
currentPage = 1;
loadMovies();
});
loadMovies();
}
}
function show(id) { document.getElementById(id).classList.remove('hidden'); }
function hide(id) { document.getElementById(id).classList.add('hidden'); }
/* ── Load page ──────────────────────────────────────────── */
async function loadMovies() {
const list = document.getElementById('movie-list');
list.innerHTML = '';
hide('empty');
document.getElementById('pagination').innerHTML = '';
show('loading');
try {
const skipped = getSkipped();
const params = new URLSearchParams({
page: currentPage,
per_page: 20,
sort: currentSort,
filter_type: currentFilter,
exclude: showSkipped ? '' : [...skipped].join(','),
});
const data = await fetch(`/api/movies?${params}`).then(r => r.json());
hide('loading');
// In "skipped" view, filter client-side to only show skipped ones
const movies = showSkipped
? data.movies.filter(m => skipped.has(m.trakt_id))
: data.movies;
totalPages = data.total_pages;
document.getElementById('count-badge').textContent =
`${data.total} film${data.total !== 1 ? 's' : ''}`;
if (!movies.length) {
show('empty');
document.getElementById('empty').querySelector('p').textContent =
showSkipped ? 'Aucun film passé.' : 'Tous les films sont notés !';
return;
}
movies.forEach(m => list.appendChild(buildRow(m)));
renderPagination();
} catch (err) {
hide('loading');
console.error(err);
showToast('Erreur de chargement', true);
}
}
/* ── Build movie row ────────────────────────────────────── */
function buildRow(movie) {
const skipped = getSkipped();
const isSkipped = skipped.has(movie.trakt_id);
const row = document.createElement('div');
row.className = 'movie-row' + (isSkipped ? ' is-skipped' : '');
row.dataset.id = movie.trakt_id;
// Poster
const poster = movie.poster
? `<img class="movie-poster" src="${movie.poster}" alt="" loading="lazy">`
: `<div class="poster-ph">🎬</div>`;
// Info
const meta = [movie.year, fmtDate(movie.watched_at)].filter(Boolean).join(' · ');
const badge = movie.current_rating === 10
? `<span class="badge-10">★ noté 10/10</span>` : '';
const info = `
<div class="movie-info">
<div class="movie-title">${esc(movie.title_fr)}</div>
<div class="movie-meta">${esc(meta)}</div>
${badge}
</div>`;
// Synopsis
const synopsis = movie.overview
? `<div class="movie-synopsis"><div class="synopsis-text">${esc(movie.overview)}</div></div>`
: `<div class="movie-synopsis"><span class="synopsis-empty">Aucun résumé disponible</span></div>`;
// Rating buttons + skip button
const btns = Array.from({ length: 10 }, (_, i) =>
`<button class="r-btn" data-n="${i + 1}">${i + 1}</button>`
).join('');
const skipLabel = isSkipped ? 'Remettre' : 'Passer';
const skipBtn = `<button class="skip-btn" title="${skipLabel}">${skipLabel}</button>`;
const ratingEl = `<div class="movie-rating">${btns}${skipBtn}</div>`;
row.innerHTML = poster + info + synopsis + ratingEl;
// Rating hover & click
const container = row.querySelector('.movie-rating');
const buttons = [...container.querySelectorAll('.r-btn')];
buttons.forEach((btn, idx) => {
btn.addEventListener('mouseenter', () => {
buttons.forEach((b, i) => {
if (i <= idx) {
const c = COLORS[i + 1];
b.style.background = c + '28';
b.style.borderColor = c;
b.style.color = c;
} else {
b.style.background = '';
b.style.borderColor = '';
b.style.color = '';
}
});
});
btn.addEventListener('click', () => rateMovie(movie, parseInt(btn.dataset.n), row));
});
container.addEventListener('mouseleave', () => {
buttons.forEach(b => {
b.style.background = '';
b.style.borderColor = '';
b.style.color = '';
});
});
// Skip button
container.querySelector('.skip-btn').addEventListener('click', () => {
if (isSkipped) {
unskipMovie(movie.trakt_id);
showToast(`${movie.title_fr} — remis dans la liste`);
} else {
skipMovie(movie.trakt_id);
showToast(`${movie.title_fr} — passé`);
}
animateOut(row);
});
return row;
}
/* ── Rate ───────────────────────────────────────────────── */
async function rateMovie(movie, rating, row) {
row.style.pointerEvents = 'none';
try {
const r = await fetch(`/api/rate/${movie.trakt_id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating }),
});
if (!r.ok) throw new Error('Failed');
showToast(`${movie.title_fr} — noté ${rating}/10`);
animateOut(row);
} catch {
row.style.pointerEvents = '';
showToast('Erreur lors de la notation', true);
}
}
/* ── Animate row out ────────────────────────────────────── */
function animateOut(row) {
row.style.transition = 'opacity .28s ease, transform .28s ease';
row.style.opacity = '0';
row.style.transform = 'translateX(16px)';
setTimeout(() => {
const h = row.offsetHeight;
row.style.height = h + 'px';
row.style.overflow = 'hidden';
row.style.transition = 'height .22s ease, padding .22s ease, margin .22s ease, border-width .22s ease';
requestAnimationFrame(() => {
row.style.height = '0';
row.style.paddingTop = '0';
row.style.paddingBottom = '0';
row.style.marginBottom = '0';
row.style.borderWidth = '0';
});
setTimeout(() => {
row.remove();
if (!document.querySelectorAll('.movie-row').length) {
if (currentPage > 1) currentPage--;
loadMovies();
}
}, 240);
}, 280);
}
/* ── Pagination ─────────────────────────────────────────── */
function renderPagination() {
if (totalPages <= 1) return;
const el = document.getElementById('pagination');
const prev = mkBtn('← Précédent', currentPage === 1, () => { currentPage--; reload(); });
const info = document.createElement('span');
info.className = 'page-info';
info.textContent = `Page ${currentPage} / ${totalPages}`;
const next = mkBtn('Suivant →', currentPage === totalPages, () => { currentPage++; reload(); });
el.append(prev, info, next);
}
function mkBtn(label, disabled, onClick) {
const b = document.createElement('button');
b.className = 'page-btn';
b.textContent = label;
b.disabled = disabled;
b.addEventListener('click', onClick);
return b;
}
function reload() {
window.scrollTo({ top: 0, behavior: 'smooth' });
loadMovies();
}
/* ── Toast ──────────────────────────────────────────────── */
let toastTimer;
function showToast(msg, isError = false) {
const t = document.getElementById('toast');
t.textContent = msg;
t.style.borderColor = isError ? 'rgba(244,63,94,.35)' : '';
t.style.color = isError ? '#f87191' : '';
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 2800);
}
/* ── Utils ──────────────────────────────────────────────── */
function fmtDate(iso) {
if (!iso) return '';
return new Date(iso).toLocaleDateString('fr-FR', {
day: 'numeric', month: 'short', year: 'numeric',
});
}
function esc(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(str ?? ''));
return d.innerHTML;
}
init();

76
static/index.html Normal file
View File

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Trakt Rater</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<!-- Login -->
<div id="login-screen" class="hidden">
<div class="login-card">
<div class="login-logo">
<svg viewBox="0 0 24 24" fill="currentColor" width="32" height="32"><path d="M7 4v16l13-8L7 4z"/></svg>
<h1>Trakt Rater</h1>
</div>
<p>Notez rapidement vos films non notés</p>
<a href="/auth/login" class="btn-primary">
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/></svg>
Se connecter avec Trakt
</a>
</div>
</div>
<!-- App -->
<div id="app" class="hidden">
<header>
<div class="header-left">
<svg class="logo-mark" viewBox="0 0 24 24" fill="currentColor"><path d="M7 4v16l13-8L7 4z"/></svg>
<span class="app-name">Trakt Rater</span>
<span id="count-badge" class="badge"></span>
</div>
<div class="header-right">
<div class="ctrl">
<label>Afficher</label>
<select id="filter-select">
<option value="all">Tous</option>
<option value="unrated">Non notés</option>
<option value="10">Notés 10/10</option>
</select>
</div>
<div class="ctrl">
<label>Trier par</label>
<select id="sort-select">
<option value="watched_at">Date de visionnage</option>
<option value="title">Titre</option>
<option value="year">Année</option>
</select>
</div>
<button id="skipped-toggle" class="btn-ghost" onclick="toggleSkipped()">Voir les passés (<span id="skipped-count">0</span>)</button>
<a href="/auth/logout" class="btn-ghost">Déconnexion</a>
</div>
</header>
<main>
<div id="loading" class="state hidden">
<div class="spinner"></div>
<span>Chargement…</span>
</div>
<div id="empty" class="state hidden">
<span style="font-size:2rem">🎉</span>
<p>Tous les films sont notés !</p>
</div>
<div id="movie-list"></div>
<div id="pagination"></div>
</main>
</div>
<div id="toast" class="toast"></div>
<script src="/static/app.js"></script>
</body>
</html>

395
static/style.css Normal file
View File

@@ -0,0 +1,395 @@
:root {
--bg: #09090e;
--surface: #101018;
--surface-2: #15151f;
--border: #1d1d2e;
--border-2: #26263a;
--accent: #7c6af7;
--accent-bg: rgba(124,106,247,.12);
--text: #e2e2f0;
--muted: #6a6a8e;
--faint: #32324e;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 14px; }
body {
font-family: 'Inter', -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
.hidden { display: none !important; }
/* ── Login ─────────────────────────────────────────────── */
#login-screen {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background:
radial-gradient(ellipse 70% 50% at 50% -10%, rgba(124,106,247,.18) 0%, transparent 60%),
var(--bg);
}
.login-card {
background: var(--surface);
border: 1px solid var(--border-2);
border-radius: 18px;
padding: 2.8rem 2.4rem;
width: 100%;
max-width: 360px;
text-align: center;
box-shadow: 0 32px 64px rgba(0,0,0,.5), 0 0 0 1px rgba(255,255,255,.03) inset;
}
.login-logo {
display: flex;
align-items: center;
justify-content: center;
gap: .5rem;
margin-bottom: .8rem;
color: var(--accent);
}
.login-logo h1 {
font-size: 1.7rem;
font-weight: 700;
letter-spacing: -.03em;
background: linear-gradient(135deg, #b39dff 0%, #7c6af7 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.login-card p {
color: var(--muted);
font-size: .88rem;
margin-bottom: 1.8rem;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: .45rem;
background: var(--accent);
color: #fff;
text-decoration: none;
border-radius: 9px;
padding: .65rem 1.4rem;
font-weight: 600;
font-size: .88rem;
transition: background .2s, transform .15s, box-shadow .2s;
}
.btn-primary:hover {
background: #8e7df8;
transform: translateY(-1px);
box-shadow: 0 6px 24px rgba(124,106,247,.38);
}
/* ── Header ─────────────────────────────────────────────── */
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: .75rem 1.5rem;
background: rgba(16,16,24,.88);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 50;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.header-left { display: flex; align-items: center; gap: .55rem; }
.logo-mark { width: 18px; height: 18px; color: var(--accent); flex-shrink: 0; }
.app-name {
font-weight: 700;
font-size: .9rem;
letter-spacing: -.02em;
background: linear-gradient(135deg, #b39dff, #7c6af7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.badge {
background: var(--accent-bg);
color: var(--accent);
border: 1px solid rgba(124,106,247,.22);
border-radius: 20px;
padding: 1px 9px;
font-size: .72rem;
font-weight: 600;
}
.header-right { display: flex; align-items: center; gap: .8rem; }
.ctrl { display: flex; align-items: center; gap: .35rem; }
.ctrl label { color: var(--muted); font-size: .78rem; white-space: nowrap; }
select {
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border-2);
border-radius: 7px;
padding: .3rem .5rem .3rem .6rem;
font-size: .78rem;
font-family: inherit;
cursor: pointer;
outline: none;
transition: border-color .15s;
appearance: none;
-webkit-appearance: none;
padding-right: 1.4rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5'%3E%3Cpath d='M0 0l4 5 4-5z' fill='%236a6a8e'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right .5rem center;
}
select:hover, select:focus { border-color: var(--accent); }
.btn-ghost {
color: var(--muted);
text-decoration: none;
font-size: .78rem;
padding: .3rem .65rem;
border: 1px solid var(--border-2);
border-radius: 7px;
transition: color .15s, border-color .15s;
}
.btn-ghost:hover { color: #f43f5e; border-color: rgba(244,63,94,.4); }
/* ── Main ─────────────────────────────────────────────── */
main {
max-width: 1420px;
margin: 0 auto;
padding: 1.2rem 1.5rem 5rem;
}
/* ── States ─────────────────────────────────────────────── */
.state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: .9rem;
padding: 5rem 2rem;
color: var(--muted);
font-size: .88rem;
}
.spinner {
width: 32px;
height: 32px;
border: 2.5px solid var(--border-2);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Movie list ─────────────────────────────────────────── */
#movie-list { display: flex; flex-direction: column; gap: .45rem; }
/* ── Movie row ─────────────────────────────────────────── */
.movie-row {
display: grid;
grid-template-columns: 68px 195px 1fr 340px;
gap: .9rem;
align-items: center;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 9px;
padding: .55rem .7rem;
transition: background .2s, border-color .2s;
}
.movie-row:hover {
background: var(--surface-2);
border-color: var(--border-2);
}
/* ── Poster ─────────────────────────────────────────────── */
.movie-poster {
width: 68px;
height: 102px;
border-radius: 5px;
object-fit: cover;
display: block;
flex-shrink: 0;
background: var(--border);
}
.poster-ph {
width: 68px;
height: 102px;
border-radius: 5px;
background: var(--surface-2);
border: 1px solid var(--border-2);
display: flex;
align-items: center;
justify-content: center;
color: var(--faint);
font-size: 1.4rem;
flex-shrink: 0;
}
/* ── Info ─────────────────────────────────────────────── */
.movie-info { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.movie-title {
font-weight: 600;
font-size: .88rem;
line-height: 1.35;
color: var(--text);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.movie-meta { color: var(--muted); font-size: .75rem; margin-top: 1px; }
.badge-10 {
display: inline-flex;
align-items: center;
gap: 3px;
background: rgba(244,63,94,.1);
color: #f87191;
border: 1px solid rgba(244,63,94,.22);
border-radius: 5px;
padding: 1px 7px;
font-size: .7rem;
font-weight: 600;
width: fit-content;
margin-top: 3px;
}
/* ── Synopsis ─────────────────────────────────────────── */
.movie-synopsis { position: relative; min-width: 0; }
.synopsis-text {
color: var(--muted);
font-size: .78rem;
line-height: 1.6;
max-height: 3.2em;
overflow: hidden;
transition: max-height .32s ease;
mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
}
.movie-synopsis:hover .synopsis-text {
max-height: 250px;
mask-image: none;
-webkit-mask-image: none;
}
.synopsis-empty {
color: var(--faint);
font-size: .75rem;
font-style: italic;
}
/* ── Rating ─────────────────────────────────────────────── */
.movie-rating {
display: flex;
gap: 3px;
justify-content: flex-end;
align-items: center;
}
.r-btn {
width: 24px;
height: 24px;
border-radius: 5px;
border: 1px solid var(--border-2);
background: transparent;
color: var(--muted);
font-size: .7rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: border-color .08s, background .08s, color .08s, transform .1s;
font-family: inherit;
}
.r-btn:active { transform: scale(.92); }
.skip-btn {
margin-left: 6px;
padding: 0 9px;
height: 24px;
border-radius: 5px;
border: 1px solid var(--border-2);
background: transparent;
color: var(--muted);
font-size: .7rem;
font-weight: 500;
cursor: pointer;
font-family: inherit;
transition: border-color .15s, color .15s, background .15s;
white-space: nowrap;
}
.skip-btn:hover {
border-color: rgba(251,146,60,.4);
color: #fb923c;
background: rgba(251,146,60,.07);
}
.is-skipped .skip-btn {
border-color: rgba(124,106,247,.3);
color: var(--accent);
}
.is-skipped .skip-btn:hover {
background: var(--accent-bg);
}
/* ── Pagination ─────────────────────────────────────────── */
#pagination {
display: flex;
align-items: center;
justify-content: center;
gap: .5rem;
padding: 1.8rem 0;
}
.page-btn {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border-2);
border-radius: 7px;
padding: .4rem .85rem;
font-size: .78rem;
font-family: inherit;
cursor: pointer;
transition: border-color .15s, color .15s;
}
.page-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.page-btn:disabled { opacity: .35; cursor: not-allowed; }
.page-info { color: var(--muted); font-size: .78rem; padding: 0 .4rem; }
/* ── Toast ─────────────────────────────────────────────── */
.toast {
position: fixed;
bottom: 1.8rem;
left: 50%;
transform: translateX(-50%) translateY(6px);
background: var(--surface-2);
border: 1px solid var(--border-2);
color: var(--text);
padding: .55rem 1.1rem;
border-radius: 9px;
font-size: .82rem;
font-weight: 500;
box-shadow: 0 12px 40px rgba(0,0,0,.55);
z-index: 1000;
opacity: 0;
transition: opacity .22s, transform .22s;
pointer-events: none;
white-space: nowrap;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}

65
tmdb.py Normal file
View File

@@ -0,0 +1,65 @@
import asyncio
import httpx
class TMDBClient:
BASE_URL = "https://api.themoviedb.org/3"
POSTER_BASE = "https://image.tmdb.org/t/p/w185"
def __init__(self, api_key: str):
self.api_key = api_key
async def _fetch(self, client: httpx.AsyncClient, path: str, params: dict) -> dict | None:
try:
r = await client.get(f"{self.BASE_URL}{path}", params={"api_key": self.api_key, **params}, timeout=10)
if r.status_code == 200:
return r.json()
except Exception:
pass
return None
async def get_movie_details(self, client: httpx.AsyncClient, movie: dict) -> dict:
tmdb_id = movie.get("tmdb_id")
imdb_id = movie.get("imdb_id")
# Resolve TMDB ID from IMDB if needed
if not tmdb_id and imdb_id:
data = await self._fetch(client, f"/find/{imdb_id}", {"external_source": "imdb_id"})
if data:
results = data.get("movie_results", [])
if results:
tmdb_id = results[0]["id"]
movie["tmdb_id"] = tmdb_id
if not tmdb_id:
movie.update({"title_fr": movie["title"], "overview": "", "poster": None})
return movie
# Fetch French details
details = await self._fetch(client, f"/movie/{tmdb_id}", {"language": "fr-FR"})
# Fallback to English overview if French is empty
if details and not details.get("overview"):
en = await self._fetch(client, f"/movie/{tmdb_id}", {"language": "en-US"})
if en:
details["overview"] = en.get("overview", "")
if details:
poster = details.get("poster_path")
movie.update({
"title_fr": details.get("title") or movie["title"],
"overview": details.get("overview") or "",
"poster": f"{self.POSTER_BASE}{poster}" if poster else None,
})
else:
movie.update({"title_fr": movie["title"], "overview": "", "poster": None})
return movie
async def enrich_movies(self, movies: list) -> list:
sem = asyncio.Semaphore(5)
async with httpx.AsyncClient() as client:
async def fetch_one(movie):
async with sem:
return await self.get_movie_details(client, movie)
return list(await asyncio.gather(*[fetch_one(m) for m in movies]))

34
trakt.py Normal file
View File

@@ -0,0 +1,34 @@
import asyncio
import httpx
class TraktClient:
BASE_URL = "https://api.trakt.tv"
def __init__(self, client_id: str, access_token: str):
self.headers = {
"Content-Type": "application/json",
"trakt-api-version": "2",
"trakt-api-key": client_id,
"Authorization": f"Bearer {access_token}",
}
async def get_watched_and_ratings(self):
async with httpx.AsyncClient(timeout=60) as client:
watched_r, ratings_r = await asyncio.gather(
client.get(f"{self.BASE_URL}/sync/watched/movies", headers=self.headers),
client.get(f"{self.BASE_URL}/sync/ratings/movies", headers=self.headers),
)
watched_r.raise_for_status()
ratings_r.raise_for_status()
return watched_r.json(), ratings_r.json()
async def rate_movie(self, trakt_id: int, rating: int):
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(
f"{self.BASE_URL}/sync/ratings",
headers=self.headers,
json={"movies": [{"rating": rating, "ids": {"trakt": trakt_id}}]},
)
r.raise_for_status()
return r.json()