#!/usr/bin/env python3 # -*- coding: utf-8 -*- import os import sys import csv import argparse import logging import getpass from pathlib import Path from typing import Dict, List, Optional, Tuple from dataclasses import dataclass import requests import yaml from dotenv import load_dotenv DEFAULT_CONFIG = { 'tag_name': 'spectacle', 'dry_run': True, 'limit': 5, 'min_runtime': 15, 'max_runtime': 240, 'extra_keywords': [ 'stand', 'stand-up', 'standup', 'one man', 'one-man', 'one woman', 'one-woman', 'theatre', 'théâtre', 'play', 'piece', 'monologue', 'cabaret', 'sketch', 'performance', 'spoken word', 'variety', 'revue', 'comedy special' ], 'exclude_keywords': [ 'concert', 'music', 'live concert', 'music video', 'festival', 'musician', 'band' ], 'detection_mode': 'loose', 'output_csv': 'results_spectacle_dryrun.csv', 'log_level': 'INFO' } def get_env_file_path() -> Path: script_dir = Path(__file__).parent.resolve() parent_dir = script_dir.parent return parent_dir / '.env.global' def prompt_for_secrets(): print() print('='*60) print('Configuration initiale - Creation de ../.env.global') print('='*60) print() print('Ce fichier stockera vos secrets en dehors du repo Git.') print('Il ne sera JAMAIS commite.') print() secrets = {} secrets['RADARR_URL'] = input('Radarr URL (ex: http://localhost:7878): ').strip() secrets['RADARR_APIKEY'] = getpass.getpass('Radarr API Key: ').strip() secrets['TMDB_APIKEY'] = getpass.getpass('TMDB API Key: ').strip() return secrets def create_env_file(env_path: Path, secrets) -> bool: try: env_path.parent.mkdir(parents=True, exist_ok=True) with open(env_path, 'w', encoding='utf-8') as f: f.write('# Fichier de secrets - Ne JAMAIS commiter ce fichier ! ') f.write('RADARR_URL="' + secrets['RADARR_URL'] + '" ') f.write('RADARR_APIKEY="' + secrets['RADARR_APIKEY'] + '" ') f.write('TMDB_APIKEY="' + secrets['TMDB_APIKEY'] + '" ') try: os.chmod(env_path, 0o600) except: pass print() print('Fichier cree: ' + str(env_path)) print('Secrets sauvegardes (fichier protege)') print() return True except Exception as e: print() print('Erreur lors de la creation du fichier: ' + str(e)) print() return False def load_secrets(): env_path = get_env_file_path() if not env_path.exists(): print() print('Fichier non trouve: ' + str(env_path)) secrets = prompt_for_secrets() if not all(secrets.values()): print() print('Erreur: Tous les champs sont obligatoires.') return None if create_env_file(env_path, secrets): load_dotenv(env_path) else: return None else: load_dotenv(env_path) required_vars = ['RADARR_URL', 'RADARR_APIKEY', 'TMDB_APIKEY'] secrets = {} for var in required_vars: value = os.getenv(var) if not value: print() print('Erreur: Variable manquante dans ' + str(env_path) + ': ' + var) print('Verifiez votre fichier ou supprimez-le pour le recreer.') print() return None secrets[var] = value return secrets def load_config(config_path='config.yaml'): config = DEFAULT_CONFIG.copy() if os.path.exists(config_path): try: with open(config_path, 'r', encoding='utf-8') as f: user_config = yaml.safe_load(f) if user_config: config.update(user_config) logging.info('Configuration chargee depuis ' + config_path) except Exception as e: logging.warning('Impossible de charger ' + config_path + ': ' + str(e)) logging.warning('Utilisation de la configuration par defaut') else: logging.info(config_path + ' non trouve, utilisation de la config par defaut') return config @dataclass class MovieMatch: radarr_id: int tmdb_id: int title: str year: int runtime: int reasons: List[str] score: float class RadarrAPI: def __init__(self, base_url, api_key): self.base_url = base_url.rstrip('/') self.api_key = api_key self.session = requests.Session() self.session.headers.update({ 'X-Api-Key': api_key, 'Content-Type': 'application/json' }) def _request(self, method, endpoint, **kwargs): url = self.base_url + '/api/v3' + endpoint for attempt in range(3): try: response = self.session.request(method, url, timeout=30, **kwargs) response.raise_for_status() return response except requests.exceptions.Timeout: if attempt == 2: raise logging.warning('Timeout, tentative ' + str(attempt + 2) + '/3...') except requests.exceptions.RequestException: if attempt == 2: raise logging.warning('Erreur reseau, tentative ' + str(attempt + 2) + '/3...') def ensure_tag_exists(self, tag_name): response = self._request('GET', '/tag') tags = response.json() for tag in tags: if tag['label'] == tag_name: logging.debug('Tag "' + tag_name + '" trouve avec ID ' + str(tag['id'])) return tag['id'] logging.info('Creation du tag "' + tag_name + '"...') response = self._request('POST', '/tag', json={'label': tag_name}) new_tag = response.json() logging.info('Tag cree avec ID ' + str(new_tag['id'])) return new_tag['id'] def get_movies(self, limit=None): logging.info('Recuperation des films depuis Radarr...') response = self._request('GET', '/movie') movies = response.json() if limit: movies = movies[:limit] logging.info('Limite a ' + str(limit) + ' films') logging.info(str(len(movies)) + ' films recuperes') return movies def add_tag_to_movie(self, movie_id, tag_id): response = self._request('GET', '/movie/' + str(movie_id)) movie = response.json() current_tags = movie.get('tags', []) if tag_id in current_tags: logging.debug('Tag deja present sur le film ' + str(movie_id)) return current_tags.append(tag_id) movie['tags'] = current_tags self._request('PUT', '/movie', json=movie) logging.info('Tag ajoute au film ' + str(movie_id)) class TMDBAPI: def __init__(self, api_key): self.api_key = api_key self.base_url = 'https://api.themoviedb.org/3' self.session = requests.Session() def _request(self, endpoint, **kwargs): url = self.base_url + endpoint params = kwargs.pop('params', {}) params['api_key'] = self.api_key for attempt in range(3): try: response = self.session.get(url, params=params, timeout=30, **kwargs) if response.status_code == 429: logging.warning('Rate limit TMDB atteint, attente...') import time time.sleep(1) continue response.raise_for_status() return response except requests.exceptions.Timeout: if attempt == 2: raise logging.warning('Timeout TMDB, tentative ' + str(attempt + 2) + '/3...') except requests.exceptions.RequestException: if attempt == 2: raise logging.warning('Erreur TMDB, tentative ' + str(attempt + 2) + '/3...') def get_movie_details(self, tmdb_id): response = self._request('/movie/' + str(tmdb_id)) return response.json() def get_movie_keywords(self, tmdb_id): try: response = self._request('/movie/' + str(tmdb_id) + '/keywords') data = response.json() return [kw['name'].lower() for kw in data.get('keywords', [])] except: return [] def detect_spectacle(movie_data, keywords, config): reasons = [] score = 0.0 title = (movie_data.get('title', '') or '').lower() overview = (movie_data.get('overview', '') or '').lower() runtime = movie_data.get('runtime', 0) or 0 # Exclusions prioritaires text_to_check = title + ' ' + overview + ' ' + ' '.join(keywords) for exclude_kw in config['exclude_keywords']: if exclude_kw.lower() in text_to_check: return False, ['Exclusion: "' + exclude_kw + '" detecte'], 0.0 # Verification du runtime min_runtime = config['min_runtime'] max_runtime = config['max_runtime'] if min_runtime <= runtime <= max_runtime: score += 0.3 reasons.append('Runtime OK (' + str(runtime) + ' min)') elif runtime > 0: score -= 0.2 reasons.append('Runtime hors plage (' + str(runtime) + ' min)') # Verification des mots-cles extra_keywords = [kw.lower() for kw in config['extra_keywords']] for kw in extra_keywords: if kw in title: score += 0.4 reasons.append('Keyword dans titre: "' + kw + '"') break for kw in extra_keywords: if kw in overview: score += 0.3 reasons.append('Keyword dans synopsis: "' + kw + '"') break for kw in extra_keywords: if any(kw in tmdb_kw for tmdb_kw in keywords): score += 0.3 reasons.append('Keyword TMDB: "' + kw + '"') break # Decision finale detection_mode = config.get('detection_mode', 'loose') if detection_mode == 'strict': is_spectacle = (score >= 0.6) and (min_runtime <= runtime <= max_runtime) else: is_spectacle = score >= 0.4 return is_spectacle, reasons, score def export_to_csv(matches, output_path): with open(output_path, 'w', newline='', encoding='utf-8') as f: writer = csv.writer(f) writer.writerow(['Radarr ID', 'TMDB ID', 'Titre', 'Annee', 'Duree (min)', 'Score', 'Raisons']) for match in matches: writer.writerow([ match.radarr_id, match.tmdb_id, match.title, match.year, match.runtime, '{:.2f}'.format(match.score), ' | '.join(match.reasons) ]) logging.info('Resultats exportes dans ' + output_path) def apply_tags(matches, radarr, tag_id, dry_run): if dry_run: logging.info('') logging.info('[DRY-RUN] ' + str(len(matches)) + ' films seraient tagues:') for match in matches: logging.info(' - ' + match.title + ' (' + str(match.year) + ')') return logging.info('') logging.info('Application du tag a ' + str(len(matches)) + ' films...') for match in matches: try: radarr.add_tag_to_movie(match.radarr_id, tag_id) logging.info(' ' + match.title + ' (' + str(match.year) + ')') except Exception as e: logging.error(' Erreur sur ' + match.title + ': ' + str(e)) def main(): parser = argparse.ArgumentParser( description='Tagueur automatique Radarr TMDB pour spectacles vivants' ) parser.add_argument('--limit', type=int, help='Limite le nombre de films') parser.add_argument('--apply', action='store_true', help='Applique les tags (sinon dry-run)') parser.add_argument('--config', default='config.yaml', help='Chemin vers config.yaml') parser.add_argument('--verbose', '-v', action='store_true', help='Mode verbeux') args = parser.parse_args() config = load_config(args.config) if args.limit is not None: config['limit'] = args.limit if args.apply: config['dry_run'] = False if args.verbose: config['log_level'] = 'DEBUG' logging.basicConfig( level=getattr(logging, config['log_level']), format='%(asctime)s - %(levelname)s - %(message)s', datefmt='%H:%M:%S' ) logging.info('Chargement des secrets depuis ../.env.global...') secrets = load_secrets() if not secrets: print() print('='*60) print('ERREUR: Impossible de charger les secrets') print('='*60) print() print('Veuillez creer le fichier: ' + str(get_env_file_path())) print() print('Format attendu:') print('RADARR_URL="http://localhost:7878"') print('RADARR_APIKEY="votre-cle-radarr"') print('TMDB_APIKEY="votre-cle-tmdb"') print() print('Ou relancez le script pour creer ce fichier interactivement.') print() sys.exit(1) logging.info('Secrets charges (valeurs masquees)') try: radarr = RadarrAPI(secrets['RADARR_URL'], secrets['RADARR_APIKEY']) tmdb = TMDBAPI(secrets['TMDB_APIKEY']) except Exception as e: logging.error('Erreur d'initialisation: ' + str(e)) sys.exit(1) try: tag_id = radarr.ensure_tag_exists(config['tag_name']) except Exception as e: logging.error('Erreur lors de la creation du tag: ' + str(e)) sys.exit(1) try: movies = radarr.get_movies(config['limit']) except Exception as e: logging.error('Erreur lors de la recuperation des films: ' + str(e)) sys.exit(1) matches = [] logging.info('') logging.info('Analyse des ' + str(len(movies)) + ' films...') logging.info('-' * 60) for i, movie in enumerate(movies, 1): tmdb_id = movie.get('tmdbId') if not tmdb_id: logging.debug('[' + str(i) + '/' + str(len(movies)) + '] ' + movie.get('title', '') + ' - Pas de TMDB ID, ignore') continue try: movie_data = tmdb.get_movie_details(tmdb_id) keywords = tmdb.get_movie_keywords(tmdb_id) is_spectacle, reasons, score = detect_spectacle(movie_data, keywords, config) if is_spectacle: match = MovieMatch( radarr_id=movie['id'], tmdb_id=tmdb_id, title=movie['title'], year=movie.get('year', 0), runtime=movie_data.get('runtime', 0), reasons=reasons, score=score ) matches.append(match) logging.info('[' + str(i) + '/' + str(len(movies)) + '] ' + movie['title'] + ' (' + str(movie.get('year', 0)) + ') - SCORE: {:.2f}'.format(score)) for reason in reasons: logging.info(' -> ' + reason) else: logging.debug('[' + str(i) + '/' + str(len(movies)) + '] ' + movie['title'] + ' - Pas un spectacle ({:.2f})'.format(score)) except Exception as e: logging.warning('[' + str(i) + '/' + str(len(movies)) + '] Erreur sur ' + movie.get('title', '') + ': ' + str(e)) continue logging.info('-' * 60) logging.info('') logging.info(str(len(matches)) + ' spectacles detectes sur ' + str(len(movies)) + ' films analyses') if matches: export_to_csv(matches, config['output_csv']) apply_tags(matches, radarr, tag_id, config['dry_run']) else: logging.info('Aucun spectacle detecte.') if not config['dry_run'] and matches: print() print('='*60) print('ROLLBACK - Pour retirer le tag en cas d'erreur:') print('='*60) print('Liste des films tagues sauvegardee dans: ' + config['output_csv']) print('Pour retirer le tag "' + config['tag_name'] + '" d'un film:') print('curl -X PUT "' + secrets['RADARR_URL'] + '/api/v3/movie/" ') print(' -H "X-Api-Key: " ') print(' -H "Content-Type: application/json" ') print(' -d "{\\"tags\\": []}"') print('='*60) print() if __name__ == '__main__': main()