Add bulk remove all 10/10 ratings
All checks were successful
Docker / docker (push) Successful in 1m43s
All checks were successful
Docker / docker (push) Successful in 1m43s
Bouton dans le header (visible uniquement en filtre 10/10) qui supprime toutes les notes 10/10 en une fois via l'API Trakt bulk. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
29
main.py
29
main.py
@@ -199,4 +199,33 @@ async def remove_rating(request: Request, trakt_id: int):
|
|||||||
return {"success": True}
|
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")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|||||||
102
remove_10s.py
Normal file
102
remove_10s.py
Normal file
@@ -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()
|
||||||
@@ -63,6 +63,7 @@ async function init() {
|
|||||||
document.getElementById('filter-select').addEventListener('change', e => {
|
document.getElementById('filter-select').addEventListener('change', e => {
|
||||||
currentFilter = e.target.value;
|
currentFilter = e.target.value;
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
|
document.getElementById('remove-all-10-btn').classList.toggle('hidden', currentFilter !== '10');
|
||||||
loadMovies();
|
loadMovies();
|
||||||
});
|
});
|
||||||
document.getElementById('sort-select').addEventListener('change', e => {
|
document.getElementById('sort-select').addEventListener('change', e => {
|
||||||
@@ -219,6 +220,28 @@ function buildRow(movie) {
|
|||||||
return row;
|
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 ──────────────────────────────────────── */
|
/* ── Remove rating ──────────────────────────────────────── */
|
||||||
async function removeRating(movie, row) {
|
async function removeRating(movie, row) {
|
||||||
row.style.pointerEvents = 'none';
|
row.style.pointerEvents = 'none';
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
<option value="year">Année</option>
|
<option value="year">Année</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="remove-all-10-btn" class="btn-danger hidden" onclick="removeAll10()">Supprimer tous les 10/10</button>
|
||||||
<button id="skipped-toggle" class="btn-ghost" onclick="toggleSkipped()">Voir les passés (<span id="skipped-count">0</span>)</button>
|
<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>
|
<a href="/auth/logout" class="btn-ghost">Déconnexion</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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-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 ─────────────────────────────────────────────── */
|
||||||
main {
|
main {
|
||||||
max-width: 1420px;
|
max-width: 1420px;
|
||||||
|
|||||||
Reference in New Issue
Block a user