Initial commit: homelab scripts (backup, system update, plex recap, cronmaster trim)
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.log
|
||||||
|
system-update-config.json
|
||||||
@@ -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
|
||||||
Executable
+61
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script wrapper pour envoyer uniquement les films
|
||||||
|
python3 /opt/container/scripts/plex-weekly-recap.py --movies
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script wrapper pour envoyer uniquement les demandes approuvées
|
||||||
|
python3 /opt/container/scripts/plex-weekly-recap.py --requests
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script wrapper pour envoyer uniquement les séries
|
||||||
|
python3 /opt/container/scripts/plex-weekly-recap.py --series
|
||||||
Executable
+797
@@ -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()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
requests>=2.31.0
|
||||||
|
urllib3>=2.0.0
|
||||||
Executable
+21
@@ -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
|
||||||
Executable
+192
@@ -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()
|
||||||
Executable
+40
@@ -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 "========================================="
|
||||||
Executable
+248
@@ -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()
|
||||||
Executable
+126
@@ -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()
|
||||||
Executable
+14
@@ -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
|
||||||
Reference in New Issue
Block a user