diff --git a/main.py b/main.py
index 012edf5..0abd44e 100644
--- a/main.py
+++ b/main.py
@@ -199,4 +199,33 @@ async def remove_rating(request: Request, trakt_id: int):
return {"success": True}
+@app.delete("/api/rates/all10")
+async def remove_all_10(request: Request):
+ token = request.session.get("access_token")
+ if not token:
+ raise HTTPException(401, "Not authenticated")
+
+ trakt = TraktClient(TRAKT_CLIENT_ID, token)
+ _, ratings = await trakt.get_watched_and_ratings()
+ ids = [{"ids": r["movie"]["ids"]} for r in ratings if r["rating"] == 10]
+
+ if not ids:
+ return {"deleted": 0}
+
+ import asyncio, httpx
+ async with httpx.AsyncClient(timeout=60) as client:
+ tasks = [
+ client.post(
+ f"{TraktClient.BASE_URL}/sync/ratings/remove",
+ headers=trakt.headers,
+ json={"movies": ids[i:i + 100]},
+ )
+ for i in range(0, len(ids), 100)
+ ]
+ responses = await asyncio.gather(*tasks)
+
+ cache_del(f"movies_{token[:16]}")
+ return {"deleted": len(ids)}
+
+
app.mount("/static", StaticFiles(directory="static"), name="static")
diff --git a/remove_10s.py b/remove_10s.py
new file mode 100644
index 0000000..bd6823e
--- /dev/null
+++ b/remove_10s.py
@@ -0,0 +1,102 @@
+"""
+One-shot script : supprime toutes les notes 10/10 de ton compte Trakt.
+Usage : python remove_10s.py
+"""
+import httpx
+import os
+import sys
+from dotenv import load_dotenv
+
+load_dotenv()
+
+CLIENT_ID = os.environ["TRAKT_CLIENT_ID"]
+CLIENT_SECRET = os.environ["TRAKT_CLIENT_SECRET"]
+REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob" # mode "device" — pas besoin de serveur
+BASE = "https://api.trakt.tv"
+
+HEADERS = {
+ "Content-Type": "application/json",
+ "trakt-api-version": "2",
+ "trakt-api-key": CLIENT_ID,
+}
+
+
+def get_token() -> str:
+ # 1. Demander un code device
+ r = httpx.post(f"{BASE}/oauth/device/code", json={"client_id": CLIENT_ID})
+ r.raise_for_status()
+ data = r.json()
+
+ print(f"\nOuvre cette URL dans ton navigateur :\n {data['verification_url']}")
+ print(f"\nSaisis ce code : {data['user_code']}")
+ print("\nAttente de l'autorisation…")
+
+ # 2. Polling automatique jusqu'à autorisation ou expiration
+ import time
+ interval = data.get("interval", 5)
+ expires_in = data.get("expires_in", 600)
+ deadline = time.time() + expires_in
+
+ while time.time() < deadline:
+ time.sleep(interval)
+ r = httpx.post(f"{BASE}/oauth/device/token", json={
+ "code": data["device_code"],
+ "client_id": CLIENT_ID,
+ "client_secret": CLIENT_SECRET,
+ })
+ if r.status_code == 200:
+ print("Autorisé !")
+ return r.json()["access_token"]
+ if r.status_code == 400:
+ continue # pending
+ if r.status_code == 409:
+ continue # slow down
+ break
+
+ print("Autorisation échouée ou expirée.")
+ sys.exit(1)
+
+
+def main():
+ token = get_token()
+ auth_headers = {**HEADERS, "Authorization": f"Bearer {token}"}
+
+ # 3. Récupérer toutes les notes
+ print("Récupération des notes…")
+ r = httpx.get(f"{BASE}/sync/ratings/movies", headers=auth_headers, timeout=60)
+ r.raise_for_status()
+ all_ratings = r.json()
+
+ movies_10 = [
+ {"ids": m["movie"]["ids"]}
+ for m in all_ratings
+ if m["rating"] == 10
+ ]
+
+ if not movies_10:
+ print("Aucun film noté 10/10.")
+ return
+
+ print(f"{len(movies_10)} films notés 10/10 trouvés. Suppression en cours…")
+
+ # 4. Supprimer par batch de 100
+ BATCH = 100
+ total = 0
+ for i in range(0, len(movies_10), BATCH):
+ batch = movies_10[i:i + BATCH]
+ r = httpx.post(
+ f"{BASE}/sync/ratings/remove",
+ headers=auth_headers,
+ json={"movies": batch},
+ timeout=30,
+ )
+ r.raise_for_status()
+ deleted = r.json().get("deleted", {}).get("movies", 0)
+ total += deleted
+ print(f" batch {i // BATCH + 1} — {deleted} supprimés")
+
+ print(f"\nTerminé. {total} notes 10/10 supprimées.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/static/app.js b/static/app.js
index 13375b7..e3fc717 100644
--- a/static/app.js
+++ b/static/app.js
@@ -63,6 +63,7 @@ async function init() {
document.getElementById('filter-select').addEventListener('change', e => {
currentFilter = e.target.value;
currentPage = 1;
+ document.getElementById('remove-all-10-btn').classList.toggle('hidden', currentFilter !== '10');
loadMovies();
});
document.getElementById('sort-select').addEventListener('change', e => {
@@ -219,6 +220,28 @@ function buildRow(movie) {
return row;
}
+/* ── Remove all 10/10 ──────────────────────────────────── */
+async function removeAll10() {
+ const btn = document.getElementById('remove-all-10-btn');
+ const total = document.getElementById('count-badge').textContent;
+ if (!confirm(`Supprimer les notes de ${total} films notés 10/10 ?`)) return;
+
+ btn.disabled = true;
+ btn.textContent = 'Suppression…';
+ try {
+ const r = await fetch('/api/rates/all10', { method: 'DELETE' });
+ if (!r.ok) throw new Error();
+ const { deleted } = await r.json();
+ showToast(`${deleted} notes 10/10 supprimées`);
+ loadMovies();
+ } catch {
+ showToast('Erreur lors de la suppression', true);
+ } finally {
+ btn.disabled = false;
+ btn.textContent = 'Supprimer tous les 10/10';
+ }
+}
+
/* ── Remove rating ──────────────────────────────────────── */
async function removeRating(movie, row) {
row.style.pointerEvents = 'none';
diff --git a/static/index.html b/static/index.html
index 284fc67..815f9d4 100644
--- a/static/index.html
+++ b/static/index.html
@@ -50,6 +50,7 @@
+
Déconnexion
diff --git a/static/style.css b/static/style.css
index ba22e1c..4511785 100644
--- a/static/style.css
+++ b/static/style.css
@@ -163,6 +163,20 @@ select:hover, select:focus { border-color: var(--accent); }
}
.btn-ghost:hover { color: #f43f5e; border-color: rgba(244,63,94,.4); }
+.btn-danger {
+ color: #f87191;
+ font-size: .78rem;
+ font-family: inherit;
+ padding: .3rem .65rem;
+ border: 1px solid rgba(244,63,94,.35);
+ border-radius: 7px;
+ background: rgba(244,63,94,.08);
+ cursor: pointer;
+ transition: background .15s, border-color .15s;
+}
+.btn-danger:hover { background: rgba(244,63,94,.15); border-color: rgba(244,63,94,.6); }
+.btn-danger:disabled { opacity: .5; cursor: not-allowed; }
+
/* ── Main ─────────────────────────────────────────────── */
main {
max-width: 1420px;