diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 85c5f0e..06a4729 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,7 +1,9 @@ name: Build & Push on: push: - branches: [main] + branches: + - main + - dev jobs: docker: @@ -18,9 +20,19 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + + - name: Définir le tag selon la branche + id: vars + run: | + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "TAG=latest" >> $GITHUB_ENV + else + echo "TAG=dev" >> $GITHUB_ENV + fi + - name: Build & push uses: docker/build-push-action@v5 with: context: . push: true - tags: ghcr.io/${{ github.repository }}:latest + tags: ghcr.io/${{ github.repository }}:${{ env.TAG }} \ No newline at end of file diff --git a/README.md b/README.md index 06dd38f..1f53f56 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,6 @@ Checks for new media added to collections automatically. ### Exemple of Docker Compose yml : ```yaml -version: "3.9" services: plex-watchlist-cleaner: image: ghcr.io/chwps/plex-watchlist-cleaner:latest @@ -25,6 +24,7 @@ services: PLEX_URL: "http://localhost:32400" ADMIN_USERNAME: "adminUser" COLLECTIONS: "Collection1,Collection2,Collection3" #No collection limit + WEBHOOK_COLLECTION: "Asking For Deletion" #Media that users rated 0.5 stars out of 5 will be added to this collection CRON_SCHEDULE: "0 */1 * * *" # every hour RUN_SYNC_AT_STARTUP: "true" #decide if it syncs directly or wait for cron, "true" or "false" volumes: diff --git a/app.py b/app.py index 8b4a1ea..cbcbaea 100644 --- a/app.py +++ b/app.py @@ -5,6 +5,7 @@ - stockage tokens utilisateurs (/data/user_tokens.json) - si l'utilisateur connecté est l'admin (ADMIN_USERNAME), on met aussi à jour /data/plex_token.json - routine de sync des collections (fonction sync_collections_once) + - Logique de consensus (une note >= 1/5 annule la suppression) """ import os @@ -12,6 +13,7 @@ import time import secrets import logging +import threading from urllib.parse import urlencode import requests @@ -33,16 +35,15 @@ CLIENT_ID_FILE = os.getenv("CLIENT_ID_FILE", "/data/client_id.txt") PLEX_API = "https://plex.tv/api/v2" -# TTL config (env: TOKEN_TTL_HOURS) default 24 hours TOKEN_TTL = int(os.getenv("TOKEN_TTL_HOURS", "24")) * 3600 - -# Admin account name if you want to auto-detect ("Tristan.Brn") -ADMIN_USERNAME = os.getenv("ADMIN_USERNAME") # si défini, on considérera ce compte comme admin +ADMIN_USERNAME = os.getenv("ADMIN_USERNAME") PLEX_URL = os.getenv("PLEX_URL", "http://localhost:32400") COLLECTIONS = [c.strip() for c in os.getenv("COLLECTIONS", "").split(",") if c.strip()] +WEBHOOK_COLLECTION = os.getenv("WEBHOOK_COLLECTION", "Demande de suppression") +DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL") # ------------------------------------------------------------------ -# UTILS stockage / client id +# UTILS # ------------------------------------------------------------------ def load_json(path): if os.path.exists(path): @@ -64,7 +65,6 @@ def get_client_id(): open(CLIENT_ID_FILE, "w").write(cid) return cid -# user tokens helpers def load_user_tokens(): return load_json(TOKENS_FILE) or {} @@ -74,32 +74,29 @@ def save_user_token(username, token): save_json(TOKENS_FILE, tokens) logging.info("Token utilisateur enregistré pour %s", username) -# admin token helpers (cached token used to access PlexServer) def cache_admin_token(token): save_json(TOKEN_FILE, {"token": token, "ts": time.time()}) logging.info("Token admin mis en cache") def get_admin_token(): - # 1) vérifier cache TOKEN_FILE d = load_json(TOKEN_FILE) if d.get("token") and d.get("ts") and (time.time() - d["ts"] < TOKEN_TTL): - logging.info("Token admin récupéré depuis le cache.") return d["token"] - - # 2) si ADMIN_USERNAME est défini, vérifier si on a le token dans user_tokens.json if ADMIN_USERNAME: tokens = load_user_tokens() admin_token = tokens.get(ADMIN_USERNAME) if admin_token: - logging.info("Token admin récupéré depuis user_tokens.json (admin connecté via onboarding).") - # on met en cache pour accélérer les lectures suivantes cache_admin_token(admin_token) return admin_token - - # 3) pas de token admin disponible - logging.warning("Aucun token admin disponible en cache ni dans user_tokens.json.") return None +def send_to_discord(message): + if DISCORD_WEBHOOK_URL: + try: + requests.post(DISCORD_WEBHOOK_URL, json={"content": message}, timeout=5) + except Exception as e: + logging.error("Erreur Discord : %s", e) + # ------------------------------------------------------------------ # PIN / Auth App flow (onboarding web) # ------------------------------------------------------------------ @@ -139,40 +136,24 @@ def index(): @app.route("/login") def login(): - """Crée un PIN et redirige l'utilisateur vers Plex Auth App""" client_id = get_client_id() - resp = requests.post( f"{PLEX_API}/pins", headers={"accept": "application/json"}, - data={ - "strong": "true", - "X-Plex-Product": APP_NAME, - "X-Plex-Client-Identifier": client_id, - }, + data={"strong": "true", "X-Plex-Product": APP_NAME, "X-Plex-Client-Identifier": client_id}, timeout=10, ) if not resp.ok: return f"Erreur création PIN : {resp.status_code} {resp.text}", 400 pin = resp.json() - pin_id = pin["id"] - pin_code = pin["code"] - - # forwardUrl: callback avec pin info (on utilisera ces params pour vérifier) - forward_url = build_redirect_uri() + f"?pin_id={pin_id}&pin_code={pin_code}" - params = { - "clientID": client_id, - "code": pin_code, - "forwardUrl": forward_url, - "context[device][product]": APP_NAME, - } + forward_url = build_redirect_uri() + f"?pin_id={pin['id']}&pin_code={pin['code']}" + params = {"clientID": client_id, "code": pin['code'], "forwardUrl": forward_url, "context[device][product]": APP_NAME} auth_url = "https://app.plex.tv/auth#?" + urlencode(params) return redirect(auth_url) @app.route("/callback") def callback(): - """Page de retour de Plex après authent""" pin_id = request.args.get("pin_id") pin_code = request.args.get("pin_code") if not pin_id or not pin_code: @@ -182,10 +163,7 @@ def callback(): resp = requests.get( f"{PLEX_API}/pins/{pin_id}", headers={"accept": "application/json"}, - data={ - "code": pin_code, - "X-Plex-Client-Identifier": client_id, - }, + data={"code": pin_code, "X-Plex-Client-Identifier": client_id}, timeout=10, ) if not resp.ok: @@ -194,46 +172,26 @@ def callback(): data = resp.json() token = data.get("authToken") if not token: - return "Authentification non terminée. Veuillez réessayer après avoir cliqué sur 'Authorize'.", 400 + return "Authentification non terminée.", 400 - # récupérer username via l'API user pour connaître le nom du compte user_resp = requests.get( f"{PLEX_API}/user", - headers={ - "accept": "application/json", - "X-Plex-Product": APP_NAME, - "X-Plex-Client-Identifier": client_id, - "X-Plex-Token": token, - }, + headers={"accept": "application/json", "X-Plex-Product": APP_NAME, "X-Plex-Client-Identifier": client_id, "X-Plex-Token": token}, timeout=10, ) - if not user_resp.ok: - logging.warning("Impossible de récupérer infos user après connexion : %s", user_resp.text) - username = "unknown" - else: - username = user_resp.json().get("username", "unknown") + username = user_resp.json().get("username", "unknown") if user_resp.ok else "unknown" - # enregister token utilisateur (remplace si existant -> évite doublons) save_user_token(username, token) - - # si c'est le compte admin configuré, on met aussi à jour le cache admin if ADMIN_USERNAME and username == ADMIN_USERNAME: cache_admin_token(token) - logging.info("Compte admin connecté via la page web. Token admin mis à jour.") - return f""" -
Le token a été enregistré. Vous pouvez fermer cette fenêtre.
- - """ + return f"Le token a été enregistré. Vous pouvez fermer cette fenêtre.
" # ------------------------------------------------------------------ -# LOGIQUE de sync (identique à ton code mais réutilisable ici) +# LOGIQUE de sync # ------------------------------------------------------------------ def list_all_users(): tokens = load_user_tokens() - if not tokens: - logging.warning("Aucun token utilisateur enregistré.") return [{"username": u, "token": t} for u, t in tokens.items()] def remove_batch(guids): @@ -246,79 +204,232 @@ def remove_batch(guids): acc.removeFromWatchlist(watchlist[g]) logging.info("Retiré %s pour %s", watchlist[g].title, user["username"]) except Exception as e: - logging.exception("Erreur pour %s : %s", user["username"], e) + logging.error("Erreur pour %s : %s", user["username"], e) -def sync_collections_once(): - if not COLLECTIONS: - logging.warning("Aucune collection configurée (env COLLECTIONS).") +def sync_ratings(): + """Vérifie les notes, agrège les données, et applique un consensus global""" + logging.info("Démarrage de la vérification des notes (Polling avec Consensus)...") + + admin_token = get_admin_token() + if not admin_token: return + users = list_all_users() + if not users: return + + try: + admin_server = PlexServer(PLEX_URL, token=admin_token) + server_name = admin_server.friendlyName + except Exception as e: + logging.error("Erreur connexion admin : %s", e) return - logging.info("Collections à surveiller : %s", ", ".join(COLLECTIONS)) + # Dictionnaire mémoire pour agréger les notes de tout le monde avant d'agir + media_state = {} - token = get_admin_token() - if not token: - logging.error("Pas de token admin disponible — impossible de se connecter au serveur Plex local.") + for u in users: + username = u["username"] + token = u["token"] + logging.info("--- Analyse du compte : %s ---", username) + + try: + if token == admin_token: + user_server = admin_server + else: + account = MyPlexAccount(token=token) + user_server = account.resource(server_name).connect() + + for section in user_server.library.sections(): + if section.type not in {"movie", "show"}: continue + + try: + # On demande les notes = 0.5 étoile + bad_items = section.search(userRating=1.0) + + # On demande TOUTES les notes >= 1 étoile (API Plex accepte mieux __gte que __gt) + good_items = section.search(userRating__gte=2.0) + + user_actual_ratings = bad_items + good_items + + if user_actual_ratings: + logging.info("Trouvé %d média(s) noté(s) pertinent(s) pour %s dans '%s'.", len(user_actual_ratings), username, section.title) + + for item in user_actual_ratings: + if item.userRating is None: continue + + rating_val = float(item.userRating) + logging.info("-> Examen de '%s' (Note trouvée : %s/10 par %s)", item.title, rating_val, username) + + rk = item.ratingKey + if rk not in media_state: + media_state[rk] = {'title': item.title, 'is_bad': False, 'bad_users': [], 'is_protected': False, 'protectors': []} + + if rating_val == 1.0: + media_state[rk]['is_bad'] = True + media_state[rk]['bad_users'].append(username) + elif rating_val >= 2.0: + media_state[rk]['is_protected'] = True + media_state[rk]['protectors'].append(f"{username} ({rating_val/2}/5)") + + except Exception as e: + logging.warning("Erreur lors de la recherche dans %s : %s", section.title, e) + + except Exception as e: + logging.error("Erreur générale pour %s : %s", username, e) + + logging.info("--- Phase de décision (Consensus global) ---") + if not media_state: + logging.info("Aucun média noté 0.5 ou protégé n'a été trouvé.") return + # Une fois qu'on a l'avis de tout le monde, on prend les décisions + for rk, state in media_state.items(): + try: + admin_item = admin_server.fetchItem(int(rk)) + current_collections = [c.tag for c in admin_item.collections] + + if state['is_protected']: + # Le média est sauvé par au moins une personne + if WEBHOOK_COLLECTION in current_collections: + logging.info("Consensus : Retrait de '%s' car protégé par %s", state['title'], state['protectors']) + admin_item.removeCollection(WEBHOOK_COLLECTION) + send_to_discord(f"🛡️ **Maintien sur le serveur** : La suppression de **{state['title']}** a été annulée grâce aux notes de : {', '.join(state['protectors'])}") + elif state['is_bad']: + logging.info("Consensus : '%s' demandé en suppression par %s, MAIS protégé par %s. Action ignorée.", state['title'], state['bad_users'], state['protectors']) + + elif state['is_bad']: + # Uniquement des notes de 0.5, aucune protection + if WEBHOOK_COLLECTION not in current_collections: + logging.info("Consensus : Ajout de '%s' (noté 0.5 par %s)", state['title'], state['bad_users']) + admin_item.addCollection(WEBHOOK_COLLECTION) + send_to_discord(f"🗑️ **Demande de suppression** validée pour **{state['title']}** (noté 0.5 par {', '.join(state['bad_users'])})") + + except Exception as e: + logging.error("Erreur lors de l'application du consensus pour '%s' : %s", state['title'], e) + +def sync_collections_once(): + sync_ratings() + + if not COLLECTIONS: return + + token = get_admin_token() + if not token: return server = PlexServer(PLEX_URL, token=token) - logging.info("Connecté au serveur Plex local.") current = set() for name in COLLECTIONS: - found = False for lib in server.library.sections(): - if lib.type not in {"movie", "show"}: - continue + if lib.type not in {"movie", "show"}: continue try: coll = next(c for c in lib.collections() if c.title == name) - nb_items = len(coll.items()) - logging.info("Collection '%s' trouvée dans %s (%d élément(s))", name, lib.title, nb_items) current.update(item.guid for item in coll.items()) - found = True break except StopIteration: - logging.debug("Collection '%s' absente de la bibliothèque %s", name, lib.title) - if not found: - logging.warning("Collection '%s' introuvable dans toutes les bibliothèques.", name) + pass previous = set(load_json(STATE_FILE) or []) new_guids = current - previous - logging.info("GUID présents dans les collections : %d", len(current)) - logging.info("GUID déjà connus : %d", len(previous)) - logging.info("Nouveaux GUID à retirer : %d", len(new_guids)) - if new_guids: remove_batch(new_guids) - else: - logging.info("Rien à retirer, watchlist déjà synchronisée.") save_json(STATE_FILE, list(current)) - logging.info("État sauvegardé dans %s", STATE_FILE) -# Expose un endpoint pour déclencher manuellement (utile pour debug/cron) -# **ATTENTION** : si exposé en prod, protège cet endpoint (token, IP, etc.) +# ------------------------------------------------------------------ +# THREAD WEBHOOK (Arrière-plan) +# ------------------------------------------------------------------ +def process_webhook_rating(rating_val, rating_key, title, username): + """Vérifie le consensus en arrière-plan suite à un webhook pour ne pas bloquer Plex""" + logging.info("Webhook reçu pour '%s' (Note: %s/10). Vérification globale...", title, rating_val) + + admin_token = get_admin_token() + if not admin_token: return + try: + admin_server = PlexServer(PLEX_URL, token=admin_token) + server_name = admin_server.friendlyName + except Exception: return + + is_protected = False + protectors = [] + + # On interroge les autres utilisateurs + for u in list_all_users(): + try: + if u["token"] == admin_token: + user_server = admin_server + else: + account = MyPlexAccount(token=u["token"]) + user_server = account.resource(server_name).connect() + + item = user_server.fetchItem(int(rating_key)) + if item.userRating is not None: + r_val = float(item.userRating) + if r_val >= 2.0: # >= 1 étoile + is_protected = True + protectors.append(f"{u['username']} ({r_val/2}/5)") + except Exception: + pass + + # Application de la décision + try: + admin_item = admin_server.fetchItem(int(rating_key)) + current_collections = [c.tag for c in admin_item.collections] + + if is_protected: + if WEBHOOK_COLLECTION in current_collections: + admin_item.removeCollection(WEBHOOK_COLLECTION) + send_to_discord(f"🛡️ **Maintien sur le serveur** : La suppression de **{title}** est bloquée/annulée grâce aux notes de : {', '.join(protectors)}") + elif rating_val == 1.0: + logging.info("Webhook : %s a noté 0.5, mais '%s' est protégé par %s. Action ignorée.", username, title, protectors) + else: + if rating_val == 1.0 and WEBHOOK_COLLECTION not in current_collections: + admin_item.addCollection(WEBHOOK_COLLECTION) + send_to_discord(f"🗑️ **Demande de suppression** via Webhook reçue par {username} pour : **{title}**") + + except Exception as e: + logging.error("Erreur Thread Webhook : %s", e) + +# ------------------------------------------------------------------ +# ROUTES API +# ------------------------------------------------------------------ @app.route("/run_sync", methods=["POST"]) def run_sync_endpoint(): - # Optional: vérifier header X-Admin-Token ou IP whitelist - # Ici on exécute synchro et retourne OK try: sync_collections_once() return "ok", 200 except Exception as e: - logging.exception("Erreur lors du run_sync") return f"error: {e}", 500 +@app.route('/webhook', methods=['POST']) +def webhook(): + payload_str = request.form.get('payload') + data = json.loads(payload_str) if payload_str else (request.get_json(silent=True) or {}) + + if data.get('event') == 'media.rate': + rating = data.get('rating') or data.get('Metadata', {}).get('userRating') + username = data.get('Account', {}).get('title', 'Inconnu') + + try: + rating_val = float(rating) if rating is not None else None + except ValueError: + rating_val = None + + if rating_val is not None: + metadata_obj = data.get('Metadata', {}) + rating_key = metadata_obj.get('ratingKey') + title = metadata_obj.get('title', 'Titre inconnu') + + # On lance le travail de vérification en arrière-plan (Thread) + # pour renvoyer tout de suite "202 Accepted" à Plex et éviter un Timeout + threading.Thread(target=process_webhook_rating, args=(rating_val, rating_key, title, username)).start() + return "Vérification de la note en arrière-plan", 202 + + return "Événement ignoré", 200 + # ------------------------------------------------------------------ # DÉMARRAGE # ------------------------------------------------------------------ if __name__ == "__main__": - logging.info("==== Démarrage combiné plex-watchlist-cleaner (web + sync) ====") - # Ne lance pas sync automatiquement ici — laisse le scheduler (cron) le faire, - # ou utilise /run_sync pour déclenchement manuel. + logging.info("==== Démarrage combiné plex-watchlist-cleaner ====") if os.getenv("RUN_SYNC_AT_STARTUP", "false").lower() in {"1", "true", "yes"}: - logging.info("Lancement initial de la synchro car RUN_SYNC_AT_STARTUP est activé.") sync_collections_once() - # Lancer le serveur Flask (il servira la page d'onboarding) - app.run(host="0.0.0.0", port=5000, debug=False) + app.run(host="0.0.0.0", port=5000, debug=False) \ No newline at end of file diff --git a/RemoveFromWebhook.py b/archive/RemoveFromWebhook_deprecated.py similarity index 100% rename from RemoveFromWebhook.py rename to archive/RemoveFromWebhook_deprecated.py diff --git a/web_onboard.py b/archive/web_onboard_deprecated.py similarity index 100% rename from web_onboard.py rename to archive/web_onboard_deprecated.py diff --git a/docker-compose.yml b/docker-compose.yml index 943d0fc..bbc0e09 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: PLEX_URL: "http://localhost:32400" ADMIN_USERNAME: "adminUser" COLLECTIONS: "Collection1,Collection2,Collection3" #No collection limit + WEBHOOK_COLLECTION: "Asking For Deletion" CRON_SCHEDULE: "0 */1 * * *" # every hour RUN_SYNC_AT_STARTUP: "true" #decide if it syncs directly or wait for cron, "true" or "false" volumes: