Files
tmdb-radarr-tag/script.py
laurent 5f3b68cedf Initial commit: Tagueur Radarr-TMDB pour spectacles vivants
- Detection automatique des spectacles vivants (stand-up, theatre, one-man shows)
- Exclusion explicite des concerts de musique
- Gestion securisee des secrets dans ../.env.global
- Mode dry-run par defaut avec option --apply
- Export CSV des resultats
- Documentation complete en francais
- Checklist pre-commit incluse
2026-02-22 12:31:54 +01:00

494 lines
16 KiB
Python

#!/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/<movie_id>" ')
print(' -H "X-Api-Key: <API_KEY>" ')
print(' -H "Content-Type: application/json" ')
print(' -d "{\\"tags\\": []}"')
print('='*60)
print()
if __name__ == '__main__':
main()