From e20938a57699feef4fb3e971407692b48c29534a Mon Sep 17 00:00:00 2001 From: WatPow Date: Tue, 10 Dec 2024 09:54:20 +0100 Subject: [PATCH] Initial commit --- .gitignore | 41 +++++ GrabNWatch.spec | 39 +++++ README.md | 101 +++++++++++++ build.py | 34 +++++ requirements.txt | Bin 0 -> 104 bytes src/__init__.py | 5 + src/assets/icon.ico | Bin 0 -> 17171 bytes src/core/__init__.py | 3 + src/core/config.py | 46 ++++++ src/core/download.py | 220 +++++++++++++++++++++++++++ src/core/m3u.py | 173 +++++++++++++++++++++ src/main.py | 35 +++++ src/ui/__init__.py | 3 + src/ui/config_tab.py | 84 +++++++++++ src/ui/download_tab.py | 153 +++++++++++++++++++ src/ui/main_window.py | 331 +++++++++++++++++++++++++++++++++++++++++ src/ui/queue_tab.py | 136 +++++++++++++++++ src/ui/stats_tab.py | 38 +++++ src/utils/__init__.py | 3 + 19 files changed, 1445 insertions(+) create mode 100644 .gitignore create mode 100644 GrabNWatch.spec create mode 100644 README.md create mode 100644 build.py create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/assets/icon.ico create mode 100644 src/core/__init__.py create mode 100644 src/core/config.py create mode 100644 src/core/download.py create mode 100644 src/core/m3u.py create mode 100644 src/main.py create mode 100644 src/ui/__init__.py create mode 100644 src/ui/config_tab.py create mode 100644 src/ui/download_tab.py create mode 100644 src/ui/main_window.py create mode 100644 src/ui/queue_tab.py create mode 100644 src/ui/stats_tab.py create mode 100644 src/utils/__init__.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a188ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# VSCode +.vscode/ +*.code-workspace + +# Environment +venv/ +env/ +ENV/ +my_env/ + +# PyInstaller +build/ +dist/ + +# Logs +*.log + +# Config local +config.json \ No newline at end of file diff --git a/GrabNWatch.spec b/GrabNWatch.spec new file mode 100644 index 0000000..5662e9f --- /dev/null +++ b/GrabNWatch.spec @@ -0,0 +1,39 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['src\\main.py'], + pathex=['.'], + binaries=[], + datas=[('src/assets', 'assets'), ('src', 'src')], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='GrabNWatch', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=['src\\assets\\icon.ico'], +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..a40b423 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# GrabNWatch + +GrabNWatch est une application de bureau permettant de télécharger des contenus VOD à partir d'une liste M3U. + +## Fonctionnalités + +- Chargement de playlists M3U +- Recherche et filtrage des VODs par catégorie +- Téléchargement avec gestion de la file d'attente +- Contrôle de la bande passante +- Pause/Reprise des téléchargements +- Statistiques de téléchargement +- Mode sombre +- Configuration personnalisable + +## Installation + +1. Clonez le dépôt : +```bash +git clone https://github.com/WatPow/GrabNWatch.git +cd GrabNWatch +``` + +2. Créer un environnement virtuel (recommandé) : +```bash +python -m venv venv +source venv/bin/activate # Linux/Mac +venv\Scripts\activate # Windows +``` + +3. Installez les dépendances : +```bash +pip install -r requirements.txt +``` + +## Utilisation + +1. Lancer l'application : +```bash +python src/main.py +``` + +2. Dans l'onglet "Configuration", entrer l'URL de votre playlist M3U et cliquer sur "Sauvegarder URL" + +3. Dans l'onglet "Téléchargement" : + - Rechercher des VODs par nom + - Filtrer par catégorie + - Sélectionner un VOD et cliquer sur "Télécharger" + +4. Dans l'onglet "File d'attente" : + - Voir les téléchargements en cours et en attente + - Mettre en pause/reprendre les téléchargements + - Annuler les téléchargements + - Voir l'historique des téléchargements + +## Build + +Pour créer un exécutable Windows : + +Option 1 - Utiliser le script de build (recommandé) : +```bash +python build.py +``` + +L'exécutable sera créé dans le dossier `dist` sous le nom `GrabNWatch.exe`. + +## Structure du projet + +``` +GrabNWatch/ +├── src/ +│ ├── assets/ # Ressources (icônes, etc.) +│ ├── core/ # Fonctionnalités principales +│ │ ├── download.py # Gestion des téléchargements +│ │ ├── config.py # Gestion de la configuration +│ │ └── m3u.py # Parsing M3U +│ ├── ui/ # Interface utilisateur +│ │ ├── main_window.py +│ │ ├── download_tab.py +│ │ ├── queue_tab.py +│ │ ├── stats_tab.py +│ │ └── config_tab.py +│ └── main.py # Point d'entrée +├── requirements.txt +└── README.md +``` + +## Configuration + +La configuration est sauvegardée dans `config.json` et comprend : +- URL de la playlist M3U +- Limite de bande passante (KB/s, 0 = illimité) +- Mode sombre +- Dossier de téléchargement +- Statistiques de téléchargement + +## Remarques importantes + +- Les téléchargements sont limités à un à la fois pour éviter la surcharge +- Les autres téléchargements sont automatiquement mis en file d'attente +- Veuillez vous assurer de ne pas avoir de flux IPTV actifs sur d'autres appareils lors de l'utilisation de GrabNWatch, sauf si vous disposez de plusieurs lignes diff --git a/build.py b/build.py new file mode 100644 index 0000000..830c41c --- /dev/null +++ b/build.py @@ -0,0 +1,34 @@ +import PyInstaller.__main__ +import os +import shutil + +def build(): + # Nettoyer les dossiers de build précédents + if os.path.exists("build"): + shutil.rmtree("build") + if os.path.exists("dist"): + shutil.rmtree("dist") + + # Créer le dossier assets dans dist si nécessaire + os.makedirs("dist/assets", exist_ok=True) + + # Copier l'icône + if os.path.exists("src/assets/icon.ico"): + shutil.copy("src/assets/icon.ico", "dist/assets/icon.ico") + + # Configuration PyInstaller + PyInstaller.__main__.run([ + 'src/main.py', # Script principal + '--name=GrabNWatch', # Nom de l'exécutable + '--onefile', # Un seul fichier exécutable + '--windowed', # Mode fenêtré (pas de console) + '--icon=src/assets/icon.ico', # Icône de l'application + '--add-data=src/assets;assets', # Inclure les assets + '--add-data=src;src', # Inclure tout le package src + '--clean', # Nettoyer avant la construction + '--noconfirm', # Ne pas demander de confirmation + '--paths=.', # Ajouter le répertoire courant au PYTHONPATH + ]) + +if __name__ == "__main__": + build() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..8057467fb62bb23c8e4470751015b91783159e5d GIT binary patch literal 104 zcmezWFMy$vA&{Yj!IZ&{!4^pBF&KhL0|s6OE`}n8RE9!^QXp9jR8tIAV+2%VgrugF aA&()GAqA)=nIRucgG@36tI}hDm;(U4TMym< literal 0 HcmV?d00001 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e64f2cc --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,5 @@ +""" +GrabNWatch - Application de téléchargement de VOD depuis une playlist M3U +""" + +__version__ = "1.0.0" \ No newline at end of file diff --git a/src/assets/icon.ico b/src/assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1ac65df5d9e68dcf8884ad241c2156b76048bd6d GIT binary patch literal 17171 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 I=akR{0O)R)x&QzG literal 0 HcmV?d00001 diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..0cd2bc6 --- /dev/null +++ b/src/core/__init__.py @@ -0,0 +1,3 @@ +""" +Package contenant les fonctionnalités principales +""" \ No newline at end of file diff --git a/src/core/config.py b/src/core/config.py new file mode 100644 index 0000000..d1f5c11 --- /dev/null +++ b/src/core/config.py @@ -0,0 +1,46 @@ +import json +import os +import logging + +CONFIG_FILE = "config.json" + +def load_config(): + """Charger la configuration depuis le fichier""" + default_config = { + "m3u_url": "", + "bandwidth_limit": 0, + "dark_mode": False, + "download_dir": "downloads", + "stats": { + "total_downloads": 0, + "total_size": 0, + "average_speed": 0, + "download_times": [] + } + } + + if os.path.exists(CONFIG_FILE): + try: + with open(CONFIG_FILE, 'r') as f: + config = json.load(f) + # S'assurer que toutes les clés par défaut existent + for key, value in default_config.items(): + if key not in config: + config[key] = value + return config + except json.JSONDecodeError: + logging.error("Erreur lors de la lecture du fichier de configuration") + return default_config.copy() + except Exception as e: + logging.error(f"Erreur inattendue lors du chargement de la configuration: {e}") + return default_config.copy() + return default_config.copy() + +def save_config(config): + """Sauvegarder la configuration dans le fichier""" + try: + with open(CONFIG_FILE, 'w') as f: + json.dump(config, f, indent=4) + logging.info("Configuration saved successfully.") + except Exception as e: + logging.error(f"Erreur lors de la sauvegarde de la configuration: {e}") \ No newline at end of file diff --git a/src/core/download.py b/src/core/download.py new file mode 100644 index 0000000..8c73a0d --- /dev/null +++ b/src/core/download.py @@ -0,0 +1,220 @@ +import os +import time +import requests +from PyQt5.QtCore import QThread, QObject, pyqtSignal, QWaitCondition, QMutex, QMutexLocker +from src.core.config import save_config + +class DownloadThread(QThread): + progress = pyqtSignal(int) + finished = pyqtSignal() + error = pyqtSignal(str) + + def __init__(self, name, url, bandwidth_limit=None): + super().__init__() + self.name = name + self.url = url + self.bandwidth_limit = bandwidth_limit + self.stop_flag = False + self.paused = False + self.pause_condition = QWaitCondition() + self.pause_mutex = QMutex() + + # Attributs pour les statistiques + self.total_size = 0 + self.downloaded_size = 0 + self.start_time = 0 + self.download_time = 0 + self.current_speed = 0 + self.speeds = [] # Liste des vitesses pour calculer la moyenne + + def run(self): + try: + self.start_time = time.time() + response = requests.get(self.url, stream=True) + response.raise_for_status() + + # Obtenir la taille totale du fichier + self.total_size = int(response.headers.get('content-length', 0)) + if self.total_size == 0: + raise Exception("Impossible de déterminer la taille du fichier") + + # Créer le dossier de téléchargement s'il n'existe pas + os.makedirs("downloads", exist_ok=True) + + # Préparer le nom du fichier avec extension .mp4 + filename = os.path.join("downloads", f"{self.name}.mp4") + + # Ouvrir le fichier en mode binaire + with open(filename, 'wb') as f: + self.downloaded_size = 0 + chunk_size = 8192 # 8KB par chunk + last_update_time = time.time() + + for chunk in response.iter_content(chunk_size=chunk_size): + # Vérifier si l'arrêt a été demandé + if self.stop_flag: + return + + # Gérer la pause + with QMutexLocker(self.pause_mutex): + while self.paused and not self.stop_flag: + self.pause_condition.wait(self.pause_mutex) + + if chunk: + f.write(chunk) + self.downloaded_size += len(chunk) + + # Calculer la vitesse toutes les 0.5 secondes + current_time = time.time() + if current_time - last_update_time >= 0.5: + elapsed = current_time - last_update_time + speed = len(chunk) / elapsed + self.speeds.append(speed) + # Garder seulement les 10 dernières mesures + if len(self.speeds) > 10: + self.speeds.pop(0) + self.current_speed = sum(self.speeds) / len(self.speeds) + last_update_time = current_time + + # Limiter la bande passante si nécessaire + if self.bandwidth_limit: + time.sleep(len(chunk) / (self.bandwidth_limit * 1024)) + + # Émettre la progression + progress = int(self.downloaded_size * 100 / self.total_size) + self.progress.emit(progress) + + self.download_time = time.time() - self.start_time + self.finished.emit() + + except Exception as e: + self.error.emit(str(e)) + + def stop(self): + self.stop_flag = True + self.resume() # Pour sortir de la pause si nécessaire + + def pause(self): + self.paused = True + + def resume(self): + self.paused = False + with QMutexLocker(self.pause_mutex): + self.pause_condition.wakeAll() + + +class DownloadManager(QObject): + download_progress = pyqtSignal(str, int) + download_finished = pyqtSignal(str) + download_error = pyqtSignal(str, str) + queue_updated = pyqtSignal() + download_paused = pyqtSignal(str) + download_resumed = pyqtSignal(str) + + def __init__(self, config): + super().__init__() + self.config = config + self.download_queue = [] # [(name, url, bandwidth_limit), ...] + self.current_download = None # DownloadThread actif + self.download_history = [] # [(name, status, timestamp), ...] + self.stats = self.config.get('stats', { + 'total_downloads': 0, + 'total_size': 0, + 'average_speed': 0, + 'download_times': [] + }) + + def add_to_queue(self, name, url, bandwidth_limit=None): + self.download_queue.append((name, url, bandwidth_limit)) + self.download_history.append((name, "En attente", time.time())) + self.queue_updated.emit() + self.process_queue() + + def process_queue(self): + if not self.current_download and self.download_queue: + name, url, bandwidth_limit = self.download_queue[0] + self.start_download(name, url, bandwidth_limit) + self.download_queue.pop(0) + + def start_download(self, name, url, bandwidth_limit=None): + self.current_download = DownloadThread(name, url, bandwidth_limit) + self.current_download.progress.connect(lambda p: self.download_progress.emit(name, p)) + self.current_download.finished.connect(lambda: self.on_download_finished(name)) + self.current_download.error.connect(lambda e: self.on_download_error(name, e)) + self.current_download.start() + self.download_history = [(n, s, t) for n, s, t in self.download_history if n != name] + self.download_history.append((name, "En cours", time.time())) + self.queue_updated.emit() + + def on_download_finished(self, name): + if self.current_download: + # Mise à jour des statistiques + self.stats['total_downloads'] += 1 + self.stats['total_size'] += self.current_download.total_size + if self.current_download.download_time > 0: + speed = self.current_download.total_size / self.current_download.download_time + self.stats['download_times'].append(speed) + self.stats['average_speed'] = sum(self.stats['download_times']) / len(self.stats['download_times']) + + # Sauvegarder les statistiques dans la configuration + self.config['stats'] = self.stats + save_config(self.config) + + self.current_download.deleteLater() + self.current_download = None + self.download_history = [(n, s, t) for n, s, t in self.download_history if n != name] + self.download_history.append((name, "Terminé", time.time())) + self.download_finished.emit(name) + self.queue_updated.emit() + + # Démarrer automatiquement le prochain téléchargement + if self.download_queue: + next_name, next_url, next_bandwidth = self.download_queue.pop(0) + self.start_download(next_name, next_url, next_bandwidth) + + def on_download_error(self, name, error): + if self.current_download: + self.current_download.deleteLater() + self.current_download = None + self.download_history = [(n, s, t) for n, s, t in self.download_history if n != name] + self.download_history.append((name, f"Erreur: {error}", time.time())) + self.download_error.emit(name, error) + self.queue_updated.emit() + + # Démarrer automatiquement le prochain téléchargement même en cas d'erreur + if self.download_queue: + next_name, next_url, next_bandwidth = self.download_queue.pop(0) + self.start_download(next_name, next_url, next_bandwidth) + + def cancel_download(self, name): + # Si c'est le téléchargement en cours + if self.current_download and self.current_download.name == name: + self.current_download.stop() + self.current_download.deleteLater() + self.current_download = None + self.download_history = [(n, s, t) for n, s, t in self.download_history if n != name] + self.download_history.append((name, "Annulé", time.time())) + self.queue_updated.emit() + self.process_queue() + # Si c'est dans la file d'attente + else: + self.download_queue = [(n, u, b) for n, u, b in self.download_queue if n != name] + self.download_history = [(n, s, t) for n, s, t in self.download_history if n != name] + self.download_history.append((name, "Annulé", time.time())) + self.queue_updated.emit() + + def pause_download(self, name): + if self.current_download and self.current_download.name == name: + self.current_download.pause() + self.download_history = [(n, s, t) for n, s, t in self.download_history if n != name] + self.download_history.append((name, "En pause", time.time())) + self.download_paused.emit(name) + self.queue_updated.emit() + + def resume_download(self, name): + if self.current_download and self.current_download.name == name: + self.current_download.resume() + self.download_history = [(n, s, t) for n, s, t in self.download_history if n != name] + self.download_history.append((name, "En cours", time.time())) + self.download_resumed.emit(name) + self.queue_updated.emit() \ No newline at end of file diff --git a/src/core/m3u.py b/src/core/m3u.py new file mode 100644 index 0000000..90929bd --- /dev/null +++ b/src/core/m3u.py @@ -0,0 +1,173 @@ +import re +import requests +import logging +from dataclasses import dataclass +from typing import List, Tuple, Dict +from PyQt5.QtCore import QThread, pyqtSignal + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +@dataclass +class M3UEntry: + name: str + url: str + xui_id: str = None + tvg_name: str = None + tvg_logo: str = None + group_title: str = None + +class M3ULoaderThread(QThread): + finished = pyqtSignal(tuple) + error = pyqtSignal(str) + progress = pyqtSignal(str) + + def __init__(self, url, parser): + super().__init__() + self.url = url + self.parser = parser + self.should_stop = False + self._is_running = False + + def stop(self): + logger.debug("Arrêt du chargement demandé") + self.should_stop = True + if self._is_running: + self.wait() + + def run(self): + try: + self._is_running = True + logger.debug(f"Début du chargement M3U depuis {self.url}") + self.progress.emit("Connexion au serveur...") + + if self.should_stop: + logger.debug("Chargement annulé avant la connexion") + return + + session = requests.Session() + session.headers.update({ + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + }) + + try: + response = session.get(self.url, timeout=30, verify=False) + response.raise_for_status() + except Exception as e: + logger.error(f"Erreur lors de la requête HTTP: {str(e)}") + raise + + if self.should_stop: + logger.debug("Chargement annulé après la connexion") + return + + logger.debug("Connexion établie, début du téléchargement") + self.progress.emit("Téléchargement du contenu...") + + content = response.text + + if self.should_stop: + logger.debug("Chargement annulé après le téléchargement") + return + + if not content.strip(): + raise ValueError("Le contenu M3U est vide") + + if "#EXTINF" not in content: + raise ValueError("Le fichier ne semble pas être un fichier M3U valide") + + logger.debug("Début de l'analyse du contenu") + self.progress.emit("Analyse du contenu...") + + if self.should_stop: + logger.debug("Chargement annulé avant l'analyse") + return + + result = self.parser.parse_content(content) + + if not result[0]: + raise ValueError("Aucune entrée VOD n'a été trouvée dans le fichier M3U") + + if self.should_stop: + logger.debug("Chargement annulé après l'analyse") + return + + logger.debug(f"Analyse terminée, {len(result[0])} entrées trouvées") + self.finished.emit(result) + + except requests.ConnectionError as e: + if not self.should_stop: + logger.error(f"Erreur de connexion: {str(e)}") + self.error.emit("Erreur de connexion au serveur. Vérifiez votre connexion internet et l'URL.") + except requests.Timeout as e: + if not self.should_stop: + logger.error(f"Timeout: {str(e)}") + self.error.emit("Le serveur met trop de temps à répondre. Réessayez plus tard.") + except requests.HTTPError as e: + if not self.should_stop: + logger.error(f"Erreur HTTP {e.response.status_code}: {str(e)}") + if e.response.status_code == 404: + self.error.emit("L'URL du fichier M3U n'est pas valide (404 Not Found)") + elif e.response.status_code == 403: + self.error.emit("Accès refusé au fichier M3U (403 Forbidden)") + else: + self.error.emit(f"Erreur HTTP {e.response.status_code} lors du chargement du M3U") + except ValueError as e: + if not self.should_stop: + logger.error(f"Erreur de validation: {str(e)}") + self.error.emit(str(e)) + except Exception as e: + if not self.should_stop: + logger.error(f"Erreur inattendue: {str(e)}", exc_info=True) + self.error.emit(f"Erreur inattendue lors du chargement du M3U: {str(e)}") + finally: + self._is_running = False + +class M3UParser: + def __init__(self): + self.pattern = r'#EXTINF:-1\s+(?:.*?xui-id="([^"]*)")?\s*(?:tvg-name="([^"]*)")?\s*(?:tvg-logo="([^"]*)")?\s*(?:group-title="([^"]*)")?,([^\n]*)\n(http[^\n]+)' + + def parse_url(self, url: str) -> M3ULoaderThread: + return M3ULoaderThread(url, self) + + def parse_content(self, content: str) -> Tuple[List[Tuple[str, str]], Dict[str, Dict]]: + entries = [] + vod_info = {} + seen_entries = set() + + try: + for match in re.finditer(self.pattern, content, re.MULTILINE): + try: + xui_id, tvg_name, tvg_logo, group_title, title, url = match.groups() + name = tvg_name or title.strip() + + entry_key = (name, url) + if entry_key in seen_entries: + continue + seen_entries.add(entry_key) + + vod_info[name] = { + 'xui_id': xui_id, + 'tvg_logo': tvg_logo, + 'group_title': group_title, + 'url': url + } + + entries.append((name, url)) + except Exception as e: + logger.warning(f"Erreur lors du parsing d'une entrée: {str(e)}") + continue + + logger.debug(f"Parsing terminé: {len(entries)} entrées valides trouvées") + return entries, vod_info + except Exception as e: + logger.error(f"Erreur lors du parsing du contenu: {str(e)}", exc_info=True) + raise ValueError(f"Erreur lors du parsing du contenu M3U: {str(e)}") + + @staticmethod + def get_categories(vod_info: Dict[str, Dict]) -> List[str]: + categories = set() + for info in vod_info.values(): + if info['group_title']: + categories.add(info['group_title']) + return sorted(list(categories)) \ No newline at end of file diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..ff290db --- /dev/null +++ b/src/main.py @@ -0,0 +1,35 @@ +import sys +import os +import logging +from PyQt5.QtWidgets import QApplication + +# Ajouter le chemin du script au PYTHONPATH +if getattr(sys, 'frozen', False): + # Si on est dans l'exécutable + application_path = sys._MEIPASS +else: + # Si on est en développement + application_path = os.path.dirname(os.path.abspath(__file__)) + +sys.path.insert(0, os.path.dirname(application_path)) + +from src.ui.main_window import MainWindow + +def setup_logging(): + """Configuration du système de logging""" + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + +def main(): + """Point d'entrée principal de l'application""" + setup_logging() + + app = QApplication(sys.argv) + ex = MainWindow() + ex.show() + sys.exit(app.exec_()) + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/src/ui/__init__.py b/src/ui/__init__.py new file mode 100644 index 0000000..9208645 --- /dev/null +++ b/src/ui/__init__.py @@ -0,0 +1,3 @@ +""" +Package contenant les composants de l'interface utilisateur +""" \ No newline at end of file diff --git a/src/ui/config_tab.py b/src/ui/config_tab.py new file mode 100644 index 0000000..0176cc1 --- /dev/null +++ b/src/ui/config_tab.py @@ -0,0 +1,84 @@ +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QLabel, QLineEdit, QPushButton, QSpinBox, + QCheckBox, QGroupBox, QMessageBox +) +from src.core.config import save_config + +class ConfigTab(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.parent = parent + self.init_ui() + + def init_ui(self): + """Initialiser l'interface de l'onglet de configuration""" + layout = QVBoxLayout() + + # Configuration M3U + m3u_group = QGroupBox("Configuration M3U") + m3u_layout = QHBoxLayout() + self.m3u_label = QLabel("M3U URL:") + self.m3u_box = QLineEdit(self.parent.m3u_url) + self.m3u_button = QPushButton("Sauvegarder URL") + m3u_layout.addWidget(self.m3u_label) + m3u_layout.addWidget(self.m3u_box) + m3u_layout.addWidget(self.m3u_button) + m3u_group.setLayout(m3u_layout) + + # Configuration des téléchargements + download_group = QGroupBox("Configuration des téléchargements") + download_layout = QGridLayout() + + self.bandwidth_label = QLabel("Limite de bande passante (KB/s):") + self.bandwidth_spin = QSpinBox() + self.bandwidth_spin.setRange(0, 100000) + self.bandwidth_spin.setValue(self.parent.config.get("bandwidth_limit", 0)) + self.bandwidth_spin.setSpecialValueText("Illimité") + + download_layout.addWidget(self.bandwidth_label, 0, 0) + download_layout.addWidget(self.bandwidth_spin, 0, 1) + + download_group.setLayout(download_layout) + + # Configuration du thème + theme_group = QGroupBox("Apparence") + theme_layout = QVBoxLayout() + self.theme_check = QCheckBox("Mode sombre") + self.theme_check.setChecked(self.parent.dark_mode) + theme_layout.addWidget(self.theme_check) + theme_group.setLayout(theme_layout) + + # Ajout des groupes au layout principal + layout.addWidget(m3u_group) + layout.addWidget(download_group) + layout.addWidget(theme_group) + layout.addStretch() + + self.setLayout(layout) + + # Connexions + self.m3u_button.clicked.connect(self.save_m3u_url) + self.bandwidth_spin.valueChanged.connect(self.save_config) + self.theme_check.stateChanged.connect(self.toggle_theme) + + def save_m3u_url(self): + """Sauvegarder l'URL M3U""" + self.parent.m3u_url = self.m3u_box.text() + self.parent.config["m3u_url"] = self.parent.m3u_url + self.save_config() + QMessageBox.information(self, "URL Sauvegardée", "L'URL M3U a été mise à jour.") + self.parent.try_load_m3u_content() + + def save_config(self): + """Sauvegarder la configuration""" + self.parent.config["bandwidth_limit"] = self.bandwidth_spin.value() + self.parent.config["dark_mode"] = self.parent.dark_mode + save_config(self.parent.config) + + def toggle_theme(self, state): + """Changer le thème de l'application""" + self.parent.dark_mode = bool(state) + self.parent.config["dark_mode"] = self.parent.dark_mode + self.save_config() + self.parent.apply_theme() \ No newline at end of file diff --git a/src/ui/download_tab.py b/src/ui/download_tab.py new file mode 100644 index 0000000..829e2b9 --- /dev/null +++ b/src/ui/download_tab.py @@ -0,0 +1,153 @@ +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QComboBox, QListWidget, QPushButton, + QMessageBox +) +from PyQt5.QtCore import Qt + +class DownloadTab(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.parent = parent + self.init_ui() + + def init_ui(self): + """Initialiser l'interface de l'onglet de téléchargement""" + layout = QVBoxLayout() + + # Zone de recherche + search_layout = QHBoxLayout() + self.search_box = QLineEdit() + self.search_box.setPlaceholderText("Rechercher...") + search_layout.addWidget(self.search_box) + + # Filtres et tri + self.filter_combo = QComboBox() + self.filter_combo.addItem("Tous") + self.filter_combo.setMinimumWidth(200) # Définir une largeur minimale + self.filter_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents) # Ajuster à la taille du contenu + + self.sort_combo = QComboBox() + self.sort_combo.addItems(["Nom (A-Z)", "Nom (Z-A)"]) + search_layout.addWidget(self.filter_combo) + search_layout.addWidget(self.sort_combo) + layout.addLayout(search_layout) + + # Liste des VODs + self.list_widget = QListWidget() + layout.addWidget(self.list_widget) + + # Informations sur le fichier + self.file_info_label = QLabel() + layout.addWidget(self.file_info_label) + + # Boutons + button_layout = QHBoxLayout() + self.download_button = QPushButton("Télécharger") + button_layout.addWidget(self.download_button) + layout.addLayout(button_layout) + + self.setLayout(layout) + + # Connexions + self.search_box.textChanged.connect(self.search_vods) + self.filter_combo.currentTextChanged.connect(self.apply_filter) + self.sort_combo.currentTextChanged.connect(self.apply_sort) + self.download_button.clicked.connect(self.download_selected_vod) + self.list_widget.currentItemChanged.connect(self.update_file_info) + + def search_vods(self): + """Rechercher dans les VODs""" + if not self.parent.entries: + QMessageBox.warning( + self, "Erreur", "Aucune donnée chargée. Veuillez charger ou actualiser le contenu M3U." + ) + return + + search_query = self.search_box.text().lower() + selected_category = self.filter_combo.currentText() + + self.list_widget.clear() + filtered = [] + + for name, url in self.parent.entries: + info = self.parent.vod_info[name] + + # Vérifier la catégorie + if selected_category != "Tous" and info['group_title'] != selected_category: + continue + + # Vérifier le terme de recherche + if search_query and search_query not in name.lower(): + continue + + filtered.append(name) + + # Appliquer le tri + sort_method = self.sort_combo.currentText() + if sort_method == "Nom (A-Z)": + filtered.sort() + elif sort_method == "Nom (Z-A)": + filtered.sort(reverse=True) + + self.list_widget.addItems(filtered) + + def apply_filter(self, filter_text): + """Appliquer le filtre de catégorie""" + self.search_vods() + + def apply_sort(self, sort_method): + """Appliquer le tri""" + items = [] + for i in range(self.list_widget.count()): + items.append(self.list_widget.item(i).text()) + + if sort_method == "Nom (A-Z)": + items.sort() + elif sort_method == "Nom (Z-A)": + items.sort(reverse=True) + + self.list_widget.clear() + self.list_widget.addItems(items) + + def update_filter_categories(self): + """Mettre à jour la liste des catégories dans le filtre""" + categories = set() + for info in self.parent.vod_info.values(): + if info['group_title']: + categories.add(info['group_title']) + + self.filter_combo.clear() + self.filter_combo.addItem("Tous") + self.filter_combo.addItems(sorted(categories)) + + def update_file_info(self, current, previous): + """Mettre à jour les informations du fichier sélectionné""" + if current: + name = current.text() + info = self.parent.vod_info.get(name, {}) + + details = [] + if info.get('group_title'): + details.append(f"Catégorie: {info['group_title']}") + if info.get('tvg_logo'): + details.append(f"Logo: {info['tvg_logo']}") + if info.get('xui_id'): + details.append(f"ID: {info['xui_id']}") + + self.file_info_label.setText("\n".join(details)) + + def download_selected_vod(self): + """Télécharger le VOD sélectionné""" + selected_item = self.list_widget.currentItem() + if selected_item: + name = selected_item.text() + url = next((url for name_, url in self.parent.entries if name == name_), None) + if url: + bandwidth_limit = self.parent.config.get("bandwidth_limit", 0) + self.parent.download_manager.add_to_queue(name, url, bandwidth_limit) + QMessageBox.information( + self, + "Ajouté à la file d'attente", + f"{name} a été ajouté à la file d'attente de téléchargement." + ) \ No newline at end of file diff --git a/src/ui/main_window.py b/src/ui/main_window.py new file mode 100644 index 0000000..9081a6d --- /dev/null +++ b/src/ui/main_window.py @@ -0,0 +1,331 @@ +import os +import logging +from PyQt5.QtWidgets import ( + QMainWindow, QTabWidget, QWidget, QMessageBox, + QProgressDialog +) +from PyQt5.QtGui import QIcon +from PyQt5.QtCore import Qt + +logger = logging.getLogger(__name__) + +from src.core.config import load_config, save_config +from src.core.download import DownloadManager +from src.core.m3u import M3UParser + +from src.ui.download_tab import DownloadTab +from src.ui.queue_tab import QueueTab +from src.ui.stats_tab import StatsTab +from src.ui.config_tab import ConfigTab + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.config = load_config() + self.m3u_url = self.config.get("m3u_url", "") + self.entries = [] + self.vod_info = {} + self.download_manager = DownloadManager(self.config) + self.m3u_parser = M3UParser() + self.loading_dialog = None + self.loader_thread = None + + # Obtenir le chemin absolu de l'icône + icon_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets", "icon.ico") + + self.setWindowTitle("GrabNWatch") + if os.path.exists(icon_path): + self.setWindowIcon(QIcon(icon_path)) + + self.setGeometry(100, 100, 1000, 700) + + self.dark_mode = self.config.get("dark_mode", False) + self.init_ui() + self.try_load_m3u_content() + self.show_startup_message() + + def init_ui(self): + """Initialiser l'interface utilisateur""" + # Configuration des onglets + self.tabs = QTabWidget(self) + + # Créer les onglets + self.download_tab = DownloadTab(self) + self.queue_tab = QueueTab(self) + self.stats_tab = StatsTab(self) + self.config_tab = ConfigTab(self) + + # Ajouter les onglets + self.tabs.addTab(self.download_tab, "Téléchargement") + self.tabs.addTab(self.queue_tab, "File d'attente") + self.tabs.addTab(self.stats_tab, "Statistiques") + self.tabs.addTab(self.config_tab, "Configuration") + + self.setCentralWidget(self.tabs) + + def try_load_m3u_content(self): + """Tente de charger le contenu M3U si l'URL est valide""" + if not self.m3u_url: + return + + if not self.m3u_url.startswith("http"): + QMessageBox.warning(self, "Erreur", "L'URL doit commencer par 'http://' ou 'https://'") + return + + try: + logger.debug(f"Début du chargement M3U depuis {self.m3u_url}") + self.loading_dialog = QProgressDialog("Préparation du chargement...", "Annuler", 0, 0, self) + self.loading_dialog.setWindowTitle("Chargement M3U") + self.loading_dialog.setWindowModality(Qt.WindowModal) + self.loading_dialog.setMinimumDuration(0) + self.loading_dialog.setAutoClose(False) + self.loading_dialog.setAutoReset(False) + self.loading_dialog.setMinimumWidth(300) + + # Arrêter le thread précédent s'il existe + if self.loader_thread is not None: + self.loader_thread.stop() + self.loader_thread.wait() + self.loader_thread.deleteLater() + + self.loader_thread = self.m3u_parser.parse_url(self.m3u_url) + self.loader_thread.finished.connect(self.on_m3u_loaded) + self.loader_thread.error.connect(self.on_m3u_error) + self.loader_thread.progress.connect(self.loading_dialog.setLabelText) + + self.loading_dialog.canceled.connect(self.loader_thread.stop) + + self.loading_dialog.show() + self.loader_thread.start() + except Exception as e: + logger.error(f"Erreur lors du démarrage du chargement: {str(e)}", exc_info=True) + QMessageBox.critical(self, "Erreur", f"Erreur lors du démarrage du chargement: {str(e)}") + if self.loading_dialog: + self.loading_dialog.close() + + def on_m3u_loaded(self, result): + """Appelé lorsque le M3U est chargé avec succès""" + try: + if self.loading_dialog and self.loading_dialog.wasCanceled(): + logger.debug("Chargement annulé par l'utilisateur") + self.loading_dialog.close() + return + + self.entries, self.vod_info = result + + if not self.entries: + logger.warning("Aucune entrée trouvée dans le fichier M3U") + QMessageBox.warning(self, "Attention", "Aucune entrée n'a été trouvée dans le fichier M3U.") + else: + logger.info(f"{len(self.entries)} entrées chargées avec succès") + self.download_tab.update_filter_categories() + QMessageBox.information(self, "Succès", f"{len(self.entries)} entrées ont été chargées avec succès.") + except Exception as e: + logger.error(f"Erreur lors du traitement des données: {str(e)}", exc_info=True) + QMessageBox.critical(self, "Erreur", f"Erreur lors du traitement des données: {str(e)}") + finally: + if self.loading_dialog: + self.loading_dialog.close() + # Nettoyer le thread + if self.loader_thread: + self.loader_thread.deleteLater() + self.loader_thread = None + + def on_m3u_error(self, error_message): + """Appelé en cas d'erreur lors du chargement du M3U""" + try: + logger.error(f"Erreur de chargement M3U: {error_message}") + if self.loading_dialog: + self.loading_dialog.close() + QMessageBox.critical(self, "Erreur", error_message) + except Exception as e: + logger.error(f"Erreur lors de l'affichage du message d'erreur: {str(e)}", exc_info=True) + QMessageBox.critical(self, "Erreur", f"Erreur lors de l'affichage du message d'erreur: {str(e)}") + finally: + # Nettoyer le thread + if self.loader_thread: + self.loader_thread.deleteLater() + self.loader_thread = None + + def show_startup_message(self): + """Afficher le message de démarrage""" + QMessageBox.information( + self, + "Attention", + "Veuillez couper les flux IPTV sur les autres appareils que vous utilisez, " + "sinon le téléchargement ne fonctionnera pas, à moins que vous ne disposiez de plusieurs lignes." + ) + + def closeEvent(self, event): + """Gérer la fermeture propre de l'application""" + try: + # Arrêter le thread de chargement M3U s'il est en cours + if self.loader_thread is not None: + logger.debug("Arrêt du thread de chargement M3U") + self.loader_thread.stop() + self.loader_thread.wait() + self.loader_thread.deleteLater() + self.loader_thread = None + + # Arrêter les téléchargements en cours + if hasattr(self, 'download_manager'): + if self.download_manager.current_download: + self.download_manager.current_download.stop() + self.download_manager.current_download.wait() + except Exception as e: + logger.error(f"Erreur lors de la fermeture: {str(e)}", exc_info=True) + finally: + event.accept() + + def apply_theme(self): + """Appliquer le thème (clair ou sombre)""" + if self.dark_mode: + self.setStyleSheet(""" + /* Style global */ + QMainWindow, QWidget { + background-color: #1e1e1e; + color: #ffffff; + } + + /* Onglets */ + QTabWidget::pane { + border: 1px solid #3d3d3d; + background-color: #1e1e1e; + top: -1px; + } + QTabBar::tab { + background-color: #2d2d2d; + color: #ffffff; + padding: 8px 20px; + border: 1px solid #3d3d3d; + border-bottom: none; + margin-right: 2px; + } + QTabBar::tab:selected { + background-color: #1e1e1e; + border-top: 2px solid #007acc; + } + QTabBar::tab:!selected { + margin-top: 2px; + } + + /* Listes */ + QListWidget { + background-color: #252526; + border: 1px solid #3d3d3d; + color: #ffffff; + outline: none; + } + QListWidget::item { + padding: 5px; + } + QListWidget::item:selected { + background-color: #094771; + color: #ffffff; + } + QListWidget::item:hover { + background-color: #2a2d2e; + } + + /* Boutons */ + QPushButton { + background-color: #0e639c; + color: white; + border: none; + padding: 6px 16px; + border-radius: 2px; + } + QPushButton:hover { + background-color: #1177bb; + } + QPushButton:pressed { + background-color: #094771; + } + QPushButton:disabled { + background-color: #3d3d3d; + color: #888888; + } + + /* Champs de texte et spinbox */ + QLineEdit, QSpinBox { + background-color: #3c3c3c; + border: 1px solid #3d3d3d; + color: white; + padding: 5px; + selection-background-color: #094771; + } + QLineEdit:focus, QSpinBox:focus { + border: 1px solid #007acc; + } + QSpinBox::up-button, QSpinBox::down-button { + background-color: #3c3c3c; + border: none; + } + QSpinBox::up-arrow { + image: none; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-bottom: 4px solid #ffffff; + } + QSpinBox::down-arrow { + image: none; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid #ffffff; + } + + /* Groupes */ + QGroupBox { + border: 1px solid #3d3d3d; + margin-top: 12px; + padding-top: 5px; + color: #ffffff; + } + QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 0 3px; + color: #ffffff; + } + + /* Labels */ + QLabel { + color: #ffffff; + } + + /* Checkbox */ + QCheckBox { + color: #ffffff; + spacing: 5px; + } + QCheckBox::indicator { + width: 16px; + height: 16px; + border: 1px solid #3d3d3d; + background: #3c3c3c; + } + QCheckBox::indicator:checked { + background: #007acc; + border: 1px solid #007acc; + } + QCheckBox::indicator:checked:hover { + background: #1177bb; + } + QCheckBox::indicator:hover { + border: 1px solid #007acc; + } + + /* Messages */ + QMessageBox { + background-color: #1e1e1e; + color: #ffffff; + } + QMessageBox QLabel { + color: #ffffff; + } + QMessageBox QPushButton { + min-width: 80px; + } + """) + else: + self.setStyleSheet("") # Réinitialiser au thème par défaut \ No newline at end of file diff --git a/src/ui/queue_tab.py b/src/ui/queue_tab.py new file mode 100644 index 0000000..8b9f945 --- /dev/null +++ b/src/ui/queue_tab.py @@ -0,0 +1,136 @@ +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, + QListWidget, QPushButton, QGroupBox, + QMessageBox +) +from PyQt5.QtCore import Qt +import time + +class QueueTab(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.parent = parent + self.init_ui() + + def init_ui(self): + """Initialiser l'interface de l'onglet de la file d'attente""" + layout = QVBoxLayout() + + # Liste des téléchargements actifs + active_group = QGroupBox("Téléchargement en cours") + self.active_list = QListWidget() + active_layout = QVBoxLayout() + active_layout.addWidget(self.active_list) + active_group.setLayout(active_layout) + + # Liste de la file d'attente + queue_group = QGroupBox("File d'attente") + self.queue_list = QListWidget() + queue_layout = QVBoxLayout() + queue_layout.addWidget(self.queue_list) + queue_group.setLayout(queue_layout) + + # Historique des téléchargements + history_group = QGroupBox("Historique") + self.history_list = QListWidget() + history_layout = QVBoxLayout() + history_layout.addWidget(self.history_list) + history_group.setLayout(history_layout) + + # Boutons de contrôle + control_layout = QHBoxLayout() + self.pause_button = QPushButton("Pause") + self.resume_button = QPushButton("Reprendre") + self.cancel_button = QPushButton("Annuler") + control_layout.addWidget(self.pause_button) + control_layout.addWidget(self.resume_button) + control_layout.addWidget(self.cancel_button) + + # Ajout des widgets au layout principal + layout.addWidget(active_group) + layout.addWidget(queue_group) + layout.addWidget(history_group) + layout.addLayout(control_layout) + + self.setLayout(layout) + + # Connexion des boutons + self.cancel_button.clicked.connect(self.cancel_selected_download) + self.pause_button.clicked.connect(self.pause_selected_download) + self.resume_button.clicked.connect(self.resume_selected_download) + + # Connexion des signaux du gestionnaire de téléchargements + self.parent.download_manager.download_progress.connect(self.update_download_progress) + self.parent.download_manager.download_finished.connect(self.on_download_finished) + self.parent.download_manager.download_error.connect(self.on_download_error) + self.parent.download_manager.queue_updated.connect(self.update_queue_display) + self.parent.download_manager.download_finished.connect( + lambda: self.parent.stats_tab.update_stats_display() + ) + + def update_queue_display(self): + """Mettre à jour l'affichage de la file d'attente""" + # Mise à jour du téléchargement actif + self.active_list.clear() + if self.parent.download_manager.current_download: + name = self.parent.download_manager.current_download.name + status = "En pause" if hasattr(self.parent.download_manager.current_download, 'paused') and self.parent.download_manager.current_download.paused else "En cours" + self.active_list.addItem(f"{name} - {status}") + + # Mise à jour de la file d'attente + self.queue_list.clear() + for name, _, _ in self.parent.download_manager.download_queue: + self.queue_list.addItem(f"{name} - En attente") + + # Mise à jour de l'historique + self.history_list.clear() + for name, status, timestamp in sorted( + self.parent.download_manager.download_history, + key=lambda x: x[2], + reverse=True + ): + self.history_list.addItem( + f"{name} - {status} - {time.strftime('%H:%M:%S', time.localtime(timestamp))}" + ) + + def update_download_progress(self, name, progress): + """Mettre à jour la progression du téléchargement""" + items = self.active_list.findItems(f"{name}", Qt.MatchStartsWith) + if items: + items[0].setText(f"{name} - {progress}%") + + def on_download_finished(self, name): + """Gérer la fin d'un téléchargement""" + # Pas de boîte de dialogue, juste mettre à jour l'interface + self.update_queue_display() + + def on_download_error(self, name, error): + """Gérer une erreur de téléchargement""" + # Pour les erreurs, on garde la boîte de dialogue car c'est important + QMessageBox.warning( + self, + "Erreur de téléchargement", + f"Erreur lors du téléchargement de {name}: {error}" + ) + self.update_queue_display() + + def cancel_selected_download(self): + """Annuler le téléchargement sélectionné""" + selected_item = self.active_list.currentItem() + if selected_item: + name = selected_item.text().split(" - ")[0] + self.parent.download_manager.cancel_download(name) + + def pause_selected_download(self): + """Mettre en pause le téléchargement sélectionné""" + selected_item = self.active_list.currentItem() + if selected_item: + name = selected_item.text().split(" - ")[0] + self.parent.download_manager.pause_download(name) + + def resume_selected_download(self): + """Reprendre le téléchargement sélectionné""" + selected_item = self.active_list.currentItem() + if selected_item: + name = selected_item.text().split(" - ")[0] + self.parent.download_manager.resume_download(name) \ No newline at end of file diff --git a/src/ui/stats_tab.py b/src/ui/stats_tab.py new file mode 100644 index 0000000..bb3b3ef --- /dev/null +++ b/src/ui/stats_tab.py @@ -0,0 +1,38 @@ +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QGridLayout, + QLabel, QGroupBox +) + +class StatsTab(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.parent = parent + self.init_ui() + + def init_ui(self): + """Initialiser l'interface de l'onglet des statistiques""" + layout = QVBoxLayout() + + # Statistiques globales + stats_group = QGroupBox("Statistiques globales") + stats_layout = QGridLayout() + + self.total_downloads_label = QLabel("Téléchargements totaux: 0") + self.total_size_label = QLabel("Taille totale: 0 MB") + self.average_speed_label = QLabel("Vitesse moyenne: 0 MB/s") + + stats_layout.addWidget(self.total_downloads_label, 0, 0) + stats_layout.addWidget(self.total_size_label, 1, 0) + stats_layout.addWidget(self.average_speed_label, 2, 0) + + stats_group.setLayout(stats_layout) + layout.addWidget(stats_group) + + self.setLayout(layout) + + def update_stats_display(self): + """Mettre à jour l'affichage des statistiques""" + stats = self.parent.download_manager.stats + self.total_downloads_label.setText(f"Téléchargements totaux: {stats['total_downloads']}") + self.total_size_label.setText(f"Taille totale: {stats['total_size'] / (1024*1024):.2f} MB") + self.average_speed_label.setText(f"Vitesse moyenne: {stats['average_speed'] / (1024*1024):.2f} MB/s") \ No newline at end of file diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..c5a57e7 --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1,3 @@ +""" +Package contenant les utilitaires +""" \ No newline at end of file