import os import requests import threading import time import tkinter as tk from tkinter import ttk, messagebox from typing import Optional, Dict, Any, List, Callable from dataclasses import dataclass from enum import Enum import json from datetime import datetime import queue class DownloadStatus(Enum): """Download status enumeration.""" QUEUED = "Queued" DOWNLOADING = "Downloading" PAUSED = "Paused" COMPLETED = "Completed" FAILED = "Failed" CANCELLED = "Cancelled" AUTH_REQUIRED = "Auth Required" @dataclass class DownloadItem: """Represents a download item.""" id: str repo_id: str filename: str url: str save_path: str total_size: int = 0 downloaded_size: int = 0 status: DownloadStatus = DownloadStatus.QUEUED error_message: str = "" start_time: Optional[float] = None end_time: Optional[float] = None speed: float = 0.0 eta: int = 0 headers: Dict[str, str] = None resume_position: int = 0 def __post_init__(self): if self.headers is None: self.headers = {} @property def progress(self) -> float: """Calculate download progress percentage.""" if self.total_size > 0: return (self.downloaded_size / self.total_size) * 100 return 0.0 @property def is_resumable(self) -> bool: """Check if download can be resumed.""" return self.status in [DownloadStatus.PAUSED, DownloadStatus.FAILED] class DownloadManager: """Manages multiple downloads with pause/resume support.""" def __init__(self, max_concurrent: int = 3): self.downloads: Dict[str, DownloadItem] = {} self.download_queue: queue.Queue = queue.Queue() self.active_downloads: Dict[str, threading.Thread] = {} self.max_concurrent = max_concurrent self.callbacks: Dict[str, List[Callable]] = { 'on_progress': [], 'on_status_change': [], 'on_complete': [], 'on_error': [], 'on_remove': [] } self._stop_flags: Dict[str, threading.Event] = {} self._pause_flags: Dict[str, threading.Event] = {} self._worker_thread = threading.Thread(target=self._process_queue, daemon=True) self._worker_thread.start() def add_download(self, repo_id: str, filename: str, url: str, save_path: str, headers: Optional[Dict[str, str]] = None) -> str: """Add a new download to the queue.""" download_id = f"{repo_id}_{filename}_{int(time.time())}" # Create download item item = DownloadItem( id=download_id, repo_id=repo_id, filename=filename, url=url, save_path=save_path, headers=headers or {} ) self.downloads[download_id] = item self.download_queue.put(download_id) self._trigger_callback('on_status_change', item) return download_id def pause_download(self, download_id: str): """Pause a download.""" if download_id in self._pause_flags: self._pause_flags[download_id].set() if download_id in self.downloads: self.downloads[download_id].status = DownloadStatus.PAUSED self._trigger_callback('on_status_change', self.downloads[download_id]) def resume_download(self, download_id: str): """Resume a paused download.""" item = self.downloads.get(download_id) if item and item.is_resumable: item.status = DownloadStatus.QUEUED item.resume_position = item.downloaded_size self.download_queue.put(download_id) self._trigger_callback('on_status_change', item) def cancel_download(self, download_id: str): """Cancel a download.""" if download_id in self._stop_flags: self._stop_flags[download_id].set() if download_id in self.downloads: item = self.downloads[download_id] item.status = DownloadStatus.CANCELLED self._trigger_callback('on_status_change', item) # Remove partial file if os.path.exists(item.save_path): try: os.remove(item.save_path) except Exception: pass # Auto-remove cancelled download after a short delay def auto_remove(): time.sleep(2) # Wait 2 seconds self.remove_download(download_id) threading.Thread(target=auto_remove, daemon=True).start() def remove_download(self, download_id: str): """Remove a specific download from the list.""" if download_id in self.downloads: # Cancel if still active if download_id in self._stop_flags: self._stop_flags[download_id].set() # Remove from downloads item = self.downloads[download_id] del self.downloads[download_id] self._trigger_callback('on_remove', item) def retry_download(self, download_id: str): """Retry a failed download.""" item = self.downloads.get(download_id) if item and item.status in [DownloadStatus.FAILED, DownloadStatus.AUTH_REQUIRED]: item.status = DownloadStatus.QUEUED item.downloaded_size = 0 item.resume_position = 0 item.error_message = "" self.download_queue.put(download_id) self._trigger_callback('on_status_change', item) def _process_queue(self): """Process download queue.""" while True: # Check if we can start more downloads if len(self.active_downloads) < self.max_concurrent: try: download_id = self.download_queue.get(timeout=1) if download_id in self.downloads: thread = threading.Thread( target=self._download_file, args=(download_id,), daemon=True ) self.active_downloads[download_id] = thread thread.start() except queue.Empty: pass # Clean up finished downloads finished = [] for download_id, thread in self.active_downloads.items(): if not thread.is_alive(): finished.append(download_id) for download_id in finished: del self.active_downloads[download_id] time.sleep(0.5) def _download_file(self, download_id: str): """Download a file with resume support.""" item = self.downloads[download_id] # Create flags for this download self._stop_flags[download_id] = threading.Event() self._pause_flags[download_id] = threading.Event() try: # Update status item.status = DownloadStatus.DOWNLOADING item.start_time = time.time() self._trigger_callback('on_status_change', item) # Create directory if needed os.makedirs(os.path.dirname(item.save_path), exist_ok=True) # Setup headers for resume headers = item.headers.copy() if item.resume_position > 0: headers['Range'] = f'bytes={item.resume_position}-' # Make request response = requests.get(item.url, headers=headers, stream=True, timeout=30) # Check for authentication issues if response.status_code == 401 or response.status_code == 403: item.status = DownloadStatus.AUTH_REQUIRED item.error_message = f"Authentication failed: {response.status_code}" self._trigger_callback('on_error', item) return response.raise_for_status() # Get total size if item.resume_position == 0: item.total_size = int(response.headers.get('content-length', 0)) # Open file for writing (append if resuming) mode = 'ab' if item.resume_position > 0 else 'wb' # Optimize chunk size based on file size and storage type base_chunk_size = 1024 * 1024 # 1MB base chunk if item.total_size > 100 * 1024 * 1024: # Files > 100MB chunk_size = base_chunk_size * 4 # 4MB chunks elif item.total_size > 10 * 1024 * 1024: # Files > 10MB chunk_size = base_chunk_size * 2 # 2MB chunks else: chunk_size = base_chunk_size # 1MB chunks # Use buffered writing for better performance buffer_size = chunk_size * 8 # 8x chunk size buffer with open(item.save_path, mode, buffering=buffer_size) as f: last_update = time.time() bytes_since_update = 0 update_interval = 0.5 # Update UI every 0.5 seconds for chunk in response.iter_content(chunk_size=chunk_size): # Check stop flag if self._stop_flags[download_id].is_set(): return # Check pause flag if self._pause_flags[download_id].is_set(): item.status = DownloadStatus.PAUSED self._trigger_callback('on_status_change', item) return if chunk: f.write(chunk) item.downloaded_size += len(chunk) bytes_since_update += len(chunk) # Calculate speed and ETA (less frequent updates for performance) current_time = time.time() time_diff = current_time - last_update if time_diff >= update_interval: item.speed = bytes_since_update / time_diff if item.speed > 0 and item.total_size > item.downloaded_size: remaining = item.total_size - item.downloaded_size item.eta = int(remaining / item.speed) self._trigger_callback('on_progress', item) last_update = current_time bytes_since_update = 0 # Force flush for USB drives f.flush() os.fsync(f.fileno()) # Download completed item.status = DownloadStatus.COMPLETED item.end_time = time.time() self._trigger_callback('on_complete', item) except requests.exceptions.RequestException as e: item.status = DownloadStatus.FAILED item.error_message = str(e) self._trigger_callback('on_error', item) except Exception as e: item.status = DownloadStatus.FAILED item.error_message = f"Unexpected error: {e}" self._trigger_callback('on_error', item) finally: # Clean up flags if download_id in self._stop_flags: del self._stop_flags[download_id] if download_id in self._pause_flags: del self._pause_flags[download_id] self._trigger_callback('on_status_change', item) def register_callback(self, event: str, callback: Callable): """Register a callback for download events.""" if event in self.callbacks: self.callbacks[event].append(callback) def _trigger_callback(self, event: str, item: DownloadItem): """Trigger callbacks for an event.""" for callback in self.callbacks.get(event, []): try: callback(item) except Exception as e: print(f"Callback error: {e}") def get_all_downloads(self) -> List[DownloadItem]: """Get all download items.""" return list(self.downloads.values()) def clear_completed(self): """Clear completed downloads from the list.""" to_remove = [] for download_id, item in self.downloads.items(): if item.status in [DownloadStatus.COMPLETED, DownloadStatus.CANCELLED]: to_remove.append(download_id) for download_id in to_remove: self.remove_download(download_id) class DownloadManagerTab: """Download Manager GUI tab with improved real-time updates.""" def __init__(self, parent: ttk.Frame, download_manager: DownloadManager): self.parent = parent self.manager = download_manager self.item_widgets: Dict[str, Dict[str, Any]] = {} # Register callbacks self.manager.register_callback('on_progress', self._on_progress) self.manager.register_callback('on_status_change', self._on_status_change) self.manager.register_callback('on_complete', self._on_complete) self.manager.register_callback('on_error', self._on_error) self.manager.register_callback('on_remove', self._on_remove) self._build_ui() # Start update timer with shorter interval for real-time updates self._update_display() def _build_ui(self): """Build the download manager UI.""" # Top controls with better layout controls_frame = ttk.Frame(self.parent) controls_frame.pack(fill=tk.X, padx=10, pady=5) # Left side controls left_controls = ttk.Frame(controls_frame) left_controls.pack(side=tk.LEFT) ttk.Button(left_controls, text="Clear Completed", command=self._clear_completed).pack(side=tk.LEFT, padx=2) ttk.Button(left_controls, text="Pause All", command=self._pause_all).pack(side=tk.LEFT, padx=2) ttk.Button(left_controls, text="Resume All", command=self._resume_all).pack(side=tk.LEFT, padx=2) ttk.Button(left_controls, text="Refresh", command=self._refresh_list).pack(side=tk.LEFT, padx=2) ttk.Button(left_controls, text="Remove Selected", command=self._remove_selected).pack(side=tk.LEFT, padx=2) # Right side status right_controls = ttk.Frame(controls_frame) right_controls.pack(side=tk.RIGHT) self.status_label = ttk.Label(right_controls, text="Downloads: 0 active, 0 queued") self.status_label.pack(side=tk.RIGHT) # Main downloads area using Treeview for better layout main_frame = ttk.Frame(self.parent) main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) # Create treeview for downloads columns = ("repo", "file", "status", "progress", "size", "speed", "eta") self.tree = ttk.Treeview(main_frame, columns=columns, show="headings", height=15) # Configure columns self.tree.heading("repo", text="Repository") self.tree.heading("file", text="File") self.tree.heading("status", text="Status") self.tree.heading("progress", text="Progress") self.tree.heading("size", text="Size") self.tree.heading("speed", text="Speed") self.tree.heading("eta", text="ETA") self.tree.column("repo", width=200) self.tree.column("file", width=250) self.tree.column("status", width=100) self.tree.column("progress", width=100) self.tree.column("size", width=100) self.tree.column("speed", width=100) self.tree.column("eta", width=80) # Scrollbars for treeview v_scrollbar = ttk.Scrollbar(main_frame, orient="vertical", command=self.tree.yview) h_scrollbar = ttk.Scrollbar(main_frame, orient="horizontal", command=self.tree.xview) self.tree.configure(yscrollcommand=v_scrollbar.set, xscrollcommand=h_scrollbar.set) # Grid layout self.tree.grid(row=0, column=0, sticky="nsew") v_scrollbar.grid(row=0, column=1, sticky="ns") h_scrollbar.grid(row=1, column=0, sticky="ew") main_frame.grid_rowconfigure(0, weight=1) main_frame.grid_columnconfigure(0, weight=1) # Context menu for downloads self.context_menu = tk.Menu(self.tree, tearoff=0) self.context_menu.add_command(label="Pause", command=self._context_pause) self.context_menu.add_command(label="Resume", command=self._context_resume) self.context_menu.add_command(label="Cancel", command=self._context_cancel) self.context_menu.add_command(label="Retry", command=self._context_retry) self.context_menu.add_separator() self.context_menu.add_command(label="Remove", command=self._context_remove) self.context_menu.add_command(label="Open Folder", command=self._context_open_folder) self.tree.bind("", self._show_context_menu) # Details frame for selected download details_frame = ttk.LabelFrame(self.parent, text="Download Details", padding=5) details_frame.pack(fill=tk.X, padx=10, pady=5) self.details_text = tk.Text(details_frame, height=3, wrap=tk.WORD) self.details_text.pack(fill=tk.X) self.tree.bind("<>", self._on_selection_change) def add_download_item(self, item: DownloadItem): """Add a download item to the treeview.""" if item.id in self.item_widgets: return # Insert into treeview tree_id = self.tree.insert("", tk.END, values=( item.repo_id, item.filename, item.status.value, f"{item.progress:.1f}%", self._format_size(item.total_size) if item.total_size > 0 else "-", "-", "-" )) # Store mapping self.item_widgets[item.id] = { 'tree_id': tree_id, 'item': item } # Initial update self._update_download_item(item) def _update_download_item(self, item: DownloadItem): """Update a download item in the treeview.""" if item.id not in self.item_widgets: self.add_download_item(item) return tree_id = self.item_widgets[item.id]['tree_id'] # Check if tree item still exists if not self.tree.exists(tree_id): # Tree item was deleted, remove from our tracking del self.item_widgets[item.id] return # Update size display if item.total_size > 0: size_text = f"{self._format_size(item.downloaded_size)} / {self._format_size(item.total_size)}" else: size_text = self._format_size(item.downloaded_size) # Update speed display speed_text = f"{self._format_size(item.speed)}/s" if item.speed > 0 else "-" # Update ETA display eta_text = self._format_time(item.eta) if item.eta > 0 else "-" try: # Update treeview row self.tree.item(tree_id, values=( item.repo_id, item.filename, item.status.value, f"{item.progress:.1f}%", size_text, speed_text, eta_text )) # Update row color based on status if item.status == DownloadStatus.COMPLETED: self.tree.item(tree_id, tags=("completed",)) elif item.status == DownloadStatus.FAILED: self.tree.item(tree_id, tags=("failed",)) elif item.status == DownloadStatus.DOWNLOADING: self.tree.item(tree_id, tags=("downloading",)) else: self.tree.item(tree_id, tags=()) # Configure tag colors self.tree.tag_configure("completed", background="#d4edda") self.tree.tag_configure("failed", background="#f8d7da") self.tree.tag_configure("downloading", background="#d1ecf1") except tk.TclError: # Tree item no longer exists if item.id in self.item_widgets: del self.item_widgets[item.id] def _format_size(self, bytes_size: float) -> str: """Format bytes to human readable size.""" for unit in ['B', 'KB', 'MB', 'GB', 'TB']: if bytes_size < 1024.0: return f"{bytes_size:.1f} {unit}" bytes_size /= 1024.0 return f"{bytes_size:.1f} PB" def _format_time(self, seconds: int) -> str: """Format seconds to human readable time.""" if seconds < 60: return f"{seconds}s" elif seconds < 3600: minutes = seconds // 60 secs = seconds % 60 return f"{minutes}m {secs}s" else: hours = seconds // 3600 minutes = (seconds % 3600) // 60 return f"{hours}h {minutes}m" def _show_context_menu(self, event): """Show context menu for downloads.""" item = self.tree.selection()[0] if self.tree.selection() else None if item: self.context_menu.post(event.x_root, event.y_root) def _get_selected_download_id(self): """Get the download ID of the selected item.""" selection = self.tree.selection() if not selection: return None tree_id = selection[0] for download_id, data in self.item_widgets.items(): if data['tree_id'] == tree_id: return download_id return None def _context_pause(self): download_id = self._get_selected_download_id() if download_id: self.manager.pause_download(download_id) def _context_resume(self): download_id = self._get_selected_download_id() if download_id: self.manager.resume_download(download_id) def _context_cancel(self): download_id = self._get_selected_download_id() if download_id: self.manager.cancel_download(download_id) def _context_retry(self): download_id = self._get_selected_download_id() if download_id: self.manager.retry_download(download_id) def _context_remove(self): download_id = self._get_selected_download_id() if download_id: self.manager.remove_download(download_id) def _context_open_folder(self): download_id = self._get_selected_download_id() if download_id and download_id in self.manager.downloads: item = self.manager.downloads[download_id] folder = os.path.dirname(item.save_path) if os.path.exists(folder): import subprocess import platform if platform.system() == "Windows": subprocess.run(["explorer", folder]) elif platform.system() == "Darwin": subprocess.run(["open", folder]) else: subprocess.run(["xdg-open", folder]) def _refresh_list(self): """Refresh the download list display.""" # Clear all items for item_id in self.tree.get_children(): self.tree.delete(item_id) # Clear widget mapping self.item_widgets.clear() # Re-add all downloads for item in self.manager.get_all_downloads(): self.add_download_item(item) def _remove_selected(self): """Remove the selected download.""" download_id = self._get_selected_download_id() if download_id: self.manager.remove_download(download_id) def _on_selection_change(self, event): """Handle selection change in treeview.""" download_id = self._get_selected_download_id() if download_id and download_id in self.manager.downloads: item = self.manager.downloads[download_id] details = f"File: {item.filename}\\n" details += f"Repository: {item.repo_id}\\n" details += f"Save Path: {item.save_path}" if item.error_message: details += f"\\nError: {item.error_message}" self.details_text.delete(1.0, tk.END) self.details_text.insert(1.0, details) def _on_progress(self, item: DownloadItem): """Handle progress update.""" if hasattr(self.parent, 'after'): self.parent.after(0, lambda: self._update_download_item(item)) def _on_status_change(self, item: DownloadItem): """Handle status change.""" if hasattr(self.parent, 'after'): self.parent.after(0, lambda: self._update_download_item(item)) def _on_complete(self, item: DownloadItem): """Handle download completion.""" if hasattr(self.parent, 'after'): self.parent.after(0, lambda: self._update_download_item(item)) def _on_error(self, item: DownloadItem): """Handle download error.""" if hasattr(self.parent, 'after'): self.parent.after(0, lambda: self._update_download_item(item)) # Show error notification for auth issues if item.status == DownloadStatus.AUTH_REQUIRED: if hasattr(self.parent, 'after'): self.parent.after(0, lambda: messagebox.showerror( "Authentication Required", f"Authentication failed for {item.filename}.\\n" f"Please check your API key in Settings." )) def _on_remove(self, item: DownloadItem): """Handle download removal.""" if hasattr(self.parent, 'after'): self.parent.after(0, lambda: self._remove_download_from_tree(item.id)) def _remove_download_from_tree(self, download_id: str): """Remove download from treeview.""" if download_id in self.item_widgets: try: tree_id = self.item_widgets[download_id]['tree_id'] self.tree.delete(tree_id) del self.item_widgets[download_id] except (tk.TclError, KeyError): # Item already removed or doesn't exist if download_id in self.item_widgets: del self.item_widgets[download_id] def _clear_completed(self): """Clear completed downloads.""" self.manager.clear_completed() def _pause_all(self): """Pause all active downloads.""" for item in self.manager.get_all_downloads(): if item.status == DownloadStatus.DOWNLOADING: self.manager.pause_download(item.id) def _resume_all(self): """Resume all paused downloads.""" for item in self.manager.get_all_downloads(): if item.status == DownloadStatus.PAUSED: self.manager.resume_download(item.id) def _update_display(self): """Update the display periodically with real-time updates.""" try: # Update status summary all_downloads = self.manager.get_all_downloads() active = sum(1 for d in all_downloads if d.status == DownloadStatus.DOWNLOADING) queued = sum(1 for d in all_downloads if d.status == DownloadStatus.QUEUED) completed = sum(1 for d in all_downloads if d.status == DownloadStatus.COMPLETED) failed = sum(1 for d in all_downloads if d.status in [DownloadStatus.FAILED, DownloadStatus.CANCELLED]) status_text = f"Downloads: {active} active, {queued} queued, {completed} completed, {failed} failed" self.status_label.config(text=status_text) # Add any new downloads for item in all_downloads: if item.id not in self.item_widgets: self.add_download_item(item) # Force update all items for real-time progress for item in all_downloads: if item.id in self.item_widgets: self._update_download_item(item) except Exception as e: print(f"Error updating download display: {e}") finally: # Schedule next update with shorter interval for real-time feel if hasattr(self.parent, 'after'): self.parent.after(200, self._update_display) # Update every 200ms for smooth progress