import sys import os import time import requests import re import threading import queue # Standard library queue, not directly used for the new link queue import hashlib import http.client import traceback import random # <-- Import random for generating delays from collections import deque # <-- Import deque for the link queue from concurrent.futures import ThreadPoolExecutor, CancelledError, Future from PyQt5.QtGui import ( QIcon, QIntValidator ) from PyQt5.QtWidgets import ( QApplication, QWidget, QLabel, QLineEdit, QTextEdit, QPushButton, QVBoxLayout, QHBoxLayout, QFileDialog, QMessageBox, QListWidget, QRadioButton, QButtonGroup, QCheckBox, QSplitter, QSizePolicy ) # Ensure QTimer is imported from PyQt5.QtCore import Qt, QThread, pyqtSignal, QMutex, QMutexLocker, QObject, QTimer from urllib.parse import urlparse try: from PIL import Image except ImportError: Image = None from io import BytesIO # --- Import from downloader_utils --- try: print("Attempting to import from downloader_utils...") # Assuming downloader_utils_link_text is the correct version from downloader_utils import ( KNOWN_NAMES, clean_folder_name, extract_post_info, download_from_api, PostProcessorSignals, PostProcessorWorker, DownloadThread as BackendDownloadThread ) print("Successfully imported names from downloader_utils.") except ImportError as e: print(f"--- IMPORT ERROR ---") print(f"Failed to import from 'downloader_utils.py': {e}") # ... (rest of error handling as in your original file) ... KNOWN_NAMES = [] PostProcessorSignals = QObject PostProcessorWorker = object BackendDownloadThread = QThread def clean_folder_name(n): return str(n) # Fallback def extract_post_info(u): return None, None, None def download_from_api(*a, **k): yield [] except Exception as e: print(f"--- UNEXPECTED IMPORT ERROR ---") print(f"An unexpected error occurred during import: {e}") traceback.print_exc() print(f"-----------------------------", file=sys.stderr) sys.exit(1) # --- End Import --- class DownloaderApp(QWidget): character_prompt_response_signal = pyqtSignal(bool) log_signal = pyqtSignal(str) add_character_prompt_signal = pyqtSignal(str) overall_progress_signal = pyqtSignal(int, int) finished_signal = pyqtSignal(int, int, bool) # Signal now carries link_text (ensure this matches downloader_utils) external_link_signal = pyqtSignal(str, str, str, str) # post_title, link_text, link_url, platform file_progress_signal = pyqtSignal(str, int, int) def __init__(self): super().__init__() self.config_file = "Known.txt" self.download_thread = None self.thread_pool = None self.cancellation_event = threading.Event() self.active_futures = [] self.total_posts_to_process = 0 self.processed_posts_count = 0 self.download_counter = 0 self.skip_counter = 0 self.worker_signals = PostProcessorSignals() # Instance of signals for multi-thread workers self.prompt_mutex = QMutex() self._add_character_response = None self.downloaded_files = set() self.downloaded_files_lock = threading.Lock() self.downloaded_file_hashes = set() self.downloaded_file_hashes_lock = threading.Lock() # self.external_links = [] # This list seems unused now self.show_external_links = False # --- For sequential delayed link display --- self.external_link_queue = deque() self._is_processing_external_link_queue = False # --- END --- # --- ADDED: For Log Verbosity --- self.basic_log_mode = False # Start with full log self.log_verbosity_button = None # --- END ADDED --- self.main_log_output = None self.external_log_output = None self.log_splitter = None # This is the VERTICAL splitter for logs self.main_splitter = None # This will be the main HORIZONTAL splitter self.reset_button = None self.manga_mode_checkbox = None self.load_known_names_from_util() self.setWindowTitle("Kemono Downloader v3.0.0") self.setGeometry(150, 150, 1050, 820) # Initial size self.setStyleSheet(self.get_dark_theme()) self.init_ui() self._connect_signals() self.log_signal.emit("ℹ️ Local API server functionality has been removed.") self.log_signal.emit("ℹ️ 'Skip Current File' button has been removed.") self.character_input.setToolTip("Enter one or more character names, separated by commas (e.g., yor, makima)") def _connect_signals(self): # Signals from the worker_signals object (used by PostProcessorWorker in multi-threaded mode) if hasattr(self.worker_signals, 'progress_signal'): self.worker_signals.progress_signal.connect(self.handle_main_log) if hasattr(self.worker_signals, 'file_progress_signal'): self.worker_signals.file_progress_signal.connect(self.update_file_progress_display) # Connect the external_link_signal from worker_signals to the queue handler if hasattr(self.worker_signals, 'external_link_signal'): self.worker_signals.external_link_signal.connect(self.handle_external_link_signal) # App's own signals (some of which might be emitted by DownloadThread which then connects to these handlers) self.log_signal.connect(self.handle_main_log) self.add_character_prompt_signal.connect(self.prompt_add_character) self.character_prompt_response_signal.connect(self.receive_add_character_result) self.overall_progress_signal.connect(self.update_progress_display) self.finished_signal.connect(self.download_finished) # Connect the app's external_link_signal also to the queue handler self.external_link_signal.connect(self.handle_external_link_signal) self.file_progress_signal.connect(self.update_file_progress_display) self.character_search_input.textChanged.connect(self.filter_character_list) self.external_links_checkbox.toggled.connect(self.update_external_links_setting) self.thread_count_input.textChanged.connect(self.update_multithreading_label) self.use_subfolder_per_post_checkbox.toggled.connect(self.update_ui_for_subfolders) if self.reset_button: self.reset_button.clicked.connect(self.reset_application_state) # Connect log verbosity button if it exists if self.log_verbosity_button: self.log_verbosity_button.clicked.connect(self.toggle_log_verbosity) if self.manga_mode_checkbox: self.manga_mode_checkbox.toggled.connect(self.update_ui_for_manga_mode) self.link_input.textChanged.connect(lambda: self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False)) # --- load_known_names_from_util, save_known_names, closeEvent --- # These methods remain unchanged from your original file def load_known_names_from_util(self): global KNOWN_NAMES if os.path.exists(self.config_file): try: with open(self.config_file, 'r', encoding='utf-8') as f: raw_names = [line.strip() for line in f] # Filter out empty strings before setting KNOWN_NAMES KNOWN_NAMES[:] = sorted(list(set(filter(None, raw_names)))) log_msg = f"ℹ️ Loaded {len(KNOWN_NAMES)} known names from {self.config_file}" except Exception as e: log_msg = f"❌ Error loading config '{self.config_file}': {e}" QMessageBox.warning(self, "Config Load Error", f"Could not load list from {self.config_file}:\n{e}") KNOWN_NAMES[:] = [] else: log_msg = f"ℹ️ Config file '{self.config_file}' not found. Starting empty." KNOWN_NAMES[:] = [] self.log_signal.emit(log_msg) if hasattr(self, 'character_list'): # Ensure character_list widget exists self.character_list.clear() self.character_list.addItems(KNOWN_NAMES) def save_known_names(self): global KNOWN_NAMES try: # Ensure KNOWN_NAMES contains unique, non-empty, sorted strings unique_sorted_names = sorted(list(set(filter(None, KNOWN_NAMES)))) KNOWN_NAMES[:] = unique_sorted_names # Update global list in place with open(self.config_file, 'w', encoding='utf-8') as f: for name in unique_sorted_names: f.write(name + '\n') self.log_signal.emit(f"💾 Saved {len(unique_sorted_names)} known names to {self.config_file}") except Exception as e: log_msg = f"❌ Error saving config '{self.config_file}': {e}" self.log_signal.emit(log_msg) QMessageBox.warning(self, "Config Save Error", f"Could not save list to {self.config_file}:\n{e}") def closeEvent(self, event): self.save_known_names() should_exit = True is_downloading = (self.download_thread and self.download_thread.isRunning()) or \ (self.thread_pool is not None and any(not f.done() for f in self.active_futures if f is not None)) if is_downloading: reply = QMessageBox.question(self, "Confirm Exit", "Download in progress. Are you sure you want to exit and cancel?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No) if reply == QMessageBox.Yes: self.log_signal.emit("⚠️ Cancelling active download due to application exit...") self.cancel_download() # Signal cancellation # --- MODIFICATION START: Wait for threads to finish --- self.log_signal.emit(" Waiting briefly for threads to acknowledge cancellation...") # Wait for the single thread if it exists if self.download_thread and self.download_thread.isRunning(): self.download_thread.wait(3000) # Wait up to 3 seconds if self.download_thread.isRunning(): self.log_signal.emit(" ⚠️ Single download thread did not terminate gracefully.") # Wait for the thread pool if it exists if self.thread_pool: # Shutdown was already initiated by cancel_download, just wait here # Use wait=True here for cleaner exit self.thread_pool.shutdown(wait=True, cancel_futures=True) self.log_signal.emit(" Thread pool shutdown complete.") self.thread_pool = None # Clear reference # --- MODIFICATION END --- else: should_exit = False self.log_signal.emit("ℹ️ Application exit cancelled.") event.ignore() return if should_exit: self.log_signal.emit("ℹ️ Application closing.") # Ensure thread pool is None if already shut down above if self.thread_pool: self.log_signal.emit(" Final thread pool check: Shutting down...") self.cancellation_event.set() self.thread_pool.shutdown(wait=True, cancel_futures=True) self.thread_pool = None self.log_signal.emit("👋 Exiting application.") event.accept() def init_ui(self): # --- MODIFIED: Use QSplitter for main layout --- self.main_splitter = QSplitter(Qt.Horizontal) # Create container widgets for left and right panels left_panel_widget = QWidget() right_panel_widget = QWidget() # Setup layouts for the panels left_layout = QVBoxLayout(left_panel_widget) # Apply layout to widget right_layout = QVBoxLayout(right_panel_widget) # Apply layout to widget left_layout.setContentsMargins(10, 10, 10, 10) # Add some margins right_layout.setContentsMargins(10, 10, 10, 10) # --- Populate Left Panel (Controls) --- # (All the QLineEdit, QCheckBox, QPushButton, etc. setup code goes here, adding to left_layout) # URL and Page Range Input url_page_layout = QHBoxLayout() url_page_layout.setContentsMargins(0,0,0,0) url_page_layout.addWidget(QLabel("🔗 Kemono Creator/Post URL:")) self.link_input = QLineEdit() self.link_input.setPlaceholderText("e.g., https://kemono.su/patreon/user/12345 or .../post/98765") self.link_input.textChanged.connect(self.update_custom_folder_visibility) # self.link_input.setFixedWidth(int(self.width() * 0.45)) # Remove fixed width for splitter url_page_layout.addWidget(self.link_input, 1) # Give it stretch factor self.page_range_label = QLabel("Page Range:") self.page_range_label.setStyleSheet("font-weight: bold; padding-left: 10px;") self.start_page_input = QLineEdit() self.start_page_input.setPlaceholderText("Start") self.start_page_input.setFixedWidth(50) self.start_page_input.setValidator(QIntValidator(1, 99999)) # Min 1 self.to_label = QLabel("to") self.end_page_input = QLineEdit() self.end_page_input.setPlaceholderText("End") self.end_page_input.setFixedWidth(50) self.end_page_input.setValidator(QIntValidator(1, 99999)) # Min 1 url_page_layout.addWidget(self.page_range_label) url_page_layout.addWidget(self.start_page_input) url_page_layout.addWidget(self.to_label) url_page_layout.addWidget(self.end_page_input) # url_page_layout.addStretch(1) # No need for stretch with splitter left_layout.addLayout(url_page_layout) # Download Directory Input left_layout.addWidget(QLabel("📁 Download Location:")) self.dir_input = QLineEdit() self.dir_input.setPlaceholderText("Select folder where downloads will be saved") self.dir_button = QPushButton("Browse...") self.dir_button.clicked.connect(self.browse_directory) dir_layout = QHBoxLayout() dir_layout.addWidget(self.dir_input, 1) # Input takes more space dir_layout.addWidget(self.dir_button) left_layout.addLayout(dir_layout) # Custom Folder Name (for single post) self.custom_folder_widget = QWidget() # Use a widget to hide/show group custom_folder_layout = QVBoxLayout(self.custom_folder_widget) custom_folder_layout.setContentsMargins(0, 5, 0, 0) # No top margin if hidden self.custom_folder_label = QLabel("🗄️ Custom Folder Name (Single Post Only):") self.custom_folder_input = QLineEdit() self.custom_folder_input.setPlaceholderText("Optional: Save this post to specific folder") custom_folder_layout.addWidget(self.custom_folder_label) custom_folder_layout.addWidget(self.custom_folder_input) self.custom_folder_widget.setVisible(False) # Initially hidden left_layout.addWidget(self.custom_folder_widget) # Character Filter Input self.character_filter_widget = QWidget() character_filter_layout = QVBoxLayout(self.character_filter_widget) character_filter_layout.setContentsMargins(0,5,0,0) self.character_label = QLabel("🎯 Filter by Character(s) (comma-separated):") self.character_input = QLineEdit() self.character_input.setPlaceholderText("e.g., yor, makima, anya forger") character_filter_layout.addWidget(self.character_label) character_filter_layout.addWidget(self.character_input) self.character_filter_widget.setVisible(True) # Visible by default left_layout.addWidget(self.character_filter_widget) # Skip Words Input left_layout.addWidget(QLabel("🚫 Skip Posts/Files with Words (comma-separated):")) self.skip_words_input = QLineEdit() self.skip_words_input.setPlaceholderText("e.g., WM, WIP, sketch, preview") left_layout.addWidget(self.skip_words_input) # File Type Filter Radio Buttons file_filter_layout = QVBoxLayout() # Group label and radio buttons file_filter_layout.setContentsMargins(0,0,0,0) # Compact file_filter_layout.addWidget(QLabel("Filter Files:")) radio_button_layout = QHBoxLayout() radio_button_layout.setSpacing(10) self.radio_group = QButtonGroup(self) # Ensures one selection self.radio_all = QRadioButton("All") self.radio_images = QRadioButton("Images/GIFs") self.radio_videos = QRadioButton("Videos") self.radio_all.setChecked(True) self.radio_group.addButton(self.radio_all) self.radio_group.addButton(self.radio_images) self.radio_group.addButton(self.radio_videos) radio_button_layout.addWidget(self.radio_all) radio_button_layout.addWidget(self.radio_images) radio_button_layout.addWidget(self.radio_videos) radio_button_layout.addStretch(1) # Pushes buttons to left file_filter_layout.addLayout(radio_button_layout) left_layout.addLayout(file_filter_layout) # Checkboxes Group checkboxes_group_layout = QVBoxLayout() checkboxes_group_layout.setSpacing(10) # Spacing between rows of checkboxes row1_layout = QHBoxLayout() # First row of checkboxes row1_layout.setSpacing(10) self.skip_zip_checkbox = QCheckBox("Skip .zip") self.skip_zip_checkbox.setChecked(True) row1_layout.addWidget(self.skip_zip_checkbox) self.skip_rar_checkbox = QCheckBox("Skip .rar") self.skip_rar_checkbox.setChecked(True) row1_layout.addWidget(self.skip_rar_checkbox) self.download_thumbnails_checkbox = QCheckBox("Download Thumbnails Only") self.download_thumbnails_checkbox.setChecked(False) self.download_thumbnails_checkbox.setToolTip("Thumbnail download functionality is currently limited without the API.") row1_layout.addWidget(self.download_thumbnails_checkbox) self.compress_images_checkbox = QCheckBox("Compress Large Images (to WebP)") self.compress_images_checkbox.setChecked(False) self.compress_images_checkbox.setToolTip("Compress images > 1.5MB to WebP format (requires Pillow).") row1_layout.addWidget(self.compress_images_checkbox) row1_layout.addStretch(1) # Pushes checkboxes to left checkboxes_group_layout.addLayout(row1_layout) # Advanced Settings Section advanced_settings_label = QLabel("⚙️ Advanced Settings:") checkboxes_group_layout.addWidget(advanced_settings_label) advanced_row1_layout = QHBoxLayout() # Subfolders options advanced_row1_layout.setSpacing(10) self.use_subfolders_checkbox = QCheckBox("Separate Folders by Name/Title") self.use_subfolders_checkbox.setChecked(True) self.use_subfolders_checkbox.toggled.connect(self.update_ui_for_subfolders) advanced_row1_layout.addWidget(self.use_subfolders_checkbox) self.use_subfolder_per_post_checkbox = QCheckBox("Subfolder per Post") self.use_subfolder_per_post_checkbox.setChecked(False) self.use_subfolder_per_post_checkbox.setToolTip("Creates a subfolder for each post inside the character/title folder.") self.use_subfolder_per_post_checkbox.toggled.connect(self.update_ui_for_subfolders) # Also update UI advanced_row1_layout.addWidget(self.use_subfolder_per_post_checkbox) advanced_row1_layout.addStretch(1) checkboxes_group_layout.addLayout(advanced_row1_layout) advanced_row2_layout = QHBoxLayout() # Multithreading, External Links, Manga Mode advanced_row2_layout.setSpacing(10) multithreading_layout = QHBoxLayout() # Group multithreading checkbox and input multithreading_layout.setContentsMargins(0,0,0,0) self.use_multithreading_checkbox = QCheckBox("Use Multithreading") self.use_multithreading_checkbox.setChecked(True) self.use_multithreading_checkbox.setToolTip("Speeds up downloads for full creator pages.\nSingle post URLs always use one thread.") multithreading_layout.addWidget(self.use_multithreading_checkbox) self.thread_count_label = QLabel("Threads:") multithreading_layout.addWidget(self.thread_count_label) self.thread_count_input = QLineEdit() self.thread_count_input.setFixedWidth(40) self.thread_count_input.setText("4") self.thread_count_input.setToolTip("Number of threads (recommended: 4-10, max: 200).") self.thread_count_input.setValidator(QIntValidator(1,200)) # Min 1, Max 200 multithreading_layout.addWidget(self.thread_count_input) advanced_row2_layout.addLayout(multithreading_layout) self.external_links_checkbox = QCheckBox("Show External Links in Log") self.external_links_checkbox.setChecked(False) advanced_row2_layout.addWidget(self.external_links_checkbox) self.manga_mode_checkbox = QCheckBox("Manga Mode") self.manga_mode_checkbox.setToolTip("Process newest posts first, rename files based on post title (for creator feeds only).") self.manga_mode_checkbox.setChecked(False) advanced_row2_layout.addWidget(self.manga_mode_checkbox) advanced_row2_layout.addStretch(1) checkboxes_group_layout.addLayout(advanced_row2_layout) left_layout.addLayout(checkboxes_group_layout) # Download and Cancel Buttons btn_layout = QHBoxLayout() btn_layout.setSpacing(10) self.download_btn = QPushButton("⬇️ Start Download") self.download_btn.setStyleSheet("padding: 8px 15px; font-weight: bold;") # Make it prominent self.download_btn.clicked.connect(self.start_download) self.cancel_btn = QPushButton("❌ Cancel") self.cancel_btn.setEnabled(False) # Initially disabled self.cancel_btn.clicked.connect(self.cancel_download) btn_layout.addWidget(self.download_btn) btn_layout.addWidget(self.cancel_btn) left_layout.addLayout(btn_layout) left_layout.addSpacing(10) # Some space before known characters list # Known Characters/Shows List Management known_chars_label_layout = QHBoxLayout() known_chars_label_layout.setSpacing(10) self.known_chars_label = QLabel("🎭 Known Shows/Characters (for Folder Names):") self.character_search_input = QLineEdit() self.character_search_input.setPlaceholderText("Search characters...") known_chars_label_layout.addWidget(self.known_chars_label, 1) # Label takes more space known_chars_label_layout.addWidget(self.character_search_input) left_layout.addLayout(known_chars_label_layout) self.character_list = QListWidget() self.character_list.setSelectionMode(QListWidget.ExtendedSelection) # Allow multi-select for delete left_layout.addWidget(self.character_list, 1) # Takes remaining vertical space char_manage_layout = QHBoxLayout() # Add/Delete character buttons char_manage_layout.setSpacing(10) self.new_char_input = QLineEdit() self.new_char_input.setPlaceholderText("Add new show/character name") self.add_char_button = QPushButton("➕ Add") self.delete_char_button = QPushButton("🗑️ Delete Selected") self.add_char_button.clicked.connect(self.add_new_character) self.new_char_input.returnPressed.connect(self.add_char_button.click) # Add on Enter self.delete_char_button.clicked.connect(self.delete_selected_character) char_manage_layout.addWidget(self.new_char_input, 2) # Input field wider char_manage_layout.addWidget(self.add_char_button, 1) char_manage_layout.addWidget(self.delete_char_button, 1) left_layout.addLayout(char_manage_layout) left_layout.addStretch(0) # Prevent vertical stretching of controls # --- Populate Right Panel (Logs) --- log_title_layout = QHBoxLayout() log_title_layout.addWidget(QLabel("📜 Progress Log:")) log_title_layout.addStretch(1) # --- ADDED: Log Verbosity Button --- self.log_verbosity_button = QPushButton("Show Basic Log") self.log_verbosity_button.setToolTip("Toggle between full and basic log details.") self.log_verbosity_button.setFixedWidth(110) # Adjust width as needed self.log_verbosity_button.setStyleSheet("padding: 4px 8px;") log_title_layout.addWidget(self.log_verbosity_button) # --- END ADDED --- self.reset_button = QPushButton("🔄 Reset") self.reset_button.setToolTip("Reset all inputs and logs to default state (only when idle).") self.reset_button.setFixedWidth(80) self.reset_button.setStyleSheet("padding: 4px 8px;") # Smaller padding log_title_layout.addWidget(self.reset_button) right_layout.addLayout(log_title_layout) self.log_splitter = QSplitter(Qt.Vertical) # Keep the vertical splitter for logs self.main_log_output = QTextEdit() self.main_log_output.setReadOnly(True) # self.main_log_output.setMinimumWidth(450) # Remove minimum width self.main_log_output.setLineWrapMode(QTextEdit.NoWrap) # Disable line wrapping self.main_log_output.setStyleSheet(""" QTextEdit { background-color: #3C3F41; border: 1px solid #5A5A5A; padding: 5px; color: #F0F0F0; border-radius: 4px; font-family: Consolas, Courier New, monospace; font-size: 9.5pt; }""") self.external_log_output = QTextEdit() self.external_log_output.setReadOnly(True) # self.external_log_output.setMinimumWidth(450) # Remove minimum width self.external_log_output.setLineWrapMode(QTextEdit.NoWrap) # Disable line wrapping self.external_log_output.setStyleSheet(""" QTextEdit { background-color: #3C3F41; border: 1px solid #5A5A5A; padding: 5px; color: #F0F0F0; border-radius: 4px; font-family: Consolas, Courier New, monospace; font-size: 9.5pt; }""") self.external_log_output.hide() # Initially hidden self.log_splitter.addWidget(self.main_log_output) self.log_splitter.addWidget(self.external_log_output) self.log_splitter.setSizes([self.height(), 0]) # Main log takes all space initially right_layout.addWidget(self.log_splitter, 1) # Log splitter takes available vertical space self.progress_label = QLabel("Progress: Idle") self.progress_label.setStyleSheet("padding-top: 5px; font-style: italic;") right_layout.addWidget(self.progress_label) self.file_progress_label = QLabel("") # For individual file progress self.file_progress_label.setWordWrap(True) # Enable word wrapping for the status label self.file_progress_label.setStyleSheet("padding-top: 2px; font-style: italic; color: #A0A0A0;") right_layout.addWidget(self.file_progress_label) # --- Add panels to the main horizontal splitter --- self.main_splitter.addWidget(left_panel_widget) self.main_splitter.addWidget(right_panel_widget) # --- Set initial sizes for the splitter --- # Calculate initial sizes (e.g., left 35%, right 65%) initial_width = self.width() # Use the initial window width left_width = int(initial_width * 0.35) right_width = initial_width - left_width self.main_splitter.setSizes([left_width, right_width]) # --- Set the main splitter as the central layout --- # Need a top-level layout to hold the splitter top_level_layout = QHBoxLayout(self) # Apply layout directly to the main widget (self) top_level_layout.setContentsMargins(0,0,0,0) # No margins for the main layout top_level_layout.addWidget(self.main_splitter) # self.setLayout(top_level_layout) # Already set above # --- End Layout Modification --- # Initial UI state updates self.update_ui_for_subfolders(self.use_subfolders_checkbox.isChecked()) self.update_custom_folder_visibility() self.update_external_links_setting(self.external_links_checkbox.isChecked()) self.update_multithreading_label(self.thread_count_input.text()) self.update_page_range_enabled_state() if self.manga_mode_checkbox: # Ensure it exists before accessing self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked()) self.link_input.textChanged.connect(self.update_page_range_enabled_state) # Connect after init self.load_known_names_from_util() # Load names into the list widget def get_dark_theme(self): return """ QWidget { background-color: #2E2E2E; color: #E0E0E0; font-family: Segoe UI, Arial, sans-serif; font-size: 10pt; } QLineEdit, QListWidget { background-color: #3C3F41; border: 1px solid #5A5A5A; padding: 5px; color: #F0F0F0; border-radius: 4px; } QTextEdit { background-color: #3C3F41; border: 1px solid #5A5A5A; padding: 5px; color: #F0F0F0; border-radius: 4px; } QPushButton { background-color: #555; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 6px 12px; border-radius: 4px; min-height: 22px; } QPushButton:hover { background-color: #656565; border: 1px solid #7A7A7A; } QPushButton:pressed { background-color: #4A4A4A; } QPushButton:disabled { background-color: #404040; color: #888; border-color: #555; } QLabel { font-weight: bold; padding-top: 4px; padding-bottom: 2px; color: #C0C0C0; } QRadioButton, QCheckBox { spacing: 5px; color: #E0E0E0; padding-top: 4px; padding-bottom: 4px; } QRadioButton::indicator, QCheckBox::indicator { width: 14px; height: 14px; } QListWidget { alternate-background-color: #353535; border: 1px solid #5A5A5A; } QListWidget::item:selected { background-color: #007ACC; color: #FFFFFF; } QToolTip { background-color: #4A4A4A; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 4px; border-radius: 3px; } QSplitter::handle { background-color: #5A5A5A; width: 5px; /* Make handle slightly wider */ } QSplitter::handle:horizontal { width: 5px; } QSplitter::handle:vertical { height: 5px; } """ # Added styling for splitter handle def browse_directory(self): current_dir = self.dir_input.text() if os.path.isdir(self.dir_input.text()) else "" folder = QFileDialog.getExistingDirectory(self, "Select Download Folder", current_dir) if folder: self.dir_input.setText(folder) def handle_main_log(self, message): # --- ADDED: Log Verbosity Filtering --- if self.basic_log_mode: # Define keywords/prefixes for messages to ALWAYS show in basic mode basic_keywords = [ '🚀 Starting Download', '🏁 Download Finished', '🏁 Download Cancelled', '❌', '⚠️', '✅ All posts processed', '✅ Reached end of posts', 'Summary:', 'Progress:', '[Fetcher]', # Show fetcher logs for context 'CRITICAL ERROR', 'IMPORT ERROR' ] # Check if the message contains any of the basic keywords/prefixes if not any(keyword in message for keyword in basic_keywords): return # Skip appending this message in basic mode # --- END ADDED --- try: # Ensure message is a string and replace null characters that can crash QTextEdit safe_message = str(message).replace('\x00', '[NULL]') self.main_log_output.append(safe_message) # Auto-scroll if near the bottom scrollbar = self.main_log_output.verticalScrollBar() if scrollbar.value() >= scrollbar.maximum() - 30: # Threshold for auto-scroll scrollbar.setValue(scrollbar.maximum()) except Exception as e: # Fallback logging if GUI logging fails print(f"GUI Main Log Error: {e}\nOriginal Message: {message}") # --- ADDED: Helper to check download state --- def _is_download_active(self): """Checks if a download thread or pool is currently active.""" single_thread_active = self.download_thread and self.download_thread.isRunning() # Check if pool exists AND has any futures that are not done pool_active = self.thread_pool is not None and any(not f.done() for f in self.active_futures if f is not None) return single_thread_active or pool_active # --- END ADDED --- # --- ADDED: New system for handling external links with sequential CONDIITONAL delay --- # MODIFIED: Slot now takes link_text as the second argument def handle_external_link_signal(self, post_title, link_text, link_url, platform): """Receives link signals, adds them to a queue, and triggers processing.""" # We still receive post_title for potential future use, but use link_text for display self.external_link_queue.append((link_text, link_url, platform)) self._try_process_next_external_link() def _try_process_next_external_link(self): """Processes the next link from the queue if not already processing.""" if self._is_processing_external_link_queue or \ not self.external_link_queue or \ not (self.show_external_links and self.external_log_output and self.external_log_output.isVisible()): return self._is_processing_external_link_queue = True # MODIFIED: Get link_text from queue link_text, link_url, platform = self.external_link_queue.popleft() self._append_link_to_external_log(link_text, link_url, platform) # Display this link now # --- MODIFIED: Conditional delay --- if self._is_download_active(): # Schedule the end of this link's "display period" with delay delay_ms = random.randint(4000, 8000) # Random delay of 4-8 seconds QTimer.singleShot(delay_ms, self._finish_current_link_processing) else: # No download active, process next link almost immediately QTimer.singleShot(0, self._finish_current_link_processing) # --- END MODIFIED --- def _finish_current_link_processing(self): """Called after a delay (or immediately if download finished); allows the next link in the queue to be processed.""" self._is_processing_external_link_queue = False self._try_process_next_external_link() # Attempt to process the next link # MODIFIED: Method now takes link_text instead of title for display def _append_link_to_external_log(self, link_text, link_url, platform): """Appends a single formatted link to the external_log_output widget.""" if not (self.show_external_links and self.external_log_output and self.external_log_output.isVisible()): return # Use link_text for display, truncate if necessary max_link_text_len = 35 # Adjust as needed display_text = link_text[:max_link_text_len].strip() + "..." if len(link_text) > max_link_text_len else link_text # Format the string as requested: text - url - platform formatted_link_text = f"{display_text} - {link_url} - {platform}" separator = "-" * 45 # Adjust length as needed try: self.external_log_output.append(separator) self.external_log_output.append(formatted_link_text) self.external_log_output.append("") # Add a blank line for spacing # Auto-scroll scrollbar = self.external_log_output.verticalScrollBar() if scrollbar.value() >= scrollbar.maximum() - 50: # Adjust threshold if needed scrollbar.setValue(scrollbar.maximum()) except Exception as e: self.log_signal.emit(f"GUI External Log Append Error: {e}\nOriginal Message: {formatted_link_text}") print(f"GUI External Log Error (Append): {e}\nOriginal Message: {formatted_link_text}") # --- END ADDED --- def update_file_progress_display(self, filename, downloaded_bytes, total_bytes): if not filename and total_bytes == 0 and downloaded_bytes == 0: # Clear signal self.file_progress_label.setText("") return # MODIFIED: Truncate filename more aggressively (e.g., max 25 chars) max_filename_len = 25 display_filename = filename[:max_filename_len-3].strip() + "..." if len(filename) > max_filename_len else filename if total_bytes > 0: downloaded_mb = downloaded_bytes / (1024 * 1024) total_mb = total_bytes / (1024 * 1024) progress_text = f"Downloading '{display_filename}' ({downloaded_mb:.1f}MB / {total_mb:.1f}MB)" else: # If total size is unknown downloaded_mb = downloaded_bytes / (1024 * 1024) progress_text = f"Downloading '{display_filename}' ({downloaded_mb:.1f}MB)" # Check if the resulting text might still be too long (heuristic) # This is a basic check, might need refinement based on typical log width if len(progress_text) > 75: # Example threshold, adjust as needed # If still too long, truncate the display_filename even more display_filename = filename[:15].strip() + "..." if len(filename) > 18 else display_filename if total_bytes > 0: progress_text = f"DL '{display_filename}' ({downloaded_mb:.1f}/{total_mb:.1f}MB)" else: progress_text = f"DL '{display_filename}' ({downloaded_mb:.1f}MB)" self.file_progress_label.setText(progress_text) def update_external_links_setting(self, checked): self.show_external_links = checked if checked: self.external_log_output.show() # Adjust splitter, give both logs some space # Use the VERTICAL splitter for logs here self.log_splitter.setSizes([self.height() // 2, self.height() // 2]) self.main_log_output.setMinimumHeight(50) # Ensure it doesn't disappear self.external_log_output.setMinimumHeight(50) self.log_signal.emit("\n" + "="*40 + "\n🔗 External Links Log Enabled\n" + "="*40) self.external_log_output.clear() # Clear previous content self.external_log_output.append("🔗 External Links Found:") # Header # --- ADDED: Try processing queue if log becomes visible --- self._try_process_next_external_link() # --- END ADDED --- else: self.external_log_output.hide() # Use the VERTICAL splitter for logs here self.log_splitter.setSizes([self.height(), 0]) # Main log takes all space self.main_log_output.setMinimumHeight(0) # Reset min height self.external_log_output.setMinimumHeight(0) self.external_log_output.clear() # Clear content when hidden self.log_signal.emit("\n" + "="*40 + "\n🔗 External Links Log Disabled\n" + "="*40) # Optional: Clear queue when log is hidden? # self.external_link_queue.clear() # self._is_processing_external_link_queue = False def get_filter_mode(self): if self.radio_images.isChecked(): return 'image' if self.radio_videos.isChecked(): return 'video' return 'all' # Default def add_new_character(self): global KNOWN_NAMES, clean_folder_name # Ensure clean_folder_name is accessible name_to_add = self.new_char_input.text().strip() if not name_to_add: QMessageBox.warning(self, "Input Error", "Name cannot be empty.") return False # Indicate failure name_lower = name_to_add.lower() # 1. Exact Duplicate Check (case-insensitive) is_exact_duplicate = any(existing.lower() == name_lower for existing in KNOWN_NAMES) if is_exact_duplicate: QMessageBox.warning(self, "Duplicate Name", f"The name '{name_to_add}' (case-insensitive) already exists.") return False # 2. Similarity Check (substring, case-insensitive) similar_names_details = [] # Store tuples of (new_name, existing_name) for existing_name in KNOWN_NAMES: existing_name_lower = existing_name.lower() # Avoid self-comparison if somehow name_lower was already in a different case if name_lower != existing_name_lower: if name_lower in existing_name_lower or existing_name_lower in name_lower: similar_names_details.append((name_to_add, existing_name)) if similar_names_details: first_similar_new, first_similar_existing = similar_names_details[0] # Determine shorter and longer for the example message shorter_name_for_msg, longer_name_for_msg = sorted( [first_similar_new, first_similar_existing], key=len ) msg_box = QMessageBox(self) msg_box.setIcon(QMessageBox.Warning) msg_box.setWindowTitle("Potential Name Conflict") msg_box.setText( f"The name '{first_similar_new}' is very similar to an existing name: '{first_similar_existing}'.\n\n" f"For example, if a post title primarily matches the shorter name ('{shorter_name_for_msg}'), " f"files might be saved under a folder for '{clean_folder_name(shorter_name_for_msg)}', " f"even if the longer name ('{longer_name_for_msg}') was also relevant or intended for a more specific folder.\n" "This could lead to files being grouped into less specific or overly broad folders than desired.\n\n" "Do you want to change the name you are adding, or proceed anyway?" ) change_button = msg_box.addButton("Change Name", QMessageBox.RejectRole) proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole) msg_box.setDefaultButton(proceed_button) # Default to proceed msg_box.setEscapeButton(change_button) # Escape cancels/rejects msg_box.exec_() if msg_box.clickedButton() == change_button: self.log_signal.emit(f"ℹ️ User chose to change the name '{first_similar_new}' due to similarity with '{first_similar_existing}'.") return False # Don't add, user will change input and click "Add" again # If proceed_button is clicked (or dialog is closed and proceed is default) self.log_signal.emit(f"⚠️ User chose to proceed with adding '{first_similar_new}' despite similarity with '{first_similar_existing}'.") # Fall through to add the name # If no exact duplicate, and (no similar names OR user chose to proceed with similar name) KNOWN_NAMES.append(name_to_add) KNOWN_NAMES.sort(key=str.lower) # Keep the list sorted (case-insensitive for sorting) self.character_list.clear() self.character_list.addItems(KNOWN_NAMES) self.filter_character_list(self.character_search_input.text()) # Re-apply filter self.log_signal.emit(f"✅ Added '{name_to_add}' to known names list.") self.new_char_input.clear() self.save_known_names() # Save to file return True # Indicate success def delete_selected_character(self): global KNOWN_NAMES selected_items = self.character_list.selectedItems() if not selected_items: QMessageBox.warning(self, "Selection Error", "Please select one or more names to delete.") return names_to_remove = {item.text() for item in selected_items} confirm = QMessageBox.question(self, "Confirm Deletion", f"Are you sure you want to delete {len(names_to_remove)} name(s)?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No) if confirm == QMessageBox.Yes: original_count = len(KNOWN_NAMES) # Filter out names to remove KNOWN_NAMES = [n for n in KNOWN_NAMES if n not in names_to_remove] removed_count = original_count - len(KNOWN_NAMES) if removed_count > 0: self.log_signal.emit(f"🗑️ Removed {removed_count} name(s).") self.character_list.clear() # Update UI self.character_list.addItems(KNOWN_NAMES) self.filter_character_list(self.character_search_input.text()) # Re-apply filter self.save_known_names() # Save changes else: self.log_signal.emit("ℹ️ No names were removed (they might not have been in the list or already deleted).") def update_custom_folder_visibility(self, url_text=None): if url_text is None: url_text = self.link_input.text() # Get current text if not passed _, _, post_id = extract_post_info(url_text.strip()) # Show if it's a post URL AND subfolders are generally enabled should_show = bool(post_id) and self.use_subfolders_checkbox.isChecked() self.custom_folder_widget.setVisible(should_show) if not should_show: self.custom_folder_input.clear() # Clear if hidden def update_ui_for_subfolders(self, checked): # Character filter input visibility depends on subfolder usage self.character_filter_widget.setVisible(checked) if not checked: self.character_input.clear() # Clear filter if hiding self.update_custom_folder_visibility() # Custom folder also depends on this # "Subfolder per Post" is only enabled if "Separate Folders" is also checked self.use_subfolder_per_post_checkbox.setEnabled(checked) if not checked: self.use_subfolder_per_post_checkbox.setChecked(False) # Uncheck if parent is disabled def update_page_range_enabled_state(self): url_text = self.link_input.text().strip() service, user_id, post_id = extract_post_info(url_text) # Page range is for creator feeds (no post_id) is_creator_feed = service is not None and user_id is not None and post_id is None manga_mode_active = self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False # Enable page range if it's a creator feed AND manga mode is NOT active enable_page_range = is_creator_feed and not manga_mode_active for widget in [self.page_range_label, self.start_page_input, self.to_label, self.end_page_input]: if widget: widget.setEnabled(enable_page_range) if not enable_page_range: # Clear inputs if disabled self.start_page_input.clear() self.end_page_input.clear() def update_ui_for_manga_mode(self, checked): url_text = self.link_input.text().strip() _, _, post_id = extract_post_info(url_text) is_creator_feed = not post_id if url_text else False # Manga mode only for creator feeds if self.manga_mode_checkbox: # Ensure checkbox exists self.manga_mode_checkbox.setEnabled(is_creator_feed) # Only enable for creator feeds if not is_creator_feed and self.manga_mode_checkbox.isChecked(): self.manga_mode_checkbox.setChecked(False) # Uncheck if URL changes to non-creator feed # If manga mode is active (checked and enabled), disable page range if is_creator_feed and self.manga_mode_checkbox and self.manga_mode_checkbox.isChecked(): self.page_range_label.setEnabled(False) self.start_page_input.setEnabled(False); self.start_page_input.clear() self.to_label.setEnabled(False) self.end_page_input.setEnabled(False); self.end_page_input.clear() else: # Otherwise, let update_page_range_enabled_state handle it self.update_page_range_enabled_state() def filter_character_list(self, search_text): search_text_lower = search_text.lower() for i in range(self.character_list.count()): item = self.character_list.item(i) item.setHidden(search_text_lower not in item.text().lower()) def update_multithreading_label(self, text): try: num_threads = int(text) if num_threads > 0 : self.use_multithreading_checkbox.setText(f"Use Multithreading ({num_threads} Threads)") else: # Should be caught by validator, but defensive self.use_multithreading_checkbox.setText("Use Multithreading (Invalid: >0)") except ValueError: # If text is not a valid integer self.use_multithreading_checkbox.setText("Use Multithreading (Invalid Input)") def update_progress_display(self, total_posts, processed_posts): if total_posts > 0: progress_percent = (processed_posts / total_posts) * 100 self.progress_label.setText(f"Progress: {processed_posts} / {total_posts} posts ({progress_percent:.1f}%)") elif processed_posts > 0 : # If total_posts is unknown (e.g., single post) self.progress_label.setText(f"Progress: Processing post {processed_posts}...") else: # Initial state or no posts self.progress_label.setText("Progress: Starting...") if total_posts > 0 or processed_posts > 0 : self.file_progress_label.setText("") # Clear file progress def start_download(self): global KNOWN_NAMES, BackendDownloadThread, PostProcessorWorker, extract_post_info, clean_folder_name if (self.download_thread and self.download_thread.isRunning()) or self.thread_pool: QMessageBox.warning(self, "Busy", "A download is already running.") return api_url = self.link_input.text().strip() output_dir = self.dir_input.text().strip() filter_mode = self.get_filter_mode() skip_zip = self.skip_zip_checkbox.isChecked() skip_rar = self.skip_rar_checkbox.isChecked() use_subfolders = self.use_subfolders_checkbox.isChecked() use_post_subfolders = self.use_subfolder_per_post_checkbox.isChecked() and use_subfolders compress_images = self.compress_images_checkbox.isChecked() download_thumbnails = self.download_thumbnails_checkbox.isChecked() use_multithreading = self.use_multithreading_checkbox.isChecked() raw_skip_words = self.skip_words_input.text().strip() skip_words_list = [word.strip().lower() for word in raw_skip_words.split(',') if word.strip()] manga_mode_is_checked = self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False if not api_url or not output_dir: QMessageBox.critical(self, "Input Error", "URL and Download Directory are required."); return service, user_id, post_id_from_url = extract_post_info(api_url) if not service or not user_id: # Basic validation of extracted info QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format."); return if not os.path.isdir(output_dir): reply = QMessageBox.question(self, "Create Directory?", f"The directory '{output_dir}' does not exist.\nCreate it now?", QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) if reply == QMessageBox.Yes: try: os.makedirs(output_dir, exist_ok=True) # exist_ok=True is safer self.log_signal.emit(f"ℹ️ Created directory: {output_dir}") except Exception as e: QMessageBox.critical(self, "Directory Error", f"Could not create directory: {e}"); return else: self.log_signal.emit("❌ Download cancelled: Output directory does not exist and was not created.") return if compress_images and Image is None: # Check for Pillow if compression is enabled QMessageBox.warning(self, "Missing Dependency", "Pillow library (for image compression) not found. Compression will be disabled.") compress_images = False # Disable it for this run self.compress_images_checkbox.setChecked(False) # Update UI manga_mode = manga_mode_is_checked and not post_id_from_url # Manga mode only for creator feeds num_threads_str = self.thread_count_input.text().strip() MAX_ALLOWED_THREADS = 200 MODERATE_THREAD_WARNING_THRESHOLD = 50 try: num_threads = int(num_threads_str) if not (1 <= num_threads <= MAX_ALLOWED_THREADS): # Validate thread count QMessageBox.critical(self, "Thread Count Error", f"Number of threads must be between 1 and {MAX_ALLOWED_THREADS}.") self.thread_count_input.setText(str(min(max(1, num_threads), MAX_ALLOWED_THREADS))) # Correct to valid range return if num_threads > MODERATE_THREAD_WARNING_THRESHOLD: # Warn for very high thread counts QMessageBox.information(self, "High Thread Count Note", f"Using {num_threads} threads (above {MODERATE_THREAD_WARNING_THRESHOLD}) may increase resource usage and risk rate-limiting from the site.\n\nProceeding with caution.") self.log_signal.emit(f"ℹ️ Using high thread count: {num_threads}.") except ValueError: QMessageBox.critical(self, "Thread Count Error", "Invalid number of threads. Please enter a numeric value."); return start_page_str, end_page_str = self.start_page_input.text().strip(), self.end_page_input.text().strip() start_page, end_page = None, None is_creator_feed = bool(not post_id_from_url) if is_creator_feed and not manga_mode: # Page range only for non-manga creator feeds try: if start_page_str: start_page = int(start_page_str) if end_page_str: end_page = int(end_page_str) if start_page is not None and start_page <= 0: raise ValueError("Start page must be positive.") if end_page is not None and end_page <= 0: raise ValueError("End page must be positive.") if start_page and end_page and start_page > end_page: raise ValueError("Start page cannot be greater than end page.") except ValueError as e: QMessageBox.critical(self, "Page Range Error", f"Invalid page range: {e}"); return elif manga_mode: # Manga mode processes all pages (reversed in downloader_utils) start_page, end_page = None, None # --- ADDED: Clear link queue before starting new download --- self.external_link_queue.clear() self._is_processing_external_link_queue = False # --- END ADDED --- raw_character_filters_text = self.character_input.text().strip() parsed_character_list = None if raw_character_filters_text: temp_list = [name.strip() for name in raw_character_filters_text.split(',') if name.strip()] if temp_list: parsed_character_list = temp_list filter_character_list_to_pass = None # This will be passed to backend if use_subfolders and parsed_character_list and not post_id_from_url: # Validate filters if used for subfolders self.log_signal.emit(f"ℹ️ Validating character filters for subfolder naming: {', '.join(parsed_character_list)}") valid_filters_for_backend = [] user_cancelled_validation = False for char_name in parsed_character_list: cleaned_name_test = clean_folder_name(char_name) # Test if name is valid for folder if not cleaned_name_test: QMessageBox.warning(self, "Invalid Filter Name", f"Filter name '{char_name}' is invalid for a folder and will be skipped.") self.log_signal.emit(f"⚠️ Skipping invalid filter for folder: '{char_name}'") continue # Prompt to add to known_names if not already there if char_name.lower() not in {kn.lower() for kn in KNOWN_NAMES}: reply = QMessageBox.question(self, "Add Filter Name to Known List?", f"The character filter '{char_name}' is not in your known names list (used for folder suggestions).\nAdd it now?", QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel, QMessageBox.Yes) if reply == QMessageBox.Yes: self.new_char_input.setText(char_name) # Use existing add mechanism if self.add_new_character(): # This now handles similarity checks too self.log_signal.emit(f"✅ Added '{char_name}' to known names via filter prompt.") valid_filters_for_backend.append(char_name) else: # add_new_character returned False (e.g., user chose "Change Name" or it failed) self.log_signal.emit(f"⚠️ Failed to add '{char_name}' via filter prompt (or user opted out). It will still be used for filtering this session if valid.") # Still add to backend list for current session if it's a valid folder name if cleaned_name_test: valid_filters_for_backend.append(char_name) elif reply == QMessageBox.Cancel: self.log_signal.emit(f"❌ Download cancelled by user during filter validation for '{char_name}'.") user_cancelled_validation = True; break else: # User chose No self.log_signal.emit(f"ℹ️ Proceeding with filter '{char_name}' for matching without adding to known list.") if cleaned_name_test: valid_filters_for_backend.append(char_name) else: # Already in known names if cleaned_name_test: valid_filters_for_backend.append(char_name) if user_cancelled_validation: return # Stop download if user cancelled if valid_filters_for_backend: filter_character_list_to_pass = valid_filters_for_backend self.log_signal.emit(f" Using validated character filters for subfolders: {', '.join(filter_character_list_to_pass)}") else: self.log_signal.emit("⚠️ No valid character filters remaining after validation for subfolder naming.") elif parsed_character_list : # Filters provided, but not for subfolders (e.g. subfolders disabled) filter_character_list_to_pass = parsed_character_list self.log_signal.emit(f"ℹ️ Character filters provided: {', '.join(filter_character_list_to_pass)} (Subfolder creation rules may differ).") # --- ADDED: Manga Mode Filter Warning --- if manga_mode and not filter_character_list_to_pass: msg_box = QMessageBox(self) msg_box.setIcon(QMessageBox.Warning) msg_box.setWindowTitle("Manga Mode Filter Warning") msg_box.setText( "Manga Mode is enabled, but the 'Filter by Character(s)' field is empty.\n\n" "For best results (correct file naming and grouping), please enter the exact Manga/Series title " "(as used by the creator on the site) into the filter field.\n\n" "Do you want to proceed without a filter (file names might be generic) or cancel?" ) proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole) # YesRole/AcceptRole makes it default cancel_button = msg_box.addButton("Cancel Download", QMessageBox.RejectRole) # NoRole/RejectRole for cancel msg_box.exec_() if msg_box.clickedButton() == cancel_button: self.log_signal.emit("❌ Download cancelled by user due to Manga Mode filter warning.") return # Stop the download process here else: self.log_signal.emit("⚠️ Proceeding with Manga Mode without a specific title filter.") # --- END ADDED --- custom_folder_name_cleaned = None if use_subfolders and post_id_from_url and self.custom_folder_widget.isVisible(): raw_custom_name = self.custom_folder_input.text().strip() if raw_custom_name: cleaned_custom = clean_folder_name(raw_custom_name) if cleaned_custom: custom_folder_name_cleaned = cleaned_custom else: self.log_signal.emit(f"⚠️ Invalid custom folder name ignored: '{raw_custom_name}'") # Reset UI elements for new download self.main_log_output.clear() if self.show_external_links: self.external_log_output.clear(); self.external_log_output.append("🔗 External Links Found:") # Changed title slightly self.file_progress_label.setText("") self.cancellation_event.clear() # IMPORTANT: Clear cancellation from previous run self.active_futures = [] self.total_posts_to_process = self.processed_posts_count = self.download_counter = self.skip_counter = 0 self.progress_label.setText("Progress: Initializing...") # Log download parameters log_messages = [ "="*40, f"🚀 Starting Download @ {time.strftime('%Y-%m-%d %H:%M:%S')}", f" URL: {api_url}", f" Save Location: {output_dir}", f" Mode: {'Single Post' if post_id_from_url else 'Creator Feed'}", ] if is_creator_feed: if manga_mode: log_messages.append(" Page Range: All (Manga Mode - Oldest Posts Processed First)") else: pr_log = "All" if start_page or end_page: # Construct page range log string pr_log = f"{f'From {start_page} ' if start_page else ''}{'to ' if start_page and end_page else ''}{f'{end_page}' if end_page else (f'Up to {end_page}' if end_page else (f'From {start_page}' if start_page else 'Specific Range'))}".strip() log_messages.append(f" Page Range: {pr_log if pr_log else 'All'}") log_messages.append(f" Subfolders: {'Enabled' if use_subfolders else 'Disabled'}") if use_subfolders: if custom_folder_name_cleaned: log_messages.append(f" Custom Folder (Post): '{custom_folder_name_cleaned}'") elif filter_character_list_to_pass and not post_id_from_url: log_messages.append(f" Character Filters for Folders: {', '.join(filter_character_list_to_pass)}") else: log_messages.append(f" Folder Naming: Automatic (based on title/known names)") log_messages.append(f" Subfolder per Post: {'Enabled' if use_post_subfolders else 'Disabled'}") log_messages.extend([ f" File Type Filter: {filter_mode}", f" Skip Archives: {'.zip' if skip_zip else ''}{', ' if skip_zip and skip_rar else ''}{'.rar' if skip_rar else ''}{'None' if not (skip_zip or skip_rar) else ''}", f" Skip Words (posts/files): {', '.join(skip_words_list) if skip_words_list else 'None'}", f" Compress Images: {'Enabled' if compress_images else 'Disabled'}", f" Thumbnails Only: {'Enabled' if download_thumbnails else 'Disabled'}", f" Show External Links: {'Enabled' if self.show_external_links else 'Disabled'}" ]) if manga_mode: log_messages.append(f" Manga Mode (File Renaming by Post Title): Enabled") should_use_multithreading = use_multithreading and not post_id_from_url # Multi-threading for creator feeds log_messages.append(f" Threading: {'Multi-threaded (posts)' if should_use_multithreading else 'Single-threaded (posts)'}") if should_use_multithreading: log_messages.append(f" Number of Post Worker Threads: {num_threads}") log_messages.append("="*40) for msg in log_messages: self.log_signal.emit(msg) self.set_ui_enabled(False) # Disable UI during download unwanted_keywords_for_folders = {'spicy', 'hd', 'nsfw', '4k', 'preview', 'teaser', 'clip'} # Prepare arguments for worker threads/classes args_template = { 'api_url_input': api_url, 'download_root': output_dir, # For PostProcessorWorker 'output_dir': output_dir, # For DownloadThread __init__ 'known_names': list(KNOWN_NAMES), # Pass a copy 'known_names_copy': list(KNOWN_NAMES), # For DownloadThread __init__ 'filter_character_list': filter_character_list_to_pass, 'filter_mode': filter_mode, 'skip_zip': skip_zip, 'skip_rar': skip_rar, 'use_subfolders': use_subfolders, 'use_post_subfolders': use_post_subfolders, 'compress_images': compress_images, 'download_thumbnails': download_thumbnails, 'service': service, 'user_id': user_id, 'downloaded_files': self.downloaded_files, # Shared set 'downloaded_files_lock': self.downloaded_files_lock, # Shared lock 'downloaded_file_hashes': self.downloaded_file_hashes, # Shared set 'downloaded_file_hashes_lock': self.downloaded_file_hashes_lock, # Shared lock 'skip_words_list': skip_words_list, 'show_external_links': self.show_external_links, 'start_page': start_page, 'end_page': end_page, 'target_post_id_from_initial_url': post_id_from_url, 'custom_folder_name': custom_folder_name_cleaned, 'manga_mode_active': manga_mode, 'unwanted_keywords': unwanted_keywords_for_folders, 'cancellation_event': self.cancellation_event, # Crucial for stopping threads 'signals': self.worker_signals, # For multi-threaded PostProcessorWorker } try: if should_use_multithreading: self.log_signal.emit(f" Initializing multi-threaded download with {num_threads} post workers...") self.start_multi_threaded_download(num_post_workers=num_threads, **args_template) else: # Single post URL or multithreading disabled self.log_signal.emit(" Initializing single-threaded download...") # Keys expected by DownloadThread constructor dt_expected_keys = [ 'api_url_input', 'output_dir', 'known_names_copy', 'cancellation_event', 'filter_character_list', 'filter_mode', 'skip_zip', 'skip_rar', 'use_subfolders', 'use_post_subfolders', 'custom_folder_name', 'compress_images', 'download_thumbnails', 'service', 'user_id', 'downloaded_files', 'downloaded_file_hashes', 'downloaded_files_lock', 'downloaded_file_hashes_lock', 'skip_words_list', 'show_external_links', 'num_file_threads_for_worker', 'skip_current_file_flag', 'start_page', 'end_page', 'target_post_id_from_initial_url', 'manga_mode_active', 'unwanted_keywords' ] args_template['num_file_threads_for_worker'] = 1 # Single thread mode, worker uses 1 file thread args_template['skip_current_file_flag'] = None # No skip flag initially single_thread_args = {} for key in dt_expected_keys: if key in args_template: single_thread_args[key] = args_template[key] # Missing optional keys will use defaults in DownloadThread's __init__ self.start_single_threaded_download(**single_thread_args) except Exception as e: self.log_signal.emit(f"❌ CRITICAL ERROR preparing download: {e}\n{traceback.format_exc()}") QMessageBox.critical(self, "Start Error", f"Failed to start download:\n{e}") self.download_finished(0,0,False) # Ensure UI is re-enabled def start_single_threaded_download(self, **kwargs): global BackendDownloadThread try: self.download_thread = BackendDownloadThread(**kwargs) # Pass all relevant args # Connect signals from the DownloadThread instance if hasattr(self.download_thread, 'progress_signal'): self.download_thread.progress_signal.connect(self.handle_main_log) if hasattr(self.download_thread, 'add_character_prompt_signal'): # Though less used by DownloadThread directly self.download_thread.add_character_prompt_signal.connect(self.add_character_prompt_signal) if hasattr(self.download_thread, 'finished_signal'): self.download_thread.finished_signal.connect(self.finished_signal) # Connect to app's finished handler if hasattr(self.download_thread, 'receive_add_character_result'): # For two-way prompt communication self.character_prompt_response_signal.connect(self.download_thread.receive_add_character_result) # MODIFIED: Connect external_link_signal to the new handler if hasattr(self.download_thread, 'external_link_signal'): self.download_thread.external_link_signal.connect(self.handle_external_link_signal) # Connect to queue handler if hasattr(self.download_thread, 'file_progress_signal'): self.download_thread.file_progress_signal.connect(self.update_file_progress_display) self.download_thread.start() self.log_signal.emit("✅ Single download thread (for posts) started.") except Exception as e: self.log_signal.emit(f"❌ CRITICAL ERROR starting single-thread: {e}\n{traceback.format_exc()}") QMessageBox.critical(self, "Thread Start Error", f"Failed to start download process: {e}") self.download_finished(0,0,False) # Cleanup def start_multi_threaded_download(self, num_post_workers, **kwargs): global PostProcessorWorker # Ensure it's the correct worker class self.thread_pool = ThreadPoolExecutor(max_workers=num_post_workers, thread_name_prefix='PostWorker_') self.active_futures = [] # Reset list of active futures self.processed_posts_count = 0 self.total_posts_to_process = 0 # Will be updated by _fetch_and_queue_posts self.download_counter = 0 self.skip_counter = 0 # Start a separate thread to fetch post data and submit tasks to the pool # This prevents the GUI from freezing during the initial API calls for post lists fetcher_thread = threading.Thread( target=self._fetch_and_queue_posts, args=(kwargs['api_url_input'], kwargs, num_post_workers), daemon=True, name="PostFetcher" # Daemon thread will exit when app exits ) fetcher_thread.start() self.log_signal.emit(f"✅ Post fetcher thread started. {num_post_workers} post worker threads initializing...") def _fetch_and_queue_posts(self, api_url_input_for_fetcher, worker_args_template, num_post_workers): global PostProcessorWorker, download_from_api # Ensure correct references all_posts_data = [] fetch_error_occurred = False manga_mode_active_for_fetch = worker_args_template.get('manga_mode_active', False) signals_for_worker = worker_args_template.get('signals') # This is self.worker_signals if not signals_for_worker: # Should always be present self.log_signal.emit("❌ CRITICAL ERROR: Signals object missing for worker in _fetch_and_queue_posts.") self.finished_signal.emit(0,0,True) # Signal failure return try: self.log_signal.emit(" Fetching post data from API...") post_generator = download_from_api( api_url_input_for_fetcher, logger=lambda msg: self.log_signal.emit(f"[Fetcher] {msg}"), # Prefix fetcher logs start_page=worker_args_template.get('start_page'), end_page=worker_args_template.get('end_page'), manga_mode=manga_mode_active_for_fetch, cancellation_event=self.cancellation_event # Pass cancellation event ) for posts_batch in post_generator: if self.cancellation_event.is_set(): # Check cancellation frequently fetch_error_occurred = True; self.log_signal.emit(" Post fetching cancelled by user."); break if isinstance(posts_batch, list): all_posts_data.extend(posts_batch) self.total_posts_to_process = len(all_posts_data) # Update total # Log progress periodically for large feeds if self.total_posts_to_process > 0 and self.total_posts_to_process % 100 == 0 : self.log_signal.emit(f" Fetched {self.total_posts_to_process} posts so far...") else: # Should not happen if download_from_api is correct fetch_error_occurred = True self.log_signal.emit(f"❌ API fetcher returned non-list type: {type(posts_batch)}"); break if not fetch_error_occurred and not self.cancellation_event.is_set(): self.log_signal.emit(f"✅ Post fetching complete. Total posts to process: {self.total_posts_to_process}") except TypeError as te: # Catch common error if downloader_utils is outdated self.log_signal.emit(f"❌ TypeError calling download_from_api: {te}") self.log_signal.emit(" Check if 'downloader_utils.py' has the correct 'download_from_api' signature (including 'manga_mode' and 'cancellation_event').") self.log_signal.emit(traceback.format_exc(limit=2)) fetch_error_occurred = True except RuntimeError as re: # Catch cancellation from fetch_posts_paginated self.log_signal.emit(f"ℹ️ Post fetching runtime error (likely cancellation): {re}") fetch_error_occurred = True # Treat as an error for cleanup except Exception as e: self.log_signal.emit(f"❌ Error during post fetching: {e}\n{traceback.format_exc(limit=2)}") fetch_error_occurred = True if self.cancellation_event.is_set() or fetch_error_occurred: self.finished_signal.emit(self.download_counter, self.skip_counter, self.cancellation_event.is_set()) if self.thread_pool: # Ensure pool is shutdown if fetch fails or is cancelled self.thread_pool.shutdown(wait=False, cancel_futures=True); self.thread_pool = None return if self.total_posts_to_process == 0: self.log_signal.emit("😕 No posts found or fetched to process.") self.finished_signal.emit(0,0,False); return self.log_signal.emit(f" Submitting {self.total_posts_to_process} post processing tasks to thread pool...") self.processed_posts_count = 0 # Reset for this run self.overall_progress_signal.emit(self.total_posts_to_process, 0) # Initial progress update num_file_dl_threads = 4 # Default for PostProcessorWorker's internal pool # Define keys PostProcessorWorker expects (ensure this matches its __init__) ppw_expected_keys = [ 'post_data', 'download_root', 'known_names', 'filter_character_list', 'unwanted_keywords', 'filter_mode', 'skip_zip', 'skip_rar', 'use_subfolders', 'use_post_subfolders', 'target_post_id_from_initial_url', 'custom_folder_name', 'compress_images', 'download_thumbnails', 'service', 'user_id', 'api_url_input', 'cancellation_event', 'signals', 'downloaded_files', 'downloaded_file_hashes', 'downloaded_files_lock', 'downloaded_file_hashes_lock', 'skip_words_list', 'show_external_links', 'extract_links_only', 'num_file_threads', 'skip_current_file_flag', 'manga_mode_active' ] # Optional keys with defaults in PostProcessorWorker's __init__ ppw_optional_keys_with_defaults = { 'skip_words_list', 'show_external_links', 'extract_links_only', 'num_file_threads', 'skip_current_file_flag', 'manga_mode_active' } for post_data_item in all_posts_data: if self.cancellation_event.is_set(): break # Check before submitting each task if not isinstance(post_data_item, dict): # Basic sanity check self.log_signal.emit(f"⚠️ Skipping invalid post data item (not a dict): {type(post_data_item)}") self.processed_posts_count += 1 # Count as processed/skipped continue # Build args for PostProcessorWorker instance worker_init_args = {} missing_keys = [] for key in ppw_expected_keys: if key == 'post_data': worker_init_args[key] = post_data_item elif key == 'num_file_threads': worker_init_args[key] = num_file_dl_threads elif key == 'signals': worker_init_args[key] = signals_for_worker # Use the app's worker_signals elif key in worker_args_template: worker_init_args[key] = worker_args_template[key] elif key in ppw_optional_keys_with_defaults: pass # Let worker use its default else: missing_keys.append(key) # Required key is missing if missing_keys: self.log_signal.emit(f"❌ CRITICAL ERROR: Missing expected keys for PostProcessorWorker: {', '.join(missing_keys)}") self.cancellation_event.set() # Stop all processing break try: worker_instance = PostProcessorWorker(**worker_init_args) if self.thread_pool: # Ensure pool is still active future = self.thread_pool.submit(worker_instance.process) future.add_done_callback(self._handle_future_result) # Handle result/exception self.active_futures.append(future) else: # Pool might have been shut down due to earlier error/cancellation self.log_signal.emit("⚠️ Thread pool not available. Cannot submit more tasks.") break except TypeError as te: # Error creating worker (e.g. wrong args) self.log_signal.emit(f"❌ TypeError creating PostProcessorWorker: {te}") passed_keys_str = ", ".join(sorted(worker_init_args.keys())) self.log_signal.emit(f" Passed Args: [{passed_keys_str}]") self.log_signal.emit(traceback.format_exc(limit=5)) self.cancellation_event.set(); break # Stop all except RuntimeError: # Pool might be shutting down self.log_signal.emit("⚠️ Runtime error submitting task (pool likely shutting down)."); break except Exception as e: # Other errors during submission self.log_signal.emit(f"❌ Error submitting post {post_data_item.get('id','N/A')} to worker: {e}"); break if not self.cancellation_event.is_set(): self.log_signal.emit(f" {len(self.active_futures)} post processing tasks submitted to pool.") else: # If cancelled during submission loop self.finished_signal.emit(self.download_counter, self.skip_counter, True) if self.thread_pool: self.thread_pool.shutdown(wait=False, cancel_futures=True); self.thread_pool = None def _handle_future_result(self, future: Future): self.processed_posts_count += 1 downloaded_files_from_future = 0 skipped_files_from_future = 0 try: if future.cancelled(): self.log_signal.emit(" A post processing task was cancelled.") # If a task was cancelled, it implies we might want to count its potential files as skipped # This is hard to determine without knowing the post_data it was handling. # For simplicity, we don't add to skip_counter here unless future.result() would have. elif future.exception(): worker_exception = future.exception() self.log_signal.emit(f"❌ Post processing worker error: {worker_exception}") # Similar to cancelled, hard to know how many files were skipped due to error. else: # Success downloaded_files_from_future, skipped_files_from_future = future.result() # Lock for updating shared counters with self.downloaded_files_lock: # Using this lock for these counters too self.download_counter += downloaded_files_from_future self.skip_counter += skipped_files_from_future self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count) except Exception as e: # Error in this callback itself self.log_signal.emit(f"❌ Error in _handle_future_result callback: {e}\n{traceback.format_exc(limit=2)}") # Check if all tasks are done if self.total_posts_to_process > 0 and self.processed_posts_count >= self.total_posts_to_process: # More robust check: ensure all submitted futures are actually done all_done = all(f.done() for f in self.active_futures) if all_done: QApplication.processEvents() # Process any pending GUI events self.log_signal.emit("🏁 All submitted post tasks have completed or failed.") self.finished_signal.emit(self.download_counter, self.skip_counter, self.cancellation_event.is_set()) def set_ui_enabled(self, enabled): # List of widgets to toggle enabled state widgets_to_toggle = [ self.download_btn, self.link_input, self.dir_input, self.dir_button, self.radio_all, self.radio_images, self.radio_videos, self.skip_zip_checkbox, self.skip_rar_checkbox, self.use_subfolders_checkbox, self.compress_images_checkbox, self.download_thumbnails_checkbox, self.use_multithreading_checkbox, self.skip_words_input, self.character_search_input, self.new_char_input, self.add_char_button, self.delete_char_button, # self.external_links_checkbox, # MODIFIED: Keep this enabled self.start_page_input, self.end_page_input, self.page_range_label, self.to_label, self.character_input, self.custom_folder_input, self.custom_folder_label, self.reset_button, # self.log_verbosity_button, # MODIFIED: Keep this enabled self.manga_mode_checkbox ] for widget in widgets_to_toggle: if widget: # Check if widget exists widget.setEnabled(enabled) # --- ADDED: Explicitly keep these enabled --- if self.external_links_checkbox: self.external_links_checkbox.setEnabled(True) if self.log_verbosity_button: self.log_verbosity_button.setEnabled(True) # --- END ADDED --- # Handle dependent widgets subfolders_currently_on = self.use_subfolders_checkbox.isChecked() self.use_subfolder_per_post_checkbox.setEnabled(enabled and subfolders_currently_on) multithreading_currently_on = self.use_multithreading_checkbox.isChecked() self.thread_count_input.setEnabled(enabled and multithreading_currently_on) self.thread_count_label.setEnabled(enabled and multithreading_currently_on) self.cancel_btn.setEnabled(not enabled) # Cancel is enabled when download is running if enabled: # When re-enabling UI, refresh dependent states self.update_ui_for_subfolders(subfolders_currently_on) self.update_custom_folder_visibility() self.update_page_range_enabled_state() if self.manga_mode_checkbox: self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked()) def cancel_download(self): if not self.cancel_btn.isEnabled() and not self.cancellation_event.is_set(): # Avoid multiple cancel calls self.log_signal.emit("ℹ️ No active download to cancel or already cancelling.") return self.log_signal.emit("⚠️ Requesting cancellation of download process...") self.cancellation_event.set() # Signal all threads/workers to stop if self.download_thread and self.download_thread.isRunning(): # For QThread, requestInterruption() is a polite request. # The thread's run() loop must check isInterruptionRequested() or self.cancellation_event. self.download_thread.requestInterruption() self.log_signal.emit(" Signaled single download thread to interrupt.") # --- MODIFICATION START: Initiate thread pool shutdown immediately --- if self.thread_pool: self.log_signal.emit(" Initiating immediate shutdown and cancellation of worker pool tasks...") # Start shutdown non-blockingly, attempting to cancel futures self.thread_pool.shutdown(wait=False, cancel_futures=True) # --- MODIFICATION END --- # --- ADDED: Clear link queue on cancel --- self.external_link_queue.clear() self._is_processing_external_link_queue = False # --- END ADDED --- self.cancel_btn.setEnabled(False) # Disable cancel button after initiating cancellation self.progress_label.setText("Progress: Cancelling...") self.file_progress_label.setText("") # The download_finished method will be called eventually when threads finally exit. def download_finished(self, total_downloaded, total_skipped, cancelled_by_user): # This method is the final cleanup point, called by DownloadThread or _handle_future_result status_message = "Cancelled by user" if cancelled_by_user else "Finished" self.log_signal.emit("="*40 + f"\n🏁 Download {status_message}!\n Summary: Downloaded Files={total_downloaded}, Skipped Files={total_skipped}\n" + "="*40) self.progress_label.setText(f"{status_message}: {total_downloaded} downloaded, {total_skipped} skipped.") self.file_progress_label.setText("") # Clear file progress # --- ADDED: Attempt to process any remaining links in queue if not cancelled --- # This will now trigger the rapid display because _is_download_active() will be false if not cancelled_by_user: self._try_process_next_external_link() # --- END ADDED --- # Disconnect signals from single download thread if it was used if self.download_thread: try: if hasattr(self.download_thread, 'progress_signal'): self.download_thread.progress_signal.disconnect(self.handle_main_log) if hasattr(self.download_thread, 'add_character_prompt_signal'): self.download_thread.add_character_prompt_signal.disconnect(self.add_character_prompt_signal) if hasattr(self.download_thread, 'finished_signal'): self.download_thread.finished_signal.disconnect(self.finished_signal) if hasattr(self.download_thread, 'receive_add_character_result'): self.character_prompt_response_signal.disconnect(self.download_thread.receive_add_character_result) # MODIFIED: Ensure disconnection from the correct handler if hasattr(self.download_thread, 'external_link_signal'): self.download_thread.external_link_signal.disconnect(self.handle_external_link_signal) if hasattr(self.download_thread, 'file_progress_signal'): self.download_thread.file_progress_signal.disconnect(self.update_file_progress_display) except (TypeError, RuntimeError) as e: self.log_signal.emit(f"ℹ️ Note during single-thread signal disconnection: {e}") self.download_thread = None # Clear reference # Shutdown thread pool if it exists and hasn't been cleared yet # Use wait=True here to ensure cleanup before UI re-enables if self.thread_pool: self.log_signal.emit(" Ensuring worker thread pool is shut down...") # Shutdown might have been initiated by cancel_download, but wait=True ensures completion. self.thread_pool.shutdown(wait=True, cancel_futures=True) self.thread_pool = None self.active_futures = [] # Clear list of futures # Clear cancellation event here AFTER threads have likely stopped checking it # self.cancellation_event.clear() # Let's clear it in start_download and reset_application_state instead for safety. self.set_ui_enabled(True) # Re-enable UI self.cancel_btn.setEnabled(False) # Disable cancel button # --- ADDED: Method to toggle log verbosity --- def toggle_log_verbosity(self): self.basic_log_mode = not self.basic_log_mode if self.basic_log_mode: self.log_verbosity_button.setText("Show Full Log") self.log_signal.emit("="*20 + " Basic Log Mode Enabled " + "="*20) else: self.log_verbosity_button.setText("Show Basic Log") self.log_signal.emit("="*20 + " Full Log Mode Enabled " + "="*20) # --- END ADDED --- def reset_application_state(self): is_running = (self.download_thread and self.download_thread.isRunning()) or \ (self.thread_pool is not None and any(not f.done() for f in self.active_futures if f is not None)) if is_running: QMessageBox.warning(self, "Reset Error", "Cannot reset while a download is in progress. Please cancel the download first.") return self.log_signal.emit("🔄 Resetting application state to defaults...") self._reset_ui_to_defaults() # Reset UI elements to their initial state self.main_log_output.clear() self.external_log_output.clear() if self.show_external_links: # Re-add header if shown self.external_log_output.append("🔗 External Links Found:") # --- ADDED: Clear link queue on reset --- self.external_link_queue.clear() self._is_processing_external_link_queue = False # --- END ADDED --- self.progress_label.setText("Progress: Idle") self.file_progress_label.setText("") # Clear session-specific data with self.downloaded_files_lock: count = len(self.downloaded_files) self.downloaded_files.clear() if count > 0: self.log_signal.emit(f" Cleared {count} downloaded filename(s) from session memory.") with self.downloaded_file_hashes_lock: count = len(self.downloaded_file_hashes) self.downloaded_file_hashes.clear() if count > 0: self.log_signal.emit(f" Cleared {count} downloaded file hash(es) from session memory.") self.total_posts_to_process = 0 self.processed_posts_count = 0 self.download_counter = 0 self.skip_counter = 0 # self.external_links = [] # This list seems unused, keeping it commented self.cancellation_event.clear() # Ensure cancellation event is reset # --- ADDED: Reset log verbosity mode --- self.basic_log_mode = False if self.log_verbosity_button: self.log_verbosity_button.setText("Show Basic Log") # --- END ADDED --- self.log_signal.emit("✅ Application reset complete.") def _reset_ui_to_defaults(self): # Reset all input fields self.link_input.clear() self.dir_input.clear() self.custom_folder_input.clear() self.character_input.clear() self.skip_words_input.clear() self.start_page_input.clear() self.end_page_input.clear() self.new_char_input.clear() self.character_search_input.clear() self.thread_count_input.setText("4") # Reset radio buttons and checkboxes to defaults self.radio_all.setChecked(True) self.skip_zip_checkbox.setChecked(True) self.skip_rar_checkbox.setChecked(True) self.download_thumbnails_checkbox.setChecked(False) self.compress_images_checkbox.setChecked(False) self.use_subfolders_checkbox.setChecked(True) self.use_subfolder_per_post_checkbox.setChecked(False) self.use_multithreading_checkbox.setChecked(True) self.external_links_checkbox.setChecked(False) if self.manga_mode_checkbox: self.manga_mode_checkbox.setChecked(False) # Explicitly call update methods that control UI element states self.update_ui_for_subfolders(self.use_subfolders_checkbox.isChecked()) self.update_custom_folder_visibility() self.update_page_range_enabled_state() self.update_multithreading_label(self.thread_count_input.text()) if self.manga_mode_checkbox: self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked()) self.filter_character_list("") # Clear character list filter # Reset button states self.download_btn.setEnabled(True) self.cancel_btn.setEnabled(False) if self.reset_button: self.reset_button.setEnabled(True) # Reset log verbosity button text if self.log_verbosity_button: self.log_verbosity_button.setText("Show Basic Log") def prompt_add_character(self, character_name): global KNOWN_NAMES # This method is called via a signal from a worker thread. # It interacts with the GUI, so it's correctly placed in the GUI class. reply = QMessageBox.question(self, "Add Filter Name to Known List?", f"The name '{character_name}' was encountered or used as a filter.\nIt's not in your known names list (used for folder suggestions).\nAdd it now?", QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) result = (reply == QMessageBox.Yes) if result: self.new_char_input.setText(character_name) # Populate input for add_new_character # Call add_new_character, which now includes similarity checks and its own QMessageBox # The result of add_new_character (True/False) reflects if it was actually added. if self.add_new_character(): self.log_signal.emit(f"✅ Added '{character_name}' to known names via background prompt.") else: # add_new_character handles its own logging and popups if it fails or user cancels similarity warning result = False # Update result if add_new_character decided not to add self.log_signal.emit(f"ℹ️ Adding '{character_name}' via background prompt was declined or failed (e.g., similarity warning, duplicate).") # Send the final outcome (whether it was added or user said yes initially but then cancelled) self.character_prompt_response_signal.emit(result) def receive_add_character_result(self, result): # This method receives the result from prompt_add_character (after it has tried to add the name) # and is typically connected to the worker thread's logic to unblock it. with QMutexLocker(self.prompt_mutex): # Ensure thread-safe access if worker modifies shared state based on this self._add_character_response = result self.log_signal.emit(f" Main thread received character prompt response: {'Action resulted in addition/confirmation' if result else 'Action resulted in no addition/declined'}") if __name__ == '__main__': import traceback try: qt_app = QApplication(sys.argv) if getattr(sys, 'frozen', False): base_dir = sys._MEIPASS else: base_dir = os.path.dirname(os.path.abspath(__file__)) icon_path = os.path.join(base_dir, 'Kemono.ico') if os.path.exists(icon_path): qt_app.setWindowIcon(QIcon(icon_path)) else: print(f"Warning: Application icon 'Kemono.ico' not found at {icon_path}") downloader_app_instance = DownloaderApp() downloader_app_instance.show() exit_code = qt_app.exec_() print(f"Application finished with exit code: {exit_code}") sys.exit(exit_code) except SystemExit: pass # Allow clean exit except Exception as e: print("--- CRITICAL APPLICATION ERROR ---") print(f"An unhandled exception occurred: {e}") traceback.print_exc() print("--- END CRITICAL ERROR ---") sys.exit(1)