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:
12
.env.example
Normal file
12
.env.example
Normal 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
|
||||||
39
.gitea/workflows/docker.yml
Normal file
39
.gitea/workflows/docker.yml
Normal 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
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal 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
153
README.md
Normal 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 (1–10) — 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
20
docker-compose.yml
Normal 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
190
main.py
Normal 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
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
httpx
|
||||||
|
python-dotenv
|
||||||
|
itsdangerous
|
||||||
315
static/app.js
Normal file
315
static/app.js
Normal 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
76
static/index.html
Normal 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
395
static/style.css
Normal 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
65
tmdb.py
Normal 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
34
trakt.py
Normal 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()
|
||||||
Reference in New Issue
Block a user