commit 26808fc2b0de523f1d18dfce63a8038e0a236d04 Author: dev Date: Wed Mar 4 13:33:58 2026 +0000 Initial commit 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a27bf2c --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml new file mode 100644 index 0000000..d5455bd --- /dev/null +++ b/.gitea/workflows/docker.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..69cac2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +.venv/ +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8d2f44c --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d223cd --- /dev/null +++ b/README.md @@ -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 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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c0f7be5 --- /dev/null +++ b/docker-compose.yml @@ -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" diff --git a/main.py b/main.py new file mode 100644 index 0000000..473ffb6 --- /dev/null +++ b/main.py @@ -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") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..aa97537 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn[standard] +httpx +python-dotenv +itsdangerous diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..d7db2e8 --- /dev/null +++ b/static/app.js @@ -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 + ? `` + : `
🎬
`; + + // Info + const meta = [movie.year, fmtDate(movie.watched_at)].filter(Boolean).join(' · '); + const badge = movie.current_rating === 10 + ? `★ noté 10/10` : ''; + + const info = ` +
+
${esc(movie.title_fr)}
+
${esc(meta)}
+ ${badge} +
`; + + // Synopsis + const synopsis = movie.overview + ? `
${esc(movie.overview)}
` + : `
Aucun résumé disponible
`; + + // Rating buttons + skip button + const btns = Array.from({ length: 10 }, (_, i) => + `` + ).join(''); + + const skipLabel = isSkipped ? 'Remettre' : 'Passer'; + const skipBtn = ``; + + const ratingEl = `
${btns}${skipBtn}
`; + + 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(); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..284fc67 --- /dev/null +++ b/static/index.html @@ -0,0 +1,76 @@ + + + + + + Trakt Rater + + + + + + + + + + + + +
+ + + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..600859d --- /dev/null +++ b/static/style.css @@ -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); +} diff --git a/tmdb.py b/tmdb.py new file mode 100644 index 0000000..b45689b --- /dev/null +++ b/tmdb.py @@ -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])) diff --git a/trakt.py b/trakt.py new file mode 100644 index 0000000..83a412d --- /dev/null +++ b/trakt.py @@ -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()