Files
CoomerDL/app/views/pyside/main_window.py

640 lines
24 KiB
Python

import datetime
from email.mime import text
import os
import subprocess
import sys
import threading
from pathlib import Path
import time
from typing import Optional
from PySide6.QtCore import QObject, Signal, Qt, QTimer
from PySide6.QtGui import QAction
from PySide6.QtWidgets import (
QMainWindow,
QWidget,
QVBoxLayout,
QMessageBox,
QHBoxLayout,
QPushButton,
QFileDialog,
)
from app.controllers.main_controller import MainController
from app.models.app_state import AppState
from app.services.settings_service import SettingsService
from app.services.translation_service import TranslationService
from app.services.log_service import LogService
from app.services.url_service import UrlService
from app.adapters.downloader_factory import DownloaderFactory
from app.views.pyside.widgets.download_panel import DownloadPanel
from app.views.pyside.widgets.log_panel import LogPanel
from app.views.pyside.widgets.footer_bar import FooterBar
from app.views.pyside.dialogs.settings_dialog import SettingsDialog
from app.views.pyside.progress.progress_controller import ProgressController
from app.adapters.pyside_frontend_bridge import PySideFrontendBridge
from app.about_window import AboutWindow
from app.donors import DonorsModal
from app.views.pyside.dialogs.startup_community_dialog import StartupCommunityDialog
VERSION = "V1.1.0"
MAX_LOG_LINES = None
class QtLineEditAdapter:
def __init__(self, widget):
self.widget = widget
def get(self):
return self.widget.text()
class QtCheckBoxAdapter:
def __init__(self, widget):
self.widget = widget
def get(self):
return self.widget.isChecked()
class QtSignals(QObject):
log_message = Signal(str)
set_download_enabled = Signal(bool)
set_cancel_enabled = Signal(bool)
global_progress = Signal(int, int)
footer_speed = Signal(str)
footer_eta = Signal(str)
footer_total_size = Signal(str)
clear_logs = Signal()
show_error_box = Signal(str, str)
class PySideMainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.version = VERSION
self.setWindowTitle(f"Downloader [{VERSION}]")
self.resize(1000, 700)
# estado y servicios
self.app_state = AppState()
self.settings_service = SettingsService()
self.app_state.language = self.settings_service.load_language_preference("en")
self.app_state.download_folder = self.settings_service.load_download_folder("downloads")
self.download_folder = self.app_state.download_folder
self.translation_service = TranslationService(language=self.app_state.language)
self.log_service = LogService()
self.url_service = UrlService()
self.frontend_bridge = PySideFrontendBridge(self)
self.downloader_factory = DownloaderFactory(self.frontend_bridge, app=self)
self.main_controller = MainController(self)
self.progress_controller = ProgressController(self)
# runtime
self.active_downloader = None
self.download_start_time = None
self.settings = self.settings_service.load_settings()
self.max_downloads = int(self.settings.get("max_downloads", 3))
self.latest_release_url = None
self._active_progress = {}
self._active_progress_lock = threading.Lock()
# señales thread-safe
self.signals = QtSignals()
self.signals.log_message.connect(self._append_log)
self.signals.set_download_enabled.connect(self._set_download_enabled)
self.signals.set_cancel_enabled.connect(self._set_cancel_enabled)
self.signals.global_progress.connect(self._apply_global_progress)
self.signals.footer_speed.connect(self.footer_set_speed)
self.signals.footer_eta.connect(self.footer_set_eta)
self.signals.footer_total_size.connect(self.footer_set_total_size)
self.signals.clear_logs.connect(self._clear_logs)
self.signals.show_error_box.connect(self._show_error_dialog)
self._build_ui()
self._bind_events()
self._create_default_downloader()
self.update_ui_texts()
self._smoothed_total_speed = 0.0
self._last_footer_activity_ts = 0.0
self._footer_hold_seconds = 1.0
self.hide()
QTimer.singleShot(0, self.show_startup_community_dialog)
# ------------------------------------------------------------------
# UI
# ------------------------------------------------------------------
def _build_ui(self):
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(12)
top_row = QHBoxLayout()
menu_bar = self.menuBar()
self.file_menu = menu_bar.addMenu("File")
self.settings_action = self.file_menu.addAction("Settings")
self.settings_action.triggered.connect(self.open_settings_dialog)
self.help_action = QAction("About", self)
self.help_action.triggered.connect(self.open_about_window)
menu_bar.addAction(self.help_action)
self.support_action = QAction("Patreons", self)
self.support_action.triggered.connect(self.open_donors_modal)
menu_bar.addAction(self.support_action)
top_row = QHBoxLayout()
top_row.addStretch(1)
layout.addLayout(top_row)
self.download_panel = DownloadPanel(self, tr=self.tr)
layout.addWidget(self.download_panel)
self.log_panel = LogPanel(self)
layout.addWidget(self.log_panel, 1)
self.footer_bar = FooterBar(self)
self.footer_bar.progress_details_button.clicked.connect(self.toggle_progress_details)
layout.addWidget(self.footer_bar)
self.url_entry = QtLineEditAdapter(self.download_panel.url_input)
self.download_images_check = QtCheckBoxAdapter(self.download_panel.images_check)
self.download_videos_check = QtCheckBoxAdapter(self.download_panel.videos_check)
self.download_compressed_check = QtCheckBoxAdapter(self.download_panel.compressed_check)
self.only_this_url_check = QtCheckBoxAdapter(self.download_panel.only_this_url_check)
self.autoscroll_log_check = QtCheckBoxAdapter(self.download_panel.autoscroll_log_check)
self.update_folder_label()
def toggle_progress_details(self):
self.progress_controller.toggle_dialog()
def remove_progress_bar(self, item_key):
self.progress_controller.remove_item(str(item_key))
def center_progress_details_frame(self):
pass
def open_settings_dialog(self):
dialog = SettingsDialog(
parent=self,
tr=self.tr,
load_translations=self.load_translations,
update_ui_texts=self.update_ui_texts,
save_language_preference=self.save_language_preference,
version=self.version,
downloader=getattr(self, "active_downloader", None),
on_settings_changed=self.apply_runtime_settings,
)
dialog.exec()
def open_about_window(self):
dialog = AboutWindow(self, self.tr, self.version)
dialog.show_about()
def open_donors_modal(self):
dialog = DonorsModal(self, self.tr)
dialog.show_modal()
def show_startup_community_dialog(self):
should_show = self.settings_service.get("startup_show_community_message", True)
if not should_show:
self.show()
return
dialog = StartupCommunityDialog(
parent=self,
initial_language=self.app_state.language
)
if dialog.exec():
selected_language = dialog.selected_language()
self.save_language_preference(selected_language)
self.load_translations(selected_language)
self.update_ui_texts()
self.show()
def load_translations(self, language=None):
target_language = language or self.app_state.language
self.app_state.language = target_language
self.translation_service.set_language(target_language)
def save_language_preference(self, language):
self.app_state.language = language
self.settings_service.save_language_preference(language)
self.translation_service.set_language(language)
def update_ui_texts(self):
self.download_panel.url_label.setText(self.tr("URL_LABEL"))
self.download_panel.browse_button.setText(self.tr("SELECT_FOLDER"))
self.download_panel.images_check.setText(self.tr("DOWNLOAD_IMAGES"))
self.download_panel.videos_check.setText(self.tr("DOWNLOAD_VIDEOS"))
self.download_panel.only_this_url_check.setText(self.tr("ONLY_THIS_URL"))
self.download_panel.autoscroll_log_check.setText(self.tr("AUTO_SCROLL_LOG"))
self.download_panel.compressed_check.setText(self.tr("DOWNLOAD_COMPRESSED"))
self.download_panel.download_button.setText(self.tr("DOWNLOAD"))
self.download_panel.cancel_button.setText(self.tr("CANCEL_DOWNLOAD"))
if hasattr(self, "file_menu"):
self.file_menu.setTitle(self.tr("File"))
if hasattr(self, "settings_action"):
self.settings_action.setText(self.tr("Settings"))
if hasattr(self, "help_action"):
self.help_action.setText(self.tr("About"))
if hasattr(self, "support_action"):
self.support_action.setText(self.tr("Patreons"))
if hasattr(self, "footer_bar") and hasattr(self.footer_bar, "progress_details_button"):
self.footer_bar.progress_details_button.setToolTip(self.tr("Progress Details"))
if hasattr(self, "progress_controller") and self.progress_controller is not None:
if hasattr(self.progress_controller, "dialog") and self.progress_controller.dialog is not None:
self.progress_controller.dialog.tr = self.tr
self.progress_controller.dialog.retranslate_ui()
if hasattr(self, "download_panel") and self.download_panel is not None:
self.download_panel.retranslate_ui()
self.setWindowTitle(f"Downloader [{self.version}]")
self.update_folder_label()
def apply_runtime_settings(self, new_settings: dict):
try:
self.settings = new_settings
self.max_downloads = int(new_settings.get("max_downloads", 3) or 3)
if hasattr(self, "default_downloader") and self.default_downloader:
dd = self.default_downloader
dd.max_workers = self.max_downloads
dd.folder_structure = new_settings.get("folder_structure", "default")
try:
dd.file_naming_mode = int(new_settings.get("file_naming_mode", 0) or 0)
except Exception:
pass
try:
dd.max_retries = int(new_settings.get("max_retries", 3) or 3)
except Exception:
pass
try:
dd.retry_interval = float(new_settings.get("retry_interval", 2.0) or 2.0)
except Exception:
pass
self.add_log_message_safe("Settings applied.")
except Exception as e:
self.add_log_message_safe(f"Error applying settings: {e}")
def _bind_events(self):
self.download_panel.browse_button.clicked.connect(self.select_folder)
self.download_panel.download_button.clicked.connect(self.start_download)
self.download_panel.cancel_button.clicked.connect(self.cancel_download)
self.download_panel.folder_label.linkActivated.connect(lambda _: self.open_download_folder())
self.download_panel.folder_label.mousePressEvent = lambda event: self.open_download_folder()
# ------------------------------------------------------------------
# compatibilidad con backend actual
# ------------------------------------------------------------------
def tr(self, key, **kwargs):
return self.translation_service.tr(key, **kwargs)
def show_error(self, title, message):
self.signals.show_error_box.emit(str(title), str(message))
def prepare_download_ui(self):
self.download_start_time = datetime.datetime.now()
self.log_service.clear_runtime()
self.signals.set_download_enabled.emit(False)
self.signals.set_cancel_enabled.emit(True)
self.signals.clear_logs.emit()
def enable_widgets(self):
self.signals.set_download_enabled.emit(True)
self.signals.set_cancel_enabled.emit(False)
def clear_progress_bars(self):
if not hasattr(self, "_active_progress"):
self._active_progress = {}
if not hasattr(self, "_active_progress_lock"):
self._active_progress_lock = threading.Lock()
with self._active_progress_lock:
self._active_progress.clear()
self.signals.global_progress.emit(0, 0)
self.footer_set_speed("Speed: 0 KB/s")
self.footer_set_eta("ETA: N/A")
self.footer_set_total_size("Total: 0 B")
self.progress_controller.clear_all()
def add_log_message_safe(self, domain_or_message: str, message: Optional[str] = None):
if message is None:
domain = "SYSTEM"
final_message = domain_or_message
else:
domain = domain_or_message
final_message = message
html = self.log_service.add_domain_log(domain, final_message)
self.signals.log_message.emit(html)
def export_logs(self):
try:
log_file_path = self.log_service.export_logs(
active_downloader=self.active_downloader,
download_images_enabled=bool(self.download_images_check.get()),
download_videos_enabled=bool(self.download_videos_check.get()),
download_start_time=self.download_start_time,
)
self.signals.log_message.emit(f"Logs exportados exitosamente a {log_file_path}")
except Exception as e:
self.signals.log_message.emit(f"No se pudo exportar los logs: {e}")
def update_progress(self, downloaded, total, file_id=None, file_path=None, speed=None, eta=None, status=None):
if not hasattr(self, "_active_progress"):
self._active_progress = {}
if not hasattr(self, "_active_progress_lock"):
self._active_progress_lock = threading.Lock()
if not hasattr(self, "_smoothed_total_speed"):
self._smoothed_total_speed = 0.0
if not hasattr(self, "_last_footer_activity_ts"):
self._last_footer_activity_ts = 0.0
if not hasattr(self, "_footer_hold_seconds"):
self._footer_hold_seconds = 0.8
if file_id is None:
if total and total > 0:
self.signals.global_progress.emit(int(downloaded), int(total))
return
eta_text = "ETA: N/A"
if eta is not None and eta > 0:
minutes = int(eta // 60)
seconds = int(eta % 60)
eta_text = f"ETA: {minutes}m {seconds}s"
self.progress_controller.update_item(
file_id=str(file_id),
file_path=str(file_path or file_id),
downloaded=int(downloaded),
total=int(total),
eta_text=eta_text,
)
progress_key = f"{file_id}|{file_path}" if file_path else str(file_id)
now = time.monotonic()
with self._active_progress_lock:
if total and total > 0 and int(downloaded) < int(total):
self._active_progress[progress_key] = {
"downloaded": int(downloaded),
"total": int(total),
"speed": float(speed or 0.0),
"eta": float(eta or 0.0),
}
else:
self._active_progress.pop(progress_key, None)
has_active_items = len(self._active_progress) > 0
if has_active_items:
self._last_footer_activity_ts = now
total_speed = sum(
item["speed"] for item in self._active_progress.values()
if item["speed"] > 0
)
total_downloaded_bytes = sum(
max(0, item["downloaded"])
for item in self._active_progress.values()
)
total_bytes = sum(
max(0, item["total"])
for item in self._active_progress.values()
)
total_remaining_bytes = sum(
max(0, item["total"] - item["downloaded"])
for item in self._active_progress.values()
)
# Suavizado de velocidad para evitar saltos bruscos en archivos pequeños
if total_speed > 0:
if self._smoothed_total_speed <= 0:
self._smoothed_total_speed = total_speed
else:
self._smoothed_total_speed = (self._smoothed_total_speed * 0.85) + (total_speed * 0.15)
else:
if now - self._last_footer_activity_ts > self._footer_hold_seconds:
self._smoothed_total_speed = 0.0
display_speed = self._smoothed_total_speed
if display_speed > 0:
if display_speed < 1_048_576:
self.signals.footer_speed.emit(f"Speed: {display_speed / 1024:.2f} KB/s")
else:
self.signals.footer_speed.emit(f"Speed: {display_speed / 1_048_576:.2f} MB/s")
else:
self.signals.footer_speed.emit("Speed: 0 KB/s")
if total_remaining_bytes > 0 and display_speed > 0:
global_eta = total_remaining_bytes / display_speed
minutes = int(global_eta // 60)
seconds = int(global_eta % 60)
self.signals.footer_eta.emit(f"ETA: {minutes}m {seconds}s")
elif now - self._last_footer_activity_ts > self._footer_hold_seconds:
self.signals.footer_eta.emit("ETA: N/A")
if total_bytes > 0:
self.signals.footer_total_size.emit(
f"Total: {self._format_bytes(total_downloaded_bytes)} / {self._format_bytes(total_bytes)}"
)
elif now - self._last_footer_activity_ts > self._footer_hold_seconds:
self.signals.footer_total_size.emit("Total: 0 B")
if status is not None:
self.signals.footer_eta.emit(f"ETA: N/A | STATUS:{status}")
def update_global_progress(self, completed_files, total_files):
self.signals.global_progress.emit(int(completed_files), int(total_files))
# ------------------------------------------------------------------
# downloader factory hooks
# ------------------------------------------------------------------
def setup_erome_downloader(self, is_profile_download=False):
self.erome_downloader = self.downloader_factory.create_erome_downloader(
is_profile_download=is_profile_download
)
def setup_simpcity_downloader(self):
self.simpcity_downloader = self.downloader_factory.create_simpcity_downloader()
def setup_bunkr_downloader(self):
self.bunkr_downloader = self.downloader_factory.create_bunkr_downloader()
def setup_general_downloader(self):
self.general_downloader = self.downloader_factory.create_general_downloader(self.settings)
def setup_jpg5_downloader(self):
self.active_downloader = self.downloader_factory.create_jpg5_downloader(
self.url_entry.get().strip(),
progress_manager=None
)
def _create_default_downloader(self):
# opcional para compatibilidad con algunos settings runtime
pass
# ------------------------------------------------------------------
# acciones
# ------------------------------------------------------------------
def start_download(self):
self.main_controller.start_download()
def cancel_download(self):
self.main_controller.cancel_download()
def select_folder(self):
folder = QFileDialog.getExistingDirectory(self, self.tr("Seleccionar Carpeta"), self.download_folder or "")
if folder:
self.download_folder = folder
self.app_state.download_folder = folder
self.settings_service.save_download_folder(folder)
self.update_folder_label()
def update_folder_label(self):
path = self.download_folder or ""
# texto clickeable visualmente simple
self.download_panel.folder_label.setText(path)
def open_download_folder(self):
if self.download_folder and os.path.exists(self.download_folder):
if sys.platform == "win32":
os.startfile(self.download_folder)
elif sys.platform == "darwin":
subprocess.Popen(["open", self.download_folder])
else:
subprocess.Popen(["xdg-open", self.download_folder])
else:
self.show_error(self.tr("Error"), self.tr("La carpeta no existe o no es válida."))
# ------------------------------------------------------------------
# slots UI
# ------------------------------------------------------------------
def _append_log(self, message: str):
self.log_panel.log_text.insertHtml(message)
self.log_panel.log_text.insertHtml("<br>")
if bool(self.autoscroll_log_check.get()):
scrollbar = self.log_panel.log_text.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
if MAX_LOG_LINES is not None:
doc = self.log_panel.log_text.document()
while doc.blockCount() > MAX_LOG_LINES:
cursor = self.log_panel.log_text.textCursor()
cursor.movePosition(cursor.Start)
cursor.select(cursor.BlockUnderCursor)
cursor.removeSelectedText()
cursor.deleteChar()
def _set_download_enabled(self, enabled: bool):
self.download_panel.download_button.setEnabled(enabled)
def _set_cancel_enabled(self, enabled: bool):
self.download_panel.cancel_button.setEnabled(enabled)
def _apply_global_progress(self, current: int, total: int):
if total > 0:
percentage = int((current / total) * 100)
else:
percentage = 0
self.footer_bar.progress_bar.setValue(percentage)
self.footer_bar.progress_label.setText(f"{percentage}%")
def _clear_logs(self):
self.log_panel.log_text.clear()
self.footer_bar.progress_bar.setValue(0)
self.footer_bar.progress_label.setText("0%")
self.footer_set_speed("Speed: 0 KB/s")
self.footer_set_eta("ETA: N/A")
self.footer_set_total_size("Total: 0 B")
def _show_error_dialog(self, title: str, message: str):
QMessageBox.critical(self, title, message)
# ------------------------------------------------------------------
# footer helpers
# ------------------------------------------------------------------
def footer_set_speed(self, text: str):
self.footer_bar.speed_label.setText(text)
def footer_set_eta(self, text: str):
self.footer_bar.eta_label.setText(text)
def footer_set_speed_from_value(self, speed):
if speed is None:
self.footer_set_speed("Speed: 0 KB/s")
return
if speed < 1_048_576:
self.footer_set_speed(f"Speed: {speed / 1024:.2f} KB/s")
else:
self.footer_set_speed(f"Speed: {speed / 1_048_576:.2f} MB/s")
def footer_set_eta_from_value(self, eta):
if eta is None:
self.footer_set_eta("ETA: N/A")
return
minutes = int(eta // 60)
seconds = int(eta % 60)
self.footer_set_eta(f"ETA: {minutes}m {seconds}s")
def _format_bytes(self, num_bytes):
try:
value = float(num_bytes or 0)
except Exception:
value = 0.0
units = ["B", "KB", "MB", "GB", "TB"]
idx = 0
while value >= 1024 and idx < len(units) - 1:
value /= 1024.0
idx += 1
if idx == 0:
return f"{int(value)} {units[idx]}"
return f"{value:.2f} {units[idx]}"
def footer_set_total_size(self, text: str):
self.footer_bar.total_size_label.setText(text)