From 534b6d9455b4180c196c0960cf0ac357b0381996 Mon Sep 17 00:00:00 2001 From: lionel <.> Date: Mon, 12 May 2025 13:28:19 +0200 Subject: [PATCH] Ajout de HandBrake_recursive proxmox_export_disk tree_stream --- HandBrake_recursive/.gitignore | 6 + HandBrake_recursive/.idea/.gitignore | 3 + .../.idea/HandBrake_recursive.iml | 14 + .../inspectionProfiles/Project_Default.xml | 6 + .../inspectionProfiles/profiles_settings.xml | 6 + HandBrake_recursive/.idea/misc.xml | 7 + HandBrake_recursive/.idea/modules.xml | 8 + HandBrake_recursive/HandBrake_recursive.py | 468 ++++++++++++++++++ HandBrake_recursive/requirements.txt | 2 + HandBrake_recursive/setup.py | 18 + proxmox_export_disk/.gitignore | 3 + proxmox_export_disk/.idea/.gitignore | 3 + .../inspectionProfiles/Project_Default.xml | 6 + .../inspectionProfiles/profiles_settings.xml | 6 + proxmox_export_disk/.idea/misc.xml | 7 + proxmox_export_disk/.idea/modules.xml | 8 + .../.idea/proxmox_export_disk.iml | 10 + proxmox_export_disk/proxmox_export_disk.py | 398 +++++++++++++++ proxmox_export_disk/setup.py | 17 + proxmox_export_disk/temps-exec | 19 + tree_stream/.gitignore | 3 + tree_stream/.idea/.gitignore | 3 + .../inspectionProfiles/Project_Default.xml | 6 + .../inspectionProfiles/profiles_settings.xml | 6 + tree_stream/.idea/misc.xml | 7 + tree_stream/.idea/modules.xml | 8 + tree_stream/.idea/tree_stream.iml | 10 + tree_stream/pyproject.toml | 3 + tree_stream/requirements.txt | 1 + tree_stream/setup.py | 22 + tree_stream/tree_stream/__init__.py | 1 + tree_stream/tree_stream/main.py | 162 ++++++ 32 files changed, 1247 insertions(+) create mode 100644 HandBrake_recursive/.gitignore create mode 100644 HandBrake_recursive/.idea/.gitignore create mode 100644 HandBrake_recursive/.idea/HandBrake_recursive.iml create mode 100644 HandBrake_recursive/.idea/inspectionProfiles/Project_Default.xml create mode 100644 HandBrake_recursive/.idea/inspectionProfiles/profiles_settings.xml create mode 100644 HandBrake_recursive/.idea/misc.xml create mode 100644 HandBrake_recursive/.idea/modules.xml create mode 100644 HandBrake_recursive/HandBrake_recursive.py create mode 100644 HandBrake_recursive/requirements.txt create mode 100644 HandBrake_recursive/setup.py create mode 100644 proxmox_export_disk/.gitignore create mode 100644 proxmox_export_disk/.idea/.gitignore create mode 100644 proxmox_export_disk/.idea/inspectionProfiles/Project_Default.xml create mode 100644 proxmox_export_disk/.idea/inspectionProfiles/profiles_settings.xml create mode 100644 proxmox_export_disk/.idea/misc.xml create mode 100644 proxmox_export_disk/.idea/modules.xml create mode 100644 proxmox_export_disk/.idea/proxmox_export_disk.iml create mode 100644 proxmox_export_disk/proxmox_export_disk.py create mode 100644 proxmox_export_disk/setup.py create mode 100644 proxmox_export_disk/temps-exec create mode 100644 tree_stream/.gitignore create mode 100644 tree_stream/.idea/.gitignore create mode 100644 tree_stream/.idea/inspectionProfiles/Project_Default.xml create mode 100644 tree_stream/.idea/inspectionProfiles/profiles_settings.xml create mode 100644 tree_stream/.idea/misc.xml create mode 100644 tree_stream/.idea/modules.xml create mode 100644 tree_stream/.idea/tree_stream.iml create mode 100644 tree_stream/pyproject.toml create mode 100644 tree_stream/requirements.txt create mode 100644 tree_stream/setup.py create mode 100644 tree_stream/tree_stream/__init__.py create mode 100644 tree_stream/tree_stream/main.py diff --git a/HandBrake_recursive/.gitignore b/HandBrake_recursive/.gitignore new file mode 100644 index 0000000..d7c6ecf --- /dev/null +++ b/HandBrake_recursive/.gitignore @@ -0,0 +1,6 @@ +build +résumé_P#.xlsx +résumé_torrent.xlsx +résumé.xlsx +HandBrake_recursive.egg-info/ +.venv/ diff --git a/HandBrake_recursive/.idea/.gitignore b/HandBrake_recursive/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/HandBrake_recursive/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/HandBrake_recursive/.idea/HandBrake_recursive.iml b/HandBrake_recursive/.idea/HandBrake_recursive.iml new file mode 100644 index 0000000..d1268a1 --- /dev/null +++ b/HandBrake_recursive/.idea/HandBrake_recursive.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/HandBrake_recursive/.idea/inspectionProfiles/Project_Default.xml b/HandBrake_recursive/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..cd83845 --- /dev/null +++ b/HandBrake_recursive/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/HandBrake_recursive/.idea/inspectionProfiles/profiles_settings.xml b/HandBrake_recursive/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/HandBrake_recursive/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/HandBrake_recursive/.idea/misc.xml b/HandBrake_recursive/.idea/misc.xml new file mode 100644 index 0000000..799d018 --- /dev/null +++ b/HandBrake_recursive/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/HandBrake_recursive/.idea/modules.xml b/HandBrake_recursive/.idea/modules.xml new file mode 100644 index 0000000..af2df4e --- /dev/null +++ b/HandBrake_recursive/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/HandBrake_recursive/HandBrake_recursive.py b/HandBrake_recursive/HandBrake_recursive.py new file mode 100644 index 0000000..8330341 --- /dev/null +++ b/HandBrake_recursive/HandBrake_recursive.py @@ -0,0 +1,468 @@ +#!/usr/bin/env python3 + +import os +import sys +import mimetypes +import subprocess +import shutil +import math +import json +import argparse +import ffmpeg + +import openpyxl +from openpyxl.styles import PatternFill +from openpyxl.formatting.rule import CellIsRule + +# Définir la largeur de chaque colonne à environ 3 cm (approximativement 10.5 unités) +def set_column_width(ws, column_range, width=15): + for col in column_range: + ws.column_dimensions[col].width = width + +def export_xlsx(lignes, fichier_sortie="résumé.xlsx"): + # Créer un nouveau classeur et sélectionner la feuille active + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Résumé" + + # En-têtes + headers = ["Fichier", "Codec", "Résolution", "Action", "Durée", "Taille SRC", "Bitrate SRC", + "Taille DES", "Bitrate DES", "Gain %", "Résultat"] + ws.append(headers) + ws.auto_filter.ref = "A1:K1" + ws.freeze_panes = "B2" + + # Appliquer à toutes les colonnes + set_column_width(ws, ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K']) + + # Styles pour les cellules + styles = { + "Réussi": PatternFill(start_color="d4f5dd", end_color="d4f5dd", fill_type="solid"), + "Erreur": PatternFill(start_color="f8d7da", end_color="f8d7da", fill_type="solid"), + "Interrompu": PatternFill(start_color="fdd692", end_color="fdd692", fill_type="solid"), + "Existant": PatternFill(start_color="dbeff7", end_color="dbeff7", fill_type="solid"), + "Simulé": PatternFill(start_color="d1e7ff", end_color="d1e7ff", fill_type="solid"), + "Réencodé": PatternFill(start_color="e9d5e5", end_color="e9d5e5", fill_type="solid"), + "Copié": PatternFill(start_color="fff3cd", end_color="fff3cd", fill_type="solid"), + "Gain > 50%": PatternFill(start_color="d4f5dd", end_color="d4f5dd", fill_type="solid"), + "Gain > 30%": PatternFill(start_color="fff3cd", end_color="fff3cd", fill_type="solid"), + "Gain < 30%": PatternFill(start_color="f8d7da", end_color="f8d7da", fill_type="solid"), + "Taille <": PatternFill(start_color="d4f5dd", end_color="d4f5dd", fill_type="solid"), + "Taille =": PatternFill(start_color="fff3cd", end_color="fff3cd", fill_type="solid"), + "Taille >": PatternFill(start_color="f8d7da", end_color="f8d7da", fill_type="solid"), + } + + # Données + for ligne in lignes: + file, codec, resolution, action, duration, input_size, output_size, input_bitrate, output_bitrate, result = ligne + + gain_percent = calc_gain_percent(input_size, output_size, raw=True) + + row = [ + file, # string + codec, # string + resolution, # string + action, # string + duration / 86400 if isinstance(duration, (float, int)) else duration, # float fraction de jour + input_size / 1024 ** 2, # int (octets) + input_bitrate, # int + output_size / 1024 ** 2, # int + output_bitrate, # int + gain_percent, # float (vrai pourcentage) + result # string + ] + ws.append(row) + nb_lignes = ws.max_row + + for row in ws.iter_rows(min_row=2, max_col=len(headers)): # skip header + row[4].number_format = '[h]"h"mm"m"ss"s"' # Excel-style format + row[5].number_format = '#,##0 "Mo"' # Taille SRC + row[6].number_format = '#,##0 "kb/s"' # Bitrate SRC + row[7].number_format = '#,##0 "Mo"' # Taille DES + row[8].number_format = '#,##0 "kb/s"' # Bitrate DES + row[9].number_format = '0.0%' # Gain % + + # Appliquer couleurs sur colonne Action (colonne D = index 3) + for row in ws.iter_rows(min_row=2, min_col=4, max_col=4, max_row=ws.max_row): + cell = row[0] + action = cell.value + if action in styles: + for col_index in [0, 1, 2, 3, 4]: # Colonnes Fichier, Codec, Résolution, Action, Durée + ws.cell(row=cell.row, column=col_index + 1).fill = styles[action] + + # Appliquer couleurs sur colonne Résultat (colonne K = index 10) + for row in ws.iter_rows(min_row=2, min_col=11, max_col=11, max_row=ws.max_row): + cell = row[0] + resultat = cell.value + if resultat in styles: + cell.fill = styles[resultat] + + # Ajouter la mise en forme conditionnelle + # Taille DES + ws.conditional_formatting.add(f'H2:H{nb_lignes}', CellIsRule(operator='lessThan', formula=[f'F2:F{nb_lignes}'], stopIfTrue=True, fill=styles["Taille <"])) + ws.conditional_formatting.add(f'H2:H{nb_lignes}', CellIsRule(operator='equal', formula=[f'F2:F{nb_lignes}'], stopIfTrue=True, fill=styles["Taille ="])) + ws.conditional_formatting.add(f'H2:H{nb_lignes}', CellIsRule(operator='greaterThan', formula=[f'F2:F{nb_lignes}'], stopIfTrue=True, fill=styles["Taille >"])) + + # Gain % + ws.conditional_formatting.add(f'J2:J{nb_lignes}', CellIsRule(operator='greaterThan', formula=['50%'], stopIfTrue=True, fill=styles["Gain > 50%"])) + ws.conditional_formatting.add(f'J2:J{nb_lignes}', CellIsRule(operator='greaterThan', formula=['30%'], stopIfTrue=True, fill=styles["Gain > 30%"])) + ws.conditional_formatting.add(f'J2:J{nb_lignes}', CellIsRule(operator='lessThan', formula=['30%'], stopIfTrue=True, fill=styles["Gain < 30%"])) + + # Sauvegarder le fichier + wb.save(fichier_sortie) + +def human_readable_size(size_bytes): + """Convertit une taille en octets en une taille lisible (Ko, Mo, Go).""" + if size_bytes == 0: + return "0 B" + size_name = ("B", "KB", "MB", "GB", "TB") + i = int(math.floor(math.log(size_bytes, 1024))) + p = math.pow(1024, i) + s = round(size_bytes / p, 2) + return f"{s} {size_name[i]}" + +def format_duration(seconds): + """Formate la durée en h/m/s lisible""" + if not isinstance(seconds, (float, int)) or seconds <= 0: + return "" + else: + return f"{int(seconds // 3600)}h" * (seconds >= 3600) + f"{int((seconds % 3600) // 60)}m" * (seconds >= 60) + f"{int(seconds % 60)}s" + +def calc_ratio(size, duration): + """Calcule le bitrate approximatif (octets/sec).""" + return str(int(size / duration)) if duration else "" + +def calc_gain_percent(input_size, output_size, raw=False): + if input_size > 0 and output_size > 0: + value = (input_size - output_size) / input_size + return value if raw else value * 100 + return 0 + +def check_file_exists(file_path): + """Vérifie si un fichier existe.""" + if not os.path.exists(file_path): + print(f"\033[91mErreur : Le fichier {file_path} n'existe pas.\033[0m") + sys.exit(1) + +def check_directory_existence(directory_path, create_if_missing=False): + """Vérifie si un dossier existe et le crée si nécessaire.""" + if not os.path.exists(directory_path): + if create_if_missing: + try: + os.makedirs(directory_path) + print(f"Dossier créé : {directory_path}") + except OSError as e: + print(f"\033[91mErreur : Impossible de créer le dossier {directory_path}. {e}\033[0m") + sys.exit(1) + else: + print(f"\033[91mErreur : Le dossier {directory_path} n'existe pas.\033[0m") + sys.exit(1) + +def check_directory_writable(directory_path): + """Vérifie si un dossier est accessible en écriture.""" + if not os.access(directory_path, os.W_OK): + print(f"\033[91mErreur : Le dossier {directory_path} n'est pas accessible en écriture.\033[0m") + sys.exit(1) + +def get_video_duration(file_path): + """Retourne la durée d'une vidéo en secondes.""" + try: + probe = ffmpeg.probe(file_path) + return float(probe['format']['duration']) + except Exception as e: + print(f"\033[91mErreur lors de la récupération de la durée pour {file_path}: {e}\033[0m") + return 0.0 + +def get_video_codec(file_path): + """Retourne le codec vidéo d'un fichier avec ffmpeg.""" + try: + probe = ffmpeg.probe(file_path) + video_stream = next((stream for stream in probe['streams'] if stream['codec_type'] == 'video'), None) + if video_stream: + return video_stream['codec_name'] + return "inconnu" + except ffmpeg.Error as e: + print(f"\033[91mErreur lors de l'analyse du codec vidéo pour {file_path}: {e}\033[0m") + return "inconnu" + +def get_video_resolution(file_path): + """Retourne la résolution de la vidéo au format 720p, 1080p, etc.""" + try: + probe = ffmpeg.probe(file_path) + height = next((stream['height'] for stream in probe['streams'] if stream['codec_type'] == 'video'), None) + return f"{height}p" if height else "" + except Exception as e: + print(f"\033[91mErreur lors de l'analyse de la résolution vidéo pour {file_path}: {e}\033[0m") + return "" + +def get_video_bitrate(file_path): + """Récupère le bitrate vidéo d'un fichier via ffmpeg.""" + try: + probe = ffmpeg.probe(file_path) + for stream in probe['streams']: + if stream['codec_type'] == 'video': + # 1. Priorité : le champ 'bit_rate' s'il est présent + if 'bit_rate' in stream: + return int(stream['bit_rate']) // 1000 + + # 2. Sinon : on tente d'utiliser 'stream_size' s'il existe + elif 'stream_size' in stream: + return int((int(stream.get('stream_size')) * 8) / float(probe['format']['duration']) / 1000) + + # 3. Fallback : calcul à partir de la taille du fichier entier (moins précis) + else: + return int((os.path.getsize(file_path) * 8) / float(probe['format']['duration']) / 1000) + except Exception as e: + print(f"\033[91mErreur lors de l'analyse du bitrate vidéo pour {file_path}: {e}\033[0m") + return 0 + +def execute_command(command, dry_run=False): + """Exécute ou simule une commande en ligne.""" + if dry_run: + print(f"Commande simulée : {' '.join(command)}") + result = "Simulé" + else: + print(f"Commande exécuté : {' '.join(command)}") + subprocess.run(command, check=True) + result = "Réussi" + return result + +def copyfile(file, destination, dry_run=False): + """Copie ou simule une copie de fichier.""" + if dry_run: + result = "Simulé" + else: + shutil.copyfile(file, destination) + result = "Réussi" + return result + +def find_video_files(directory): + """Trouve tous les fichiers vidéo dans un dossier.""" + video_files = [] + for root, dirs, files in os.walk(directory): + for file in files: + file_path = os.path.join(root, file) + mime_type, _ = mimetypes.guess_type(file_path) + if mime_type and mime_type.startswith('video'): + video_files.append(os.path.relpath(file_path, directory)) + return video_files + +def get_preset_name_from_file(preset_file): + """Extrait le nom du preset à partir du fichier JSON.""" + try: + with open(preset_file, 'r', encoding='utf-8') as f: + data = json.load(f) + return data["PresetList"][0]["PresetName"] + except Exception as e: + print(f"\033[91mErreur : Impossible de lire le preset name depuis {preset_file} : {e}\033[0m") + sys.exit(1) + +def afficher_tableau(lignes, use_pager=False): + buffer = [] + + # En-tête avec colonnes ajustées + header = ( + f"{'Fichier':<100} | " + f"{'Codec':<10} | " + f"{'Résolution':<10} | " + f"{'Action':<10} | " + f"{'Durée':<10} | " + f"{'Taille SRC':<10} | " + f"{'Bitrate SRC':<12} | " + f"{'Taille DES':<10} | " + f"{'Bitrate DES':<12} | " + f"{'Gain %':<6} | " + f"{'Résultat':<10} |" + ) + + # Calculer la longueur de la ligne de séparation en fonction de la longueur de l'en-tête + separator_length = len(header) + + # En-tête et ligne de séparation + buffer.append("\nRésumé des opérations :") + buffer.append(header) + buffer.append("-" * separator_length) + + for ligne in lignes: + file, codec, resolution, action, duration, input_size, output_size, input_bitrate, output_bitrate, result = ligne + + gain_percent = calc_gain_percent(input_size, output_size) + + action_color = "" + result_color = "" + reset_color = "\033[0m" + + # Couleurs d'action + if action == "Réencodé": + action_color = "\033[1;35m" # Magenta gras + elif action == "Copié": + action_color = "\033[1;33m" # Jaune gras + + # Couleurs de résultat + if result == "Réussi": + result_color = "\033[1;32m" # Vert gras + elif result == "Erreur": + result_color = "\033[1;31m" # Rouge gras + elif result == "Interrompu": + result_color = "\033[1;38;5;208m" # Orange gras + elif result == "Existant": + result_color = "\033[1;36m" # Cyan gras + elif result == "Simulé": + result_color = "\033[1;34m" # Bleu vif gras + + # Couleur de la taille destination selon gain ou perte + if output_size < input_size: + output_size_color = "\033[1;32m" # Vert gras (gain) + elif output_size == input_size: + output_size_color = "\033[1;38;5;208m" # Orange gras (égal) + else: + output_size_color = "\033[1;31m" # Rouge gras (perte) + + # Définir une couleur selon le gain + # > 50% => vert, > 30% => orange, sinon rouge + if gain_percent > 50: + gain_color = "\033[1;32m" # Vert gras + elif gain_percent > 30: + gain_color = "\033[1;38;5;208m" # Orange gras + else: + gain_color = "\033[1;31m" # Rouge gras + + buffer.append( + f"{action_color}{file[:100]:<100}{reset_color} | " # Fichier + f"{action_color}{codec[:10]:<10}{reset_color} | " # Codec + f"{action_color}{resolution[:10]:>10}{reset_color} | " # Résolution + f"{action_color}{action[:10]:<10}{reset_color} | " # Action + f"{action_color}{format_duration(duration)[:10]:>10}{reset_color} | " # Durée + f"{human_readable_size(input_size)[:10]:>10} | " # Taille SRC + f"{(f'{input_bitrate} kb/s' if isinstance(input_bitrate, (float, int)) else '')[:12]:>12} | " # Bitrate SRC + f"{output_size_color}{human_readable_size(output_size)[:10]:>10}{reset_color} | " # Taille DES + f"{(f'{output_bitrate} kb/s' if isinstance(output_bitrate, (float, int)) else '')[:12]:>12} | " # Bitrate DES + f"{gain_color}{gain_percent:>5.1f}%{reset_color} | " # Gain % + f"{result_color}{result[:10]:<10}{reset_color} |" # Résultat + ) + + tableau = "\n".join(buffer) + if use_pager: + subprocess.run(["less", "-SR"], input=tableau.encode('utf-8')) + else: + print(tableau) + +def main(): + # Configuration des arguments en ligne de commande + parser = argparse.ArgumentParser(description='Traitement des fichiers vidéo avec HandBrakeCLI.') + parser.add_argument('-p', '--preset', required=True, help='Fichier preset JSON à utiliser avec HandBrakeCLI') + parser.add_argument('-i', '--input_directory', required=True, help='Dossier d’entrée contenant les fichiers vidéo') + parser.add_argument('-o', '--output_directory', required=True, help='Dossier de sortie pour les fichiers traités') + parser.add_argument('--dry-run', action='store_true', help='Simule les actions sans modifier les fichiers') + parser.add_argument('--pager', action='store_true', help='Utilise less pour paginer le résumé final') + args = parser.parse_args() + + # Récupération des arguments + preset_file = args.preset + input_directory = args.input_directory + output_directory = args.output_directory + dry_run = args.dry_run + use_pager = args.pager + + # Vérification de l'existence des fichiers et dossiers + check_file_exists(preset_file) + check_directory_existence(input_directory) + if not dry_run: + check_directory_existence(output_directory, create_if_missing=True) + check_directory_writable(output_directory) + + # Récupération du nom du preset + preset_name = get_preset_name_from_file(preset_file) + + # Recherche des fichiers vidéo dans le dossier d'entrée + video_files = find_video_files(input_directory) + + # Liste pour enregistrer le résumé + lignes = [] + + try: + # Traitement de chaque fichier vidéo + for video_file in video_files: + input_file_path = os.path.join(input_directory, video_file) + output_file_path = os.path.join(output_directory, video_file) + if not dry_run: check_directory_existence(os.path.dirname(output_file_path), create_if_missing=True) + + # Récupération du codec du fichier vidéo + codec = get_video_codec(input_file_path) + + # Choix de l'action + if codec == "hevc": + action = "Copié" + output_file_path = os.path.join(output_directory, video_file) + else: + action = "Réencodé" + output_file_path = os.path.splitext(os.path.join(output_directory, video_file))[0] + '.mp4' + + try: + # Si le fichier existe déjà + if os.path.exists(output_file_path): + result = "Existant" + print(f"\033[96mFichier existant : {input_file_path} vers {output_file_path} (action aurait été : {action})\033[0m") + elif action == "Réencodé": + print(f"\033[95mRéencodage de {input_file_path} vers {output_file_path} (Non-HEVC détecté)\033[0m") + command = ['HandBrakeCLI', '--preset-import-file', preset_file, '-Z', preset_name, '-i', + input_file_path, '-o', output_file_path] + result = execute_command(command, dry_run) + elif action == "Copié": + print(f"\033[93mCopie de {input_file_path} vers {output_file_path} (HEVC détecté)\033[0m") + result = copyfile(input_file_path, output_file_path, dry_run) + except KeyboardInterrupt: + print("\033[91mInterruption clavier détectée.\033[0m") + if os.path.exists(output_file_path): + os.remove(output_file_path) + result = "Interrompu" + raise + except Exception as e: + print(f"\033[91mErreur lors du traitement de {input_file_path} : {e}\033[0m") + if os.path.exists(output_file_path): + os.remove(output_file_path) + result = "Erreur" + finally: + # Enregistrer le résultat même en cas d'erreur ou interruption + + # Récupère des informations du fichier de départ + resolution = get_video_resolution(input_file_path) + input_size = os.path.getsize(input_file_path) + input_bitrate = get_video_bitrate(input_file_path) + + # Récupère des informations du fichier final + output_size = os.path.getsize(output_file_path) if os.path.exists(output_file_path) else 0 + output_bitrate = get_video_bitrate(output_file_path) if os.path.exists(output_file_path) else "" + + duration = get_video_duration(input_file_path) + lignes.append((video_file, codec, resolution, action, duration, input_size, output_size, input_bitrate, output_bitrate, result)) + + except KeyboardInterrupt: + pass + finally: + # Calcul des tailles des dossiers source et destination (après traitement) + input_total = sum(r[5] for r in lignes) + output_total = sum(r[6] for r in lignes) + + # Insertion d'une ligne récapitulative des tailles des dossiers + lignes.insert(0, ( + "[Taille Totale]", # Fichier + "", # Codec + "", # Résolution + "", # Action + "", # Durée + input_total, # Taille SRC + output_total, # Taille DES + "", # Bitrate SRC + "", # Bitrate DES + "" # Résultat + )) + + # Affichage du résumé + afficher_tableau(lignes, use_pager=use_pager) + xlsx_file = "résumé.xlsx" + export_xlsx(lignes, fichier_sortie=xlsx_file) + print(f"📝 Classeur généré : {xlsx_file}") + +if __name__ == "__main__": + main() diff --git a/HandBrake_recursive/requirements.txt b/HandBrake_recursive/requirements.txt new file mode 100644 index 0000000..125da89 --- /dev/null +++ b/HandBrake_recursive/requirements.txt @@ -0,0 +1,2 @@ +ffmpeg-python +openpyxl \ No newline at end of file diff --git a/HandBrake_recursive/setup.py b/HandBrake_recursive/setup.py new file mode 100644 index 0000000..5fab5e4 --- /dev/null +++ b/HandBrake_recursive/setup.py @@ -0,0 +1,18 @@ +from setuptools import setup + +setup( + name='HandBrake_recursive', + version='0.1.0', + description='Un script pour traiter les fichiers vidéo avec HandBrake de manière récursive.', + py_modules=['HandBrake_recursive'], + install_requires=[ + 'ffmpeg-python', + 'openpyxl', + ], + entry_points={ + 'console_scripts': [ + 'handbrake_recursive=HandBrake_recursive:main', + ], + }, + python_requires='>=3.6', +) diff --git a/proxmox_export_disk/.gitignore b/proxmox_export_disk/.gitignore new file mode 100644 index 0000000..6093b56 --- /dev/null +++ b/proxmox_export_disk/.gitignore @@ -0,0 +1,3 @@ +build +proxmox_export_disk.egg-info/ +.venv/ diff --git a/proxmox_export_disk/.idea/.gitignore b/proxmox_export_disk/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/proxmox_export_disk/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/proxmox_export_disk/.idea/inspectionProfiles/Project_Default.xml b/proxmox_export_disk/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..cd83845 --- /dev/null +++ b/proxmox_export_disk/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/proxmox_export_disk/.idea/inspectionProfiles/profiles_settings.xml b/proxmox_export_disk/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/proxmox_export_disk/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/proxmox_export_disk/.idea/misc.xml b/proxmox_export_disk/.idea/misc.xml new file mode 100644 index 0000000..4656c86 --- /dev/null +++ b/proxmox_export_disk/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/proxmox_export_disk/.idea/modules.xml b/proxmox_export_disk/.idea/modules.xml new file mode 100644 index 0000000..3585d08 --- /dev/null +++ b/proxmox_export_disk/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/proxmox_export_disk/.idea/proxmox_export_disk.iml b/proxmox_export_disk/.idea/proxmox_export_disk.iml new file mode 100644 index 0000000..b8597a7 --- /dev/null +++ b/proxmox_export_disk/.idea/proxmox_export_disk.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/proxmox_export_disk/proxmox_export_disk.py b/proxmox_export_disk/proxmox_export_disk.py new file mode 100644 index 0000000..28d6fd6 --- /dev/null +++ b/proxmox_export_disk/proxmox_export_disk.py @@ -0,0 +1,398 @@ +#!/usr/bin/env python3 + +import subprocess +import json +import re +import openpyxl +import time +import tempfile +import os +import shutil + +# Configuration +hote_proxmox = "proxmox.lan" +utilisateur_ssh = "impt" +classeur = "proxmox_export_disk" + +def executer_commande_ssh(commande, entree=None): + """Exécute une commande sur le serveur Proxmox via SSH en utilisant sudo.""" + commande_ssh = [ + "ssh", + f"{utilisateur_ssh}@{hote_proxmox}", + f"sudo {commande}" + ] + resultat = subprocess.run(commande_ssh, input=entree, capture_output=True, text=True) + + # Affiche stderr s’il y a quelque chose dedans + if resultat.stderr: + print("---- SSH STDERR ----") + print(resultat.stderr) + + if resultat.returncode != 0: + return None + return resultat.stdout + +def recuperer_noeuds(): + """Récupère la liste des nœuds du cluster Proxmox.""" + donnees_noeuds = executer_commande_ssh("pvesh get /nodes --output-format json") + return json.loads(donnees_noeuds) if donnees_noeuds else [] + +def recuperer_vms(noeuds): + """Récupère toutes les VM et conteneurs sur un nœud donné.""" + vms = [] + for noeud in noeuds: + nom_noeud = noeud.get("node") + for type_vm in ["qemu", "lxc"]: + cmd = f"pvesh get /nodes/{nom_noeud}/{type_vm} --output-format json" + donnees_vms = executer_commande_ssh(cmd) + liste_vm = json.loads(donnees_vms) + for vm in liste_vm: + vm['vmid'] = int(vm.get('vmid')) + vms.extend(liste_vm) + return vms + +def recuperer_configs_vms(vms): + """Récupère les configurations de toutes les VMs/CTs en une seule commande SSH (retour JSON).""" + + # Contenu du script Python à exécuter côté serveur + script = """ +import json, sys +res={} +for i in json.load(sys.stdin): + v = i.get('vmid') + t = i.get('type') + c = f'/etc/pve/{t}/{v}.conf' + res[v]=open(c).read() +print(json.dumps(res)) +""" + + # Préparer les données avec les VMID et les types + donnees = [{'vmid': vm.get('vmid'), 'type': vm.get('type', 'qemu-server')} for vm in vms] + + # On prépare la commande SSH qui exécute le script Python + commande = f'python3 -c "{script}"' + + # On exécute en passant 'data' comme entrée standard du script + sortie = executer_commande_ssh(commande, entree=json.dumps(donnees, separators=(",", ":"))) + + # Parser le JSON retourné + try: + configs_vms = json.loads(sortie) + return configs_vms + except json.JSONDecodeError as e: + print("Erreur lors du décodage du JSON de configurations des VMs :", e) + return {} + +def recuperer_infos_stockage(): + """Récupère les informations de configuration du stockage.""" + cmd = "cat /etc/pve/storage.cfg" + sortie = executer_commande_ssh(cmd) + if sortie: + infos_stockage = {} + stockage_actuel = None + for ligne in sortie.splitlines(): + if not ligne: + stockage_actuel = None + elif ligne.startswith('\t'): + parties = ligne[1:].split(' ', 1) + cle = parties[0].strip() + valeur = parties[1].strip() if len(parties) > 1 else True + if stockage_actuel: + infos_stockage[stockage_actuel][cle] = valeur + else: + type_stockage, stockage_actuel = ligne.split(': ', 1) + infos_stockage[stockage_actuel] = {'type': type_stockage} + return infos_stockage + +def recuperer_info_disques(vms, infos_stockage, configs): + """Récupère tous les disques à partir des configurations et des tailles déjà récupérées.""" + + info_disques = [] + for vm in vms: + vmid = vm.get("vmid") + type_vm = "lxc" if vm.get("type") == "lxc" else "qemu" + config = configs.get(str(vmid)) + if not config: + continue + + for ligne in config.splitlines(): + if "media=cdrom" in ligne or "iso" in ligne: + continue + elif not ligne: # Arrêter à la première ligne vide + break + elif re.match(r"^(scsi\d|sata\d|ide\d|mp\d|rootfs)", ligne): + try: + infos_ligne = ligne.split(":", 1)[1].split(",") + nom_stockage, nom_disque = [x.strip() for x in infos_ligne[0].split(":")] + point_montage = None + if type_vm == "lxc": + if re.match(r"^(mp\d)", ligne): + point_montage = ligne.split("mp=")[1].split(",")[0] + elif ligne.startswith("rootfs"): + point_montage = "/" + if nom_stockage in infos_stockage: + type_stockage = infos_stockage[nom_stockage]['type'] + if type_stockage == "lvmthin": + # LVM + chemin = f"/dev/{infos_stockage[nom_stockage]['vgname']}/{nom_disque}" + elif type_stockage == "dir": + # Fichier raw ou qcow2 + chemin = f"{infos_stockage[nom_stockage]['path']}/images/{nom_disque}" + nom_disque = nom_disque.replace(str(vmid) + "/", "") + else: + continue + info_disques.append({'vm': vm, 'chemin': chemin, 'nom_disque': nom_disque, 'nom_stockage': nom_stockage, 'type_stockage': type_stockage, 'point_montage': point_montage}) + except (ValueError, AttributeError): + print(f"Erreur de parsing de la ligne de configuration : {ligne}") + return info_disques + +def recuperer_taille_disques(info_disques): + tailles_disques = {} + chemin_disques_lvms = [] + chemin_disques_fichiers = [] + for info_disque in info_disques: + # Récupère la taille d'un disque. + if info_disque.get('type_stockage') == "lvmthin": + # LVM + chemin_disques_lvms.append(info_disque.get('chemin')) + elif info_disque.get('type_stockage') == "dir": + # Fichier raw ou qcow2 + chemin_disques_fichiers.append(info_disque.get('chemin')) + tailles_disques.update(recuperer_taille_lvm(chemin_disques_lvms) or {}) + tailles_disques.update(recuperer_taille_fichier(chemin_disques_fichiers) or {}) + return tailles_disques + +def recuperer_taille_lvm(chemin_disques): + """Analyse les tailles d'un volume logique LVM.""" + tailles_disques = {} + + cmd = f'lvs -o lv_path,lv_size,data_percent --noheadings --units g --separator "|" --nosuffix {" ".join(chemin_disques)}' + sortie = executer_commande_ssh(cmd) + lignes = sortie.splitlines() + + # Analyse LVM + for ligne in lignes: + chemin, taille, pourcentage = ligne.strip().replace(',', '.').split('|') + tailles_disques[chemin] = { + "taille_totale": float(taille), + "pourcentage_utilise": float(pourcentage), + "taille_utilisee": float(taille) * float(pourcentage) / 100 + } + return tailles_disques + +def recuperer_taille_fichier(chemin_disques): + """Analyse les tailles d'une image disque (qcow2/raw).""" + tailles_disques = {} + for chemin_disque in chemin_disques: + cmd = f"qemu-img info {chemin_disque}" + sortie = executer_commande_ssh(cmd) + if sortie: + lignes = sortie.splitlines() + taille_virtuelle = None + taille_disque = None + for ligne in lignes: + if 'virtual size:' in ligne: + taille_virtuelle = ligne.split(':')[1].strip().split('(')[0].strip() + elif 'disk size:' in ligne: + taille_disque = ligne.split(':')[1].strip() + if taille_virtuelle and taille_disque: + valeur_virt, unite_virt = taille_virtuelle.split(' ') + valeur_phys, unite_phys = taille_disque.split(' ') + taille_virt = float(valeur_virt) * {'GiB': 1, 'MiB': 1/1024, 'KiB': 1/1024/1024}[unite_virt] + taille_phys = float(valeur_phys) * {'GiB': 1, 'MiB': 1/1024, 'KiB': 1/1024/1024}[unite_phys] + pourcentage_utilise = (taille_phys / taille_virt) * 100 + tailles_disques[chemin_disque] = { + "taille_totale": taille_virt, + "pourcentage_utilise": pourcentage_utilise, + "taille_utilisee": taille_phys + } + return tailles_disques + +def croisement_info(info_disques, taille_disques): + disques = [] + for info_disque in info_disques: + vm = info_disque.get('vm') + taille_disque = taille_disques.get(info_disque.get('chemin')) + if taille_disque is None: + continue + disques.append({ + "VMID": vm.get('vmid'), + "Nom": vm.get("name"), + "Type": vm.get("type", "qemu"), + "Disque": info_disque.get('nom_disque'), + "Stockage": info_disque.get('nom_stockage'), + "Taille Totale (GB)": taille_disque.get('taille_totale'), + "% Utilisé": taille_disque.get('pourcentage_utilise') / 100, + "Taille Utilisée (GB)": taille_disque.get('taille_utilisee'), + "Point de Montage": info_disque.get('point_montage'), + }) + return disques + +def exporter_classeur(disques, fichier_sortie): + """Exporte les données des disques dans un fichier, en choisissant automatiquement entre XLSX et ODS.""" + # Vérifie si LibreOffice est disponible sur le système + libreoffice_disponible = subprocess.run( + ["which", "soffice"], capture_output=True, text=True + ).returncode == 0 + + # Si LibreOffice est disponible, exporte en ODS, sinon en XLSX + if libreoffice_disponible: + fichier_sortie_avec_extension = ajouter_extension(fichier_sortie, ".ods") + print("LibreOffice est disponible, exportation en ODS...") + exporter_vers_ods(disques, fichier_sortie_avec_extension) + else: + fichier_sortie_avec_extension = ajouter_extension(fichier_sortie, ".xlsx") + print("LibreOffice n'est pas disponible, exportation en XLSX...") + exporter_vers_xlsx(disques, fichier_sortie_avec_extension) + return fichier_sortie_avec_extension + +def exporter_vers_xlsx(disques, fichier_sortie): + """Exporte les données des disques dans un fichier XLSX avec un filtre automatique.""" + champs = ["VMID", "Nom", "Type", "Disque", "Stockage", "Taille Totale (GB)", "% Utilisé", "Taille Utilisée (GB)", "Point de Montage"] + + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Disques" + + ws.append(champs) + ws.freeze_panes = "B2" + + # Tri croissant des disques par VMID + disques = sorted(disques, key=lambda x: x.get("VMID")) # Si VMID est absent, on lui attribue la valeur 0 par défaut + + for disque in disques: + ligne = [disque.get(champ, "") for champ in champs] + ws.append(ligne) + + derniere_ligne = ws.max_row + + ws[f'A{derniere_ligne + 2}'] = f'=SUBTOTAL(103, A2:A{derniere_ligne})' + ws[f'H{derniere_ligne + 2}'] = f'=SUBTOTAL(109, H2:H{derniere_ligne})' + + for ligne in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=len(champs)): + ligne[5].number_format = '#,##0.00 "GB"' + ligne[6].number_format = '0.00%' + ligne[7].number_format = '#,##0.00 "GB"' + + ws.auto_filter.ref = f"A1:{ws.cell(row=derniere_ligne, column=len(champs)).coordinate}" + + for colonne in ws.columns: + cellules = [cell for cell in colonne if cell.row <= derniere_ligne] + longueur = max(len(str(cell.value)) if cell.value is not None else 0 for cell in cellules) + largeur = max(longueur + 2, 8) + ws.column_dimensions[colonne[0].column_letter].width = largeur + + wb.save(fichier_sortie) + +def exporter_vers_ods(disques, fichier_sortie): + """Exporte directement en ODS sans conserver le XLSX sur disque.""" + + # Crée un dossier temporaire pour le XLSX + with tempfile.TemporaryDirectory() as dossier_temp: + xlsx_temp = os.path.join(dossier_temp, "temp.xlsx") + ods_temp = os.path.join(dossier_temp, "temp.ods") + + # Crée le XLSX + exporter_vers_xlsx(disques, xlsx_temp) + + # Utilise LibreOffice pour convertir le fichier temporaire + commande = [ + "soffice", + "--headless", + "--convert-to", "ods", + "--outdir", dossier_temp, + xlsx_temp + ] + subprocess.run(commande, capture_output=True, check=True) + + # Renomme le fichier généré par LibreOffice au bon nom de sortie + chemin_ods_converti = os.path.join(dossier_temp, ods_temp) + shutil.copyfile(chemin_ods_converti, fichier_sortie) + +def ajouter_extension(chemin_fichier, extension): + """Ajoute l’extension souhaitée si elle est absente ou incorrecte.""" + racine, ext = os.path.splitext(chemin_fichier) + ext = ext.lower() + extension = extension.lower() + + # S’assure que l’extension commence par un point + if not extension.startswith('.'): + extension = '.' + extension + + # Si l’extension est déjà correcte → ne rien changer + if ext == extension: + return chemin_fichier + + # Sinon, remplace ou ajoute la bonne extension + return racine + extension + +def main(): + """ Récupère les informations sur les disques des VM et conteneurs de tous les nœuds Proxmox, puis exporte les données dans un fichier XLSX. """ + + print("Récupération des nœuds...") + debut = time.time() + noeuds = recuperer_noeuds() + fin = time.time() + print(f"Temps écoulé pour la récupération des nœuds : {fin - debut:.2f} secondes") + if not noeuds: + print("Aucun nœud trouvé.") + return + + print("Récupération des VMs...") + debut = time.time() + vms = recuperer_vms(noeuds) + fin = time.time() + print(f"Temps écoulé pour la récupération des VMs : {fin - debut:.2f} secondes") + if not vms: + print("Aucune VM trouvée.") + return + + print("Récupération des stockages...") + debut = time.time() + infos_stockage = recuperer_infos_stockage() + fin = time.time() + print(f"Temps écoulé pour la récupération des stockages : {fin - debut:.2f} secondes") + if not infos_stockage: + print("Aucun stockage trouvé.") + return + + print("Récupération des configurations des VMs...") + debut = time.time() + configs = recuperer_configs_vms(vms) + fin = time.time() + print(f"Temps écoulé pour la récupération des configurations : {fin - debut:.2f} secondes") + + print("Récupération de emplacement des disques...") + debut = time.time() + chemin_disques = recuperer_info_disques(vms, infos_stockage, configs) + fin = time.time() + print(f"Temps écoulé pour la récupération de emplacement des disques : {fin - debut:.2f} secondes") + if not chemin_disques: + print("Aucune information à exporter.") + return + + print("Récupération de la taille des disques...") + debut = time.time() + taille_disques = recuperer_taille_disques(chemin_disques) + fin = time.time() + print(f"Temps écoulé pour la récupération de la taille des disques : {fin - debut:.2f} secondes") + if not taille_disques: + print("Aucune information à exporter.") + return + + print("Croisement des informations...") + debut = time.time() + disques = croisement_info(chemin_disques, taille_disques) + fin = time.time() + print(f"Temps écoulé pour la récupération des disques : {fin - debut:.2f} secondes") + if not disques: + print("Aucune information à exporter.") + return + + print("Exportation des données en Classeur...") + nom_du_classeur = exporter_classeur(disques, classeur) + print(f"Données exportées dans {nom_du_classeur}") + + +if __name__ == "__main__": + main() diff --git a/proxmox_export_disk/setup.py b/proxmox_export_disk/setup.py new file mode 100644 index 0000000..c47abc8 --- /dev/null +++ b/proxmox_export_disk/setup.py @@ -0,0 +1,17 @@ +from setuptools import setup + +setup( + name='proxmox_export_disk', + version='0.1.0', + description='Un script pour récupérer l\'utilisation des disques des VMs dans Proxmox.', + py_modules=['proxmox_export_disk'], + install_requires=[ + 'openpyxl', + ], + entry_points={ + 'console_scripts': [ + 'proxmox_export_disk=proxmox_export_disk:main', + ], + }, + python_requires='>=3.6', +) diff --git a/proxmox_export_disk/temps-exec b/proxmox_export_disk/temps-exec new file mode 100644 index 0000000..afa1f6c --- /dev/null +++ b/proxmox_export_disk/temps-exec @@ -0,0 +1,19 @@ +Récupération des nœuds... +Temps écoulé pour la récupération des nœuds : 1.04 secondes +Récupération des VMs... +Temps écoulé pour la récupération des VMs : 1.83 secondes +Récupération des stockages... +Temps écoulé pour la récupération des stockages : 0.16 secondes +Récupération des disques... +Erreur de parsing de la ligne de configuration : mp0: /media/videos,mp=/media/videos,ro=1 +Erreur de parsing de la ligne de configuration : mp0: /media/data,mp=/media/data,ro=1 +Erreur de parsing de la ligne de configuration : mp1: /media/videos,mp=/media/videos,ro=1 +Erreur de parsing de la ligne de configuration : mp0: /media/data,mp=/media/data,ro=1 +Erreur de parsing de la ligne de configuration : mp1: /media/videos,mp=/media/videos,ro=1 +Erreur de parsing de la ligne de configuration : mp3: /var/lib/vz/dump/,mp=/media/dump,ro=1 +Erreur de parsing de la ligne de configuration : mp0: /media/videos,mp=/media/videos,ro=1 +Erreur de parsing de la ligne de configuration : mp1: /media/data/documents/media/music,mp=/media/music,ro=1 +Temps écoulé pour la récupération des disques : 63.49 secondes +Exportation des données en XLSX... +Temps écoulé pour l'exportation en XLSX : 0.01 secondes +Données exportées dans proxmox_export_disk.xlsx \ No newline at end of file diff --git a/tree_stream/.gitignore b/tree_stream/.gitignore new file mode 100644 index 0000000..08da8a6 --- /dev/null +++ b/tree_stream/.gitignore @@ -0,0 +1,3 @@ +build +tree_stream.egg-info/ +.venv/ diff --git a/tree_stream/.idea/.gitignore b/tree_stream/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/tree_stream/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/tree_stream/.idea/inspectionProfiles/Project_Default.xml b/tree_stream/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..cd83845 --- /dev/null +++ b/tree_stream/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/tree_stream/.idea/inspectionProfiles/profiles_settings.xml b/tree_stream/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/tree_stream/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/tree_stream/.idea/misc.xml b/tree_stream/.idea/misc.xml new file mode 100644 index 0000000..579ca86 --- /dev/null +++ b/tree_stream/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/tree_stream/.idea/modules.xml b/tree_stream/.idea/modules.xml new file mode 100644 index 0000000..4875be3 --- /dev/null +++ b/tree_stream/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/tree_stream/.idea/tree_stream.iml b/tree_stream/.idea/tree_stream.iml new file mode 100644 index 0000000..19efa1b --- /dev/null +++ b/tree_stream/.idea/tree_stream.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/tree_stream/pyproject.toml b/tree_stream/pyproject.toml new file mode 100644 index 0000000..9787c3b --- /dev/null +++ b/tree_stream/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/tree_stream/requirements.txt b/tree_stream/requirements.txt new file mode 100644 index 0000000..9abb303 --- /dev/null +++ b/tree_stream/requirements.txt @@ -0,0 +1 @@ +ffmpeg-python \ No newline at end of file diff --git a/tree_stream/setup.py b/tree_stream/setup.py new file mode 100644 index 0000000..77353b0 --- /dev/null +++ b/tree_stream/setup.py @@ -0,0 +1,22 @@ +from setuptools import setup, find_packages + +setup( + name="tree_stream", + version="0.1.0", + description="Affiche une arborescence enrichie des fichiers vidéo avec chapitres et flux", + author="TonNom", + packages=find_packages(), + install_requires=["ffmpeg-python"], + entry_points={ + "console_scripts": [ + "tree_stream=tree_stream.main:main", + ] + }, + classifiers=[ + "Programming Language :: Python :: 3", + "Environment :: Console", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ], + python_requires='>=3.6', +) diff --git a/tree_stream/tree_stream/__init__.py b/tree_stream/tree_stream/__init__.py new file mode 100644 index 0000000..c28a133 --- /dev/null +++ b/tree_stream/tree_stream/__init__.py @@ -0,0 +1 @@ +from .main import main diff --git a/tree_stream/tree_stream/main.py b/tree_stream/tree_stream/main.py new file mode 100644 index 0000000..8f97e03 --- /dev/null +++ b/tree_stream/tree_stream/main.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 + +import os +import sys +import argparse +import ffmpeg + +# Codes des couleurs ANSI +NOIR = "\033[30m" +ROUGE = "\033[31m" +VERT = "\033[32m" +ORANGE = "\033[33m" # On considère le marron comme orange +BLEU = "\033[34m" +VIOLET = "\033[35m" +CYAN = "\033[36m" +GRIS_CLAIR = "\033[37m" + +GRIS_FONCE = "\033[90m" +ROUGE_CLAIR = "\033[91m" +VERT_CLAIR = "\033[92m" +JAUNE = "\033[93m" +BLEU_CLAIR = "\033[94m" +VIOLET_CLAIR = "\033[95m" +CYAN_CLAIR = "\033[96m" +BLANC = "\033[97m" + +# Styles de texte +NORMAL = "\033[0m" # Normal (pas de style) +GRAS = "\033[1m" # Gras +SOULIGNE = "\033[4m" # Souligné +CLIGNOTANT = "\033[5m" # Clignotant +INVERSE = "\033[7m" # Inversé (texte clair sur fond sombre) + + +def couleur(nom_fichier, est_dossier): + if est_dossier: + return BLEU + nom_fichier + NORMAL + elif nom_fichier.lower().endswith(('.mp4', '.mkv', '.avi')): + return VIOLET + nom_fichier + NORMAL # 💜 fichier vidéo + else: + return nom_fichier + +def analyser_fichier_video(chemin): + try: + probe = ffmpeg.probe(chemin, show_chapters=None) # Contrairement à ce que l'on pourrait croire, on demande les chapitres, c'est l'option show_chapters qui ne prend pas de paramètre. + info = [] + max_index_len = len(str(len(probe.get("streams", [])) - 1)) + streams = probe.get('streams', []) + + # Filtrer pour ne garder que les flux pertinents (video, audio, subtitle) + flux_valides = [stream for stream in streams if stream.get('codec_type') in ['video', 'audio', 'subtitle']] + + # Si on n'a pas de flux valide à afficher, on s'arrête + if not flux_valides: + return [] + + for i, stream in enumerate(flux_valides): + codec_type = stream.get('codec_type', 'und') + codec_name = stream.get('codec_name', 'und') + langue = stream.get('tags', {}).get('language', 'und') + titre = stream.get('tags', {}).get('title', '') + disposition = stream.get('disposition', {}) + defaut = f"{ROUGE}défaut{NORMAL}" if disposition.get('default') == 1 else "" + force = f"{ROUGE}forcé{NORMAL}" if disposition.get('forced') == 1 else "" + + # Déterminer les informations à afficher + ligne = ( + f"{ORANGE}Stream{NORMAL} " + f"{ROUGE}{i}{NORMAL}" + f"{BLANC}:{NORMAL} " + f"{BLEU}{codec_type:<8}{NORMAL} " + f"{BLANC}({codec_name}, {langue}){NORMAL}" + ) + + # Ajouter les flags défaut/forcé s'ils existent + flags = [] + if defaut: + flags.append(defaut) + if force: + flags.append(force) + if flags: + ligne += f" {' '.join(flags)}" + + if titre: + ligne += f" {VERT}\"{titre}\"{NORMAL}" + + info.append(ligne) + + nb_chapitres = len(probe.get('chapters', [])) + info.append( + f"{ORANGE}Chapitres{NORMAL} " + f"{BLANC}:{NORMAL} " + f"{VERT}{nb_chapitres}{NORMAL}") + + return info + + except ffmpeg.Error as e: + return [f"{ROUGE}Erreur ffmpeg : {e}{NORMAL}"] + except Exception as e: + return [f"{ROUGE}Erreur : {e}{NORMAL}"] + +def afficher_arborescence(dossier, prefixe="", tout_afficher=False, niveau_max=None, niveau_actuel=0): + if niveau_max is not None and niveau_actuel > niveau_max: + return + + try: + elements = sorted( + os.listdir(dossier), + key=lambda x: (not os.path.isdir(os.path.join(dossier, x)), x) + ) + except PermissionError: + print(prefixe + "└── [Permission refusée]") + return + + if not tout_afficher: + elements = [e for e in elements if not e.startswith('.')] + for index, nom in enumerate(elements): + chemin_complet = os.path.join(dossier, nom) + est_dernier = (index == len(elements) - 1) + branche = "└── " if est_dernier else "├── " + sous_prefixe = " " if est_dernier else "│ " + est_dossier = os.path.isdir(chemin_complet) + print(prefixe + branche + couleur(nom, est_dossier)) + + if est_dossier: + afficher_arborescence( + chemin_complet, + prefixe + sous_prefixe, + tout_afficher=tout_afficher, + niveau_max=niveau_max, + niveau_actuel=niveau_actuel + 1 + ) + elif nom.lower().endswith(('.mp4', '.mkv', '.avi')): + infos = analyser_fichier_video(chemin_complet) + for i, ligne in enumerate(infos): + print(prefixe + sous_prefixe + ("└── " if i == len(infos) - 1 else "├── ") + ligne) + +def main(): + parser = argparse.ArgumentParser(description="Affiche l’arborescence avec informations sur les fichiers vidéo.") + parser.add_argument("dossiers", nargs="*", default=["."], help="Un ou plusieurs dossiers à analyser") + parser.add_argument("-a", "--tout", action="store_true", help="Afficher les fichiers masqués") + parser.add_argument("-L", "--niveau", type=int, help="Niveau d’affichage maximale") + + args = parser.parse_args() + + try: + for dossier in args.dossiers: + if not os.path.exists(dossier): + print(f"{ROUGE}Erreur : le dossier '{dossier}' n'existe pas.{NORMAL}") + continue + + print(couleur(dossier, est_dossier=True)) + afficher_arborescence( + dossier, + tout_afficher=args.tout, + niveau_max=args.niveau + ) + except KeyboardInterrupt: + sys.exit(0) + +if __name__ == "__main__": + main()