commit 4846d9cc488074c91d7bc5fb0b9c0bbbaeb3a775 Author: perco Date: Mon May 4 10:35:36 2026 +0200 Initial commit: homelab scripts (backup, system update, plex recap, cronmaster trim) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a4d1979 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +*.log +system-update-config.json diff --git a/README-plex-recap.md b/README-plex-recap.md new file mode 100644 index 0000000..292770a --- /dev/null +++ b/README-plex-recap.md @@ -0,0 +1,147 @@ +# Récapitulatif hebdomadaire Plex pour Discord + +Ce script génère et envoie automatiquement un récapitulatif hebdomadaire des nouveautés de votre serveur Plex sur Discord. + +## Installation + +Les dépendances Python (`requests` et `urllib3`) sont généralement déjà installées avec Python3. Si ce n'est pas le cas, installez-les : + +```bash +# Option 1 : Via apt (recommandé sur Debian/Ubuntu) +apt install python3-pip +pip3 install -r /opt/container/scripts/requirements.txt + +# Option 2 : Vérifier si les modules sont déjà disponibles +python3 -c "import requests, urllib3; print('Dépendances OK')" +``` + +## Configuration + +1. **Créer un webhook Discord** : + - Allez dans les paramètres de votre salon Discord + - Intégrations > Webhooks > Nouveau Webhook + - Copiez l'URL du webhook + +2. **Configurer le fichier de configuration** : + - Éditez `/opt/container/scripts/plex-recap-config.json` + - Ajoutez l'URL du webhook Discord dans `discord.webhook_url` + - Vérifiez que les URLs et clés API sont correctes + +## Utilisation + +Le script peut être utilisé de plusieurs façons pour envoyer des récapitulatifs séparés : + +### Méthode 1 : Scripts wrapper (recommandé pour cronmaster) + +Des scripts wrapper sont disponibles pour simplifier l'utilisation : + +**Envoyer uniquement les films** : +```bash +/opt/container/scripts/plex-recap-movies.sh +``` + +**Envoyer uniquement les séries** : +```bash +/opt/container/scripts/plex-recap-series.sh +``` + +**Envoyer uniquement les demandes approuvées** : +```bash +/opt/container/scripts/plex-recap-requests.sh +``` + +### Méthode 2 : Arguments en ligne de commande + +**Envoyer uniquement les films** : +```bash +python3 /opt/container/scripts/plex-weekly-recap.py --movies +``` + +**Envoyer uniquement les séries** : +```bash +python3 /opt/container/scripts/plex-weekly-recap.py --series +``` + +**Envoyer uniquement les demandes approuvées** : +```bash +python3 /opt/container/scripts/plex-weekly-recap.py --requests +``` + +**Envoyer tout (films, séries, demandes) - par défaut** : +```bash +python3 /opt/container/scripts/plex-weekly-recap.py --all +# ou simplement +python3 /opt/container/scripts/plex-weekly-recap.py +``` + +**Personnaliser le nombre de jours** : +```bash +python3 /opt/container/scripts/plex-weekly-recap.py --movies --days 14 +``` + +## Planification + +### Option 1 : Via cronmaster (recommandé) + +Vous pouvez créer plusieurs tâches séparées dans cronmaster : + +**Films uniquement** (chaque dimanche à 18h00) : +- **Commande** : `/opt/container/scripts/plex-recap-movies.sh` (ou `python3 /opt/container/scripts/plex-weekly-recap.py --movies`) +- **Planification** : `0 18 * * 0` + +**Séries uniquement** (chaque lundi à 18h00) : +- **Commande** : `/opt/container/scripts/plex-recap-series.sh` (ou `python3 /opt/container/scripts/plex-weekly-recap.py --series`) +- **Planification** : `0 18 * * 1` + +**Demandes uniquement** (chaque mardi à 18h00) : +- **Commande** : `/opt/container/scripts/plex-recap-requests.sh` (ou `python3 /opt/container/scripts/plex-weekly-recap.py --requests`) +- **Planification** : `0 18 * * 2` + +**Tout** (chaque dimanche à 19h00) : +- **Commande** : `python3 /opt/container/scripts/plex-weekly-recap.py --all` +- **Planification** : `0 19 * * 0` + +### Option 2 : Via crontab système + +Ajoutez ces lignes dans votre crontab (`crontab -e`) : +``` +# Films chaque dimanche à 18h00 +0 18 * * 0 /opt/container/scripts/plex-recap-movies.sh + +# Séries chaque lundi à 18h00 +0 18 * * 1 /opt/container/scripts/plex-recap-series.sh + +# Demandes chaque mardi à 18h00 +0 18 * * 2 /opt/container/scripts/plex-recap-requests.sh +``` + +### Option 3 : Test manuel + +Pour tester le script manuellement : +```bash +# Tester les films +python3 /opt/container/scripts/plex-weekly-recap.py --movies + +# Tester les séries +python3 /opt/container/scripts/plex-weekly-recap.py --series + +# Tester les demandes +python3 /opt/container/scripts/plex-weekly-recap.py --requests +``` + +## Format du message Discord + +Le message Discord contiendra : +- 📺 **Nouveaux Films** : Liste des films ajoutés via Radarr +- 📺 **Nouvelles Séries** : Liste des séries ajoutées via Sonarr +- ✅ **Demandes approuvées** : Liste des demandes approuvées dans Jellyseerr + +## Logs + +Les logs sont écrits dans `/opt/container/scripts/plex-recap.log` + +## Dépannage + +- Vérifiez que les APIs de Jellyseerr, Radarr et Sonarr sont accessibles +- Vérifiez que l'URL du webhook Discord est correcte +- Consultez le fichier de log pour les erreurs détaillées diff --git a/backup_immich_usb.sh b/backup_immich_usb.sh new file mode 100755 index 0000000..c4e52df --- /dev/null +++ b/backup_immich_usb.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# ============================================================ +# backup_immich_usb.sh — Backup Immich → clé USB /backup +# Exclut : postgres (géré séparément), thumbs, encoded-video +# ============================================================ + +SOURCE="/mnt/serveurNAS/Immich/" +DEST="/backup/immich/" +LOGFILE="/var/log/backup_immich_usb.log" +DATE=$(date '+%Y-%m-%d %H:%M:%S') + +echo "[$DATE] === Début backup Immich → USB ===" >> "$LOGFILE" + +# Monter la clé USB si elle ne l'est pas déjà (l'automount systemd peut être inactif après débranchement) +mount /backup 2>/dev/null || true + +# Vérifier que la clé USB est bien montée +if ! mountpoint -q /backup; then + echo "[$DATE] ERREUR: /backup n'est pas monté (clé USB absente ?)" >> "$LOGFILE" + exit 1 +fi + +# Vérifier espace disponible (alerte si < 5 Go) +AVAIL=$(df /backup | awk 'NR==2 {print $4}') +AVAIL_GB=$((AVAIL / 1024 / 1024)) +echo "[$DATE] Espace disponible sur clé USB: ${AVAIL_GB} Go" >> "$LOGFILE" +if [ "$AVAIL_GB" -lt 5 ]; then + echo "[$DATE] AVERTISSEMENT: Moins de 5 Go disponibles sur la clé USB !" >> "$LOGFILE" +fi + +# Créer le dossier destination +mkdir -p "$DEST" + +# Rsync — copie uniquement les fichiers modifiés +# Exclut : postgres (base de données), thumbs et encoded-video (régénérables) +rsync -av \ + --no-perms --no-owner --no-group \ + --exclude='postgres/' \ + --exclude='thumbs/' \ + --exclude='encoded-video/' \ + --delete \ + --progress \ + --stats \ + "$SOURCE" "$DEST" >> "$LOGFILE" 2>&1 + +EXIT_CODE=$? +DATE_END=$(date '+%Y-%m-%d %H:%M:%S') + +if [ $EXIT_CODE -eq 0 ]; then + echo "[$DATE_END] ✅ Backup terminé avec succès" >> "$LOGFILE" + # Taille finale + SIZE=$(du -sh "$DEST" 2>/dev/null | cut -f1) + echo "[$DATE_END] Taille backup: $SIZE" >> "$LOGFILE" +else + echo "[$DATE_END] ❌ Backup terminé avec erreurs (code: $EXIT_CODE)" >> "$LOGFILE" +fi + +echo "[$DATE_END] === Fin backup ===" >> "$LOGFILE" +echo "" >> "$LOGFILE" + +exit $EXIT_CODE diff --git a/plex-recap-config.json b/plex-recap-config.json new file mode 100644 index 0000000..5eb67c1 --- /dev/null +++ b/plex-recap-config.json @@ -0,0 +1,23 @@ +{ + "discord": { + "webhook_url": "https://discord.com/api/webhooks/1463183628157718634/6Tux8xOpA96vm_8mLgP1-izVzgmUV5PWxA5XUjNSSXlxwX8SHu-n4P3ZqGjumjhLcvtS", + "embed_color": 3447003 + }, + "jellyseerr": { + "url": "http://192.168.1.29:5055", + "api_key": "MTc2ODEzNzUzODQ5N2QyZTJlOTE1LWMzYTMtNGRhYi1iZmQwLWFiZDM1OTc3NzkzNQ==" + }, + "radarr": { + "hostname": "192.168.1.29", + "port": 7878, + "api_key": "2208f084bcf94a7ba7020b3b4df2a695", + "use_ssl": false + }, + "sonarr": { + "hostname": "192.168.1.29", + "port": 8989, + "api_key": "2998fffae00a43648e67d927d8536ed6", + "use_ssl": false + }, + "days": 7 +} diff --git a/plex-recap-movies.sh b/plex-recap-movies.sh new file mode 100755 index 0000000..ee93b5b --- /dev/null +++ b/plex-recap-movies.sh @@ -0,0 +1,3 @@ +#!/bin/bash +# Script wrapper pour envoyer uniquement les films +python3 /opt/container/scripts/plex-weekly-recap.py --movies diff --git a/plex-recap-requests.sh b/plex-recap-requests.sh new file mode 100755 index 0000000..83f1054 --- /dev/null +++ b/plex-recap-requests.sh @@ -0,0 +1,3 @@ +#!/bin/bash +# Script wrapper pour envoyer uniquement les demandes approuvées +python3 /opt/container/scripts/plex-weekly-recap.py --requests diff --git a/plex-recap-series.sh b/plex-recap-series.sh new file mode 100755 index 0000000..8650d33 --- /dev/null +++ b/plex-recap-series.sh @@ -0,0 +1,3 @@ +#!/bin/bash +# Script wrapper pour envoyer uniquement les séries +python3 /opt/container/scripts/plex-weekly-recap.py --series diff --git a/plex-weekly-recap.py b/plex-weekly-recap.py new file mode 100755 index 0000000..9a04640 --- /dev/null +++ b/plex-weekly-recap.py @@ -0,0 +1,797 @@ +#!/usr/bin/env python3 +""" +Script de récapitulatif hebdomadaire Plex pour Discord +Récupère les nouveautés de Jellyseerr, Radarr et Sonarr et les envoie sur Discord +Usage: + python3 plex-weekly-recap.py --movies # Envoie uniquement les films + python3 plex-weekly-recap.py --series # Envoie uniquement les séries + python3 plex-weekly-recap.py --requests # Envoie uniquement les demandes + python3 plex-weekly-recap.py --all # Envoie tout (par défaut) +""" + +import json +import sys +import os +import argparse +from datetime import datetime, timedelta +from typing import List, Dict, Optional +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +# Configuration du chemin du fichier de config +CONFIG_FILE = os.path.join(os.path.dirname(__file__), 'plex-recap-config.json') +LOG_FILE = os.path.join(os.path.dirname(__file__), 'plex-recap.log') + +def log(message: str, level: str = "INFO"): + """Écrit un message dans le fichier de log""" + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + log_message = f"[{timestamp}] [{level}] {message}\n" + try: + with open(LOG_FILE, 'a', encoding='utf-8') as f: + f.write(log_message) + except Exception as e: + print(f"Erreur lors de l'écriture du log: {e}") + if level == "ERROR": + print(log_message.strip()) + +def load_config() -> Dict: + """Charge la configuration depuis le fichier JSON""" + try: + with open(CONFIG_FILE, 'r', encoding='utf-8') as f: + config = json.load(f) + return config + except FileNotFoundError: + log(f"Fichier de configuration non trouvé: {CONFIG_FILE}", "ERROR") + sys.exit(1) + except json.JSONDecodeError as e: + log(f"Erreur de parsing JSON dans la configuration: {e}", "ERROR") + sys.exit(1) + +def create_session() -> requests.Session: + """Crée une session requests avec retry automatique""" + session = requests.Session() + retry_strategy = Retry( + total=3, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + session.mount("http://", adapter) + session.mount("https://", adapter) + return session + +def get_jellyseerr_requests(config: Dict, days: int = 7) -> List[Dict]: + """Récupère les demandes approuvées de Jellyseerr des derniers jours""" + jellyseerr_config = config.get('jellyseerr', {}) + url = jellyseerr_config.get('url', 'http://localhost:5055') + api_key = jellyseerr_config.get('api_key', '') + + if not api_key: + log("Clé API Jellyseerr non configurée", "ERROR") + return [] + + # Calcul de la date limite + cutoff_date = datetime.now() - timedelta(days=days) + + try: + session = create_session() + headers = {'X-Api-Key': api_key} + + # Récupération de toutes les demandes disponibles + # On récupère toutes les demandes et on filtre ensuite + response = session.get( + f"{url}/api/v1/request?take=100", + headers=headers, + timeout=10 + ) + response.raise_for_status() + + requests_data = response.json() + results = requests_data.get('results', []) + + # Filtrage par date (demandes approuvées/disponibles dans les derniers jours) + filtered_requests = [] + for req in results: + # Vérifier que la demande est disponible + status = req.get('status', 0) + # Status 2 = Available, Status 3 = Partially Available + if status in [2, 3]: + # Vérifier la date de modification ou d'approbation + modified = req.get('modified', '') + if modified: + try: + # Gérer différents formats de date + modified_str = modified.replace('Z', '+00:00') + if '.' in modified_str and '+' in modified_str: + # Format ISO avec timezone + modified_date = datetime.fromisoformat(modified_str) + else: + # Format ISO simple + modified_date = datetime.fromisoformat(modified_str.split('+')[0]) + + if modified_date.replace(tzinfo=None) >= cutoff_date: + filtered_requests.append(req) + except (ValueError, AttributeError) as e: + log(f"Erreur de parsing de date pour demande {req.get('id', 'unknown')}: {e}", "ERROR") + continue + + log(f"Récupéré {len(filtered_requests)} demandes Jellyseerr des {days} derniers jours") + return filtered_requests + + except requests.exceptions.RequestException as e: + log(f"Erreur lors de la récupération des demandes Jellyseerr: {e}", "ERROR") + return [] + +def get_radarr_movies(config: Dict, days: int = 7) -> List[Dict]: + """Récupère les nouveaux films de Radarr des derniers jours""" + radarr_config = config.get('radarr', {}) + hostname = radarr_config.get('hostname', 'localhost') + port = radarr_config.get('port', 7878) + api_key = radarr_config.get('api_key', '') + use_ssl = radarr_config.get('use_ssl', False) + + if not api_key: + log("Clé API Radarr non configurée", "ERROR") + return [] + + protocol = 'https' if use_ssl else 'http' + url = f"{protocol}://{hostname}:{port}" + + # Calcul de la date limite + cutoff_date = datetime.now() - timedelta(days=days) + + try: + session = create_session() + params = {'apikey': api_key} + + response = session.get( + f"{url}/api/v3/movie", + params=params, + timeout=10 + ) + response.raise_for_status() + + movies = response.json() + + # Filtrage par date d'ajout + new_movies = [] + for movie in movies: + added = movie.get('added', '') + if added: + try: + added_date = datetime.fromisoformat(added.replace('Z', '+00:00')) + if added_date.replace(tzinfo=None) >= cutoff_date: + # Vérifier que le film est disponible + if movie.get('hasFile', False): + new_movies.append(movie) + except (ValueError, AttributeError): + pass + + log(f"Récupéré {len(new_movies)} nouveaux films Radarr des {days} derniers jours") + return new_movies + + except requests.exceptions.RequestException as e: + log(f"Erreur lors de la récupération des films Radarr: {e}", "ERROR") + return [] + +def get_sonarr_series(config: Dict, days: int = 7) -> List[Dict]: + """Récupère les séries avec de nouveaux épisodes de Sonarr des derniers jours""" + sonarr_config = config.get('sonarr', {}) + hostname = sonarr_config.get('hostname', 'localhost') + port = sonarr_config.get('port', 8989) + api_key = sonarr_config.get('api_key', '') + use_ssl = sonarr_config.get('use_ssl', False) + + if not api_key: + log("Clé API Sonarr non configurée", "ERROR") + return [] + + protocol = 'https' if use_ssl else 'http' + url = f"{protocol}://{hostname}:{port}" + + # Calcul de la date limite + cutoff_date = datetime.now() - timedelta(days=days) + log(f"Recherche d'épisodes ajoutés après le {cutoff_date.strftime('%Y-%m-%d %H:%M:%S')}") + + try: + session = create_session() + params = {'apikey': api_key} + + # D'abord récupérer toutes les séries pour avoir les infos + response_series = session.get( + f"{url}/api/v3/series", + params=params, + timeout=10 + ) + response_series.raise_for_status() + all_series = response_series.json() + series_map = {s.get('id'): s for s in all_series} + log(f"Récupéré {len(series_map)} séries depuis Sonarr") + + # Utiliser l'historique pour trouver les épisodes récemment téléchargés + # L'historique contient les événements de téléchargement avec les dates + cutoff_timestamp = int(cutoff_date.timestamp()) + history_params = { + 'apikey': api_key, + 'page': 1, + 'pageSize': 1000, + 'sortKey': 'date', + 'sortDirection': 'descending' + } + + response_history = session.get( + f"{url}/api/v3/history", + params=history_params, + timeout=10 + ) + response_history.raise_for_status() + + history_data = response_history.json() + history_records = history_data.get('records', []) + log(f"Récupéré {len(history_records)} enregistrements d'historique depuis Sonarr") + + # Filtrer les épisodes téléchargés récemment + new_episodes_by_series = {} + event_types_found = set() + for record in history_records: + event_type = record.get('eventType', '') + event_types_found.add(event_type) + + # Vérifier que c'est un événement de téléchargement d'épisode + # Les types peuvent varier selon la version de Sonarr + if event_type in ['downloadFolderImported', 'grabbed', 'episodeFileDeleted', 'episodeFileRenamed']: + date_str = record.get('date', '') + if date_str: + try: + # Gérer différents formats de date + date_str_clean = date_str.replace('Z', '+00:00') + if '.' in date_str_clean and '+' in date_str_clean: + event_date = datetime.fromisoformat(date_str_clean) + else: + event_date = datetime.fromisoformat(date_str_clean.split('+')[0]) + + if event_date.replace(tzinfo=None) >= cutoff_date: + # Récupérer les infos de l'épisode depuis l'historique + episode_info = record.get('episode', {}) + if not episode_info: + # Parfois l'info est directement dans le record + episode_info = record + + series_id = episode_info.get('seriesId', 0) + + if series_id and series_id in series_map: + series_info = series_map[series_id] + if series_id not in new_episodes_by_series: + # Récupérer la langue originale + original_language = series_info.get('originalLanguage', {}) + language_name = original_language.get('name', '') if isinstance(original_language, dict) else '' + + new_episodes_by_series[series_id] = { + 'seriesId': series_id, + 'title': series_info.get('title', 'Titre inconnu'), + 'year': series_info.get('year', ''), + 'originalLanguage': language_name, + 'episodeCount': 0, + 'episodes': [] + } + new_episodes_by_series[series_id]['episodeCount'] += 1 + except (ValueError, AttributeError) as e: + log(f"Erreur de parsing de date pour historique: {date_str} - {e}", "ERROR") + pass + + log(f"Types d'événements trouvés dans l'historique: {', '.join(sorted(event_types_found))}") + log(f"Épisodes trouvés dans l'historique: {len(new_episodes_by_series)} séries") + + # Si l'historique ne donne pas assez d'infos, parcourir les séries et leurs épisodes + if len(new_episodes_by_series) == 0: + log("Aucun épisode trouvé dans l'historique, parcours des séries...") + series_checked = 0 + episodes_found_in_series = 0 + for series in all_series: + series_id = series.get('id', 0) + if not series_id: + continue + + series_checked += 1 + # Récupérer les épisodes de cette série + episode_params = {'apikey': api_key, 'seriesId': series_id} + try: + response_episodes = session.get( + f"{url}/api/v3/episode", + params=episode_params, + timeout=10 + ) + response_episodes.raise_for_status() + episodes = response_episodes.json() + + # Filtrer les épisodes récemment ajoutés + for episode in episodes: + if episode.get('hasFile', False): + episode_file = episode.get('episodeFile', {}) + if episode_file: + date_added = episode_file.get('dateAdded', '') + if date_added: + try: + date_str_clean = date_added.replace('Z', '+00:00') + if '.' in date_str_clean and '+' in date_str_clean: + added_date = datetime.fromisoformat(date_str_clean) + else: + added_date = datetime.fromisoformat(date_str_clean.split('+')[0]) + + if added_date.replace(tzinfo=None) >= cutoff_date: + episodes_found_in_series += 1 + if series_id not in new_episodes_by_series: + # Récupérer la langue originale + original_language = series.get('originalLanguage', {}) + language_name = original_language.get('name', '') if isinstance(original_language, dict) else '' + + new_episodes_by_series[series_id] = { + 'seriesId': series_id, + 'title': series.get('title', 'Titre inconnu'), + 'year': series.get('year', ''), + 'originalLanguage': language_name, + 'episodeCount': 0, + 'episodes': [] + } + new_episodes_by_series[series_id]['episodeCount'] += 1 + except (ValueError, AttributeError) as e: + log(f"Erreur de parsing date épisode: {date_added} - {e}", "ERROR") + pass + except requests.exceptions.RequestException as e: + log(f"Erreur lors de la récupération des épisodes pour série {series_id}: {e}", "ERROR") + continue + + log(f"Parcours terminé: {series_checked} séries vérifiées, {episodes_found_in_series} épisodes trouvés") + + # Convertir en liste + new_series = list(new_episodes_by_series.values()) + + log(f"Récupéré {len(new_series)} séries avec nouveaux épisodes Sonarr des {days} derniers jours ({sum(s['episodeCount'] for s in new_series)} épisodes au total)") + return new_series + + except requests.exceptions.RequestException as e: + log(f"Erreur lors de la récupération des séries Sonarr: {e}", "ERROR") + if hasattr(e, 'response') and e.response is not None: + try: + log(f"Réponse API: {e.response.text[:200]}", "ERROR") + except: + pass + return [] + +def format_movie_title(movie: Dict) -> str: + """Formate le titre d'un film avec année et qualité""" + title = movie.get('title', 'Titre inconnu') + year = movie.get('year', '') + quality = '' + + # Récupération de la qualité depuis le fichier + movie_file = movie.get('movieFile', {}) + if movie_file: + quality_profile = movie_file.get('quality', {}) + if quality_profile: + quality_name = quality_profile.get('quality', {}).get('name', '') + if quality_name: + quality = f" ({quality_name})" + + if year: + return f"**{title}** ({year}){quality}" + return f"**{title}**{quality}" + +def format_series_title(series: Dict) -> str: + """Formate le titre d'une série avec année et nombre d'épisodes""" + title = series.get('title', 'Titre inconnu') + year = series.get('year', '') + episode_count = series.get('episodeCount', 0) + + if year: + return f"**{title}** ({year}) - {episode_count} épisode(s)" + return f"**{title}** - {episode_count} épisode(s)" + +def format_request_title(request: Dict) -> str: + """Formate le titre d'une demande Jellyseerr""" + media = request.get('media', {}) + media_type = request.get('type', '') + + if media_type == 'movie': + title = media.get('title', 'Titre inconnu') + year = media.get('releaseDate', '') + if year: + year = year[:4] # Extraire l'année + return f"**{title}** ({year})" + return f"**{title}**" + else: # TV show + title = media.get('name', 'Titre inconnu') + season = request.get('seasons', []) + if season: + season_num = season[0].get('seasonNumber', '') + return f"**{title}** - Saison {season_num}" + return f"**{title}**" + +def split_text_field(text: str, max_length: int = 1024) -> List[str]: + """Divise un texte en plusieurs parties si trop long pour Discord""" + if len(text) <= max_length: + return [text] + + parts = [] + lines = text.split('\n') + current_part = "" + + for line in lines: + # Si la ligne seule dépasse la limite, on la tronque + if len(line) > max_length: + if current_part: + parts.append(current_part) + current_part = "" + # Tronquer la ligne + parts.append(line[:max_length-3] + "...") + # Si ajouter la ligne dépasse la limite, sauvegarder la partie actuelle + elif len(current_part) + len(line) + 1 > max_length: + if current_part: + parts.append(current_part) + current_part = line + else: + # Ajouter la ligne à la partie actuelle + if current_part: + current_part += "\n" + line + else: + current_part = line + + if current_part: + parts.append(current_part) + + return parts + +def create_movies_embed(config: Dict, movies: List[Dict], days: int = 7) -> List[Dict]: + """Crée l'embed Discord pour les films uniquement""" + embed_color = config.get('discord', {}).get('embed_color', 0x00ff00) + + # Calcul des dates de la semaine + now = datetime.now() + week_start = now - timedelta(days=days) + week_start_str = week_start.strftime("%d/%m/%Y") + week_end_str = now.strftime("%d/%m/%Y") + + embeds = [] + + if movies: + movies_text = "\n".join([format_movie_title(m) for m in movies]) + movies_parts = split_text_field(movies_text, 1024) + + for i, part in enumerate(movies_parts): + embed = { + "title": "🎬 Nouveaux Films Plex" if i == 0 else "🎬 Nouveaux Films Plex (suite)", + "description": f"Films ajoutés du {week_start_str} au {week_end_str}" if i == 0 else "", + "color": embed_color, + "timestamp": now.isoformat(), + "fields": [{ + "name": f"🎬 Nouveaux Films ({len(movies)})" if i == 0 else f"🎬 Suite ({len(movies)} au total)", + "value": part, + "inline": False + }], + "footer": { + "text": "Récapitulatif automatique Plex" + } + } + embeds.append(embed) + else: + # Aucun film + embed = { + "title": "🎬 Nouveaux Films Plex", + "description": f"Films ajoutés du {week_start_str} au {week_end_str}", + "color": embed_color, + "timestamp": now.isoformat(), + "fields": [{ + "name": "ℹ️ Aucun nouveau film", + "value": "Aucun nouveau film cette semaine.", + "inline": False + }], + "footer": { + "text": "Récapitulatif automatique Plex" + } + } + embeds.append(embed) + + return embeds + +def create_series_embed(config: Dict, series: List[Dict], days: int = 7) -> List[Dict]: + """Crée l'embed Discord pour les séries uniquement, séparées en séries normales et manga""" + embed_color = config.get('discord', {}).get('embed_color', 0x00ff00) + + # Calcul des dates de la semaine + now = datetime.now() + week_start = now - timedelta(days=days) + week_start_str = week_start.strftime("%d/%m/%Y") + week_end_str = now.strftime("%d/%m/%Y") + + embeds = [] + + if series: + # Séparer les séries normales et les manga (Japanese/Chinese) + normal_series = [] + manga_series = [] + + for s in series: + original_language = s.get('originalLanguage', '').lower() + if original_language in ['japanese', 'chinese']: + manga_series.append(s) + else: + normal_series.append(s) + + # Créer l'embed principal + main_embed = { + "title": "📺 Nouveautés Séries Plex", + "description": f"Séries ajoutées du {week_start_str} au {week_end_str}", + "color": embed_color, + "timestamp": now.isoformat(), + "fields": [], + "footer": { + "text": "Récapitulatif automatique Plex" + } + } + + # Section Séries normales + if normal_series: + normal_text = "\n".join([format_series_title(s) for s in normal_series]) + normal_parts = split_text_field(normal_text, 1024) + + for i, part in enumerate(normal_parts): + if i == 0: + main_embed["fields"].append({ + "name": f"📺 Nouveautés Séries ({len(normal_series)})", + "value": part, + "inline": False + }) + else: + # Créer un embed supplémentaire pour la suite + if len(embeds) == 0: + embeds.append({ + "color": embed_color, + "fields": [] + }) + embeds[0]["fields"].append({ + "name": f"📺 Nouveautés Séries (suite)", + "value": part, + "inline": False + }) + + # Section Manga + if manga_series: + manga_text = "\n".join([format_series_title(s) for s in manga_series]) + manga_parts = split_text_field(manga_text, 1024) + + for i, part in enumerate(manga_parts): + if i == 0: + main_embed["fields"].append({ + "name": f"🏮 Nouveautés Manga ({len(manga_series)})", + "value": part, + "inline": False + }) + else: + # Utiliser le dernier embed ou en créer un nouveau + if len(embeds) == 0: + embeds.append({ + "color": embed_color, + "fields": [] + }) + embeds[0]["fields"].append({ + "name": f"🏮 Nouveautés Manga (suite)", + "value": part, + "inline": False + }) + + # Si aucune série dans aucune catégorie (ne devrait pas arriver) + if not normal_series and not manga_series: + main_embed["fields"].append({ + "name": "ℹ️ Aucune Nouveautés série", + "value": "Aucune Nouveautés série cette semaine.", + "inline": False + }) + + embeds.insert(0, main_embed) + else: + # Aucune série + embed = { + "title": "📺 Nouveautés Séries Plex", + "description": f"Séries ajoutées du {week_start_str} au {week_end_str}", + "color": embed_color, + "timestamp": now.isoformat(), + "fields": [{ + "name": "ℹ️ Aucune Nouveautés série", + "value": "Aucune Nouveautés série cette semaine.", + "inline": False + }], + "footer": { + "text": "Récapitulatif automatique Plex" + } + } + embeds.append(embed) + + return embeds + +def create_requests_embed(config: Dict, requests: List[Dict], days: int = 7) -> List[Dict]: + """Crée l'embed Discord pour les demandes approuvées uniquement""" + embed_color = config.get('discord', {}).get('embed_color', 0x00ff00) + + # Calcul des dates de la semaine + now = datetime.now() + week_start = now - timedelta(days=days) + week_start_str = week_start.strftime("%d/%m/%Y") + week_end_str = now.strftime("%d/%m/%Y") + + embeds = [] + + if requests: + requests_text = "\n".join([format_request_title(r) for r in requests]) + requests_parts = split_text_field(requests_text, 1024) + + for i, part in enumerate(requests_parts): + embed = { + "title": "✅ Demandes approuvées Jellyseerr" if i == 0 else "✅ Demandes approuvées Jellyseerr (suite)", + "description": f"Demandes approuvées du {week_start_str} au {week_end_str}" if i == 0 else "", + "color": embed_color, + "timestamp": now.isoformat(), + "fields": [{ + "name": f"✅ Demandes approuvées ({len(requests)})" if i == 0 else f"✅ Suite ({len(requests)} au total)", + "value": part, + "inline": False + }], + "footer": { + "text": "Récapitulatif automatique Plex" + } + } + embeds.append(embed) + else: + # Aucune demande + embed = { + "title": "✅ Demandes approuvées Jellyseerr", + "description": f"Demandes approuvées du {week_start_str} au {week_end_str}", + "color": embed_color, + "timestamp": now.isoformat(), + "fields": [{ + "name": "ℹ️ Aucune demande approuvée", + "value": "Aucune demande approuvée cette semaine.", + "inline": False + }], + "footer": { + "text": "Récapitulatif automatique Plex" + } + } + embeds.append(embed) + + return embeds + +def send_discord_webhook(config: Dict, embeds: List[Dict]) -> bool: + """Envoie le message Discord via webhook (peut envoyer plusieurs embeds)""" + discord_config = config.get('discord', {}) + webhook_url = discord_config.get('webhook_url', '') + + if not webhook_url: + log("URL du webhook Discord non configurée", "ERROR") + return False + + # Discord limite à 10 embeds par message + max_embeds_per_message = 10 + + try: + session = create_session() + + # Diviser les embeds en groupes de 10 + for i in range(0, len(embeds), max_embeds_per_message): + embeds_batch = embeds[i:i + max_embeds_per_message] + payload = { + "embeds": embeds_batch + } + + response = session.post( + webhook_url, + json=payload, + timeout=10 + ) + response.raise_for_status() + + log(f"Message Discord envoyé avec succès ({len(embeds_batch)} embed(s))") + + return True + + except requests.exceptions.RequestException as e: + log(f"Erreur lors de l'envoi du message Discord: {e}", "ERROR") + if hasattr(e, 'response') and e.response is not None: + try: + error_detail = e.response.json() + log(f"Détails de l'erreur Discord: {error_detail}", "ERROR") + except: + log(f"Réponse brute: {e.response.text[:200]}", "ERROR") + return False + +def send_movies(config: Dict, days: int = 7) -> bool: + """Envoie uniquement les films sur Discord""" + log("Récupération des films...") + movies = get_radarr_movies(config, days) + embeds = create_movies_embed(config, movies, days) + return send_discord_webhook(config, embeds) + +def send_series(config: Dict, days: int = 7) -> bool: + """Envoie uniquement les séries sur Discord""" + log("Récupération des séries...") + series = get_sonarr_series(config, days) + embeds = create_series_embed(config, series, days) + return send_discord_webhook(config, embeds) + +def send_requests(config: Dict, days: int = 7) -> bool: + """Envoie uniquement les demandes approuvées sur Discord""" + log("Récupération des demandes...") + requests = get_jellyseerr_requests(config, days) + embeds = create_requests_embed(config, requests, days) + return send_discord_webhook(config, embeds) + +def send_all(config: Dict, days: int = 7) -> bool: + """Envoie tous les récapitulatifs (films, séries, demandes) sur Discord""" + log("Récupération de toutes les données...") + movies = get_radarr_movies(config, days) + series = get_sonarr_series(config, days) + requests = get_jellyseerr_requests(config, days) + + # Envoyer chaque type séparément + success_movies = send_movies(config, days) + success_series = send_series(config, days) + success_requests = send_requests(config, days) + + return success_movies and success_series and success_requests + +def main(): + """Fonction principale""" + parser = argparse.ArgumentParser( + description='Récapitulatif hebdomadaire Plex pour Discord', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Exemples: + python3 plex-weekly-recap.py --movies # Envoie uniquement les films + python3 plex-weekly-recap.py --series # Envoie uniquement les séries + python3 plex-weekly-recap.py --requests # Envoie uniquement les demandes + python3 plex-weekly-recap.py --all # Envoie tout (par défaut) + """ + ) + + parser.add_argument('--movies', action='store_true', help='Envoyer uniquement les films') + parser.add_argument('--series', action='store_true', help='Envoyer uniquement les séries') + parser.add_argument('--requests', action='store_true', help='Envoyer uniquement les demandes approuvées') + parser.add_argument('--all', action='store_true', help='Envoyer tout (films, séries, demandes)') + parser.add_argument('--days', type=int, default=None, help='Nombre de jours à considérer (défaut: depuis config)') + + args = parser.parse_args() + + log("Démarrage du récapitulatif hebdomadaire Plex") + + # Chargement de la configuration + config = load_config() + + # Nombre de jours à considérer + days = args.days if args.days is not None else config.get('days', 7) + + success = False + + # Déterminer ce qu'on doit envoyer + if args.movies: + log("Mode: Films uniquement") + success = send_movies(config, days) + elif args.series: + log("Mode: Séries uniquement") + success = send_series(config, days) + elif args.requests: + log("Mode: Demandes uniquement") + success = send_requests(config, days) + else: + # Par défaut, envoyer tout + log("Mode: Tout (films, séries, demandes)") + success = send_all(config, days) + + if success: + log("Récapitulatif envoyé avec succès") + sys.exit(0) + else: + log("Échec de l'envoi du récapitulatif", "ERROR") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..23b0230 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.31.0 +urllib3>=2.0.0 diff --git a/setup-cron.sh b/setup-cron.sh new file mode 100755 index 0000000..b6d6fdc --- /dev/null +++ b/setup-cron.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Script pour configurer la tâche cron hebdomadaire +# Ce script ajoute une tâche cron pour exécuter le récapitulatif chaque dimanche à 18h00 + +SCRIPT_DIR="/opt/container/scripts" +SCRIPT_PATH="$SCRIPT_DIR/plex-weekly-recap.py" +CRON_JOB="0 18 * * 0 python3 $SCRIPT_PATH" + +# Vérifier si la tâche existe déjà +if crontab -l 2>/dev/null | grep -q "$SCRIPT_PATH"; then + echo "La tâche cron existe déjà." + echo "Pour la modifier, utilisez: crontab -e" +else + # Ajouter la tâche cron + (crontab -l 2>/dev/null; echo "$CRON_JOB") | crontab - + echo "Tâche cron ajoutée avec succès !" + echo "Le script s'exécutera chaque dimanche à 18h00" + echo "" + echo "Pour voir les tâches cron : crontab -l" + echo "Pour éditer les tâches cron : crontab -e" +fi diff --git a/sync_gitea_to_github.py b/sync_gitea_to_github.py new file mode 100755 index 0000000..8cf764a --- /dev/null +++ b/sync_gitea_to_github.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +""" +Script de synchronisation des dépôts Gitea vers GitHub +Synchronise tous les dépôts depuis http://192.168.1.29:3500/perco vers GitHub (percolouco) +""" + +import os +import sys +import json +import subprocess +import tempfile +import shutil +from urllib.request import urlopen, Request +from urllib.error import URLError, HTTPError + +# Configuration +GITEA_URL = "http://192.168.1.29:3500" +GITEA_USER = "perco" +GITHUB_USER = "percolouco" + +# Les tokens doivent être configurés comme secrets dans Cursor Dashboard +GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "") +GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "") + +def log(message): + """Affiche un message avec timestamp""" + from datetime import datetime + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"[{timestamp}] {message}") + +def api_request(url, token=None, method="GET", data=None): + """Effectue une requête HTTP avec gestion du token""" + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = f"token {token}" + if data: + headers["Content-Type"] = "application/json" + data = json.dumps(data).encode('utf-8') + + req = Request(url, headers=headers, data=data, method=method) + try: + with urlopen(req, timeout=30) as response: + return json.loads(response.read().decode('utf-8')) + except HTTPError as e: + log(f"Erreur HTTP {e.code} pour {url}: {e.read().decode('utf-8')}") + return None + except URLError as e: + log(f"Erreur URL pour {url}: {e.reason}") + return None + +def get_gitea_repos(): + """Liste tous les dépôts de l'utilisateur Gitea""" + log(f"Récupération des dépôts Gitea pour {GITEA_USER}...") + url = f"{GITEA_URL}/api/v1/repos/search?limit=50&owner={GITEA_USER}" + repos_data = api_request(url, token=GITEA_TOKEN) + repos = repos_data.get("data", []) if repos_data else None + + if repos is None: + log("Erreur lors de la récupération des dépôts Gitea") + return [] + + log(f"Trouvé {len(repos)} dépôts Gitea") + return repos + +def github_repo_exists(repo_name): + """Vérifie si un dépôt existe sur GitHub""" + url = f"https://api.github.com/repos/{GITHUB_USER}/{repo_name}" + result = api_request(url, token=GITHUB_TOKEN) + return result is not None + +def create_github_repo(repo_name, description="", is_private=False): + """Crée un dépôt sur GitHub""" + log(f"Création du dépôt GitHub: {repo_name}") + url = "https://api.github.com/user/repos" + data = { + "name": repo_name, + "description": description, + "private": is_private, + "auto_init": False + } + result = api_request(url, token=GITHUB_TOKEN, method="POST", data=data) + return result is not None + +def sync_repo(gitea_repo): + """Synchronise un dépôt Gitea vers GitHub en mode mirror""" + repo_name = gitea_repo["name"] + log(f"Début de la synchronisation: {repo_name}") + + # Construire l'URL clone avec token + clone_url = gitea_repo["clone_url"] + if GITEA_TOKEN: + # Insérer le token dans l'URL + clone_url = clone_url.replace("http://", f"http://oauth2:{GITEA_TOKEN}@") + + # URL GitHub avec token + github_url = f"https://{GITHUB_TOKEN}@github.com/{GITHUB_USER}/{repo_name}.git" + + # Créer le dépôt GitHub s'il n'existe pas + if not github_repo_exists(repo_name): + description = gitea_repo.get("description", "") + is_private = gitea_repo.get("private", False) + if not create_github_repo(repo_name, description, is_private): + log(f"Échec de la création du dépôt GitHub: {repo_name}") + return False + + # Créer un répertoire temporaire + temp_dir = tempfile.mkdtemp(prefix=f"gitea_sync_{repo_name}_") + + try: + log(f"Clone mirror depuis Gitea: {repo_name}") + # Clone en mode mirror + result = subprocess.run( + ["git", "clone", "--mirror", clone_url, temp_dir], + capture_output=True, + text=True, + timeout=300 + ) + + if result.returncode != 0: + log(f"Erreur lors du clone: {result.stderr}") + return False + + log(f"Push mirror vers GitHub: {repo_name}") + # Push en mode mirror vers GitHub (hors refs/pull/* non supportées par GitHub) + result = subprocess.run( + ["git", "push", "--prune", + github_url, + "+refs/heads/*:refs/heads/*", + "+refs/tags/*:refs/tags/*"], + cwd=temp_dir, + capture_output=True, + text=True, + timeout=300 + ) + + if result.returncode != 0: + log(f"Erreur lors du push: {result.stderr}") + return False + + log(f"✓ Synchronisation réussie: {repo_name}") + return True + + except subprocess.TimeoutExpired: + log(f"Timeout lors de la synchronisation de {repo_name}") + return False + except Exception as e: + log(f"Erreur lors de la synchronisation de {repo_name}: {e}") + return False + finally: + # Nettoyer le répertoire temporaire + try: + shutil.rmtree(temp_dir) + except Exception as e: + log(f"Erreur lors du nettoyage de {temp_dir}: {e}") + +def main(): + """Fonction principale""" + log("=== Début de la synchronisation Gitea → GitHub ===") + + # Vérifier les tokens + if not GITEA_TOKEN: + log("ATTENTION: GITEA_TOKEN non défini, l'accès aux dépôts privés peut échouer") + if not GITHUB_TOKEN: + log("ERREUR: GITHUB_TOKEN non défini (requis)") + sys.exit(1) + + # Récupérer la liste des dépôts + repos = get_gitea_repos() + if not repos: + log("Aucun dépôt à synchroniser") + return + + # Synchroniser chaque dépôt + success_count = 0 + fail_count = 0 + + for repo in repos: + if sync_repo(repo): + success_count += 1 + else: + fail_count += 1 + + # Résumé + log("=== Synchronisation terminée ===") + log(f"Réussis: {success_count}/{len(repos)}") + log(f"Échecs: {fail_count}/{len(repos)}") + + if fail_count > 0: + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/system-full-update.sh b/system-full-update.sh new file mode 100755 index 0000000..cf2204d --- /dev/null +++ b/system-full-update.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Script de mise à jour complète : Debian + npm + +set -euo pipefail + +echo "=========================================" +echo "Mise à jour système complète" +echo "Timestamp : $(date '+%Y-%m-%d %H:%M:%S')" +echo "=========================================" +echo "" + +# ===== CAPTURE VERSIONS AVANT ===== +echo "📊 Versions AVANT mise à jour :" +echo " npm: $(npm -v 2>/dev/null || echo 'N/A')" +echo "" + +# ===== DEBIAN UPDATES ===== +echo "🔄 Mise à jour Debian (hôte via nsenter)..." +NSENTER="nsenter --target 1 --mount --uts --ipc --net --pid -- env PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +$NSENTER dpkg --configure -a +$NSENTER apt update +$NSENTER apt full-upgrade -y +$NSENTER apt autoremove -y +$NSENTER apt autoclean +echo "" + +# ===== NPM UPDATES ===== +echo "🔄 Mise à jour npm globaux..." +npm install -g npm@latest || echo "⚠️ npm update skipped (non-fatal)" +echo "" + +# ===== CAPTURE VERSIONS APRÈS ===== +echo "📊 Versions APRÈS mise à jour :" +NEW_NPM=$(npm -v 2>/dev/null || echo 'N/A') +echo " npm: $NEW_NPM" +echo "" + +echo "=========================================" +echo "✅ Mise à jour complète terminée" +echo "=========================================" diff --git a/system-update-recap.py b/system-update-recap.py new file mode 100755 index 0000000..2a7382a --- /dev/null +++ b/system-update-recap.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +""" +Script de récapitulatif DÉTAILLÉ des mises à jour système +Affiche packages Debian mis à jour +""" + +import json +import sys +import os +import re +import argparse +from datetime import datetime +from typing import Dict, List, Optional, Tuple +import requests +from pathlib import Path + +CRONMASTER_LOGS_DIR = "/opt/container/cronmaster/data/logs" +CONFIG_FILE = os.path.join(os.path.dirname(__file__), 'system-update-config.json') +LOG_FILE = os.path.join(os.path.dirname(__file__), 'system-update-recap.log') + +def log(message: str, level: str = "INFO"): + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + log_message = f"[{timestamp}] [{level}] {message}\n" + try: + with open(LOG_FILE, 'a', encoding='utf-8') as f: + f.write(log_message) + except Exception: + pass + if level in ["ERROR", "INFO"]: + print(log_message.strip()) + +def load_config() -> Dict: + try: + with open(CONFIG_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + log(f"Erreur config: {e}", "ERROR") + sys.exit(1) + +def find_latest_update_job() -> Optional[Tuple[str, str]]: + log("Recherche du dernier job...") + logs_path = Path(CRONMASTER_LOGS_DIR) + if not logs_path.exists(): + return None + + latest_job = None + latest_time = None + + for job_dir in sorted(logs_path.iterdir(), reverse=True): + if not job_dir.is_dir(): + continue + + for log_file in sorted(job_dir.iterdir(), reverse=True): + if not log_file.suffix == '.log': + continue + + try: + try: + with open(log_file, 'r', encoding='utf-8') as f: + content = f.read() + except UnicodeDecodeError: + with open(log_file, 'r', encoding='latin-1') as f: + content = f.read() + + if 'apt update' in content or 'apt full-upgrade' in content or 'Mise à jour système complète' in content or 'system-full-update' in content: + timestamp_match = re.search(r'Timestamp\s*:\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})', content) + if timestamp_match: + timestamp_str = timestamp_match.group(1) + timestamp = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S") + + if latest_time is None or timestamp > latest_time: + latest_time = timestamp + latest_job = (job_dir.name, str(log_file)) + except Exception: + continue + + if latest_job: + log(f"Job trouvé: {latest_job[0]}") + return latest_job + +def parse_package_upgrades(content: str) -> List[Dict]: + packages = [] + + for line in content.split('\n'): + unpacking = re.search(r'Unpacking\s+([^\s]+)\s+\(([^)]+)\)\s+over\s+\(([^)]+)\)', line) + if unpacking: + packages.append({ + 'name': unpacking.group(1), + 'old_version': unpacking.group(3), + 'new_version': unpacking.group(2) + }) + continue + + setting_up = re.search(r'Paramétrage de\s+([^\s]+)\s+\(([^)]+)\)', line) + if setting_up: + pkg_name = setting_up.group(1) + pkg_version = setting_up.group(2) + if not any(p['name'] == pkg_name for p in packages): + packages.append({ + 'name': pkg_name, + 'old_version': None, + 'new_version': pkg_version + }) + + return packages + +def parse_update_log(log_file_path: str) -> Dict: + log(f"Parsing: {log_file_path}") + + try: + try: + with open(log_file_path, 'r', encoding='utf-8') as f: + content = f.read() + except UnicodeDecodeError: + with open(log_file_path, 'r', encoding='latin-1') as f: + content = f.read() + except Exception as e: + log(f"Erreur lecture: {e}", "ERROR") + return {} + + result = { + 'timestamp': None, + 'duration': None, + 'exit_code': None, + 'status': None, + 'packages_upgraded': 0, + 'package_details': [] + } + + timestamp_match = re.search(r'Timestamp\s*:\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})', content) + if timestamp_match: + result['timestamp'] = timestamp_match.group(1) + + duration_match = re.search(r'Duration\s*:\s*(\d+)s', content) + if duration_match: + result['duration'] = int(duration_match.group(1)) + + exit_code_match = re.search(r'Exit Code\s*:\s*(\d+)', content) + if exit_code_match: + result['exit_code'] = int(exit_code_match.group(1)) + + status_match = re.search(r'Status\s*:\s*(\w+)', content) + if status_match: + result['status'] = status_match.group(1) + + result['package_details'] = parse_package_upgrades(content) + result['packages_upgraded'] = len(result['package_details']) + + return result + +def send_to_discord(config: Dict, update_info: Dict) -> bool: + log("Envoi Discord...") + + discord_config = config.get('discord', {}) + webhook_url = discord_config.get('webhook_url', '') + + if not webhook_url or webhook_url == "WEBHOOK_URL_HERE": + log("Webhook non configuré", "ERROR") + return False + + try: + status_emoji = "✅" if update_info['status'] == 'SUCCESS' else "❌" + duration_str = f"{update_info['duration']}s" if update_info['duration'] < 60 else f"{update_info['duration']//60}m {update_info['duration']%60}s" + + fields = [] + + fields.append({ + "name": "📊 Status", + "value": f"{status_emoji} {update_info['status']}\n⏱️ {duration_str}", + "inline": True + }) + + pkg_details = update_info.get('package_details', []) + if pkg_details: + pkg_list = [] + for pkg in pkg_details[:10]: + if pkg.get('old_version'): + pkg_list.append(f"• {pkg['name']}: {pkg['old_version']} → {pkg['new_version']}") + else: + pkg_list.append(f"• {pkg['name']}: {pkg['new_version']}") + + if len(pkg_details) > 10: + pkg_list.append(f"... et {len(pkg_details) - 10} autres") + + fields.append({ + "name": f"📦 Packages Debian ({len(pkg_details)} mis à jour)", + "value": "\n".join(pkg_list), + "inline": False + }) + else: + fields.append({ + "name": "📦 Packages Debian", + "value": "Aucune mise à jour", + "inline": False + }) + + embed = { + "title": f"{status_emoji} Mise à jour système complète", + "description": f"Exécutée le {update_info['timestamp']}", + "color": 5814783, + "fields": fields, + "timestamp": datetime.now().isoformat(), + "footer": {"text": "Récapitulatif détaillé système"} + } + + response = requests.post(webhook_url, json={"embeds": [embed]}, timeout=10) + response.raise_for_status() + + log("Envoyé ✅") + return True + + except Exception as e: + log(f"Erreur Discord: {e}", "ERROR") + return False + +def main(): + parser = argparse.ArgumentParser(description='Récap détaillé mises à jour') + parser.add_argument('--job-id', type=str) + parser.add_argument('--auto', action='store_true') + args = parser.parse_args() + + config = load_config() + + if args.job_id: + job_dir = Path(CRONMASTER_LOGS_DIR) / args.job_id + log_files = sorted(job_dir.glob('*.log'), reverse=True) + log_file = str(log_files[0]) if log_files else None + else: + result = find_latest_update_job() + if not result: + log("Aucun job trouvé", "ERROR") + sys.exit(1) + _, log_file = result + + if not log_file: + log("Log introuvable", "ERROR") + sys.exit(1) + + update_info = parse_update_log(log_file) + if not update_info.get('timestamp'): + log("Parse impossible", "ERROR") + sys.exit(1) + + success = send_to_discord(config, update_info) + sys.exit(0 if success else 1) + +if __name__ == "__main__": + main() diff --git a/test-config.py b/test-config.py new file mode 100755 index 0000000..8e367cd --- /dev/null +++ b/test-config.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +""" +Script de test pour valider la configuration du récapitulatif Plex +""" + +import json +import sys +import os +import requests + +CONFIG_FILE = os.path.join(os.path.dirname(__file__), 'plex-recap-config.json') + +def test_config(): + """Teste la configuration""" + print("🔍 Test de la configuration...\n") + + # Charger la configuration + try: + with open(CONFIG_FILE, 'r', encoding='utf-8') as f: + config = json.load(f) + except FileNotFoundError: + print(f"❌ Fichier de configuration non trouvé: {CONFIG_FILE}") + return False + except json.JSONDecodeError as e: + print(f"❌ Erreur de parsing JSON: {e}") + return False + + print("✅ Fichier de configuration chargé\n") + + # Test Discord webhook + discord_config = config.get('discord', {}) + webhook_url = discord_config.get('webhook_url', '') + if not webhook_url: + print("⚠️ URL du webhook Discord non configurée") + print(" Veuillez ajouter l'URL dans plex-recap-config.json\n") + else: + print(f"✅ Webhook Discord configuré: {webhook_url[:50]}...") + # Test de connexion + try: + response = requests.get(webhook_url, timeout=5) + print("✅ Webhook Discord accessible\n") + except requests.exceptions.RequestException as e: + print(f"⚠️ Impossible de tester le webhook: {e}\n") + + # Test Jellyseerr + jellyseerr_config = config.get('jellyseerr', {}) + jellyseerr_url = jellyseerr_config.get('url', '') + jellyseerr_key = jellyseerr_config.get('api_key', '') + + if not jellyseerr_key: + print("⚠️ Clé API Jellyseerr non configurée\n") + else: + print(f"✅ Jellyseerr configuré: {jellyseerr_url}") + try: + headers = {'X-Api-Key': jellyseerr_key} + response = requests.get( + f"{jellyseerr_url}/api/v1/status", + headers=headers, + timeout=5 + ) + if response.status_code == 200: + print("✅ Jellyseerr API accessible\n") + else: + print(f"⚠️ Jellyseerr API retourne le code: {response.status_code}\n") + except requests.exceptions.RequestException as e: + print(f"⚠️ Impossible d'accéder à Jellyseerr: {e}\n") + + # Test Radarr + radarr_config = config.get('radarr', {}) + radarr_host = radarr_config.get('hostname', '') + radarr_port = radarr_config.get('port', 7878) + radarr_key = radarr_config.get('api_key', '') + use_ssl = radarr_config.get('use_ssl', False) + + if not radarr_key: + print("⚠️ Clé API Radarr non configurée\n") + else: + protocol = 'https' if use_ssl else 'http' + radarr_url = f"{protocol}://{radarr_host}:{radarr_port}" + print(f"✅ Radarr configuré: {radarr_url}") + try: + params = {'apikey': radarr_key} + response = requests.get( + f"{radarr_url}/api/v3/system/status", + params=params, + timeout=5 + ) + if response.status_code == 200: + print("✅ Radarr API accessible\n") + else: + print(f"⚠️ Radarr API retourne le code: {response.status_code}\n") + except requests.exceptions.RequestException as e: + print(f"⚠️ Impossible d'accéder à Radarr: {e}\n") + + # Test Sonarr + sonarr_config = config.get('sonarr', {}) + sonarr_host = sonarr_config.get('hostname', '') + sonarr_port = sonarr_config.get('port', 8989) + sonarr_key = sonarr_config.get('api_key', '') + use_ssl = sonarr_config.get('use_ssl', False) + + if not sonarr_key: + print("⚠️ Clé API Sonarr non configurée\n") + else: + protocol = 'https' if use_ssl else 'http' + sonarr_url = f"{protocol}://{sonarr_host}:{sonarr_port}" + print(f"✅ Sonarr configuré: {sonarr_url}") + try: + params = {'apikey': sonarr_key} + response = requests.get( + f"{sonarr_url}/api/v3/system/status", + params=params, + timeout=5 + ) + if response.status_code == 200: + print("✅ Sonarr API accessible\n") + else: + print(f"⚠️ Sonarr API retourne le code: {response.status_code}\n") + except requests.exceptions.RequestException as e: + print(f"⚠️ Impossible d'accéder à Sonarr: {e}\n") + + print("\n✅ Test de configuration terminé") + return True + +if __name__ == "__main__": + test_config() diff --git a/trim-cronmaster-logs.sh b/trim-cronmaster-logs.sh new file mode 100755 index 0000000..c4ea559 --- /dev/null +++ b/trim-cronmaster-logs.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Garde uniquement les 50 derniers logs par job cronmaster +LOGS_DIR="/cronmaster-data/logs" +MAX_LOGS=50 + +for dir in "$LOGS_DIR"/*/; do + [ -d "$dir" ] || continue + count=$(ls "$dir" | wc -l) + if [ "$count" -gt "$MAX_LOGS" ]; then + ls -t "$dir" | tail -n +"$((MAX_LOGS + 1))" | while read f; do + rm -f "$dir$f" + done + fi +done