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 0000000..8057467 Binary files /dev/null and b/requirements.txt differ 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 0000000..1ac65df Binary files /dev/null and b/src/assets/icon.ico differ 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